Initial commit
This commit is contained in:
321
src/pages/CreatePosition.jsx
Normal file
321
src/pages/CreatePosition.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react';
|
||||
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const ROLE_CATEGORIES = ['Server', 'Bartender', 'Chef', 'Security', 'Picker', 'Event Staff', 'Other'];
|
||||
const CERT_OPTIONS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
|
||||
const ENGLISH_LEVELS = [
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
{ value: 'conversational', label: 'Conversational' },
|
||||
{ value: 'fluent', label: 'Fluent' },
|
||||
{ value: 'native', label: 'Native' },
|
||||
];
|
||||
|
||||
const CRITERIA_LABELS = {
|
||||
experience: 'Experience',
|
||||
english: 'English',
|
||||
reliability: 'Reliability',
|
||||
certifications: 'Certifications',
|
||||
availability: 'Availability',
|
||||
};
|
||||
|
||||
export default function CreatePosition() {
|
||||
const navigate = useNavigate();
|
||||
const createJob = useCreateJobPosting();
|
||||
const updateJob = useUpdateJobPosting();
|
||||
const generateDesc = useGenerateJobDescription();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
role_category: 'Server',
|
||||
custom_requirements: '',
|
||||
physical_requirements: '',
|
||||
leadership_expectations: '',
|
||||
attendance_expectations: '',
|
||||
min_experience_years: 0,
|
||||
english_required: 'basic',
|
||||
location: '',
|
||||
pay_range_min: '',
|
||||
pay_range_max: '',
|
||||
certifications_required: [],
|
||||
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
|
||||
});
|
||||
const { data: customCategories = [] } = useRoleCategories();
|
||||
const createRoleCategory = useCreateRoleCategory();
|
||||
const [newCategory, setNewCategory] = useState('');
|
||||
const [showWeights, setShowWeights] = useState(false);
|
||||
|
||||
const allCategories = [...new Set([...ROLE_CATEGORIES, ...customCategories.map(c => c.name)])];
|
||||
const [draftId, setDraftId] = useState(null);
|
||||
const [aiResult, setAiResult] = useState(null);
|
||||
|
||||
const update = (field, value) => setForm({ ...form, [field]: value });
|
||||
|
||||
const toggleCert = (cert) => {
|
||||
update('certifications_required', form.certifications_required.includes(cert)
|
||||
? form.certifications_required.filter(c => c !== cert)
|
||||
: [...form.certifications_required, cert]);
|
||||
};
|
||||
|
||||
const updateWeight = (key, value) => {
|
||||
update('vetting_criteria', { ...form.vetting_criteria, [key]: parseInt(value) || 0 });
|
||||
};
|
||||
|
||||
const totalWeight = Object.values(form.vetting_criteria).reduce((a, b) => a + b, 0);
|
||||
|
||||
const handleGenerateDescription = async () => {
|
||||
if (!form.title) {
|
||||
toast.error('Please enter a job title first');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Create draft if not exists
|
||||
let id = draftId;
|
||||
if (!id) {
|
||||
const draft = await createJob.mutateAsync({ ...form, status: 'draft', pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 });
|
||||
id = draft.id;
|
||||
setDraftId(id);
|
||||
}
|
||||
const result = await generateDesc.mutateAsync({ data: { ...form, pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 }, draftId: id });
|
||||
setAiResult(result);
|
||||
toast.success('Job description generated!');
|
||||
} catch (e) {
|
||||
toast.error('AI generation failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (status) => {
|
||||
if (!form.title) {
|
||||
toast.error('Job title is required');
|
||||
return;
|
||||
}
|
||||
if (totalWeight !== 100) {
|
||||
toast.error('Vetting weights must total 100%');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
status,
|
||||
pay_range_min: Number(form.pay_range_min) || 0,
|
||||
pay_range_max: Number(form.pay_range_max) || 0,
|
||||
...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}),
|
||||
};
|
||||
let createdId = draftId;
|
||||
if (draftId) {
|
||||
await updateJob.mutateAsync({ id: draftId, data: payload });
|
||||
} else {
|
||||
const created = await createJob.mutateAsync(payload);
|
||||
createdId = created?.id;
|
||||
}
|
||||
toast.success(status === 'active' ? 'Job posting published!' : 'Saved as draft');
|
||||
if (status === 'active' && createdId) {
|
||||
navigate(`/positions/${createdId}`);
|
||||
} else {
|
||||
navigate('/positions');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to save');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-bold text-[#111827]">Create a Position</h1>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">Set hiring criteria once — KROW interviews and ranks every applicant automatically.</p>
|
||||
</div>
|
||||
<button onClick={() => navigate('/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
|
||||
</div>
|
||||
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
|
||||
</div>
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Role Category *</label>
|
||||
<select value={form.role_category} onChange={(e) => update('role_category', e.target.value)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
|
||||
{allCategories.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Job Title *</label>
|
||||
<input value={form.title} onChange={(e) => update('title', e.target.value)} placeholder="e.g. Experienced Server" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add new category */}
|
||||
<div className="flex gap-2">
|
||||
<input value={newCategory} onChange={(e) => setNewCategory(e.target.value)} placeholder="Create a new role category (saved for reuse)..." className="flex-1 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (newCategory.trim()) {
|
||||
const name = newCategory.trim();
|
||||
await createRoleCategory.mutateAsync({ name });
|
||||
update('role_category', name);
|
||||
setNewCategory('');
|
||||
toast.success('Role category added');
|
||||
}
|
||||
}}
|
||||
disabled={createRoleCategory.isPending || !newCategory.trim()}
|
||||
className="px-4 bg-[#0A39DF] text-white text-[13px] font-medium rounded-lg hover:bg-[#082EB4] disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Custom Requirements (AI will use this)</label>
|
||||
<textarea value={form.custom_requirements} onChange={(e) => update('custom_requirements', e.target.value)} placeholder="e.g. Must have fine dining experience..." className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" />
|
||||
</div>
|
||||
|
||||
{/* Physical / Leadership / Attendance Expectations */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Physical Requirements</label>
|
||||
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Lift 50 lbs, stand 8 hrs" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Leadership Expectations</label>
|
||||
<input value={form.leadership_expectations} onChange={(e) => update('leadership_expectations', e.target.value)} placeholder="e.g. Lead a team of 5" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Attendance Expectations</label>
|
||||
<input value={form.attendance_expectations} onChange={(e) => update('attendance_expectations', e.target.value)} placeholder="e.g. On-time, no weekends off" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Min. Experience (yrs)</label>
|
||||
<input type="number" min="0" value={form.min_experience_years} onChange={(e) => update('min_experience_years', parseInt(e.target.value) || 0)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">English Level</label>
|
||||
<select value={form.english_required} onChange={(e) => update('english_required', e.target.value)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
|
||||
{ENGLISH_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Location</label>
|
||||
<input value={form.location} onChange={(e) => update('location', e.target.value)} placeholder="Bay Area, LA..." className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Pay Min ($/hr)</label>
|
||||
<input type="number" min="0" value={form.pay_range_min} onChange={(e) => update('pay_range_min', e.target.value)} placeholder="e.g. 18" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Pay Max ($/hr)</label>
|
||||
<input type="number" min="0" value={form.pay_range_max} onChange={(e) => update('pay_range_max', e.target.value)} placeholder="e.g. 28" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certifications */}
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Required Certifications</label>
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{CERT_OPTIONS.map(cert => (
|
||||
<button
|
||||
key={cert}
|
||||
type="button"
|
||||
onClick={() => toggleCert(cert)}
|
||||
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
|
||||
form.certifications_required.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
|
||||
}`}
|
||||
>
|
||||
{cert}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Generate Button */}
|
||||
<button
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={generateDesc.isPending || createJob.isPending}
|
||||
className="w-full bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-60 text-white font-medium py-3 rounded-xl text-[14px] flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
{generateDesc.isPending || createJob.isPending ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> Generating...</>
|
||||
) : (
|
||||
<><Star className="w-4 h-4" /> Generate Job Description with AI</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* AI Result Preview */}
|
||||
{aiResult && (
|
||||
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] p-4 space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-[#1E40AF] text-[13px] font-medium">
|
||||
<Sparkles className="w-3.5 h-3.5" /> AI-Generated Description
|
||||
</div>
|
||||
<p className="text-[13px] text-[#374151] whitespace-pre-wrap">{aiResult.description}</p>
|
||||
{aiResult.responsibilities?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[12px] font-semibold text-[#374151] mb-1">Key Responsibilities</p>
|
||||
<ul className="text-[12px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{aiResult.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Vetting Weights */}
|
||||
<div className="border-t border-[#E5E7EB] pt-4">
|
||||
<button onClick={() => setShowWeights(!showWeights)} className="w-full flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4 text-[#059669]" />
|
||||
<span className="text-[14px] font-medium text-[#059669]">AI Vetting Weights (Total: {totalWeight}% — must = 100%)</span>
|
||||
</span>
|
||||
{showWeights ? <ChevronUp className="w-4 h-4 text-[#9CA3AF]" /> : <ChevronDown className="w-4 h-4 text-[#9CA3AF]" />}
|
||||
</button>
|
||||
{showWeights && (
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(form.vetting_criteria).map(([key, val]) => (
|
||||
<div key={key}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[12px] text-[#374151]">{CRITERIA_LABELS[key]}</label>
|
||||
<span className={`text-[12px] font-medium ${totalWeight === 100 ? 'text-[#059669]' : 'text-[#F59E0B]'}`}>{val}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" value={val} onChange={(e) => updateWeight(key, e.target.value)} className="w-full accent-[#0A39DF]" />
|
||||
</div>
|
||||
))}
|
||||
<div className="md:col-span-2 text-[12px] text-[#6B7280]">
|
||||
Total: <span className={totalWeight === 100 ? 'text-[#059669] font-medium' : 'text-[#F59E0B] font-medium'}>{totalWeight}%</span>
|
||||
{totalWeight !== 100 && ' — adjust to reach 100%'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<button onClick={() => handleSave('draft')} disabled={createJob.isPending || updateJob.isPending} className="border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
|
||||
Save as Draft
|
||||
</button>
|
||||
<button onClick={() => handleSave('active')} disabled={createJob.isPending || updateJob.isPending} className="bg-[#0A39DF] hover:bg-[#082EB4] text-white font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
|
||||
Publish Job Posting
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user