Integrate complete Candidat platform with ASP.NET chatbot service
- Added Next.js frontend for candidate interviews - Added Node.js backend with TypeScript and AI integration - Added ASP.NET Core chatbot service for specialized AI conversations - Added MySQL database with complete schema - Added Nginx reverse proxy configuration - Complete Docker Compose orchestration for all services - Environment configuration for production, development, and Cloudflare - Comprehensive documentation and setup instructions - Flattened nested folder structures for clean organization - Integrated chatbot service with fallback to direct AI calls
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import axios from 'axios';
|
||||
|
||||
// Form validation schema
|
||||
const createJobSchema = z.object({
|
||||
title: z.string().min(1, 'Job title is required').max(255, 'Title too long'),
|
||||
description: z.string().min(1, 'Job description is required'),
|
||||
requirements: z.string().min(1, 'Job requirements are required'),
|
||||
skills_required: z.array(z.string()).min(1, 'At least one skill is required'),
|
||||
location: z.string().optional(),
|
||||
employment_type: z.enum(['full_time', 'part_time', 'contract', 'internship']).default('full_time'),
|
||||
experience_level: z.enum(['entry', 'mid', 'senior', 'lead', 'executive']).default('mid'),
|
||||
salary_min: z.number().min(0).optional(),
|
||||
salary_max: z.number().min(0).optional(),
|
||||
currency: z.string().length(3).default('USD'),
|
||||
application_deadline: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
|
||||
type CreateJobFormData = z.infer<typeof createJobSchema>;
|
||||
|
||||
interface CreateJobModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (jobData: CreateJobFormData) => void;
|
||||
}
|
||||
|
||||
export default function CreateJobModal({ isOpen, onClose, onSubmit }: CreateJobModalProps) {
|
||||
const [formData, setFormData] = useState<CreateJobFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
requirements: '',
|
||||
skills_required: [],
|
||||
location: '',
|
||||
employment_type: 'full_time',
|
||||
experience_level: 'mid',
|
||||
salary_min: undefined,
|
||||
salary_max: undefined,
|
||||
currency: 'USD',
|
||||
application_deadline: '',
|
||||
icon: 'briefcase',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [skillInput, setSkillInput] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [shakeAnimation, setShakeAnimation] = useState(false);
|
||||
const totalSteps = 4;
|
||||
|
||||
// Available job icons
|
||||
const jobIcons = [
|
||||
{ id: 'briefcase', name: 'Briefcase', emoji: '💼' },
|
||||
{ id: 'code', name: 'Code', emoji: '💻' },
|
||||
{ id: 'chart', name: 'Analytics', emoji: '📊' },
|
||||
{ id: 'design', name: 'Design', emoji: '🎨' },
|
||||
{ id: 'marketing', name: 'Marketing', emoji: '📈' },
|
||||
{ id: 'sales', name: 'Sales', emoji: '💼' },
|
||||
{ id: 'support', name: 'Support', emoji: '🎧' },
|
||||
{ id: 'engineering', name: 'Engineering', emoji: '⚙️' },
|
||||
{ id: 'data', name: 'Data', emoji: '📊' },
|
||||
{ id: 'security', name: 'Security', emoji: '🔒' },
|
||||
{ id: 'mobile', name: 'Mobile', emoji: '📱' },
|
||||
{ id: 'cloud', name: 'Cloud', emoji: '☁️' },
|
||||
];
|
||||
|
||||
const handleInputChange = (field: keyof CreateJobFormData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
// Clear shake animation when user starts typing
|
||||
if (shakeAnimation) {
|
||||
setShakeAnimation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSkill = () => {
|
||||
if (skillInput.trim() && !formData.skills_required.includes(skillInput.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
skills_required: [...prev.skills_required, skillInput.trim()]
|
||||
}));
|
||||
setSkillInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSkill = (skillToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
skills_required: prev.skills_required.filter(skill => skill !== skillToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const validateCurrentStep = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (currentStep === 1) {
|
||||
// Validate Step 1: Basic Information
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Job title is required';
|
||||
}
|
||||
// Other fields in step 1 are optional
|
||||
} else if (currentStep === 2) {
|
||||
// Validate Step 2: Job Details
|
||||
if (formData.skills_required.length === 0) {
|
||||
newErrors.skills_required = 'At least one skill is required';
|
||||
}
|
||||
if (!formData.description.trim()) {
|
||||
newErrors.description = 'Job description is required';
|
||||
}
|
||||
if (!formData.requirements.trim()) {
|
||||
newErrors.requirements = 'Job requirements are required';
|
||||
}
|
||||
} else if (currentStep === 3) {
|
||||
// Validate Step 3: Icon Selection
|
||||
if (!formData.icon) {
|
||||
newErrors.icon = 'Please select an icon for this job';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (validateCurrentStep() && currentStep < totalSteps) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
// Trigger shake animation when validation fails
|
||||
setShakeAnimation(true);
|
||||
setTimeout(() => setShakeAnimation(false), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log('Form submitted, current step:', currentStep, 'total steps:', totalSteps);
|
||||
|
||||
// Only create job if we're on the final step (review)
|
||||
if (currentStep === totalSteps) {
|
||||
console.log('Creating job on final step');
|
||||
await createJob();
|
||||
} else {
|
||||
console.log('Not on final step, just validating');
|
||||
// Just validate and move to next step
|
||||
if (validateCurrentStep()) {
|
||||
nextStep();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createJob = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
console.log('Creating job with form data:', formData);
|
||||
const validatedData = createJobSchema.parse(formData);
|
||||
console.log('Validated data:', validatedData);
|
||||
|
||||
// Call the API to create the job
|
||||
const token = localStorage.getItem("token");
|
||||
console.log('Sending request to backend...');
|
||||
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs`, validatedData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Job created successfully:', response.data);
|
||||
|
||||
// Call the parent onSubmit callback
|
||||
onSubmit(validatedData);
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
requirements: '',
|
||||
skills_required: [],
|
||||
location: '',
|
||||
employment_type: 'full_time',
|
||||
experience_level: 'mid',
|
||||
salary_min: undefined,
|
||||
salary_max: undefined,
|
||||
currency: 'USD',
|
||||
application_deadline: '',
|
||||
});
|
||||
setErrors({});
|
||||
setCurrentStep(1);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
error.issues.forEach((err) => {
|
||||
if (err.path[0]) {
|
||||
fieldErrors[err.path[0] as string] = err.message;
|
||||
}
|
||||
});
|
||||
setErrors(fieldErrors);
|
||||
} else {
|
||||
console.error('Error creating job:', error);
|
||||
|
||||
// Handle API errors
|
||||
if ((error as any).response) {
|
||||
// Server responded with error status
|
||||
console.error('API Error Response:', (error as any).response.data);
|
||||
console.error('API Error Status:', (error as any).response.status);
|
||||
console.error('API Error Headers:', (error as any).response.headers);
|
||||
|
||||
// Show user-friendly error message
|
||||
alert(`Failed to create job: ${(error as any).response.data?.message || (error as any).response.statusText || 'Unknown error'}`);
|
||||
} else if ((error as any).request) {
|
||||
// Request was made but no response received
|
||||
console.error('API Error Request:', (error as any).request);
|
||||
alert('Failed to create job: No response from server. Please check if the backend is running.');
|
||||
} else {
|
||||
// Something else happened
|
||||
console.error('API Error:', (error as any).message);
|
||||
alert(`Failed to create job: ${(error as any).message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[95vh] overflow-hidden">
|
||||
{/* Header with Progress */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 px-8 py-6 text-white">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Create New Job</h2>
|
||||
<p className="text-blue-100 mt-1">Step {currentStep} of {totalSteps}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-blue-200 hover:text-white transition-colors p-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-blue-500 bg-opacity-30 rounded-full h-2">
|
||||
<div
|
||||
className="bg-white h-2 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicators */}
|
||||
<div className="flex justify-between mt-4">
|
||||
{[1, 2, 3, 4].map((step) => {
|
||||
const hasErrors = (step === 1 && errors.title) ||
|
||||
(step === 2 && (errors.skills_required || errors.description || errors.requirements)) ||
|
||||
(step === 3 && errors.icon);
|
||||
|
||||
return (
|
||||
//step 3 shall be called "Icon" and step 4 shall be called "Review"
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-200 ${
|
||||
step <= currentStep
|
||||
? hasErrors
|
||||
? 'bg-red-500 text-white animate-pulse'
|
||||
: 'bg-white text-blue-600'
|
||||
: 'bg-blue-500 bg-opacity-30 text-blue-200'
|
||||
}`}>
|
||||
{hasErrors ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
step
|
||||
)}
|
||||
</div>
|
||||
<span className={`ml-2 text-sm transition-colors ${
|
||||
hasErrors ? 'text-red-200' : 'text-blue-100'
|
||||
}`}>
|
||||
{step === 1 ? 'Basic Info' : step === 2 ? 'Details' : step === 3 ? 'Icon' : 'Review'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className={`p-8 overflow-y-auto max-h-[calc(95vh-200px)] transition-transform duration-500 ${
|
||||
shakeAnimation ? 'animate-pulse' : ''
|
||||
}`}>
|
||||
{/* Validation Error Summary */}
|
||||
{shakeAnimation && Object.keys(errors).length > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg className="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-red-800 dark:text-red-200">Please complete the required fields:</h4>
|
||||
<ul className="mt-2 text-sm text-red-700 dark:text-red-300 space-y-1">
|
||||
{errors.title && <li>• Job title is required</li>}
|
||||
{errors.skills_required && <li>• At least one skill is required</li>}
|
||||
{errors.description && <li>• Job description is required</li>}
|
||||
{errors.requirements && <li>• Job requirements are required</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} id="job-form">
|
||||
{/* Step 1: Basic Information */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Basic Information</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Let's start with the essential details about this position</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Job Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
||||
errors.title ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="e.g. Senior Frontend Developer"
|
||||
/>
|
||||
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Employment Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.employment_type}
|
||||
onChange={(e) => handleInputChange('employment_type', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="full_time">Full Time</option>
|
||||
<option value="part_time">Part Time</option>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="internship">Internship</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Experience Level
|
||||
</label>
|
||||
<select
|
||||
value={formData.experience_level}
|
||||
onChange={(e) => handleInputChange('experience_level', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="entry">Entry Level</option>
|
||||
<option value="mid">Mid Level</option>
|
||||
<option value="senior">Senior Level</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="executive">Executive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.location}
|
||||
onChange={(e) => handleInputChange('location', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder="e.g. New York, NY or Remote"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Application Deadline
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.application_deadline}
|
||||
onChange={(e) => handleInputChange('application_deadline', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Job Details */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Job Details</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Add comprehensive details about the role and compensation</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Salary Information */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Compensation</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Min Salary
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.salary_min || ''}
|
||||
onChange={(e) => handleInputChange('salary_min', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||
placeholder="50000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Max Salary
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.salary_max || ''}
|
||||
onChange={(e) => handleInputChange('salary_max', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||
placeholder="80000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => handleInputChange('currency', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills Required */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Required Skills *
|
||||
</label>
|
||||
<div className={`flex gap-2 mb-3 p-3 rounded-lg transition-colors ${
|
||||
errors.skills_required ? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800' : ''
|
||||
}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={skillInput}
|
||||
onChange={(e) => setSkillInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddSkill())}
|
||||
className={`flex-1 px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
||||
errors.skills_required ? 'border-red-500 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Add a skill and press Enter"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSkill}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{errors.skills_required && (
|
||||
<div className="flex items-center space-x-2 text-red-500 text-sm mb-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{errors.skills_required}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.skills_required.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||
>
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSkill(skill)}
|
||||
className="ml-2 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Job Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={5}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
||||
errors.description ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Describe the role, responsibilities, and what makes this opportunity exciting..."
|
||||
/>
|
||||
{errors.description && (
|
||||
<div className="flex items-center space-x-2 text-red-500 text-sm mt-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{errors.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job Requirements */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Job Requirements *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.requirements}
|
||||
onChange={(e) => handleInputChange('requirements', e.target.value)}
|
||||
rows={5}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white transition-colors ${
|
||||
errors.requirements ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="List the specific requirements, qualifications, and experience needed..."
|
||||
/>
|
||||
{errors.requirements && (
|
||||
<div className="flex items-center space-x-2 text-red-500 text-sm mt-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{errors.requirements}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Icon Selection */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Choose an Icon</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Select an icon that best represents this job position</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{jobIcons.map((icon) => (
|
||||
<button
|
||||
key={icon.id}
|
||||
type="button"
|
||||
onClick={() => handleInputChange('icon', icon.id)}
|
||||
className={`p-4 rounded-lg border-2 transition-all duration-200 hover:scale-105 ${
|
||||
formData.icon === icon.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-200 dark:ring-blue-800'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
} ${errors.icon ? 'border-red-500 bg-red-50 dark:bg-red-900/20 ring-2 ring-red-200 dark:ring-red-800' : ''}`}
|
||||
>
|
||||
<div className="text-3xl mb-2">{icon.emoji}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 text-center">{icon.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errors.icon && (
|
||||
<div className="text-red-500 text-sm text-center">{errors.icon}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Review */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Review & Create</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Review your job posting before publishing</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg space-y-4">
|
||||
{/* Job Preview Card */}
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-4xl">
|
||||
{jobIcons.find(icon => icon.id === formData.icon)?.emoji || '💼'}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{formData.title}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-1 line-clamp-2">
|
||||
{formData.description?.substring(0, 100)}...
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded">
|
||||
{formData.employment_type?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs rounded">
|
||||
{formData.experience_level?.replace('_', ' ')}
|
||||
</span>
|
||||
{formData.location && (
|
||||
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 text-xs rounded">
|
||||
{formData.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Job Title</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{formData.title || 'Not specified'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Employment Type</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400 capitalize">{formData.employment_type?.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Experience Level</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400 capitalize">{formData.experience_level?.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Location</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{formData.location || 'Not specified'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Salary Range</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{formData.salary_min && formData.salary_max
|
||||
? `${formData.currency} ${formData.salary_min.toLocaleString()} - ${formData.salary_max.toLocaleString()}`
|
||||
: formData.salary_min
|
||||
? `${formData.currency} ${formData.salary_min.toLocaleString()}+`
|
||||
: 'Not specified'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Application Deadline</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{formData.application_deadline
|
||||
? new Date(formData.application_deadline).toLocaleDateString()
|
||||
: 'No deadline set'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Required Skills</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.skills_required.length > 0 ? (
|
||||
formData.skills_required.map((skill, index) => (
|
||||
<span key={index} className="px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm">
|
||||
{skill}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400">No skills specified</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Description</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{formData.description || 'No description provided'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Requirements</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{formData.requirements || 'No requirements specified'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
className="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{currentStep < totalSteps ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (validateCurrentStep()) {
|
||||
nextStep();
|
||||
} else {
|
||||
setShakeAnimation(true);
|
||||
setTimeout(() => setShakeAnimation(false), 500);
|
||||
}
|
||||
}}
|
||||
className={`px-6 py-3 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 ${
|
||||
shakeAnimation
|
||||
? 'bg-red-600 hover:bg-red-700 ring-2 ring-red-300 animate-pulse'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{shakeAnimation ? 'Please fill required fields' : 'Next'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Creating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Create Job</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user