candidate data

master
Suvodip 2025-03-25 10:43:04 +05:30
commit f98b9f9036
83 changed files with 19007 additions and 0 deletions

9
.eslintrc.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"react-hooks/exhaustive-deps": "warn",
"prefer-const": "warn",
"@typescript-eslint/no-empty-object-type": "off"
}
}

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

105
README.md Normal file
View File

@ -0,0 +1,105 @@
# Candidate Filter Portal
A portal for filtering and managing candidate information, built with Next.js and MongoDB.
## Features
- User authentication with NextAuth.js
- CRUD operations for candidates
- Filtering candidates by various criteria
- Hotlisting candidates
- Admin management of users
- Reporting and analytics
- Responsive design using Tailwind CSS and shadcn/ui
## MongoDB Integration
This project is integrated with MongoDB for data storage. Here's how to set it up:
### MongoDB Connection
1. Make sure you have a MongoDB instance running locally or use MongoDB Atlas for cloud hosting.
2. Copy the `.env.example` file to `.env` and update the `MONGODB_URI` variable with your MongoDB connection string.
3. The application will automatically connect to MongoDB when it starts.
### Data Models
The application uses the following main data models:
- **Candidate**: Stores candidate information including personal details, skills, education, and interview status.
- **User**: Stores user information for authentication and authorization.
### CRUD Operations
All CRUD operations for candidates are available through the API:
- **Create**: POST to `/api/candidates`
- **Read**: GET to `/api/candidates` or `/api/candidates/[id]`
- **Update**: PUT/PATCH to `/api/candidates/[id]`
- **Delete**: DELETE to `/api/candidates/[id]`
Additionally, there are special operations:
- **Hotlist**: Add or remove candidates from the hotlist using `/api/hotlist`
- **Filter**: Filter candidates by various criteria using query parameters
## Testing MongoDB Connection
To test your MongoDB connection and CRUD operations, you can run:
```bash
# Test MongoDB connection
bun test:mongodb
# Interactive CRUD test
bun test:crud
```
## Seeding Demo Data
To seed the database with demo data for testing:
```bash
# Start the development server first
bun dev
# In another terminal, run the seed command
bun seed
```
You can also seed the database through the admin interface when logged in as an admin user.
## Development
### Prerequisites
- Node.js 18+ or bun
- MongoDB
### Installing dependencies
```bash
bun install
```
### Running the development server
```bash
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### Building for production
```bash
bun build
```
## Deployment
The application is ready to be deployed to Netlify. The `netlify.toml` file contains the necessary configuration.
## License
MIT

1164
bun.lock Normal file

File diff suppressed because it is too large Load Diff

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

403
docs/API.md Normal file
View File

@ -0,0 +1,403 @@
# API Documentation
This document provides information about the available API endpoints in the Candidate Filter Portal.
## Authentication
### Login
```
POST /api/auth/signin
```
Login using credentials or OAuth providers configured with NextAuth.
### Logout
```
GET /api/auth/signout
```
Log out the current user.
### Register
```
POST /api/register
```
Register a new user.
#### Request Body
```json
{
"name": "User Name",
"email": "user@example.com",
"password": "password123"
}
```
## Candidates
### Get All Candidates
```
GET /api/candidates
```
Retrieves a list of candidates with pagination and optional filtering.
#### Query Parameters
- `page`: Page number (default: 1)
- `limit`: Number of records per page (default: 50)
- `status`: Filter by status ('Active', 'Hold', 'Inactive')
- `technology`: Filter by technology
- `visaStatus`: Filter by visa status
- `openToRelocate`: Filter by relocation preference (true/false)
- `currentStatus`: Filter by current status
- `search`: Search by name, email, or technology
#### Response
```json
{
"candidates": [
{
"_id": "1234567890",
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "123-456-7890",
"technology": "React, Node.js",
"visaStatus": "H1B",
"location": "New York, NY",
"openToRelocate": true,
"experience": "5 years",
"currentStatus": "In marketing",
"status": "Active",
"marketingStartDate": "2023-04-15T00:00:00.000Z",
"dayRecruiter": "Jane Smith",
"nightRecruiter": "Mike Johnson"
},
// ... more candidates
],
"pagination": {
"total": 50,
"page": 1,
"limit": 10,
"totalPages": 5
}
}
```
### Get a Single Candidate
```
GET /api/candidates/:id
```
Retrieves a specific candidate by ID.
#### Response
```json
{
"_id": "1234567890",
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "123-456-7890",
"technology": "React, Node.js",
"visaStatus": "H1B",
"location": "New York, NY",
"openToRelocate": true,
"experience": "5 years",
"currentStatus": "In marketing",
"status": "Active",
"marketingStartDate": "2023-04-15T00:00:00.000Z",
"dayRecruiter": "Jane Smith",
"nightRecruiter": "Mike Johnson",
"education": [
{
"degree": "BS",
"field": "Computer Science",
"university": "Stanford University",
"yearCompleted": "2018"
}
],
"skills": ["JavaScript", "React", "Node.js", "MongoDB"],
"interviewing": [
{
"company": "Google",
"position": "Frontend Developer",
"date": "2023-05-15T00:00:00.000Z",
"status": "Scheduled",
"feedback": ""
}
],
"assessment": [],
"notes": "Excellent candidate with strong technical skills",
"createdAt": "2023-04-10T00:00:00.000Z",
"updatedAt": "2023-04-10T00:00:00.000Z"
}
```
### Create a Candidate
```
POST /api/candidates
```
Creates a new candidate.
#### Request Body
```json
{
"name": "Jane Smith",
"email": "jane.smith@example.com",
"phone": "123-456-7890",
"visaStatus": "OPT",
"location": "San Francisco, CA",
"openToRelocate": true,
"experience": "3 years",
"technology": "React, Node.js",
"bsYear": "2019",
"msYear": "2021",
"education": [
{
"degree": "BS",
"field": "Computer Science",
"university": "Stanford University",
"yearCompleted": "2019"
}
],
"skills": ["JavaScript", "React", "Node.js"],
"currentStatus": "In marketing",
"status": "Active"
}
```
#### Response
```json
{
"message": "Candidate created successfully",
"candidate": {
"_id": "new_candidate_id",
"name": "Jane Smith",
// ... other fields
}
}
```
### Update a Candidate
```
PUT /api/candidates/:id
```
Updates a specific candidate.
#### Request Body
Partial update with only the fields that need to be changed:
```json
{
"location": "New York, NY",
"openToRelocate": false,
"technology": "React, Node.js, MongoDB"
}
```
#### Response
```json
{
"message": "Candidate updated successfully",
"candidate": {
"_id": "1234567890",
"name": "Jane Smith",
// ... updated fields
}
}
```
### Delete a Candidate
```
DELETE /api/candidates/:id
```
Deletes a specific candidate.
#### Response
```json
{
"message": "Candidate deleted successfully"
}
```
## Hotlist
### Get Hotlisted Candidates
```
GET /api/hotlist
```
Retrieves candidates that have been hotlisted, with pagination and optional filtering.
#### Query Parameters
Same as for `GET /api/candidates`.
### Add to Hotlist
```
POST /api/hotlist
```
Adds a candidate to the hotlist.
#### Request Body
```json
{
"candidateId": "1234567890"
}
```
#### Response
```json
{
"message": "Candidate added to hotlist successfully",
"candidate": {
"_id": "1234567890",
"name": "Jane Smith",
// ... other fields
"hotlisted": true
}
}
```
### Remove from Hotlist
```
DELETE /api/hotlist?candidateId=1234567890
```
Removes a candidate from the hotlist.
#### Response
```json
{
"message": "Candidate removed from hotlist successfully",
"candidate": {
"_id": "1234567890",
"name": "Jane Smith",
// ... other fields
"hotlisted": false
}
}
```
## Reports
### Get Reports Data
```
GET /api/reports
```
Retrieves data for generating reports.
#### Response
```json
{
"candidates": {
"total": 100,
"byStatus": {
"Active": 75,
"Hold": 15,
"Inactive": 10
},
"byVisaStatus": {
"H1B": 25,
"OPT": 40,
"Green Card": 20,
"Citizen": 15
},
"byTechnology": {
"React": 30,
"Node.js": 25,
"Angular": 20,
"Python": 15,
"Java": 10
}
}
}
```
## Admin
### Get All Users
```
GET /api/admin/users
```
Retrieves a list of users (admin only).
### Create User
```
POST /api/admin/users
```
Creates a new user (admin only).
### Update User
```
PUT /api/admin/users/:id
```
Updates a specific user (admin only).
### Delete User
```
DELETE /api/admin/users/:id
```
Deletes a specific user (admin only).
### Seed Database
```
POST /api/admin/seed
```
Seeds the database with demo data (admin only).
### Purge Database
```
DELETE /api/admin/purge
```
Purges all data from the database (admin only).
#### Request Body
```json
{
"confirm": true
}
```

22
eslint.config.mjs Normal file
View File

@ -0,0 +1,22 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-unused-vars": "off",
"react/no-unescaped-entities": "off",
},
},
];
export default eslintConfig;

13
netlify.toml Normal file
View File

@ -0,0 +1,13 @@
[images]
remote_images = ["https://source.unsplash.com/.*", "https://images.unsplash.com/.*", "https://ext.same-assets.com/.*", "https://ugc.same-assets.com/.*"]
[build]
command = "bun run build"
publish = ".next"
[build.environment]
NEXT_PUBLIC_BASE_URL = "/"
NODE_VERSION = "18.17.0"
[[plugins]]
package = "@netlify/plugin-nextjs"

37
next.config.mjs Normal file
View File

@ -0,0 +1,37 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
images: {
unoptimized: true,
domains: [
"source.unsplash.com",
"images.unsplash.com",
"ext.same-assets.com",
"ugc.same-assets.com",
],
remotePatterns: [
{
protocol: "https",
hostname: "source.unsplash.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "ext.same-assets.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "ugc.same-assets.com",
pathname: "/**",
},
],
},
};
export default nextConfig;

8363
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "nextjs-shadcn",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0",
"build": "next build --no-lint",
"start": "next start",
"lint": "next lint",
"test:mongodb": "node scripts/test-mongodb.js",
"test:crud": "node scripts/crud-test.js",
"seed": "node -e \"fetch('http://localhost:3000/api/seed', { method: 'POST' }).then(r => r.json()).then(console.log).catch(console.error)\""
},
"dependencies": {
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"colorette": "^2.0.20",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"lucide-react": "^0.475.0",
"mongoose": "^8.12.2",
"next": "^15.2.0",
"next-auth": "^4.24.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

296
scripts/crud-test.js Normal file
View File

@ -0,0 +1,296 @@
/**
* Interactive CRUD Testing Script
*
* This script demonstrates CRUD operations with MongoDB and the Candidate model
* It will:
* 1. Connect to MongoDB
* 2. Allow you to create, read, update and delete candidate records
* 3. Display the results in a formatted way
*/
require('dotenv').config();
const mongoose = require('mongoose');
const readline = require('readline');
const { cyan, green, yellow, red, bold } = require('colorette');
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/candidate-portal';
// Create readline interface
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Candidate Schema
const CandidateSchema = new mongoose.Schema(
{
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
phone: String,
visaStatus: { type: String, enum: ['H1B', 'Green Card', 'OPT', 'CPT', 'Citizen', 'Other'] },
location: String,
openToRelocate: Boolean,
experience: String,
technology: String,
skills: [String],
currentStatus: String,
status: String,
marketingStartDate: Date,
dayRecruiter: String,
nightRecruiter: String,
notes: String,
hotlisted: Boolean
},
{ timestamps: true }
);
// Main async function
async function main() {
try {
console.log(cyan('🔌 Connecting to MongoDB...'));
// Connect to MongoDB
await mongoose.connect(MONGODB_URI, {
bufferCommands: false,
serverSelectionTimeoutMS: 5000,
});
console.log(green('✅ Connected to MongoDB successfully!'));
// Define the model
const Candidate = mongoose.model('Candidate', CandidateSchema);
// Display menu and handle user input
await showMenu(Candidate);
} catch (error) {
console.error(red('❌ Error:'), error);
}
}
// Show menu
async function showMenu(Candidate) {
console.log('\n' + bold(cyan('===== CANDIDATE CRUD OPERATIONS =====')));
console.log(cyan('1. Create a new candidate'));
console.log(cyan('2. List all candidates'));
console.log(cyan('3. Find a candidate by email'));
console.log(cyan('4. Update a candidate'));
console.log(cyan('5. Delete a candidate'));
console.log(cyan('6. Exit'));
rl.question(bold('\nEnter your choice (1-6): '), async (choice) => {
switch (choice) {
case '1':
await createCandidate(Candidate);
break;
case '2':
await listCandidates(Candidate);
break;
case '3':
await findCandidate(Candidate);
break;
case '4':
await updateCandidate(Candidate);
break;
case '5':
await deleteCandidate(Candidate);
break;
case '6':
console.log(yellow('👋 Goodbye!'));
await mongoose.disconnect();
rl.close();
return;
default:
console.log(red('❌ Invalid choice. Please try again.'));
await showMenu(Candidate);
break;
}
});
}
// Create a new candidate
async function createCandidate(Candidate) {
console.log('\n' + bold(cyan('===== CREATE NEW CANDIDATE =====')));
const candidate = {
name: await askQuestion('Enter name: '),
email: await askQuestion('Enter email: '),
phone: await askQuestion('Enter phone (optional): '),
visaStatus: await askQuestion('Enter visa status (H1B, Green Card, OPT, CPT, Citizen, Other): ') || 'OPT',
location: await askQuestion('Enter location (optional): '),
technology: await askQuestion('Enter technology (optional): '),
experience: await askQuestion('Enter experience (optional): '),
openToRelocate: (await askQuestion('Open to relocate? (y/n): ')).toLowerCase() === 'y'
};
try {
const result = await Candidate.create(candidate);
console.log(green('✅ Candidate created successfully!'));
console.log(formatCandidate(result));
} catch (error) {
console.error(red('❌ Error creating candidate:'), error.message);
}
await continuePrompt(Candidate);
}
// List all candidates
async function listCandidates(Candidate) {
console.log('\n' + bold(cyan('===== LIST ALL CANDIDATES =====')));
try {
const limit = parseInt(await askQuestion('Enter limit (default 10): ') || '10');
const candidates = await Candidate.find().limit(limit);
if (candidates.length === 0) {
console.log(yellow('⚠️ No candidates found.'));
} else {
console.log(green(`Found ${candidates.length} candidates:`));
candidates.forEach((candidate, index) => {
console.log(`\n${bold(cyan(`Candidate #${index + 1}:`))} ${bold(candidate.name)}`);
console.log(formatCandidate(candidate));
});
}
} catch (error) {
console.error(red('❌ Error listing candidates:'), error.message);
}
await continuePrompt(Candidate);
}
// Find a candidate by email
async function findCandidate(Candidate) {
console.log('\n' + bold(cyan('===== FIND CANDIDATE BY EMAIL =====')));
const email = await askQuestion('Enter email to search: ');
try {
const candidate = await Candidate.findOne({ email });
if (!candidate) {
console.log(yellow(`⚠️ No candidate found with email: ${email}`));
} else {
console.log(green('✅ Candidate found:'));
console.log(formatCandidate(candidate));
}
} catch (error) {
console.error(red('❌ Error finding candidate:'), error.message);
}
await continuePrompt(Candidate);
}
// Update a candidate
async function updateCandidate(Candidate) {
console.log('\n' + bold(cyan('===== UPDATE CANDIDATE =====')));
const email = await askQuestion('Enter email of candidate to update: ');
try {
const candidate = await Candidate.findOne({ email });
if (!candidate) {
console.log(yellow(`⚠️ No candidate found with email: ${email}`));
} else {
console.log(green('✅ Current candidate information:'));
console.log(formatCandidate(candidate));
console.log('\nEnter new values (or leave blank to keep current value):');
const updates = {};
updates.name = await askQuestion(`Name (${candidate.name}): `) || candidate.name;
updates.phone = await askQuestion(`Phone (${candidate.phone}): `) || candidate.phone;
updates.location = await askQuestion(`Location (${candidate.location}): `) || candidate.location;
updates.technology = await askQuestion(`Technology (${candidate.technology}): `) || candidate.technology;
updates.experience = await askQuestion(`Experience (${candidate.experience}): `) || candidate.experience;
const relocateAnswer = await askQuestion(`Open to relocate? (y/n) (current: ${candidate.openToRelocate ? 'yes' : 'no'}): `);
if (relocateAnswer) {
updates.openToRelocate = relocateAnswer.toLowerCase() === 'y';
}
const hotlistAnswer = await askQuestion(`Hotlisted? (y/n) (current: ${candidate.hotlisted ? 'yes' : 'no'}): `);
if (hotlistAnswer) {
updates.hotlisted = hotlistAnswer.toLowerCase() === 'y';
}
const updated = await Candidate.findByIdAndUpdate(
candidate._id,
{ $set: updates },
{ new: true, runValidators: true }
);
console.log(green('\n✅ Candidate updated successfully!'));
console.log(formatCandidate(updated));
}
} catch (error) {
console.error(red('❌ Error updating candidate:'), error.message);
}
await continuePrompt(Candidate);
}
// Delete a candidate
async function deleteCandidate(Candidate) {
console.log('\n' + bold(cyan('===== DELETE CANDIDATE =====')));
const email = await askQuestion('Enter email of candidate to delete: ');
try {
const candidate = await Candidate.findOne({ email });
if (!candidate) {
console.log(yellow(`⚠️ No candidate found with email: ${email}`));
} else {
console.log(red('⚠️ You are about to delete the following candidate:'));
console.log(formatCandidate(candidate));
const confirm = await askQuestion('Are you sure you want to delete this candidate? (y/n): ');
if (confirm.toLowerCase() === 'y') {
await Candidate.findByIdAndDelete(candidate._id);
console.log(green('✅ Candidate deleted successfully!'));
} else {
console.log(yellow('⚠️ Deletion cancelled.'));
}
}
} catch (error) {
console.error(red('❌ Error deleting candidate:'), error.message);
}
await continuePrompt(Candidate);
}
// Helper to format candidate for display
function formatCandidate(candidate) {
return `
ID: ${candidate._id}
Name: ${candidate.name}
Email: ${candidate.email}
Phone: ${candidate.phone || 'N/A'}
Visa Status: ${candidate.visaStatus || 'N/A'}
Location: ${candidate.location || 'N/A'}
Open to Relocate: ${candidate.openToRelocate ? 'Yes' : 'No'}
Experience: ${candidate.experience || 'N/A'}
Technology: ${candidate.technology || 'N/A'}
Hotlisted: ${candidate.hotlisted ? 'Yes' : 'No'}
Created: ${candidate.createdAt ? new Date(candidate.createdAt).toLocaleString() : 'N/A'}
Updated: ${candidate.updatedAt ? new Date(candidate.updatedAt).toLocaleString() : 'N/A'}
`;
}
// Helper to ask a question and get an answer
function askQuestion(question) {
return new Promise(resolve => {
rl.question(question, resolve);
});
}
// Continue prompt
async function continuePrompt(Candidate) {
await askQuestion('\nPress Enter to continue...');
await showMenu(Candidate);
}
// Run the program
main().catch(console.error);

76
scripts/test-mongodb.js Normal file
View File

@ -0,0 +1,76 @@
require('dotenv').config();
const mongoose = require('mongoose');
const { cyan, green, red, yellow } = require('colorette');
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/candidate-portal';
async function main() {
console.log(cyan('🔍 Testing MongoDB connection...'));
try {
// Connect to MongoDB
await mongoose.connect(MONGODB_URI, {
bufferCommands: false,
serverSelectionTimeoutMS: 5000,
});
console.log(green('✅ Connected to MongoDB successfully!'));
// Create a temporary test model
const TestSchema = new mongoose.Schema({
name: String,
email: String,
createdAt: {
type: Date,
default: Date.now
}
});
// Define the model name with a timestamp to ensure uniqueness
const modelName = `Test_${Date.now()}`;
const Test = mongoose.model(modelName, TestSchema);
// Create test document
console.log(cyan('📝 Creating test document...'));
const testDoc = await Test.create({
name: 'Test User',
email: `test-${Date.now()}@example.com`
});
console.log(green('✅ Created test document:'), testDoc._id.toString());
// Read test document
console.log(cyan('📖 Reading test document...'));
const foundDoc = await Test.findById(testDoc._id);
console.log(green('✅ Found test document:'), foundDoc.name);
// Update test document
console.log(cyan('🔄 Updating test document...'));
const updatedDoc = await Test.findByIdAndUpdate(
testDoc._id,
{ name: 'Updated Test User' },
{ new: true }
);
console.log(green('✅ Updated test document:'), updatedDoc.name);
// Delete test document
console.log(cyan('🗑️ Deleting test document...'));
await Test.findByIdAndDelete(testDoc._id);
const checkDeleted = await Test.findById(testDoc._id);
console.log(green('✅ Test document deleted'), checkDeleted === null ? 'successfully' : 'failed');
// Test collection operations
console.log(cyan('📊 Testing collection operations...'));
const collections = await mongoose.connection.db.listCollections().toArray();
console.log(green('✅ Collections in database:'), collections.map(c => c.name).join(', '));
console.log(green('✅ All MongoDB operations completed successfully!'));
} catch (error) {
console.error(red('❌ MongoDB Test Error:'), error);
} finally {
// Clean up connection
await mongoose.disconnect();
console.log(yellow('🔌 Disconnected from MongoDB'));
}
}
main().catch(console.error);

21
src/app/ClientBody.tsx Normal file
View File

@ -0,0 +1,21 @@
"use client";
import { useEffect } from "react";
export default function ClientBody({
children,
}: {
children: React.ReactNode;
}) {
// Remove any extension-added classes during hydration
useEffect(() => {
// This runs only on the client after hydration
document.body.className = "antialiased";
}, []);
return (
<body className="antialiased" suppressHydrationWarning>
{children}
</body>
);
}

329
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,329 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import MainLayout from "@/components/layout/MainLayout";
import { requireAdmin } from "@/lib/auth";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "sonner";
import { Trash, UserPlus, Database, RefreshCw, AlertCircle, CheckCircle } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
// This intermediate Client component is needed for the useRouter hook
function AdminDashboardClient() {
const router = useRouter();
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loadingAction, setLoadingAction] = useState(false);
const fetchUsers = async () => {
setLoading(true);
try {
const res = await fetch('/api/admin/users');
if (!res.ok) {
throw new Error('Failed to fetch users');
}
const data = await res.json();
setUsers(data.users);
} catch (error) {
console.error('Error fetching users:', error);
toast.error('Failed to load users');
} finally {
setLoading(false);
}
};
const loadDummyData = async () => {
setLoadingAction(true);
try {
const res = await fetch('/api/admin/seed', {
method: 'POST',
});
if (!res.ok) {
throw new Error('Failed to load dummy data');
}
toast.success('Dummy data loaded successfully');
router.refresh();
} catch (error) {
console.error('Error loading dummy data:', error);
toast.error('Failed to load dummy data');
} finally {
setLoadingAction(false);
}
};
const purgeDummyData = async () => {
setLoadingAction(true);
try {
const res = await fetch('/api/admin/purge', {
method: 'POST',
});
if (!res.ok) {
throw new Error('Failed to purge dummy data');
}
toast.success('Dummy data purged successfully');
router.refresh();
} catch (error) {
console.error('Error purging dummy data:', error);
toast.error('Failed to purge dummy data');
} finally {
setLoadingAction(false);
}
};
const toggleUserStatus = async (userId: string, currentStatus: boolean) => {
try {
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
isActive: !currentStatus,
}),
});
if (!res.ok) {
throw new Error('Failed to update user');
}
toast.success(`User ${currentStatus ? 'disabled' : 'enabled'} successfully`);
fetchUsers();
} catch (error) {
console.error('Error updating user:', error);
toast.error('Failed to update user');
}
};
const deleteUser = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
return;
}
try {
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error('Failed to delete user');
}
toast.success('User deleted successfully');
fetchUsers();
} catch (error) {
console.error('Error deleting user:', error);
toast.error('Failed to delete user');
}
};
const addNewHRUser = () => {
router.push('/admin/users/new');
};
useEffect(() => {
fetchUsers();
}, []);
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="text-muted-foreground">
Manage users and system data
</p>
</div>
</div>
<Tabs defaultValue="users">
<TabsList>
<TabsTrigger value="users">User Management</TabsTrigger>
<TabsTrigger value="data">Data Management</TabsTrigger>
</TabsList>
<TabsContent value="users" className="mt-4 space-y-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle>HR Users</CardTitle>
<CardDescription>
Manage HR users in the system
</CardDescription>
</div>
<Button
onClick={addNewHRUser}
className="flex items-center gap-2"
>
<UserPlus className="h-4 w-4" />
Add HR User
</Button>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-4">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded-full text-xs ${
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}>
{user.role}
</span>
</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded-full text-xs ${
user.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</TableCell>
<TableCell className="space-x-2 text-right">
{user.role !== 'admin' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => toggleUserStatus(user.id, user.isActive)}
>
{user.isActive ? (
<>
<AlertCircle className="h-4 w-4 mr-1" />
Disable
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-1" />
Enable
</>
)}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteUser(user.id)}
>
<Trash className="h-4 w-4 mr-1" />
Delete
</Button>
</>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="data" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Data Management</CardTitle>
<CardDescription>
Load or purge data in the system
</CardDescription>
</CardHeader>
<CardContent className="flex gap-4">
<Button
onClick={loadDummyData}
disabled={loadingAction}
className="flex items-center gap-2"
>
<Database className="h-4 w-4" />
{loadingAction ? 'Loading...' : 'Load Dummy Data'}
</Button>
<Button
variant="destructive"
onClick={purgeDummyData}
disabled={loadingAction}
className="flex items-center gap-2"
>
<Trash className="h-4 w-4" />
{loadingAction ? 'Purging...' : 'Purge Dummy Data'}
</Button>
<Button
variant="outline"
onClick={() => router.refresh()}
className="flex items-center gap-2"
>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</MainLayout>
);
}
// Server component wrapper to handle authorization
export default async function AdminDashboard() {
// This ensures only admin can access this page
await requireAdmin();
return <AdminDashboardClient />;
}

View File

@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import MainLayout from "@/components/layout/MainLayout";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { requireAdmin } from "@/lib/auth";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
// Form validation schema
const userFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters long"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters long"),
role: z.enum(["hr", "admin"]).default("hr"),
isActive: z.boolean().default(true),
});
type UserFormValues = z.infer<typeof userFormSchema>;
// Client component that contains the form and submission logic
function AddUserFormClient() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: {
name: "",
email: "",
password: "",
role: "hr",
isActive: true,
},
});
const onSubmit = async (data: UserFormValues) => {
setIsLoading(true);
try {
const response = await fetch("/api/admin/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to create user");
}
toast.success("User created successfully");
router.push("/admin");
} catch (error) {
console.error("Error creating user:", error);
toast.error(error instanceof Error ? error.message : "Failed to create user");
} finally {
setIsLoading(false);
}
};
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Admin Dashboard
</Link>
</Button>
</div>
<Card className="max-w-2xl mx-auto">
<CardHeader>
<CardTitle>Add New User</CardTitle>
<CardDescription>
Create a new HR user account
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john.doe@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="hr">HR</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.push("/admin")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating..." : "Create User"}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</MainLayout>
);
}
// Server component wrapper to handle authorization
export default async function AddUserPage() {
// This ensures only admin can access this page
await requireAdmin();
return <AddUserFormClient />;
}

View File

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import * as seedService from '@/lib/services/seedService';
// DELETE to purge all data
export async function DELETE(request: NextRequest) {
try {
// Ensure user is authenticated and is an admin
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
if (session.user.role !== 'ADMIN') {
return NextResponse.json(
{ error: 'Admin privileges required' },
{ status: 403 }
);
}
// Additional security check - require confirmation in the request body
const body = await request.json();
if (!body.confirm || body.confirm !== true) {
return NextResponse.json(
{ error: 'Confirmation required to purge data' },
{ status: 400 }
);
}
// Purge all data
await seedService.purgeData();
return NextResponse.json(
{ message: 'All data purged successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error purging data:', error);
return NextResponse.json(
{ error: 'Failed to purge data' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import * as seedService from '@/lib/services/seedService';
// POST to seed data (admin only)
export async function POST(request: NextRequest) {
try {
// Ensure user is authenticated and is an admin
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
if (session.user.role !== 'ADMIN') {
return NextResponse.json(
{ error: 'Admin privileges required' },
{ status: 403 }
);
}
// Create demo users
const users = await seedService.seedUsers();
// Create demo candidates using the first user's ID
const candidates = await seedService.seedCandidates(users[0]._id);
return NextResponse.json({
message: 'Database seeded successfully',
data: {
usersCreated: users.length,
candidatesCreated: candidates.length
}
});
} catch (error) {
console.error('Error seeding database:', error);
return NextResponse.json(
{ error: 'Failed to seed database' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,255 @@
import { NextRequest, NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/db';
import { User } from '@/models/User';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import mongoose from 'mongoose';
interface RequestContext {
params: {
id: string;
};
}
// GET - Get a single user by ID
export async function GET(
request: NextRequest,
context: RequestContext
) {
try {
// Ensure user is admin
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 403 }
);
}
const { id } = context.params;
try {
// Try with MongoDB first
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
const user = await User.findById(id).select('-password');
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json({
user: {
id: user._id.toString(),
name: user.name,
email: user.email,
role: user.role,
isActive: user.isActive,
createdAt: user.createdAt,
}
});
} catch (error) {
console.log('MongoDB connection error, using demo mode');
// Demo mode - just return 404 for simplicity
return NextResponse.json(
{ error: 'User not found or database unavailable' },
{ status: 404 }
);
}
} catch (error) {
console.error('Error fetching user:', error);
return NextResponse.json(
{ error: 'Failed to fetch user' },
{ status: 500 }
);
}
}
// PATCH - Update a user
export async function PATCH(
request: NextRequest,
context: RequestContext
) {
try {
// Ensure user is admin
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 403 }
);
}
const { id } = context.params;
const body = await request.json();
try {
// Try with MongoDB first
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Find the user first to check if it exists and is not an admin
const existingUser = await User.findById(id);
if (!existingUser) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Prevent modifying admin users
if (existingUser.role === 'admin' && id !== session.user.id) {
return NextResponse.json(
{ error: 'Cannot modify another admin user' },
{ status: 403 }
);
}
// Update user fields
const updateData: Record<string, any> = {};
if (body.name !== undefined) updateData.name = body.name;
if (body.email !== undefined) updateData.email = body.email;
if (body.isActive !== undefined) updateData.isActive = body.isActive;
// Only allow role change if not changing self
if (body.role !== undefined && id !== session.user.id) {
updateData.role = body.role;
}
// Update password if provided
if (body.password) {
updateData.password = body.password;
}
const updatedUser = await User.findByIdAndUpdate(
id,
{ $set: updateData },
{ new: true, runValidators: true }
).select('-password');
return NextResponse.json({
message: 'User updated successfully',
user: {
id: updatedUser._id.toString(),
name: updatedUser.name,
email: updatedUser.email,
role: updatedUser.role,
isActive: updatedUser.isActive,
createdAt: updatedUser.createdAt,
}
});
} catch (error) {
console.log('MongoDB connection error, using demo mode');
// Demo mode - just return success for simplicity
return NextResponse.json({
message: 'User updated successfully in demo mode',
user: {
id,
...body
}
});
}
} catch (error) {
console.error('Error updating user:', error);
return NextResponse.json(
{ error: 'Failed to update user' },
{ status: 500 }
);
}
}
// DELETE - Delete a user
export async function DELETE(
request: NextRequest,
context: RequestContext
) {
try {
// Ensure user is admin
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 403 }
);
}
const { id } = context.params;
try {
// Try with MongoDB first
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Find the user first to check if it exists and is not an admin
const existingUser = await User.findById(id);
if (!existingUser) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Prevent deleting admin users
if (existingUser.role === 'admin') {
return NextResponse.json(
{ error: 'Cannot delete admin user' },
{ status: 403 }
);
}
// Prevent deleting yourself
if (id === session.user.id) {
return NextResponse.json(
{ error: 'Cannot delete your own account' },
{ status: 403 }
);
}
await User.findByIdAndDelete(id);
return NextResponse.json({
message: 'User deleted successfully'
});
} catch (error) {
console.log('MongoDB connection error, using demo mode');
// Demo mode - just return success for simplicity
return NextResponse.json({
message: 'User deleted successfully in demo mode'
});
}
} catch (error) {
console.error('Error deleting user:', error);
return NextResponse.json(
{ error: 'Failed to delete user' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/db';
import { User } from '@/models/User';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
// Demo users for when MongoDB is not available
const DEMO_USERS = [
{
id: "1",
name: "Admin User",
email: "admin@example.com",
role: "admin",
isActive: true
},
{
id: "2",
name: "HR User",
email: "hr@example.com",
role: "hr",
isActive: true
},
{
id: "3",
name: "Inactive HR User",
email: "inactive@example.com",
role: "hr",
isActive: false
}
];
// GET - List all users
export async function GET(request: NextRequest) {
try {
// Ensure user is admin
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 403 }
);
}
try {
// Try with MongoDB first
await connectToDatabase();
// Find all users
const users = await User.find().select('-password');
return NextResponse.json({
users: users.map(user => ({
id: user._id.toString(),
name: user.name,
email: user.email,
role: user.role,
isActive: user.isActive,
createdAt: user.createdAt,
}))
});
} catch (error) {
console.log('MongoDB connection error, using demo users');
// Return demo users if MongoDB fails
return NextResponse.json({
users: DEMO_USERS
});
}
} catch (error) {
console.error('Error fetching users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
}
// POST - Create a new user
export async function POST(request: NextRequest) {
try {
// Ensure user is admin
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 403 }
);
}
const body = await request.json();
// Validate required fields
if (!body.name || !body.email || !body.password) {
return NextResponse.json(
{ error: 'Name, email and password are required' },
{ status: 400 }
);
}
try {
// Try with MongoDB first
await connectToDatabase();
// Check if user with this email already exists
const existingUser = await User.findOne({ email: body.email });
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
// Create the user
const user = await User.create({
name: body.name,
email: body.email,
password: body.password, // Will be hashed by pre-save hook
role: body.role || 'hr',
isActive: body.isActive !== undefined ? body.isActive : true,
});
return NextResponse.json(
{
message: 'User created successfully',
user: {
id: user._id.toString(),
name: user.name,
email: user.email,
role: user.role,
isActive: user.isActive
}
},
{ status: 201 }
);
} catch (error) {
// Demo mode - just simulate success
console.log('MongoDB connection error, simulating user creation');
// Check if user with this email already exists in demo users
if (DEMO_USERS.some(user => user.email === body.email)) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
const newUser = {
id: (DEMO_USERS.length + 1).toString(),
name: body.name,
email: body.email,
role: body.role || 'hr',
isActive: body.isActive !== undefined ? body.isActive : true,
};
// In a real implementation, we'd add this to the DEMO_USERS array
// But since this is just for demo purposes, we don't need to
return NextResponse.json(
{
message: 'User created successfully',
user: newUser
},
{ status: 201 }
);
}
} catch (error) {
console.error('Error creating user:', error);
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,165 @@
import NextAuth, { AuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { connectToDatabase } from "@/lib/db";
import { User } from "@/models/User";
// For demo/sample purpose credentials (MongoDB not required)
const DEMO_USERS = [
{
id: "1",
name: "Admin User",
email: "admin@example.com",
password: "password123", // Plain text for demo
role: "admin",
isActive: true
},
{
id: "2",
name: "HR User",
email: "hr@example.com",
password: "password123", // Plain text for demo
role: "hr",
isActive: true
},
{
id: "3",
name: "Inactive HR User",
email: "inactive@example.com",
password: "password123", // Plain text for demo
role: "hr",
isActive: false
}
];
export const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
console.log("Missing credentials");
return null;
}
try {
console.log(`Attempting login for: ${credentials.email}`);
// First check demo users directly with plain text comparison
const demoUser = DEMO_USERS.find(
(user) => user.email === credentials.email
);
if (demoUser) {
console.log("Found demo user");
// Check if demo user is active
if (!demoUser.isActive) {
console.log("Demo user found but inactive");
throw new Error("Account is inactive");
}
// Direct comparison for demo users
if (credentials.password === demoUser.password) {
console.log("Demo user password matched");
return {
id: demoUser.id,
email: demoUser.email,
name: demoUser.name,
role: demoUser.role,
isActive: demoUser.isActive
};
} else {
console.log("Invalid password for demo user");
return null;
}
}
// If no demo user found, try with MongoDB
let dbUser = null;
try {
await connectToDatabase();
dbUser = await User.findOne({ email: credentials.email });
console.log(`MongoDB connected, user found:`, !!dbUser);
} catch (dbError) {
console.error("MongoDB connection error:", dbError);
return null; // If no demo user and MongoDB fails, authentication fails
}
// If MongoDB user exists
if (dbUser) {
// Check if user is active
if (!dbUser.isActive) {
console.log("MongoDB user found but inactive");
throw new Error("Account is inactive");
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
dbUser.password
);
console.log(`Password validation for MongoDB user: ${isPasswordValid}`);
if (isPasswordValid) {
return {
id: dbUser._id.toString(),
email: dbUser.email,
name: dbUser.name,
role: dbUser.role,
isActive: dbUser.isActive
};
} else {
console.log("Invalid password for MongoDB user");
return null;
}
}
// No user found in demo or database
console.log("User not found in demo users or database");
return null;
} catch (error) {
console.error("Authentication error:", error);
throw error; // Propagate errors like 'Account is inactive'
}
},
}),
],
callbacks: {
async jwt({ token, user }: { token: any; user: any }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.isActive = user.isActive;
}
return token;
},
async session({ session, token }: { session: any; token: any }) {
if (token) {
session.user.id = token.id;
session.user.role = token.role;
session.user.isActive = token.isActive;
}
return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours
},
secret: process.env.NEXTAUTH_SECRET || "a-default-secret-for-development-only",
debug: process.env.NODE_ENV !== "production",
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@ -0,0 +1,214 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import * as candidateService from '@/lib/services/candidateService';
// Import demo candidates from the main route
// This is a workaround since we can't directly share variables between API route files
const getDemoCandidates = async () => {
const res = await fetch(new URL('/api/candidates', process.env.NEXTAUTH_URL));
const data = await res.json();
return data.candidates || [];
};
interface RequestContext {
params: {
id: string;
};
}
// GET a single candidate by ID
export async function GET(
request: NextRequest,
context: RequestContext
) {
try {
const { id } = context.params;
try {
// Try to get candidate from the service
const candidate = await candidateService.getCandidateById(id);
return NextResponse.json(candidate);
} catch (error) {
console.log("MongoDB error, using demo data:", error);
// Fall back to demo data
const demoCandidates = await getDemoCandidates();
const candidate = demoCandidates.find(c => c._id === id);
if (!candidate) {
return NextResponse.json(
{ error: 'Candidate not found' },
{ status: 404 }
);
}
return NextResponse.json(candidate);
}
} catch (error) {
console.error('Error fetching candidate:', error);
return NextResponse.json(
{ error: 'Failed to fetch candidate' },
{ status: 500 }
);
}
}
// PUT/PATCH to update a candidate
export async function PUT(
request: NextRequest,
context: RequestContext
) {
try {
const { id } = context.params;
// Ensure user is authenticated
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const body = await request.json();
try {
// Try to update candidate using the service
const candidate = await candidateService.updateCandidate(id, body);
return NextResponse.json({
message: 'Candidate updated successfully',
candidate
});
} catch (error) {
console.log("MongoDB error, using demo data:", error);
// Handle specific errors
if ((error as Error).message === 'Invalid candidate ID') {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 400 }
);
}
if ((error as Error).message === 'Candidate not found') {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 404 }
);
}
// Fall back to demo data
const demoCandidates = await getDemoCandidates();
const index = demoCandidates.findIndex(c => c._id === id);
if (index === -1) {
return NextResponse.json(
{ error: 'Candidate not found' },
{ status: 404 }
);
}
// Remove any sensitive or non-updatable fields
const { createdAt, updatedAt, createdBy, _id, ...updateData } = body;
// Update the demo candidate
const updatedCandidate = {
...demoCandidates[index],
...updateData,
updatedAt: new Date().toISOString()
};
// Since we can't directly modify the array in the main route,
// We'll return the updated candidate but won't persist changes across requests
return NextResponse.json({
message: 'Candidate updated successfully',
candidate: updatedCandidate
});
}
} catch (error) {
console.error('Error updating candidate:', error);
return NextResponse.json(
{ error: 'Failed to update candidate' },
{ status: 500 }
);
}
}
// Also support PATCH for partial updates
export async function PATCH(request: NextRequest, context: RequestContext) {
return PUT(request, context);
}
// DELETE a candidate
export async function DELETE(
request: NextRequest,
context: RequestContext
) {
try {
const { id } = context.params;
// Ensure user is authenticated
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
try {
// Try to delete candidate using the service
await candidateService.deleteCandidate(id);
return NextResponse.json(
{ message: 'Candidate deleted successfully' },
{ status: 200 }
);
} catch (error) {
console.log("MongoDB error, using demo data:", error);
// Handle specific errors
if ((error as Error).message === 'Invalid candidate ID') {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 400 }
);
}
if ((error as Error).message === 'Candidate not found') {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 404 }
);
}
// Fall back to demo data
const demoCandidates = await getDemoCandidates();
const index = demoCandidates.findIndex(c => c._id === id);
if (index === -1) {
return NextResponse.json(
{ error: 'Candidate not found' },
{ status: 404 }
);
}
// Since we can't directly modify the array in the main route,
// We'll just return success but won't persist changes across requests
return NextResponse.json(
{ message: 'Candidate deleted successfully' },
{ status: 200 }
);
}
} catch (error) {
console.error('Error deleting candidate:', error);
return NextResponse.json(
{ error: 'Failed to delete candidate' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,250 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import * as candidateService from '@/lib/services/candidateService';
// Demo data for when MongoDB is not available
const DEMO_CANDIDATES = [
{
_id: "1",
name: "John Doe",
email: "john.doe@example.com",
phone: "123-456-7890",
technology: "React, Node.js",
visaStatus: "H1B",
location: "New York, NY",
openToRelocate: true,
experience: "5 years",
currentStatus: "In marketing",
status: "Active",
marketingStartDate: "2023-04-15",
dayRecruiter: "Jane Smith",
nightRecruiter: "Mike Johnson",
},
{
_id: "2",
name: "Jane Smith",
email: "jane.smith@example.com",
phone: "234-567-8901",
technology: "Angular, Python",
visaStatus: "Green Card",
location: "San Francisco, CA",
openToRelocate: false,
experience: "3 years",
currentStatus: "In marketing",
status: "Active",
marketingStartDate: "2023-04-10",
dayRecruiter: "Bob Williams",
nightRecruiter: "Alice Brown",
},
{
_id: "3",
name: "Mike Johnson",
email: "mike.johnson@example.com",
phone: "345-678-9012",
technology: "Java, Spring",
visaStatus: "OPT",
location: "Austin, TX",
openToRelocate: true,
experience: "2 years",
currentStatus: "In marketing",
status: "Active",
marketingStartDate: "2023-04-05",
dayRecruiter: "Jane Smith",
nightRecruiter: "Bob Williams",
},
{
_id: "4",
name: "Sarah Williams",
email: "sarah.williams@example.com",
phone: "456-789-0123",
technology: "Python, Django",
visaStatus: "H1B",
location: "Chicago, IL",
openToRelocate: true,
experience: "4 years",
currentStatus: "Hold",
status: "Hold",
marketingStartDate: "2023-03-25",
dayRecruiter: "Mike Johnson",
nightRecruiter: "Jane Smith",
},
{
_id: "5",
name: "David Brown",
email: "david.brown@example.com",
phone: "567-890-1234",
technology: "React, Redux, TypeScript",
visaStatus: "Citizen",
location: "Seattle, WA",
openToRelocate: false,
experience: "6 years",
currentStatus: "Placed",
status: "Inactive",
marketingStartDate: "2023-03-15",
dayRecruiter: "Alice Brown",
nightRecruiter: "Bob Williams",
}
];
// GET all candidates, with optional filtering
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const technology = searchParams.get('technology');
const visaStatus = searchParams.get('visaStatus');
const openToRelocate = searchParams.get('openToRelocate');
const currentStatus = searchParams.get('currentStatus');
const search = searchParams.get('search');
const limit = Number(searchParams.get('limit') || '50');
const page = Number(searchParams.get('page') || '1');
try {
// Try to get candidates using the service
const result = await candidateService.getCandidates({
status,
technology,
visaStatus,
openToRelocate: openToRelocate === 'true',
currentStatus,
search,
limit,
page
});
console.log('mongo result', result);
return NextResponse.json(result);
} catch (error) {
console.log("MongoDB connection error, using demo candidates", error);
// Fall back to demo data
let candidates = [...DEMO_CANDIDATES];
// Filter demo data based on query params
if (status) {
candidates = candidates.filter(c => c.status === status);
}
if (technology) {
candidates = candidates.filter(c =>
c.technology.toLowerCase().includes(technology.toLowerCase())
);
}
if (visaStatus) {
candidates = candidates.filter(c => c.visaStatus === visaStatus);
}
if (openToRelocate) {
candidates = candidates.filter(c =>
c.openToRelocate === (openToRelocate === 'true')
);
}
if (currentStatus) {
candidates = candidates.filter(c => c.currentStatus === currentStatus);
}
if (search) {
const query = search.toLowerCase();
candidates = candidates.filter(c =>
c.name.toLowerCase().includes(query) ||
c.email.toLowerCase().includes(query) ||
c.technology.toLowerCase().includes(query)
);
}
const total = candidates.length;
const skip = (page - 1) * limit;
candidates = candidates.slice(skip, skip + limit);
return NextResponse.json({
candidates,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
}
});
}
} catch (error) {
console.error('Error fetching candidates:', error);
return NextResponse.json(
{ error: 'Failed to fetch candidates' },
{ status: 500 }
);
}
}
// POST a new candidate
export async function POST(request: NextRequest) {
try {
// Get the session to associate the candidate with the user
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const body = await request.json();
try {
// Try to create a candidate using the service
const newCandidate = await candidateService.createCandidate(
body,
session.user.id
);
return NextResponse.json(
{ message: 'Candidate created successfully', candidate: newCandidate },
{ status: 201 }
);
} catch (error) {
// Check if it's a duplicate email error
if ((error as Error).message === 'A candidate with this email already exists') {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 409 }
);
}
console.log("MongoDB connection error, handling demo creation", error);
// Check for duplicate in demo data
const existingCandidate = DEMO_CANDIDATES.find(c => c.email === body.email);
if (existingCandidate) {
return NextResponse.json(
{ error: 'A candidate with this email already exists' },
{ status: 409 }
);
}
// Create demo candidate with generated ID
const newCandidate = {
_id: (DEMO_CANDIDATES.length + 1).toString(),
...body,
createdBy: session.user.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Add to demo candidates array
DEMO_CANDIDATES.push(newCandidate);
return NextResponse.json(
{ message: 'Candidate created successfully', candidate: newCandidate },
{ status: 201 }
);
}
} catch (error) {
console.error('Error creating candidate:', error);
return NextResponse.json(
{ error: 'Failed to create candidate' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import * as candidateService from '@/lib/services/candidateService';
// GET hotlisted candidates
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const technology = searchParams.get('technology');
const visaStatus = searchParams.get('visaStatus');
const currentStatus = searchParams.get('currentStatus');
const search = searchParams.get('search');
const limit = Number(searchParams.get('limit') || '50');
const page = Number(searchParams.get('page') || '1');
try {
// Try to get hotlisted candidates using the service
const result = await candidateService.getHotlistCandidates({
status,
technology,
visaStatus,
currentStatus,
search,
limit,
page
});
return NextResponse.json(result);
} catch (error) {
console.error('Error fetching hotlisted candidates:', error);
// Return empty results in case of error
return NextResponse.json({
candidates: [],
pagination: {
total: 0,
page,
limit,
totalPages: 0
}
});
}
} catch (error) {
console.error('Error in hotlist route:', error);
return NextResponse.json(
{ error: 'Failed to fetch hotlisted candidates' },
{ status: 500 }
);
}
}
// POST to add a candidate to the hotlist
export async function POST(request: NextRequest) {
try {
// Ensure user is authenticated
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const body = await request.json();
const { candidateId } = body;
if (!candidateId) {
return NextResponse.json(
{ error: 'Candidate ID is required' },
{ status: 400 }
);
}
try {
// Add to hotlist using the service
const candidate = await candidateService.addToHotlist(candidateId);
return NextResponse.json({
message: 'Candidate added to hotlist successfully',
candidate
});
} catch (error) {
console.error('Error adding to hotlist:', error);
// Handle specific errors
if ((error as Error).message === 'Invalid candidate ID' ||
(error as Error).message === 'Candidate not found') {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 404 }
);
}
throw error; // Re-throw to be caught by outer catch
}
} catch (error) {
console.error('Error in hotlist route:', error);
return NextResponse.json(
{ error: 'Failed to add candidate to hotlist' },
{ status: 500 }
);
}
}
// DELETE to remove a candidate from the hotlist
export async function DELETE(request: NextRequest) {
try {
// Ensure user is authenticated
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const candidateId = searchParams.get('candidateId');
if (!candidateId) {
return NextResponse.json(
{ error: 'Candidate ID is required' },
{ status: 400 }
);
}
try {
// Remove from hotlist using the service
const candidate = await candidateService.removeFromHotlist(candidateId);
return NextResponse.json({
message: 'Candidate removed from hotlist successfully',
candidate
});
} catch (error) {
console.error('Error removing from hotlist:', error);
// Handle specific errors
if ((error as Error).message === 'Invalid candidate ID' ||
(error as Error).message === 'Candidate not found') {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 404 }
);
}
throw error; // Re-throw to be caught by outer catch
}
} catch (error) {
console.error('Error in hotlist route:', error);
return NextResponse.json(
{ error: 'Failed to remove candidate from hotlist' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { connectToDatabase } from "@/lib/db";
import { User } from "@/models/User";
export async function POST(request: NextRequest) {
try {
await connectToDatabase();
const { name, email, password, role = "user" } = await request.json();
// Check if a user with this email already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return NextResponse.json(
{ error: "User with this email already exists" },
{ status: 409 }
);
}
// Hash the password
// const hashedPassword = await bcrypt.hash(password, 12);
// Create the user
const user = await User.create({
name,
email,
password,
role,
});
// Return the user without the password
const { password: _, ...userWithoutPassword } = user.toObject();
return NextResponse.json(
{ message: "User created successfully", user: userWithoutPassword },
{ status: 201 }
);
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/db';
import { Candidate } from '@/models/Candidate';
export async function GET(request: NextRequest) {
try {
await connectToDatabase();
const { searchParams } = new URL(request.url);
const reportType = searchParams.get('type') || 'interviews';
const limit = Number(searchParams.get('limit') || '50');
const page = Number(searchParams.get('page') || '1');
const skip = (page - 1) * limit;
// Simplified response for the demo version
// In a real application, we would implement the MongoDB aggregation pipeline
const results: any[] = [];
const total = 0;
return NextResponse.json({
results,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Error fetching reports:', error);
return NextResponse.json(
{ error: 'Failed to fetch reports' },
{ status: 500 }
);
}
}

39
src/app/api/seed/route.ts Normal file
View File

@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import * as seedService from '@/lib/services/seedService';
// POST to seed data
export async function POST(request: NextRequest) {
try {
// Check for API key or restrict to development environment
if (process.env.NODE_ENV !== 'development' &&
request.headers.get('x-api-key') !== process.env.SEED_API_KEY) {
return NextResponse.json(
{ error: 'Unauthorized - Cannot seed data in production without API key' },
{ status: 401 }
);
}
// Create demo users
const users = await seedService.seedUsers();
// Create demo candidates using the first user's ID
const firstUser = users[0];
const candidates = await seedService.seedCandidates(firstUser._id);
return NextResponse.json({
message: 'Database seeded successfully',
data: {
usersCreated: users.length,
candidatesCreated: candidates.length
}
});
} catch (error) {
console.error('Error seeding database:', error);
return NextResponse.json(
{ error: 'Failed to seed database' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,92 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import MainLayout from "@/components/layout/MainLayout";
import { CandidateForm } from "@/components/candidates/CandidateForm";
import { requireAuth } from "@/lib/auth";
import { connectToDatabase } from "@/lib/db";
import { Candidate } from "@/models/Candidate";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export const metadata: Metadata = {
title: "Edit Candidate - Candidate Portal",
description: "Edit candidate information",
};
interface EditCandidatePageProps {
params: {
id: string;
};
}
async function getCandidateById(id: string) {
await connectToDatabase();
const candidate = await Candidate.findById(id);
if (!candidate) {
return null;
}
return JSON.parse(JSON.stringify(candidate));
}
function FormSkeleton() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-1/2" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
<Skeleton className="h-32 w-full" />
<div className="flex justify-end space-x-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-32" />
</div>
</div>
);
}
export default async function EditCandidatePage({
params,
}: EditCandidatePageProps) {
// Ensure user is authenticated
await requireAuth();
const candidateData = await getCandidateById(params.id);
if (!candidateData) {
notFound();
}
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<Card>
<CardHeader>
<CardTitle>Edit Candidate</CardTitle>
<CardDescription>
Update information for {candidateData.name}
</CardDescription>
</CardHeader>
<CardContent>
<Suspense fallback={<FormSkeleton />}>
<CandidateForm
initialData={candidateData}
isEditing={true}
/>
</Suspense>
</CardContent>
</Card>
</div>
</MainLayout>
);
}

View File

@ -0,0 +1,306 @@
import { Suspense } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import MainLayout from "@/components/layout/MainLayout";
import { requireAuth } from "@/lib/auth";
import { connectToDatabase } from "@/lib/db";
import { Candidate } from "@/models/Candidate";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowLeft, Calendar, Mail, MapPin, Phone, Edit, Trash2 } from "lucide-react";
import { format } from "date-fns";
import { DeleteCandidateButton } from "@/components/candidates/DeleteCandidateButton";
interface CandidateDetailsPageProps {
params: {
id: string;
};
}
async function getCandidateById(id: string) {
await connectToDatabase();
const candidate = await Candidate.findById(id);
if (!candidate) {
return null;
}
return JSON.parse(JSON.stringify(candidate));
}
function CandidateDetailsSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-20" />
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
<div>
<Skeleton className="h-10 w-80 mb-4" />
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
export default async function CandidateDetailsPage({
params,
}: CandidateDetailsPageProps) {
// Ensure user is authenticated
await requireAuth();
const candidateData = await getCandidateById(params.id);
if (!candidateData) {
notFound();
}
const formatDate = (dateString: string) => {
if (!dateString) return "N/A";
try {
return format(new Date(dateString), "PPP");
} catch (error) {
return "Invalid date";
}
};
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/candidates">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Candidates
</Link>
</Button>
</div>
<Suspense fallback={<CandidateDetailsSkeleton />}>
<div className="flex flex-col space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{candidateData.name}</h1>
<p className="text-muted-foreground">
{candidateData.technology || "Technology not specified"}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/candidates/${params.id}/edit`}>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</Button>
<DeleteCandidateButton id={params.id} />
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{candidateData.email}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{candidateData.phone || "No phone number"}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span>{candidateData.location || "No location specified"}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Candidate Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium">Visa Status</p>
<p className="text-sm text-muted-foreground">
{candidateData.visaStatus || "Not specified"}
</p>
</div>
<div>
<p className="text-sm font-medium">Current Status</p>
<p className="text-sm text-muted-foreground">
{candidateData.currentStatus || "Not specified"}
</p>
</div>
<div>
<p className="text-sm font-medium">Experience</p>
<p className="text-sm text-muted-foreground">
{candidateData.experience || "Not specified"}
</p>
</div>
<div>
<p className="text-sm font-medium">Open to Relocate</p>
<p className="text-sm text-muted-foreground">
{candidateData.openToRelocate ? "Yes" : "No"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="interviews" className="w-full">
<TabsList>
<TabsTrigger value="interviews">Interviews</TabsTrigger>
<TabsTrigger value="assessments">Assessments</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
</TabsList>
<TabsContent value="interviews" className="pt-4">
<Card>
<CardHeader>
<CardTitle>Interview History</CardTitle>
<CardDescription>
All interviews scheduled for this candidate
</CardDescription>
</CardHeader>
<CardContent>
{candidateData.interviewing && candidateData.interviewing.length > 0 ? (
<div className="space-y-4">
{candidateData.interviewing.map((interview: any, index: number) => (
<div key={index} className="border rounded-md p-4">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium">{interview.company}</h4>
<p className="text-sm text-muted-foreground">{interview.position}</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
{formatDate(interview.date)}
</div>
</div>
<div className="mt-2">
<span className="text-xs bg-muted px-2 py-1 rounded-md">
{interview.status}
</span>
</div>
{interview.feedback && (
<div className="mt-4 border-t pt-2">
<p className="text-sm font-medium">Feedback:</p>
<p className="text-sm text-muted-foreground">
{interview.feedback}
</p>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No interviews scheduled yet.</p>
)}
</CardContent>
<CardFooter>
<Button size="sm" variant="outline" asChild>
<Link href={`/candidates/${params.id}/interviews/new`}>
Add Interview
</Link>
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="assessments" className="pt-4">
<Card>
<CardHeader>
<CardTitle>Assessment Results</CardTitle>
<CardDescription>
Technical and skill assessments
</CardDescription>
</CardHeader>
<CardContent>
{candidateData.assessment && candidateData.assessment.length > 0 ? (
<div className="space-y-4">
{candidateData.assessment.map((assessment: any, index: number) => (
<div key={index} className="border rounded-md p-4">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium">{assessment.type} Assessment</h4>
<p className="text-sm">Score: {assessment.score || "Not scored"}</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
{formatDate(assessment.date)}
</div>
</div>
{assessment.notes && (
<div className="mt-4 border-t pt-2">
<p className="text-sm font-medium">Notes:</p>
<p className="text-sm text-muted-foreground">
{assessment.notes}
</p>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No assessments yet.</p>
)}
</CardContent>
<CardFooter>
<Button size="sm" variant="outline" asChild>
<Link href={`/candidates/${params.id}/assessments/new`}>
Add Assessment
</Link>
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="notes" className="pt-4">
<Card>
<CardHeader>
<CardTitle>Additional Notes</CardTitle>
</CardHeader>
<CardContent>
{candidateData.notes ? (
<div className="whitespace-pre-wrap">{candidateData.notes}</div>
) : (
<p className="text-muted-foreground">No additional notes.</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</Suspense>
</div>
</MainLayout>
);
}

View File

@ -0,0 +1,61 @@
import { Suspense } from "react";
import { Metadata } from "next";
import MainLayout from "@/components/layout/MainLayout";
import { CandidateForm } from "@/components/candidates/CandidateForm";
import { requireAuth } from "@/lib/auth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export const metadata: Metadata = {
title: "Add New Candidate - Candidate Portal",
description: "Add a new candidate to the system",
};
function FormSkeleton() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-1/2" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
<Skeleton className="h-32 w-full" />
<div className="flex justify-end space-x-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-32" />
</div>
</div>
);
}
export default async function NewCandidatePage() {
// Ensure user is authenticated
await requireAuth();
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<Card>
<CardHeader>
<CardTitle>Add New Candidate</CardTitle>
<CardDescription>
Fill in the details to add a new candidate to the system
</CardDescription>
</CardHeader>
<CardContent>
<Suspense fallback={<FormSkeleton />}>
<CandidateForm />
</Suspense>
</CardContent>
</Card>
</div>
</MainLayout>
);
}

599
src/app/candidates/page.tsx Normal file
View File

@ -0,0 +1,599 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import MainLayout from "@/components/layout/MainLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Checkbox } from "@/components/ui/checkbox";
import { format } from "date-fns";
import { toast } from "sonner";
import { ChevronLeft, ChevronRight, Download, Filter, PlusCircle, Search, SlidersHorizontal,} from "lucide-react";
interface Candidate {
_id: string;
name: string;
email: string;
phone: string;
technology: string;
visaStatus: string;
location: string;
openToRelocate: boolean;
experience: string;
currentStatus: string;
status: string;
marketingStartDate: string;
dayRecruiter: string;
nightRecruiter: string;
}
export default function CandidatesPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [pagination, setPagination] = useState({
currentPage: 1,
totalPages: 1,
totalItems: 0,
itemsPerPage: 10,
});
const [filters, setFilters] = useState({
status: "",
technology: "",
visaStatus: "",
openToRelocate: null as boolean | null,
});
const [showFilters, setShowFilters] = useState(false);
const fetchCandidates = useCallback(async () => {
setIsLoading(true);
try {
// Build the query string
const queryParams = new URLSearchParams();
queryParams.append("page", pagination.currentPage.toString());
queryParams.append("limit", pagination.itemsPerPage.toString());
if (searchQuery) {
queryParams.append("search", searchQuery);
}
if (filters.status) {
queryParams.append("status", filters.status);
}
if (filters.technology) {
queryParams.append("technology", filters.technology);
}
if (filters.visaStatus) {
queryParams.append("visaStatus", filters.visaStatus);
}
if (filters.openToRelocate !== null) {
queryParams.append("openToRelocate", filters.openToRelocate.toString());
}
const response = await fetch(`/api/candidates?${queryParams.toString()}`);
if (!response.ok) {
throw new Error("Failed to fetch candidates");
}
const data = await response.json();
console.log('frontend response', data)
// For this demo, we'll use sample data since we can't connect to a real DB
// In a real app, we would use data.candidates and data.pagination
const sampleCandidates: Candidate[] = [
{
_id: "1",
name: "John Doe",
email: "john.doe@example.com",
phone: "123-456-7890",
technology: "React, Node.js",
visaStatus: "H1B",
location: "New York, NY",
openToRelocate: true,
experience: "5 years",
currentStatus: "In marketing",
status: "Active",
marketingStartDate: "2023-04-15",
dayRecruiter: "Jane Smith",
nightRecruiter: "Mike Johnson",
},
{
_id: "2",
name: "Jane Smith",
email: "jane.smith@example.com",
phone: "234-567-8901",
technology: "Angular, Python",
visaStatus: "Green Card",
location: "San Francisco, CA",
openToRelocate: false,
experience: "3 years",
currentStatus: "In marketing",
status: "Active",
marketingStartDate: "2023-04-10",
dayRecruiter: "Bob Williams",
nightRecruiter: "Alice Brown",
},
{
_id: "3",
name: "Mike Johnson",
email: "mike.johnson@example.com",
phone: "345-678-9012",
technology: "Java, Spring",
visaStatus: "OPT",
location: "Austin, TX",
openToRelocate: true,
experience: "2 years",
currentStatus: "In marketing",
status: "Active",
marketingStartDate: "2023-04-05",
dayRecruiter: "Jane Smith",
nightRecruiter: "Bob Williams",
},
{
_id: "4",
name: "Sarah Williams",
email: "sarah.williams@example.com",
phone: "456-789-0123",
technology: "Python, Django",
visaStatus: "H1B",
location: "Chicago, IL",
openToRelocate: true,
experience: "4 years",
currentStatus: "Hold",
status: "Hold",
marketingStartDate: "2023-03-25",
dayRecruiter: "Mike Johnson",
nightRecruiter: "Jane Smith",
},
{
_id: "5",
name: "David Brown",
email: "david.brown@example.com",
phone: "567-890-1234",
technology: "React, Redux, TypeScript",
visaStatus: "Citizen",
location: "Seattle, WA",
openToRelocate: false,
experience: "6 years",
currentStatus: "Placed",
status: "Inactive",
marketingStartDate: "2023-03-15",
dayRecruiter: "Alice Brown",
nightRecruiter: "Bob Williams",
},
];
// Filter samples based on our filters
let filteredCandidates = sampleCandidates;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filteredCandidates = filteredCandidates.filter(
(candidate) =>
candidate.name.toLowerCase().includes(query) ||
candidate.email.toLowerCase().includes(query) ||
candidate.technology.toLowerCase().includes(query)
);
}
if (filters.status) {
filteredCandidates = filteredCandidates.filter(
(candidate) => candidate.status === filters.status
);
}
if (filters.technology) {
filteredCandidates = filteredCandidates.filter(
(candidate) => candidate.technology.toLowerCase().includes(filters.technology.toLowerCase())
);
}
if (filters.visaStatus) {
filteredCandidates = filteredCandidates.filter(
(candidate) => candidate.visaStatus === filters.visaStatus
);
}
if (filters.openToRelocate !== null) {
filteredCandidates = filteredCandidates.filter(
(candidate) => candidate.openToRelocate === filters.openToRelocate
);
}
setCandidates(filteredCandidates);
setPagination({
currentPage: 1,
totalPages: 1,
totalItems: filteredCandidates.length,
itemsPerPage: 10,
});
} catch (error) {
console.error("Error fetching candidates:", error);
toast.error("Failed to load candidates");
} finally {
setIsLoading(false);
}
}, [filters, pagination.currentPage, pagination.itemsPerPage, searchQuery]);
useEffect(() => {
fetchCandidates();
}, [fetchCandidates]);
const handlePageChange = (newPage: number) => {
if (newPage > 0 && newPage <= pagination.totalPages) {
setPagination((prev) => ({
...prev,
currentPage: newPage,
}));
}
};
const handleExportCSV = () => {
// Simple CSV export
const headers = [
"Name",
"Email",
"Phone",
"Technology",
"Visa Status",
"Location",
"Open To Relocate",
"Experience",
"Current Status",
"Status",
].join(",");
const csvRows = candidates.map((candidate) => {
return [
candidate.name,
candidate.email,
candidate.phone,
candidate.technology,
candidate.visaStatus,
candidate.location,
candidate.openToRelocate ? "Yes" : "No",
candidate.experience,
candidate.currentStatus,
candidate.status,
]
.map((value) => `"${value}"`)
.join(",");
});
const csvContent = [headers, ...csvRows].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", "candidates.csv");
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("Exported candidates to CSV successfully");
};
const resetFilters = () => {
setFilters({
status: "",
technology: "",
visaStatus: "",
openToRelocate: null,
});
setSearchQuery("");
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "N/A";
try {
return format(new Date(dateStr), "MMM dd, yyyy");
} catch (error) {
return "Invalid date";
}
};
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Candidates</h1>
<p className="text-muted-foreground">
Manage and track all candidates in the system
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={handleExportCSV}
>
<Download className="h-4 w-4" />
Export CSV
</Button>
<Button
size="sm"
className="flex items-center gap-2"
onClick={() => router.push("/candidates/new")}
>
<PlusCircle className="h-4 w-4" />
Add Candidate
</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<CardTitle>Candidate List</CardTitle>
<CardDescription>
{pagination.totalItems} candidates found
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative w-full md:w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search candidates..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="h-4 w-4 mr-2" />
Filters
</Button>
</div>
</div>
</CardHeader>
{showFilters && (
<div className="px-6 py-3 border-b">
<div className="text-sm font-medium mb-3">Filter Candidates</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<label className="text-sm">Status</label>
<Select
value={filters.status}
onValueChange={(value) =>
setFilters({ ...filters, status: value })
}
>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Statuses</SelectItem>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
<SelectItem value="Inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm">Technology</label>
<Input
placeholder="e.g. React"
value={filters.technology}
onChange={(e) =>
setFilters({ ...filters, technology: e.target.value })
}
/>
</div>
<div className="space-y-2">
<label className="text-sm">Visa Status</label>
<Select
value={filters.visaStatus}
onValueChange={(value) =>
setFilters({ ...filters, visaStatus: value })
}
>
<SelectTrigger>
<SelectValue placeholder="All Visa Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Visa Statuses</SelectItem>
<SelectItem value="H1B">H1B</SelectItem>
<SelectItem value="Green Card">Green Card</SelectItem>
<SelectItem value="OPT">OPT</SelectItem>
<SelectItem value="CPT">CPT</SelectItem>
<SelectItem value="Citizen">Citizen</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm">Open to Relocate</label>
<Select
value={
filters.openToRelocate === null
? ""
: filters.openToRelocate
? "true"
: "false"
}
onValueChange={(value) =>
setFilters({
...filters,
openToRelocate:
value === ""
? null
: value === "true"
? true
: false,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any</SelectItem>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={resetFilters}
>
Reset Filters
</Button>
<Button
size="sm"
onClick={() => {
fetchCandidates();
setShowFilters(false);
}}
>
Apply Filters
</Button>
</div>
</div>
)}
<CardContent className="p-0">
<div className="relative w-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Technology</TableHead>
<TableHead>Visa Status</TableHead>
<TableHead>Location</TableHead>
<TableHead>Status</TableHead>
<TableHead>Marketing Date</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={8}>
<Skeleton className="h-6 w-full" />
</TableCell>
</TableRow>
))
) : candidates.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-10">
No candidates found
</TableCell>
</TableRow>
) : (
candidates.map((candidate) => (
<TableRow key={candidate._id}>
<TableCell className="font-medium">
<Link
href={`/candidates/${candidate._id}`}
className="text-primary hover:underline"
>
{candidate.name}
</Link>
</TableCell>
<TableCell>{candidate.email}</TableCell>
<TableCell>{candidate.technology}</TableCell>
<TableCell>{candidate.visaStatus}</TableCell>
<TableCell>{candidate.location}</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded-full text-xs ${
candidate.status === "Active"
? "bg-green-100 text-green-800"
: candidate.status === "Hold"
? "bg-yellow-100 text-yellow-800"
: "bg-gray-100 text-gray-800"
}`}
>
{candidate.status}
</span>
</TableCell>
<TableCell>
{formatDate(candidate.marketingStartDate)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/candidates/${candidate._id}`)}
>
View
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/candidates/${candidate._id}/edit`)}
>
Edit
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t">
<div className="text-sm text-muted-foreground">
Showing {candidates.length} of {pagination.totalItems} results
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(pagination.currentPage - 1)}
disabled={pagination.currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(pagination.currentPage + 1)}
disabled={pagination.currentPage === pagination.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
</MainLayout>
);
}

51
src/app/globals.css Normal file
View File

@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222 17% 20%;
--foreground: 220 13% 82%;
--muted: 222 17% 25%;
--muted-foreground: 220 13% 65%;
--popover: 222 17% 23%;
--popover-foreground: 220 13% 82%;
--card: 222 17% 23%;
--card-foreground: 220 13% 82%;
--border: 222 17% 30%;
--input: 222 17% 30%;
--primary: 87 49% 42%;
--primary-foreground: 0 0% 100%;
--secondary: 87 49% 35%;
--secondary-foreground: 0 0% 100%;
--accent: 87 49% 35%;
--accent-foreground: 0 0% 100%;
--destructive: 0 63% 45%;
--destructive-foreground: 210 40% 98%;
--ring: 87 49% 42%;
--radius: 0.5rem;
}
body {
@apply bg-[#2b323b] text-[#c7ccd5];
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

316
src/app/hotlist/page.tsx Normal file
View File

@ -0,0 +1,316 @@
"use client";
import { useState, useEffect } from "react";
import MainLayout from "@/components/layout/MainLayout";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { formatDate } from "@/lib/utils";
interface Candidate {
_id: string;
name: string;
email: string;
phone: string;
status: string;
trainerName: string;
marketingStartDate: string;
yearsOfExperience: string;
dayRecruiter: string;
nightRecruiter: string;
}
export default function HotlistPage() {
const [loading, setLoading] = useState(true);
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 10,
totalPages: 0,
});
// Filters
const [trainerFilter, setTrainerFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("Active");
const [dayRecruiterFilter, setDayRecruiterFilter] = useState("");
const [nightRecruiterFilter, setNightRecruiterFilter] = useState("");
const fetchCandidates = async () => {
try {
setLoading(true);
let url = `/api/hotlist?page=${pagination.page}&limit=${pagination.limit}&status=${statusFilter}`;
if (trainerFilter) {
url += `&trainerName=${encodeURIComponent(trainerFilter)}`;
}
if (dayRecruiterFilter) {
url += `&dayRecruiter=${encodeURIComponent(dayRecruiterFilter)}`;
}
if (nightRecruiterFilter) {
url += `&nightRecruiter=${encodeURIComponent(nightRecruiterFilter)}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch candidates');
}
const data = await response.json();
setCandidates(data.candidates);
setPagination(data.pagination);
} catch (error) {
console.error('Error fetching candidates:', error);
toast.error('Failed to load candidates');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCandidates();
}, [pagination.page, statusFilter, trainerFilter, dayRecruiterFilter, nightRecruiterFilter]);
const handlePageChange = (newPage: number) => {
if (newPage > 0 && newPage <= pagination.totalPages) {
setPagination((prev) => ({ ...prev, page: newPage }));
}
};
// For demo purposes, let's add some sample data
useEffect(() => {
// This is just for demo before we have the API working
const sampleCandidates = [
{
_id: '1',
name: 'John Smith',
email: 'john@example.com',
phone: '555-123-4567',
status: 'Active',
trainerName: 'Alice Johnson',
marketingStartDate: '2023-04-15T00:00:00.000Z',
yearsOfExperience: '3',
dayRecruiter: 'Bob Williams',
nightRecruiter: 'Charlie Davis'
},
{
_id: '2',
name: 'Jane Doe',
email: 'jane@example.com',
phone: '555-987-6543',
status: 'Active',
trainerName: 'Alice Johnson',
marketingStartDate: '2023-04-10T00:00:00.000Z',
yearsOfExperience: '5',
dayRecruiter: 'Bob Williams',
nightRecruiter: 'Charlie Davis'
},
{
_id: '3',
name: 'Michael Brown',
email: 'michael@example.com',
phone: '555-555-5555',
status: 'Hold',
trainerName: 'David Miller',
marketingStartDate: '2023-03-20T00:00:00.000Z',
yearsOfExperience: '2',
dayRecruiter: 'Eve Rogers',
nightRecruiter: 'Frank Thomas'
},
{
_id: '4',
name: 'Sarah Johnson',
email: 'sarah@example.com',
phone: '555-222-3333',
status: 'Active',
trainerName: 'David Miller',
marketingStartDate: '2023-04-01T00:00:00.000Z',
yearsOfExperience: '4',
dayRecruiter: 'Eve Rogers',
nightRecruiter: 'Frank Thomas'
},
{
_id: '5',
name: 'Robert Wilson',
email: 'robert@example.com',
phone: '555-444-8888',
status: 'Active',
trainerName: 'Grace Lee',
marketingStartDate: '2023-04-05T00:00:00.000Z',
yearsOfExperience: '6',
dayRecruiter: 'Henry Clark',
nightRecruiter: 'Irene Phillips'
}
];
setCandidates(sampleCandidates);
setPagination({
total: sampleCandidates.length,
page: 1,
limit: 10,
totalPages: 1
});
setLoading(false);
}, []);
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<div className="flex flex-col space-y-2">
<h1 className="text-3xl font-bold">Candidate Hotlist</h1>
<p className="text-muted-foreground">
View and filter candidates currently in marketing.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<label className="text-sm font-medium">Status</label>
<Select
value={statusFilter}
onValueChange={setStatusFilter}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
<SelectItem value="Closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Trainer</label>
<Input
placeholder="Trainer name"
value={trainerFilter}
onChange={(e) => setTrainerFilter(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Day Recruiter</label>
<Input
placeholder="Day recruiter"
value={dayRecruiterFilter}
onChange={(e) => setDayRecruiterFilter(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Night Recruiter</label>
<Input
placeholder="Night recruiter"
value={nightRecruiterFilter}
onChange={(e) => setNightRecruiterFilter(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Status</TableHead>
<TableHead>Trainer</TableHead>
<TableHead>Marketing Start</TableHead>
<TableHead>Experience</TableHead>
<TableHead>Day Recruiter</TableHead>
<TableHead>Night Recruiter</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-4">
Loading candidates...
</TableCell>
</TableRow>
) : candidates.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-4">
No candidates found.
</TableCell>
</TableRow>
) : (
candidates.map((candidate) => (
<TableRow key={candidate._id}>
<TableCell className="font-medium">{candidate.name}</TableCell>
<TableCell>{candidate.email}</TableCell>
<TableCell>{candidate.phone}</TableCell>
<TableCell>{candidate.status}</TableCell>
<TableCell>{candidate.trainerName}</TableCell>
<TableCell>{formatDate(candidate.marketingStartDate)}</TableCell>
<TableCell>{candidate.yearsOfExperience} years</TableCell>
<TableCell>{candidate.dayRecruiter}</TableCell>
<TableCell>{candidate.nightRecruiter}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-center space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
>
Next
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</MainLayout>
);
}

29
src/app/layout.tsx Normal file
View File

@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/providers/AuthProvider";
import { Toaster } from "sonner";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Candidate Filter Portal",
description: "Manage your candidate filtering process efficiently",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<AuthProvider>
{children}
<Toaster position="top-right" />
</AuthProvider>
</body>
</html>
);
}

178
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,178 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { signIn } from "next-auth/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Info, AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
// Form definition
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
// Handle form submission
const onSubmit = async (data: LoginFormValues) => {
setIsLoading(true);
setLoginError(null);
try {
const result = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
if (result?.error) {
if (result.error === "Account is inactive") {
setLoginError("This account has been disabled by an administrator");
toast.error("This account has been disabled by an administrator");
} else {
setLoginError("Invalid email or password");
toast.error("Invalid email or password");
}
return;
}
toast.success("Login successful!");
router.push("/");
router.refresh();
} catch (error) {
console.error("Login error:", error);
setLoginError("An error occurred during login");
toast.error("An error occurred during login");
} finally {
setIsLoading(false);
}
};
// Handle error messages from URL parameters
useEffect(() => {
const error = searchParams.get("error");
if (error) {
setLoginError(decodeURIComponent(error));
toast.error(decodeURIComponent(error));
}
}, [searchParams]);
return (
<div className="flex min-h-screen items-center justify-center bg-[#1a2840] px-4 py-12">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold tracking-tight text-white">
Candidate Portal
</h1>
<p className="text-slate-400">
Enter your credentials to sign in
</p>
</div>
<div className="bg-[#192133] rounded-xl p-6 shadow-lg border border-slate-800">
<Alert variant="info" className="mb-6 bg-[#192133] border-blue-500/30">
<Info className="h-4 w-4 text-blue-500" />
<AlertTitle className="text-blue-500">Demo Account</AlertTitle>
<AlertDescription className="text-slate-300">
<p>Admin: admin@example.com / password123</p>
<p>HR: hr@example.com / password123</p>
</AlertDescription>
</Alert>
{loginError && (
<Alert variant="destructive" className="mb-4 bg-red-900/30 border-red-500/50">
<AlertCircle className="h-4 w-4 text-red-500" />
<AlertTitle className="text-red-400">Login Failed</AlertTitle>
<AlertDescription className="text-slate-300">
{loginError}
</AlertDescription>
</Alert>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-300">Email</FormLabel>
<FormControl>
<Input
placeholder="your.email@example.com"
autoComplete="email"
{...field}
className="bg-slate-800 border-slate-700 text-white"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-300">Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
autoComplete="current-password"
{...field}
className="bg-slate-800 border-slate-700 text-white"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 mt-2"
>
{isLoading ? "Signing In..." : "Sign In"}
</Button>
</form>
</Form>
<div className="mt-4 text-center text-sm text-slate-400">
<p>
Don&apos;t have an account?{" "}
<Link href="/register" className="text-blue-400 hover:underline">
Register
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

95
src/app/page.tsx Normal file
View File

@ -0,0 +1,95 @@
import Link from "next/link";
import MainLayout from "@/components/layout/MainLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowRight, BarChart2, List, User } from "lucide-react";
import { requireAuth } from "@/lib/auth";
export default async function Home() {
// Ensure user is authenticated
const user = await requireAuth();
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<div className="flex flex-col space-y-2">
<h1 className="text-3xl font-bold">Welcome to Candidate Filter Portal</h1>
<p className="text-muted-foreground">
Manage, filter, and track your candidates efficiently.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between mb-4">
<div className="text-sm font-medium">
Hotlist
</div>
<List className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-4">
<div className="text-xl font-bold">Active Candidates</div>
<p className="text-sm text-muted-foreground">
View the most recent candidates in marketing.
</p>
<Button asChild variant="outline" className="w-full group">
<Link href="/hotlist" className="flex items-center justify-center">
View Hotlist
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between mb-4">
<div className="text-sm font-medium">
Candidates
</div>
<User className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-4">
<div className="text-xl font-bold">Manage Candidates</div>
<p className="text-sm text-muted-foreground">
Add, edit, and filter candidate information.
</p>
<Button asChild variant="outline" className="w-full group">
<Link href="/candidates" className="flex items-center justify-center">
View Candidates
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between mb-4">
<div className="text-sm font-medium">
Reports
</div>
<BarChart2 className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-4">
<div className="text-xl font-bold">Analytics & Reports</div>
<p className="text-sm text-muted-foreground">
Track interviews, screenings, and assessments.
</p>
<Button asChild variant="outline" className="w-full group">
<Link href="/reports" className="flex items-center justify-center">
View Reports
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</MainLayout>
);
}

168
src/app/register/page.tsx Normal file
View File

@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
const registerSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters" }),
email: z.string().email({ message: "Please enter a valid email address" }),
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type RegisterValues = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "", },
});
async function onSubmit(data: RegisterValues) {
setIsLoading(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: data.name,
email: data.email,
password: data.password,
}),
});
const result = await response.json();
if (!response.ok) {
toast.error(result.error || "Failed to register");
return;
}
toast.success("Registration successful! Please sign in.");
router.push("/login");
} catch (error) {
console.error("Registration error:", error);
toast.error("Something went wrong");
} finally {
setIsLoading(false);
}
}
return (
<div className="flex h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Create an Account</CardTitle>
<CardDescription>Enter your details to create a new account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Your name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="your.email@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Registering..." : "Register"}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}

672
src/app/reports/page.tsx Normal file
View File

@ -0,0 +1,672 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import MainLayout from "@/components/layout/MainLayout";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line,
} from "recharts";
import { Calendar, ChevronDownIcon, Download, Search } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
// Sample data for visualizations
const sampleInterviewData = [
{ name: "Google", value: 10, fill: "#8884d8" },
{ name: "Microsoft", value: 8, fill: "#82ca9d" },
{ name: "Amazon", value: 15, fill: "#ffc658" },
{ name: "Apple", value: 5, fill: "#ff8042" },
{ name: "Meta", value: 7, fill: "#0088fe" },
];
const sampleMonthlyData = [
{ month: "Jan", interviews: 12, offers: 3 },
{ month: "Feb", interviews: 19, offers: 5 },
{ month: "Mar", interviews: 15, offers: 4 },
{ month: "Apr", interviews: 22, offers: 8 },
{ month: "May", interviews: 28, offers: 10 },
{ month: "Jun", interviews: 18, offers: 6 },
];
const sampleVisaData = [
{ name: "H1B", value: 35, fill: "#0088FE" },
{ name: "OPT", value: 45, fill: "#00C49F" },
{ name: "Green Card", value: 15, fill: "#FFBB28" },
{ name: "Citizen", value: 5, fill: "#FF8042" },
];
const sampleSkillsData = [
{ name: "React", candidates: 40 },
{ name: "Node.js", candidates: 30 },
{ name: "Python", candidates: 25 },
{ name: "Java", candidates: 20 },
{ name: "AWS", candidates: 35 },
{ name: "Angular", candidates: 15 },
];
interface ReportDataItem {
id: string;
date: string;
company?: string;
candidate: string;
result?: string;
status?: string;
feedback?: string;
notes?: string;
score?: string;
type?: string;
}
export default function ReportsPage() {
const [activeTab, setActiveTab] = useState("overview");
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [interviewsData, setInterviewsData] = useState<ReportDataItem[]>([]);
const [screeningsData, setScreeningsData] = useState<ReportDataItem[]>([]);
const [assessmentsData, setAssessmentsData] = useState<ReportDataItem[]>([]);
// Sample data for reports
const sampleInterviews = [
{
id: "1",
date: "2023-05-15",
company: "Google",
candidate: "John Doe",
status: "Completed",
feedback: "Strong technical skills, good culture fit",
},
{
id: "2",
date: "2023-05-12",
company: "Microsoft",
candidate: "Jane Smith",
status: "Scheduled",
feedback: "Pending",
},
{
id: "3",
date: "2023-05-10",
company: "Amazon",
candidate: "Mike Johnson",
status: "Completed",
feedback: "Good communication, needs to improve on system design",
},
{
id: "4",
date: "2023-05-05",
company: "Meta",
candidate: "Sarah Williams",
status: "Cancelled",
feedback: "N/A",
},
{
id: "5",
date: "2023-05-02",
company: "Apple",
candidate: "David Brown",
status: "Completed",
feedback: "Excellent problem-solving abilities",
},
];
const sampleScreenings = [
{
id: "1",
date: "2023-05-14",
candidate: "John Doe",
result: "Pass",
notes: "Good fit for frontend roles",
},
{
id: "2",
date: "2023-05-11",
candidate: "Jane Smith",
result: "Pass",
notes: "Strong backend experience",
},
{
id: "3",
date: "2023-05-09",
candidate: "Mike Johnson",
result: "Fail",
notes: "Lacks experience in required technologies",
},
{
id: "4",
date: "2023-05-07",
candidate: "Sarah Williams",
result: "Pass",
notes: "Great communication skills",
},
{
id: "5",
date: "2023-05-01",
candidate: "David Brown",
result: "Pass",
notes: "Strong problem-solving abilities",
},
];
const sampleAssessments = [
{
id: "1",
date: "2023-05-13",
candidate: "John Doe",
type: "Technical",
score: "85%",
notes: "Strong in React, needs work on backend technologies",
},
{
id: "2",
date: "2023-05-10",
candidate: "Jane Smith",
type: "Communication",
score: "90%",
notes: "Excellent communication and problem articulation",
},
{
id: "3",
date: "2023-05-08",
candidate: "Mike Johnson",
type: "Technical",
score: "75%",
notes: "Good JavaScript knowledge, needs improvement in system design",
},
{
id: "4",
date: "2023-05-05",
candidate: "Sarah Williams",
type: "Mock Interview",
score: "82%",
notes: "Handled stress well, good technical communication",
},
{
id: "5",
date: "2023-04-30",
candidate: "David Brown",
type: "Technical",
score: "88%",
notes: "Very strong in algorithms and data structures",
},
];
// Create memoized data loading function
const loadSampleData = useCallback(() => {
setInterviewsData(sampleInterviews);
setScreeningsData(sampleScreenings);
setAssessmentsData(sampleAssessments);
setIsLoading(false);
}, []);
useEffect(() => {
// Simulate fetching data
const timer = setTimeout(() => {
loadSampleData();
}, 1000);
return () => clearTimeout(timer);
}, [loadSampleData]);
const handleExportCSV = () => {
let dataToExport: ReportDataItem[] = [];
let filename = "";
switch (activeTab) {
case "interviews":
dataToExport = interviewsData;
filename = "interview-reports.csv";
break;
case "screenings":
dataToExport = screeningsData;
filename = "screening-reports.csv";
break;
case "assessments":
dataToExport = assessmentsData;
filename = "assessment-reports.csv";
break;
default:
dataToExport = [...interviewsData, ...screeningsData, ...assessmentsData];
filename = "all-reports.csv";
break;
}
// Simple CSV export
const headers = Object.keys(dataToExport[0] || {}).join(",");
const csvRows = dataToExport.map((row) =>
Object.values(row)
.map((value) => `"${value}"`)
.join(",")
);
const csvContent = [headers, ...csvRows].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", filename);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(`Exported ${filename} successfully`);
};
const formatDate = (dateStr: string) => {
return format(new Date(dateStr), "MMM dd, yyyy");
};
const filteredData = (data: ReportDataItem[]) => {
if (!searchQuery) return data;
return data.filter((item) =>
Object.values(item).some(
(value) => typeof value === "string" &&
value.toLowerCase().includes(searchQuery.toLowerCase())
)
);
};
return (
<MainLayout>
<div className="flex flex-col space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Reports & Analytics</h1>
<p className="text-muted-foreground">
View and analyze candidate performance and interview data
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={handleExportCSV}
>
<Download className="h-4 w-4" />
Export CSV
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Calendar className="h-4 w-4" />
Time Period
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select period</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Last 7 days</DropdownMenuItem>
<DropdownMenuItem>Last 30 days</DropdownMenuItem>
<DropdownMenuItem>Last 90 days</DropdownMenuItem>
<DropdownMenuItem>This year</DropdownMenuItem>
<DropdownMenuItem>All time</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Interviews
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">58</div>
<p className="text-xs text-muted-foreground">
+12% from last month
</p>
<div className="mt-4 h-[80px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
width={500}
height={80}
data={sampleMonthlyData}
margin={{
top: 5,
right: 0,
left: 0,
bottom: 5,
}}
>
<Line
type="monotone"
dataKey="interviews"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Offer Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">32%</div>
<p className="text-xs text-muted-foreground">
+5% from last month
</p>
<div className="mt-4 h-[80px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
width={500}
height={80}
data={sampleMonthlyData}
margin={{
top: 5,
right: 0,
left: 0,
bottom: 5,
}}
>
<Line
type="monotone"
dataKey="offers"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Active Candidates
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">124</div>
<p className="text-xs text-muted-foreground">
+18% from last month
</p>
<div className="mt-4 h-[80px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart width={80} height={80}>
<Pie
data={sampleVisaData}
cx="50%"
cy="50%"
innerRadius={15}
outerRadius={30}
dataKey="value"
>
{sampleVisaData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card className="col-span-1">
<CardHeader>
<CardTitle>Interview Distribution</CardTitle>
<CardDescription>
Companies with the most interviews
</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={500}
height={300}
data={sampleInterviewData}
margin={{
top: 20,
right: 30,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill="hsl(var(--primary))" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="col-span-1">
<CardHeader>
<CardTitle>Candidate Skills</CardTitle>
<CardDescription>
Top skills among candidates
</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={500}
height={300}
data={sampleSkillsData}
margin={{
top: 20,
right: 30,
left: 0,
bottom: 5,
}}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis type="category" dataKey="name" />
<Tooltip />
<Bar dataKey="candidates" fill="hsl(var(--primary))" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Detailed Reports</CardTitle>
<CardDescription>
View detailed information about interviews, screenings, and assessments
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-4">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search reports..."
className="max-w-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Tabs
defaultValue="interviews"
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="mb-4">
<TabsTrigger value="interviews">Interviews</TabsTrigger>
<TabsTrigger value="screenings">Screenings</TabsTrigger>
<TabsTrigger value="assessments">Assessments</TabsTrigger>
</TabsList>
<TabsContent value="interviews">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Candidate</TableHead>
<TableHead>Company</TableHead>
<TableHead>Status</TableHead>
<TableHead>Feedback</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData(interviewsData).map((interview) => (
<TableRow key={interview.id}>
<TableCell>
{formatDate(interview.date)}
</TableCell>
<TableCell>{interview.candidate}</TableCell>
<TableCell>{interview.company}</TableCell>
<TableCell>{interview.status}</TableCell>
<TableCell>{interview.feedback}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
<TabsContent value="screenings">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Candidate</TableHead>
<TableHead>Result</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData(screeningsData).map((screening) => (
<TableRow key={screening.id}>
<TableCell>
{formatDate(screening.date)}
</TableCell>
<TableCell>{screening.candidate}</TableCell>
<TableCell>{screening.result}</TableCell>
<TableCell>{screening.notes}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
<TabsContent value="assessments">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Candidate</TableHead>
<TableHead>Type</TableHead>
<TableHead>Score</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData(assessmentsData).map((assessment) => (
<TableRow key={assessment.id}>
<TableCell>
{formatDate(assessment.date)}
</TableCell>
<TableCell>{assessment.candidate}</TableCell>
<TableCell>{assessment.type}</TableCell>
<TableCell>{assessment.score}</TableCell>
<TableCell>{assessment.notes}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</MainLayout>
);
}

View File

@ -0,0 +1,27 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { AlertCircle } from "lucide-react";
export default function UnauthorizedPage() {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<div className="flex flex-col items-center justify-center max-w-md text-center space-y-4">
<div className="rounded-full bg-destructive/20 p-3">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<h1 className="text-2xl font-bold">Unauthorized Access</h1>
<p className="text-muted-foreground">
You don't have permission to access this page. Please contact an administrator if you believe this is an error.
</p>
<div className="flex flex-col sm:flex-row gap-2 mt-6">
<Button asChild>
<Link href="/">Return to Home</Link>
</Button>
<Button asChild variant="outline">
<Link href="/login">Sign In</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,402 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { ICandidate } from "@/models/Candidate";
// Define validation schema
const candidateSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email"),
phone: z.string().min(10, "Phone number must be at least 10 digits"),
visaStatus: z.string(),
location: z.string().min(2, "Location must be at least 2 characters"),
openToRelocate: z.boolean().default(true),
experience: z.string().min(1, "Experience is required"),
technology: z.string().min(2, "Technology must be at least 2 characters"),
currentStatus: z.string(),
status: z.string(),
trainerName: z.string().optional(),
dayRecruiter: z.string().optional(),
nightRecruiter: z.string().optional(),
fullAddress: z.string().optional(),
notes: z.string().optional(),
});
type CandidateFormValues = z.infer<typeof candidateSchema>;
interface CandidateFormProps {
initialData?: Partial<ICandidate>;
isEditing?: boolean;
}
export function CandidateForm({
initialData,
isEditing = false
}: CandidateFormProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
// Set default values based on initialData
const defaultValues: Partial<CandidateFormValues> = {
name: initialData?.name || "",
email: initialData?.email || "",
phone: initialData?.phone || "",
visaStatus: initialData?.visaStatus || "OPT",
location: initialData?.location || "",
openToRelocate: initialData?.openToRelocate || true,
experience: initialData?.experience || "",
technology: initialData?.technology || "",
currentStatus: initialData?.currentStatus || "In marketing",
status: initialData?.status || "Active",
trainerName: initialData?.trainerName || "",
dayRecruiter: initialData?.dayRecruiter || "",
nightRecruiter: initialData?.nightRecruiter || "",
fullAddress: initialData?.fullAddress || "",
notes: initialData?.notes || "",
};
const form = useForm<CandidateFormValues>({
resolver: zodResolver(candidateSchema),
defaultValues,
});
async function onSubmit(data: CandidateFormValues) {
setIsLoading(true);
try {
const url = isEditing
? `/api/candidates/${initialData?._id}`
: "/api/candidates";
const method = isEditing ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
toast.error(result.error || "Something went wrong");
return;
}
toast.success(
isEditing
? "Candidate updated successfully"
: "Candidate created successfully"
);
router.refresh();
router.push("/candidates");
} catch (error) {
console.error("Error saving candidate:", error);
toast.error("Something went wrong");
} finally {
setIsLoading(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name *</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email *</FormLabel>
<FormControl>
<Input type="email" placeholder="john.doe@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone *</FormLabel>
<FormControl>
<Input placeholder="(123) 456-7890" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="technology"
render={({ field }) => (
<FormItem>
<FormLabel>Technology *</FormLabel>
<FormControl>
<Input placeholder="React, Node.js, etc." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visaStatus"
render={({ field }) => (
<FormItem>
<FormLabel>Visa Status *</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visa status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="H1B">H1B</SelectItem>
<SelectItem value="Green Card">Green Card</SelectItem>
<SelectItem value="OPT">OPT</SelectItem>
<SelectItem value="CPT">CPT</SelectItem>
<SelectItem value="Citizen">Citizen</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="experience"
render={({ field }) => (
<FormItem>
<FormLabel>Experience *</FormLabel>
<FormControl>
<Input placeholder="2 years" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Current Location *</FormLabel>
<FormControl>
<Input placeholder="New York, NY" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="openToRelocate"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Open to Relocate</FormLabel>
<FormDescription>
Is this candidate willing to relocate for a job?
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentStatus"
render={({ field }) => (
<FormItem>
<FormLabel>Current Status *</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="In marketing">In marketing</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
<SelectItem value="Placed">Placed</SelectItem>
<SelectItem value="Inactive">Inactive</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status *</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
<SelectItem value="Inactive">Inactive</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="trainerName"
render={({ field }) => (
<FormItem>
<FormLabel>Trainer Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dayRecruiter"
render={({ field }) => (
<FormItem>
<FormLabel>Day Recruiter</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nightRecruiter"
render={({ field }) => (
<FormItem>
<FormLabel>Night Recruiter</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="fullAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Full Address</FormLabel>
<FormControl>
<Textarea
placeholder="Full address including zip code"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
placeholder="Any additional notes about this candidate"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => router.push("/candidates")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : isEditing ? "Update Candidate" : "Add Candidate"}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
interface DeleteCandidateButtonProps {
id: string;
}
export function DeleteCandidateButton({ id }: DeleteCandidateButtonProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
async function handleDelete() {
setIsLoading(true);
try {
const response = await fetch(`/api/candidates/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete candidate");
}
toast.success("Candidate deleted successfully");
router.refresh();
router.push("/candidates");
} catch (error) {
console.error("Error deleting candidate:", error);
toast.error((error as Error).message || "Something went wrong");
} finally {
setIsLoading(false);
setIsOpen(false);
}
}
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this candidate and all associated data.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import Link from "next/link";
import { UserButton } from "@/components/user/UserButton";
import MainNav from "./MainNav";
import { MobileNav } from "./MobileNav";
export function Header() {
return (
<header className="sticky top-0 z-40 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center">
<div className="flex items-center gap-2 mr-4">
<MobileNav />
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight">Candidate Portal</span>
</Link>
</div>
<MainNav />
<div className="flex flex-1 items-center justify-end">
<UserButton />
</div>
</div>
</header>
);
}
export default Header;

View File

@ -0,0 +1,20 @@
"use client";
import React, { ReactNode } from "react";
import Header from "./Header";
import Sidebar from "./Sidebar";
interface MainLayoutProps {
children: ReactNode;
}
export function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex min-h-screen bg-background">
<Sidebar />
<main className="flex-1 p-6 md:p-8">{children}</main>
</div>
);
}
export default MainLayout;

View File

@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface NavItem {
title: string;
href: string;
disabled?: boolean;
}
export function MainNav() {
const pathname = usePathname();
const items: NavItem[] = [
{
title: "Hotlist",
href: "/hotlist",
},
{
title: "Candidates",
href: "/candidates",
},
{
title: "Reports",
href: "/reports",
},
{
title: "Notifications",
href: "/notifications",
disabled: true,
},
];
return (
<nav className="flex items-center space-x-6">
{items.map((item) => (
<Link
key={item.href}
href={item.disabled ? "#" : item.href}
className={cn(
"text-sm font-medium transition-colors hover:text-primary",
pathname === item.href || pathname?.startsWith(`${item.href}/`)
? "text-foreground"
: "text-muted-foreground",
item.disabled && "pointer-events-none opacity-60"
)}
>
{item.title}
</Link>
))}
</nav>
);
}
export default MainNav;

View File

@ -0,0 +1,83 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react";
interface NavItem {
title: string;
href: string;
disabled?: boolean;
}
export function MobileNav() {
const pathname = usePathname();
const [open, setOpen] = React.useState(false);
const items: NavItem[] = [
{
title: "Hotlist",
href: "/hotlist",
},
{
title: "Candidates",
href: "/candidates",
},
{
title: "Reports",
href: "/reports",
},
{
title: "Notifications",
href: "/notifications",
disabled: true,
},
];
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="bg-background border-r border-border/40">
<div className="px-2 py-6">
<Link
href="/"
className="flex items-center"
onClick={() => setOpen(false)}
>
<span className="text-lg font-bold">Candidate Portal</span>
</Link>
</div>
<nav className="flex flex-col gap-3 px-2">
{items.map((item) => (
<Link
key={item.href}
href={item.disabled ? "#" : item.href}
onClick={() => setOpen(false)}
className={cn(
"flex w-full items-center rounded-md px-3 py-2 text-sm font-medium transition-colors",
pathname === item.href
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
item.disabled && "pointer-events-none opacity-60"
)}
>
{item.title}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
);
}
export default MobileNav;

View File

@ -0,0 +1,184 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { signOut, useSession } from "next-auth/react";
import {
LayoutDashboard,
Users,
PieChart,
LogOut,
Settings,
Menu,
X,
CircleUser,
Shield,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function Sidebar() {
const pathname = usePathname();
const { data: session } = useSession();
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const isAdmin = session?.user?.role === "admin";
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 768);
};
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => {
window.removeEventListener("resize", checkScreenSize);
};
}, []);
const toggleSidebar = () => {
setIsOpen(!isOpen);
};
const closeSidebar = () => {
if (isMobile) {
setIsOpen(false);
}
};
const navigationItems = [
{
name: "Dashboard",
href: "/",
icon: LayoutDashboard,
},
{
name: "Candidates",
href: "/candidates",
icon: Users,
},
{
name: "Reports",
href: "/reports",
icon: PieChart,
},
...(isAdmin
? [
{
name: "Admin",
href: "/admin",
icon: Shield,
},
]
: []),
];
const SidebarContent = () => (
<>
<div className="flex h-14 items-center border-b px-4">
<div className="flex items-center gap-2 font-semibold">
<CircleUser />
<span className="text-xl">Candidate Portal</span>
</div>
{isMobile && (
<Button
variant="ghost"
size="icon"
className="ml-auto"
onClick={toggleSidebar}
>
<X />
</Button>
)}
</div>
<div className="flex-1 overflow-auto py-2">
<nav className="grid items-start px-2 gap-1">
{navigationItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={closeSidebar}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all hover:bg-accent",
pathname === item.href
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
))}
</nav>
</div>
<div className="mt-auto border-t">
<div className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex flex-col flex-1 overflow-hidden">
<p className="text-sm font-medium leading-none truncate">
{session?.user?.name || "User"}
</p>
<p className="text-xs text-muted-foreground truncate">
{session?.user?.email || ""}
</p>
</div>
</div>
<Button
variant="outline"
className="flex items-center justify-start gap-2"
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</div>
</div>
</>
);
return (
<>
{/* Mobile Toggle Button */}
{isMobile && (
<Button
variant="outline"
size="icon"
className="fixed left-4 top-4 z-40 md:hidden"
onClick={toggleSidebar}
>
<Menu />
</Button>
)}
{/* Mobile Sidebar - Overlay */}
{isMobile && isOpen && (
<div
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
onClick={toggleSidebar}
/>
)}
{/* Sidebar for both mobile and desktop */}
<div
className={cn(
"fixed z-50 flex h-full w-64 flex-col border-r bg-background transition-transform duration-300 ease-in-out",
isMobile
? isOpen
? "translate-x-0"
: "-translate-x-full"
: "translate-x-0"
)}
>
<SidebarContent />
</div>
{/* Spacer for content when sidebar is visible on desktop */}
<div className={cn("hidden md:block w-64 flex-shrink-0")} />
</>
);
}

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 text-green-500 dark:border-green-500 [&>svg]:text-green-500",
info:
"border-blue-500/50 text-blue-500 dark:border-blue-500 [&>svg]:text-blue-500",
warning:
"border-yellow-500/50 text-yellow-500 dark:border-yellow-500 [&>svg]:text-yellow-500",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,59 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-border bg-transparent hover:bg-accent/10 hover:text-accent",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent/10 hover:text-accent",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-md border border-border/40 bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-medium leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

178
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

140
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

114
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-primary-foreground", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,103 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
User,
Settings,
HelpCircle,
LogOut,
LogIn
} from "lucide-react";
import { Button } from "../ui/button";
export function UserButton() {
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated";
const user = session?.user || {
name: "Guest",
email: null,
initials: "G"
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
if (!isAuthenticated) {
return (
<Button asChild variant="outline" size="sm">
<Link href="/login" className="flex items-center gap-2">
<LogIn className="h-4 w-4" />
<span>Sign In</span>
</Link>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center justify-center rounded-full h-8 w-8 transition-opacity hover:opacity-80">
<Avatar className="h-8 w-8 border border-border/50">
<AvatarFallback className="bg-background text-foreground text-sm">
{user.name ? getInitials(user.name) : "AU"}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile" className="cursor-pointer flex w-full items-center">
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings" className="cursor-pointer flex w-full items-center">
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/help" className="cursor-pointer flex w-full items-center">
<HelpCircle className="mr-2 h-4 w-4" />
<span>Help</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onSelect={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign Out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default UserButton;

42
src/lib/auth.ts Normal file
View File

@ -0,0 +1,42 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
export async function getSession() {
return await getServerSession(authOptions);
}
export async function getCurrentUser() {
const session = await getSession();
return session?.user;
}
export async function requireAuth() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
// Check if the user is active
if (!user.isActive) {
redirect("/login?error=Your+account+is+inactive");
}
return user;
}
export async function requireAdmin() {
const user = await requireAuth();
if (user.role !== "admin") {
redirect("/unauthorized?message=Admin+access+required");
}
return user;
}
export async function requireHR() {
const user = await requireAuth();
if (user.role !== "hr" && user.role !== "admin") {
redirect("/unauthorized?message=HR+access+required");
}
return user;
}

56
src/lib/db.ts Normal file
View File

@ -0,0 +1,56 @@
import mongoose from 'mongoose';
// MongoDB connection URI
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/class_box';
// Define an interface for the cached connection
interface ConnectionCache {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
// Create a "global" variable without TypeScript errors
// This is a safe way to create a global connection cache in Next.js
declare global {
// eslint-disable-next-line no-var
var mongooseConnection: ConnectionCache | undefined;
}
// Cache the connection to avoid reconnecting on every API call
const cached: ConnectionCache = global.mongooseConnection || { conn: null, promise: null };
// Make the cache available globally
if (process.env.NODE_ENV !== 'production') {
global.mongooseConnection = cached;
}
export async function connectToDatabase(): Promise<typeof mongoose> {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
serverSelectionTimeoutMS: 5000,
};
cached.promise = mongoose.connect(MONGODB_URI, opts)
.then((mongoose) => {
console.log('Connected to MongoDB');
return mongoose;
})
.catch((error) => {
console.error('Error connecting to MongoDB:', error);
throw error;
});
}
try {
cached.conn = await cached.promise;
return cached.conn;
} catch (error) {
cached.promise = null;
throw error;
}
}

View File

@ -0,0 +1,250 @@
import { connectToDatabase } from '@/lib/db';
import { Candidate, ICandidate } from '@/models/Candidate';
import mongoose from 'mongoose';
// Interface for candidate filter/query
export interface CandidateQuery {
status?: string;
technology?: string;
visaStatus?: string;
openToRelocate?: boolean;
currentStatus?: string;
search?: string;
page?: number;
limit?: number;
}
// Interface for pagination
export interface Pagination {
total: number;
page: number;
limit: number;
totalPages: number;
}
// Interface for candidate response
export interface CandidateResponse {
candidates: ICandidate[];
pagination: Pagination;
}
/**
* Get all candidates with pagination and filters
*/
export async function getCandidates(queryParams: CandidateQuery): Promise<CandidateResponse> {
await connectToDatabase();
const limit = queryParams.limit || 50;
const page = queryParams.page || 1;
const skip = (page - 1) * limit;
// Build query based on parameters
const query: Record<string, any> = {};
if (queryParams.status) query.status = queryParams.status;
if (queryParams.technology) query.technology = { $regex: queryParams.technology, $options: 'i' };
if (queryParams.visaStatus) query.visaStatus = queryParams.visaStatus;
if (queryParams.openToRelocate !== undefined) query.openToRelocate = queryParams.openToRelocate;
if (queryParams.currentStatus) query.currentStatus = queryParams.currentStatus;
// Add search query
if (queryParams.search) {
query.$or = [
{ name: { $regex: queryParams.search, $options: 'i' } },
{ email: { $regex: queryParams.search, $options: 'i' } },
{ technology: { $regex: queryParams.search, $options: 'i' } }
];
}
// Execute query with pagination
const candidates = await Candidate.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).select('name email phone technology status visaStatus location openToRelocate currentStatus marketingStartDate dayRecruiter nightRecruiter');
const total = await Candidate.countDocuments(query);
return {
candidates,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get a candidate by ID
*/
export async function getCandidateById(id: string): Promise<ICandidate> {
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
throw new Error('Invalid candidate ID');
}
const candidate = await Candidate.findById(id);
if (!candidate) {
throw new Error('Candidate not found');
}
return candidate;
}
/**
* Create a new candidate
*/
export async function createCandidate(candidateData: Partial<ICandidate>, userId: string): Promise<ICandidate> {
await connectToDatabase();
// Check if a candidate with this email already exists
const existingCandidate = await Candidate.findOne({ email: candidateData.email });
if (existingCandidate) {
throw new Error('A candidate with this email already exists');
}
// Create the candidate
const newCandidate = await Candidate.create({
...candidateData,
createdBy: userId
});
return newCandidate;
}
/**
* Update a candidate
*/
export async function updateCandidate(id: string, candidateData: Partial<ICandidate>): Promise<ICandidate> {
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
throw new Error('Invalid candidate ID');
}
// Remove any sensitive or non-updatable fields
const { createdAt, updatedAt, createdBy, _id, ...updateData } = candidateData as any;
const candidate = await Candidate.findByIdAndUpdate(
id,
{ $set: updateData },
{ new: true, runValidators: true }
);
if (!candidate) {
throw new Error('Candidate not found');
}
return candidate;
}
/**
* Delete a candidate
*/
export async function deleteCandidate(id: string): Promise<void> {
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
throw new Error('Invalid candidate ID');
}
const candidate = await Candidate.findByIdAndDelete(id);
if (!candidate) {
throw new Error('Candidate not found');
}
}
/**
* Add candidate to hotlist
*/
export async function addToHotlist(id: string): Promise<ICandidate> {
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
throw new Error('Invalid candidate ID');
}
const candidate = await Candidate.findByIdAndUpdate(
id,
{ $set: { hotlisted: true } },
{ new: true }
);
if (!candidate) {
throw new Error('Candidate not found');
}
return candidate;
}
/**
* Remove candidate from hotlist
*/
export async function removeFromHotlist(id: string): Promise<ICandidate> {
await connectToDatabase();
if (!mongoose.isValidObjectId(id)) {
throw new Error('Invalid candidate ID');
}
const candidate = await Candidate.findByIdAndUpdate(
id,
{ $set: { hotlisted: false } },
{ new: true }
);
if (!candidate) {
throw new Error('Candidate not found');
}
return candidate;
}
/**
* Get all hotlisted candidates
*/
export async function getHotlistCandidates(queryParams: CandidateQuery): Promise<CandidateResponse> {
await connectToDatabase();
const limit = queryParams.limit || 50;
const page = queryParams.page || 1;
const skip = (page - 1) * limit;
// Build query based on parameters
const query: Record<string, any> = { hotlisted: true };
// Add other filters
if (queryParams.status) query.status = queryParams.status;
if (queryParams.technology) query.technology = { $regex: queryParams.technology, $options: 'i' };
if (queryParams.visaStatus) query.visaStatus = queryParams.visaStatus;
if (queryParams.currentStatus) query.currentStatus = queryParams.currentStatus;
// Add search query
if (queryParams.search) {
query.$or = [
{ name: { $regex: queryParams.search, $options: 'i' } },
{ email: { $regex: queryParams.search, $options: 'i' } },
{ technology: { $regex: queryParams.search, $options: 'i' } }
];
}
// Execute query with pagination
const candidates = await Candidate
.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.select('name email phone technology status visaStatus location openToRelocate currentStatus marketingStartDate dayRecruiter nightRecruiter');
const total = await Candidate.countDocuments(query);
return {
candidates,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
}
};
}

View File

@ -0,0 +1,226 @@
import { connectToDatabase } from '@/lib/db';
import { Candidate } from '@/models/Candidate';
import { User } from '@/models/User';
import bcrypt from 'bcryptjs';
import mongoose from 'mongoose';
/**
* Seed demo users
*/
export async function seedUsers() {
await connectToDatabase();
const users = [
{
name: 'Admin User',
email: 'admin@example.com',
password: await bcrypt.hash('password123', 10),
role: 'ADMIN',
},
{
name: 'Regular User',
email: 'user@example.com',
password: await bcrypt.hash('password123', 10),
role: 'USER',
},
];
// Clear existing users first
await User.deleteMany({});
// Create new users
const createdUsers = await User.create(users);
return createdUsers;
}
/**
* Seed demo candidates
*/
export async function seedCandidates(userId: mongoose.Types.ObjectId) {
await connectToDatabase();
const technologies = [
'React', 'Angular', 'Vue', 'Node.js', 'Python', 'Java', 'Spring',
'Django', 'Ruby on Rails', 'Flutter', 'React Native', 'iOS', 'Android',
'.NET', 'PHP', 'Laravel', 'Go', 'Rust', 'C#', 'C++', 'TypeScript'
];
const visaStatuses = ['H1B', 'Green Card', 'OPT', 'CPT', 'Citizen', 'Other'];
const currentStatuses = ['In marketing', 'Hold', 'Placed', 'Inactive'];
const statuses = ['Active', 'Hold', 'Inactive'];
const locations = [
'New York, NY', 'San Francisco, CA', 'Austin, TX', 'Chicago, IL',
'Seattle, WA', 'Boston, MA', 'Los Angeles, CA', 'Denver, CO',
'Atlanta, GA', 'Washington, DC', 'Portland, OR', 'Dallas, TX'
];
const universities = [
'Stanford University', 'MIT', 'University of California', 'Georgia Tech',
'University of Texas', 'Cornell University', 'NYU', 'Purdue University',
'Columbia University', 'University of Michigan', 'University of Illinois'
];
const companies = [
'Google', 'Microsoft', 'Amazon', 'Facebook', 'Apple', 'Netflix', 'Uber',
'Airbnb', 'Salesforce', 'Oracle', 'IBM', 'Intel', 'Cisco', 'Twitter'
];
const recruiters = [
'Jane Smith', 'Mike Johnson', 'Sarah Williams', 'Bob Williams',
'Alice Brown', 'David Wilson', 'Emily Davis', 'Alex Martinez'
];
const candidates = [];
// Generate 50 random candidates
for (let i = 0; i < 50; i++) {
const firstName = `FirstName${i}`;
const lastName = `LastName${i}`;
const name = `${firstName} ${lastName}`;
const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`;
// Random data
const technology = sample(technologies) + ', ' + sample(technologies);
const visaStatus = sample(visaStatuses);
const location = sample(locations);
const openToRelocate = Math.random() > 0.3; // 70% chance of being open to relocate
const experience = `${Math.floor(Math.random() * 10) + 1} years`;
const currentStatus = sample(currentStatuses);
const status = sample(statuses);
const bsYear = String(2010 + Math.floor(Math.random() * 12));
const msYear = String(parseInt(bsYear, 10) + 2);
// Generate education
const education = [
{
degree: 'BS',
field: 'Computer Science',
university: sample(universities),
yearCompleted: bsYear
}
];
// 70% chance of having a Masters degree
if (Math.random() > 0.3) {
education.push({
degree: 'MS',
field: 'Computer Science',
university: sample(universities),
yearCompleted: msYear
});
}
// Generate skills
const skills = [];
const skillCount = Math.floor(Math.random() * 6) + 3; // 3 to 8 skills
for (let j = 0; j < skillCount; j++) {
const skill = sample(technologies);
if (!skills.includes(skill)) {
skills.push(skill);
}
}
// Generate interviews
const interviews = [];
const interviewCount = Math.floor(Math.random() * 3); // 0 to 2 interviews
for (let j = 0; j < interviewCount; j++) {
const company = sample(companies);
const position = `${sample(['Senior', 'Junior', 'Lead', ''])} ${sample(['Frontend', 'Backend', 'Full Stack'])} Developer`;
const date = new Date();
date.setDate(date.getDate() + Math.floor(Math.random() * 14)); // Interview in the next 14 days
const status = sample(['Scheduled', 'Completed', 'Cancelled', 'No Show']);
const feedback = status === 'Completed' ? sample(['Positive', 'Negative', 'Mixed', 'Need follow-up']) : '';
interviews.push({
company,
position,
date,
status,
feedback
});
}
// Generate assessments
const assessments = [];
const assessmentCount = Math.floor(Math.random() * 3); // 0 to 2 assessments
for (let j = 0; j < assessmentCount; j++) {
const type = sample(['Technical', 'Communication', 'Mock Interview']);
const score = `${Math.floor(Math.random() * 40) + 60}/100`; // Score between 60 and 100
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 30)); // Assessment in the last 30 days
assessments.push({
type,
score,
date,
notes: `${type} assessment completed with ${score} score.`
});
}
// Marketing start date (between 1 and 180 days ago)
const marketingStartDate = new Date();
marketingStartDate.setDate(marketingStartDate.getDate() - Math.floor(Math.random() * 180) + 1);
// Create candidate object
candidates.push({
name,
email,
phone: `${Math.floor(Math.random() * 900) + 100}-${Math.floor(Math.random() * 900) + 100}-${Math.floor(Math.random() * 9000) + 1000}`,
visaStatus,
location,
openToRelocate,
experience,
technology,
bsYear,
msYear,
education,
skills,
currentStatus,
status,
marketingStartDate,
dayRecruiter: sample(recruiters),
nightRecruiter: sample(recruiters),
trainerName: sample(recruiters),
fullAddress: `${Math.floor(Math.random() * 9000) + 1000} ${sample(['Main', 'Oak', 'Maple', 'Pine', 'Cedar'])} St, ${location}`,
resumeUrl: '',
hotlisted: Math.random() > 0.7, // 30% chance of being hotlisted
interviewing: interviews,
assessment: assessments,
notes: `Candidate has ${experience} of experience in ${technology}.`,
createdBy: userId
});
}
// Clear existing candidates first
await Candidate.deleteMany({});
// Create new candidates
const createdCandidates = await Candidate.create(candidates);
return createdCandidates;
}
/**
* Helper function to get a random item from an array
*/
function sample<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
/**
* Purge all data
*/
export async function purgeData() {
await connectToDatabase();
await Candidate.deleteMany({});
await User.deleteMany({});
return { success: true };
}

22
src/lib/utils.ts Normal file
View File

@ -0,0 +1,22 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
if (isNaN(d.getTime())) {
return "";
}
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}

190
src/models/Candidate.ts Normal file
View File

@ -0,0 +1,190 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface ICandidate extends Document {
name: string;
email: string;
phone: string;
visaStatus: string;
location: string;
openToRelocate: boolean;
experience: string;
technology: string;
bsYear: string;
msYear: string;
education: {
degree: string;
field: string;
university: string;
yearCompleted: string;
}[];
skills: string[];
currentStatus: string;
status: string;
marketingStartDate: Date;
dayRecruiter: string;
nightRecruiter: string;
trainerName: string;
fullAddress: string;
resumeUrl: string;
hotlisted: boolean; // Added hotlisted field
interviewing: {
company: string;
position: string;
date: Date;
status: string;
feedback: string;
}[];
assessment: {
type: string;
score: string;
date: Date;
notes: string;
}[];
notes: string;
createdAt: Date;
updatedAt: Date;
createdBy: Schema.Types.ObjectId;
}
const CandidateSchema = new Schema<ICandidate>(
{
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
},
phone: {
type: String,
trim: true,
},
visaStatus: {
type: String,
enum: ['H1B', 'Green Card', 'OPT', 'CPT', 'Citizen', 'Other'],
default: 'OPT',
},
location: {
type: String,
trim: true,
},
openToRelocate: {
type: Boolean,
default: true,
},
experience: {
type: String,
trim: true,
},
technology: {
type: String,
trim: true,
},
bsYear: {
type: String,
trim: true,
},
msYear: {
type: String,
trim: true,
},
education: [
{
degree: String,
field: String,
university: String,
yearCompleted: String,
},
],
skills: [String],
currentStatus: {
type: String,
enum: ['In marketing', 'Hold', 'Placed', 'Inactive'],
default: 'In marketing',
},
status: {
type: String,
enum: ['Active', 'Hold', 'Inactive'],
default: 'Active',
},
marketingStartDate: {
type: Date,
default: Date.now,
},
dayRecruiter: {
type: String,
trim: true,
},
nightRecruiter: {
type: String,
trim: true,
},
trainerName: {
type: String,
trim: true,
},
fullAddress: {
type: String,
trim: true,
},
resumeUrl: {
type: String,
trim: true,
},
hotlisted: { // Added hotlisted field
type: Boolean,
default: false,
},
interviewing: [
{
company: String,
position: String,
date: Date,
status: {
type: String,
enum: ['Scheduled', 'Completed', 'Cancelled', 'No Show'],
default: 'Scheduled',
},
feedback: String,
},
],
assessment: [
{
type: {
type: String,
enum: ['Technical', 'Communication', 'Mock Interview'],
},
score: String,
date: Date,
notes: String,
},
],
notes: {
type: String,
trim: true,
},
createdBy: {
type: Schema.Types.ObjectId,
ref: 'User',
},
},
{
timestamps: true,
}
);
// Add indexes for better performance
CandidateSchema.index({ name: 1 });
CandidateSchema.index({ email: 1 }, { unique: true });
CandidateSchema.index({ technology: 1 });
CandidateSchema.index({ visaStatus: 1 });
CandidateSchema.index({ currentStatus: 1 });
CandidateSchema.index({ marketingStartDate: -1 });
CandidateSchema.index({ hotlisted: 1 }); // Add index for hotlisted field
export const Candidate = mongoose.models.Candidate || mongoose.model<ICandidate>('Candidate', CandidateSchema, 'candidate');

60
src/models/User.ts Normal file
View File

@ -0,0 +1,60 @@
import mongoose, { Document, Schema } from 'mongoose';
import { hash } from 'bcryptjs';
export interface IUser extends Document {
name: string;
email: string;
password: string;
role: 'admin' | 'hr' | 'user';
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
const UserSchema = new Schema<IUser>({
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters long'],
},
role: {
type: String,
enum: ['admin', 'hr', 'user'],
default: 'hr',
},
isActive: {
type: Boolean,
default: true,
}
}, {
timestamps: true,
});
// Hash password before saving
UserSchema.pre('save', async function(next) {
if (!this.isModified('password')) {
return next();
}
try {
const hashedPassword = await hash(this.password, 12);
this.password = hashedPassword;
next();
} catch (error) {
next(error as Error);
}
});
export const User = mongoose.models.User || mongoose.model<IUser>('User', UserSchema);

View File

@ -0,0 +1,8 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { ReactNode } from "react";
export function AuthProvider({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

27
src/types/next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
interface User {
id: string;
role: string;
name?: string | null;
email?: string | null;
}
}
declare global {
namespace NodeJS {
interface ProcessEnv {
NEXTAUTH_SECRET: string;
MONGODB_URI: string;
NEXTAUTH_URL: string;
}
}
}

76
tailwind.config.js Normal file
View File

@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

79
tailwind.config.ts Normal file
View File

@ -0,0 +1,79 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
container: {
center: true,
padding: {
DEFAULT: '1rem',
sm: '2rem',
lg: '4rem',
xl: '5rem',
'2xl': '6rem',
},
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
},
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;

24
test-db.js Normal file
View File

@ -0,0 +1,24 @@
// Test database connection
require('dotenv').config({ path: '.env.local' });
const mongoose = require('mongoose');
async function testDbConnection() {
try {
const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/candidate_filter_portal';
console.log('Connecting to:', mongoUri);
await mongoose.connect(mongoUri, {
serverSelectionTimeoutMS: 5000
});
console.log('MongoDB connected successfully!');
// Close the connection
await mongoose.disconnect();
console.log('MongoDB disconnected');
} catch (error) {
console.error('MongoDB connection error:', error);
}
}
testDbConnection();

36
test-login-flow.js Normal file
View File

@ -0,0 +1,36 @@
// Test login flow script
const credentials = {
admin: {
email: "admin@example.com",
password: "password123"
},
hr: {
email: "hr@example.com",
password: "password123"
}
};
// Simulate login with demo users
function testLoginFlow() {
console.log("Testing login flow with demo credentials");
// Admin user test
console.log("\nTesting admin login:");
console.log("Email:", credentials.admin.email);
console.log("Password:", credentials.admin.password);
console.log("Expected result: Success - Should authenticate and return admin role");
// HR user test
console.log("\nTesting HR login:");
console.log("Email:", credentials.hr.email);
console.log("Password:", credentials.hr.password);
console.log("Expected result: Success - Should authenticate and return hr role");
console.log("\nTo test this login:");
console.log("1. Go to the login page");
console.log("2. Enter the credentials above");
console.log("3. Click 'Sign In'");
console.log("4. You should be redirected to the dashboard");
}
testLoginFlow();

17
test-login.js Normal file
View File

@ -0,0 +1,17 @@
// Test login script
const bcrypt = require('bcryptjs');
// Test password hashing to verify what's stored in the demo users
async function testPassword() {
// Hash password
const password = 'password123';
const hashedPassword = await bcrypt.hash(password, 12);
console.log('Hashed password:', hashedPassword);
// Compare with stored hash
const storedHash = '$2b$12$CCIPxtBFUjfJqz8sMvJdj.9MQLE6ghAoIl/amO9uNaRc6VXcWUDiW';
const isMatch = await bcrypt.compare(password, storedHash);
console.log('Password matches:', isMatch);
}
testPassword();

40
tsconfig.json Normal file
View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}