Files
krow_talent_app/src/pages/CreatePosition.tsx

457 lines
24 KiB
TypeScript

import React, { useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react';
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition, toPositionPayload } from '@/lib/positionModel';
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
import { SkillSurface } from '@/components/skills/SkillSurface';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant';
import { useTranslation } from 'react-i18next';
/**
* Create a Position.
*
* The manual form, reachable at `/admin/positions/new`. `embedded` drops the
* page header for a host that supplies its own, and `onDone` lets that host
* close it instead of navigating away.
*
* Owliver does not open this form. Asking Owliver for a position is a
* conversation that ends in the same record (see `lib/skills/positionFlow.js`),
* and both paths share one field definition — `lib/positionModel.js` — so
* neither can drift into a different shape of position.
*/
interface CreatePositionProps {
prefill?: any;
embedded?: any;
onDone?: (...args: any[]) => void;
}
export default function CreatePosition({ prefill: prefillProp, embedded = false, onDone }: CreatePositionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
/* Owliver opens this page with the fields it could infer from the request.
It is a starting point, not a submission: every field stays editable and
the position is still created by the button at the bottom. */
const { state } = useLocation();
const prefill = prefillProp || state?.prefill || null;
const createJob = useCreateJobPosting();
const updateJob = useUpdateJobPosting();
const generateDesc = useGenerateJobDescription();
const [form, setForm] = useState({ ...defaultPosition(), ...prefill });
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)])];
/**
* Continuing a draft.
*
* `?draft=<id>` is how the Positions list hands an unfinished position back to
* the form that was writing it. It is the same route, the same component and
* the same state as writing a new one — the only difference is that the form
* starts from the saved record and saves back to it, so nothing the author
* already entered is asked for twice and no second editor exists.
*/
const [searchParams] = useSearchParams();
const continuingId = searchParams.get('draft');
const { data: continuing } = useJobPosting(continuingId);
const [draftId, setDraftId] = useState(continuingId || null);
const [loadedId, setLoadedId] = useState(null);
const [aiResult, setAiResult] = useState(null);
/* Seeded once per record, not on every render of it: after this the form owns
the values, and re-seeding would discard whatever has been typed since. */
if (continuing && continuing.id !== loadedId) {
setLoadedId(continuing.id);
setDraftId(continuing.id);
setForm({ ...defaultPosition(), ...continuing });
}
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 });
};
/* `vetting_criteria` is a `jsonb` column, so the registry types its values
`unknown`. It holds the five weighting percentages this page edits. */
const totalWeight = Object.values(form.vetting_criteria as Record<string, number>)
.reduce((a, b) => a + b, 0);
/* What a skill on this surface reads: the position being specified, as it
stands right now. The draft carries the same fields a saved position does,
so one declared section reports the form and the record it becomes.
Memoized on the form so the sections re-resolve on a change rather than on
every render. */
const skillContext = React.useMemo(() => ({ position: form }), [form]);
/* The same draft, published to the panel beside the form. A skill declaring
`create-position` is available to both consumers, and both read this one
record — so "summarize the vetting weights" answers about the weights on
screen, updating as they are edited. */
usePublishPageContext(skillContext);
/**
* The write that goes with that read.
*
* Owliver can already see the weights on this form; this is what lets it set
* them. The handler is the form's own setter — there is no second copy of the
* weights anywhere, so a change made in the panel and a change made on the
* slider below are the same change, and the section further down this page
* re-renders from the same state either way.
*
* Keyed by the data source rather than by a skill or an invented action name:
* any definition reading `position.vetting` can offer to set it, and no
* definition can write anywhere it cannot read. A functional update keeps the
* handler stable, so the published map is memoized once rather than rebuilt as
* the form is typed into.
*/
const applyVettingWeights = React.useCallback((next) => {
setForm((previous) => ({
...previous,
vetting_criteria: { ...previous.vetting_criteria, ...next },
}));
toast.success('Vetting weights updated');
}, []);
usePublishPageActions(
React.useMemo(() => ({ 'position.vetting': applyVettingWeights }), [applyVettingWeights])
);
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(toPositionPayload(form, { status: 'draft' }));
id = draft.id;
setDraftId(id);
}
const result = await generateDesc.mutateAsync({ data: toPositionPayload(form), draftId: id });
setAiResult(result);
toast.success('Job description generated!');
} catch {
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 = {
...toPositionPayload(form, { status }),
...(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');
/* Inline, the list behind the form is already the destination — closing
returns to it with the new position in place. */
if (onDone) {
onDone(createdId);
} else if (status === 'active' && createdId) {
/* Back to the board, with the published role named in the URL. The list
is where the position now lives, and it is the surface Owliver sits
beside — so the role that was just published is the one the panel is
reasoning about, and offering candidates for, on arrival. */
navigate(`/admin/positions?published=${encodeURIComponent(createdId)}`);
} else {
navigate('/admin/positions');
}
} catch {
toast.error('Failed to save');
}
};
return (
<div className={embedded ? '' : 'max-w-3xl mx-auto'}>
{/* Header */}
{!embedded && (
<div className="flex items-start justify-between gap-4 mb-6">
<div className="min-w-0">
<h1 className="text-[22px] font-bold text-[#111827]">{t('createPosition.createAPosition')}</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">{t('createPosition.setHiringCriteriaOnceKrowInterviews')}</p>
</div>
{/* `shrink-0` and a real gap: without them the subtitle's intrinsic
width pushed Cancel against the edge of a phone screen with the
title wrapping underneath it. */}
<button onClick={() => navigate('/admin/positions')} className="shrink-0 pt-1 text-[13px] text-[#6B7280] hover:text-[#111827]">{t('createPosition.cancel')}</button>
</div>
)}
{/* Card */}
<div className={embedded
? 'space-y-6'
: 'bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6'}>
{/* Section Header */}
<div className="flex items-center gap-2">
<OwliverAvatar className="w-7 h-7" rounded="rounded-lg" />
<h2 className="text-[16px] font-semibold text-[#111827]">{t('createPosition.createJobPosting')}</h2>
</div>
{/* Extension points. A definition naming `create-position` and one of
these placements renders here, against the draft in the form. The
form knows no skill; the definition names the slot. Nothing below is
moved or replaced — the sections sit between what is already here. */}
<SkillSurface page="create-position" placement="after-header" context={skillContext} />
{/* Row 1 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.roleCategory')}</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-[#0838E0] focus:outline-none">
{allCategories.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.jobTitle')}</label>
<input value={form.title} onChange={(e) => update('title', e.target.value)} placeholder={t('createPosition.eGExperiencedServer')} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
</div>
{/* The client this role is being staffed for. Stored on the position
record (`company`), which is what every surface reads to show who the
hire is actually for — the field existed in the model and had no way
in from this form until now. */}
<div>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.clientCompany')}</label>
<input
value={form.company}
onChange={(e) => update('company', e.target.value)}
placeholder={t('createPosition.eGFairmontSanJose')}
className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
</div>
{/* Add new category */}
<div className="flex gap-2">
<input value={newCategory} onChange={(e) => setNewCategory(e.target.value)} placeholder={t('createPosition.createANewRoleCategorySaved')} className="flex-1 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] 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-[#0838E0] text-white text-[13px] font-medium rounded-lg hover:bg-[#062BAF] disabled:opacity-50 whitespace-nowrap"
>
{t('createPosition.add')}
</button>
</div>
{/* Requirements */}
<div>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.customRequirementsAiWillUseThis')}</label>
<textarea value={form.custom_requirements} onChange={(e) => update('custom_requirements', e.target.value)} placeholder={t('createPosition.eGMustHaveFineDining')} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
</div>
{/* Physical / Leadership / Attendance Expectations.
All three are role-specific and optional, and the labels say so: a
blank one is not shown on the position at all. The placeholder used
to read "Lift 50 lbs, stand 8 hrs", which invited every role to be
given a warehouse requirement whether or not the work has one. */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">
{t('createPosition.physicalRequirements')} <span className="font-normal text-[#9CA3AF]">{t('createPosition.optional')}</span>
</label>
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">{t('createPosition.onlyIfThisRoleGenuinelyHas')}</p>
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder={t('createPosition.eGExtendedStandingLiftingOr')} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">
{t('createPosition.leadershipExpectations')} <span className="font-normal text-[#9CA3AF]">{t('createPosition.optional')}</span>
</label>
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">{t('createPosition.onlyIfThisRoleLeadsPeople')}</p>
<input value={form.leadership_expectations} onChange={(e) => update('leadership_expectations', e.target.value)} placeholder={t('createPosition.eGLeadATeamOf')} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">
{t('createPosition.attendanceExpectations')} <span className="font-normal text-[#9CA3AF]">{t('createPosition.optional')}</span>
</label>
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">{t('createPosition.shiftPatternOrAvailabilityIfIt')}</p>
<input value={form.attendance_expectations} onChange={(e) => update('attendance_expectations', e.target.value)} placeholder={t('createPosition.eGOnTimeNoWeekends')} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] 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]">{t('createPosition.minExperienceYrs')}</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-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.englishLevel')}</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-[#0838E0] 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]">{t('createPosition.location')}</label>
<input value={form.location} onChange={(e) => update('location', e.target.value)} placeholder={t('createPosition.bayAreaLa')} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] 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]">{t('createPosition.payMinHr')}</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-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.payMaxHr')}</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-[#0838E0] focus:outline-none" />
</div>
</div>
{/* Certifications */}
<div>
<label className="text-[13px] font-medium text-[#374151]">{t('createPosition.requiredCertifications')}</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-[#0838E0] border-[#0838E0] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
}`}
>
{cert}
</button>
))}
</div>
</div>
{/* Required skills — what the matching engine reads. Placed next to the
certifications it sits beside conceptually: both are "what a person
must hold", one asserted on a résumé and one verified in Forge. */}
<SkillRequirementsField
value={form.skill_requirements}
onChange={(next) => update('skill_requirements', next)}
/>
{/* AI Generate Button */}
<button
onClick={handleGenerateDescription}
disabled={generateDesc.isPending || createJob.isPending}
className="w-full bg-[#0838E0] hover:bg-[#062BAF] 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" /> {t('createPosition.generating')}</>
) : (
<><Star className="w-4 h-4" /> {t('createPosition.generateJobDescriptionWithAi')}</>
)}
</button>
{/* AI Result Preview */}
{aiResult && (
<div className="rounded-xl border border-[#DBEAFE] bg-[#EEF3FE] 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" /> {t('createPosition.aiGeneratedDescription')}
</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">{t('createPosition.keyResponsibilities')}</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>
)}
<SkillSurface
page="create-position"
placement="after-job-description"
context={skillContext}
/>
{/* 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]">{t('createPosition.aiVettingWeightsTotal')} {totalWeight}{t('createPosition.must100')}</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 as Record<string, number>).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-[#0838E0]" />
</div>
))}
<div className="md:col-span-2 text-[12px] text-[#6B7280]">
{t('createPosition.total')} <span className={totalWeight === 100 ? 'text-[#059669] font-medium' : 'text-[#F59E0B] font-medium'}>{totalWeight}%</span>
{totalWeight !== 100 && ' — adjust to reach 100%'}
</div>
</div>
)}
</div>
<SkillSurface
page="create-position"
placement="after-vetting-weights"
context={skillContext}
/>
<SkillSurface
page="create-position"
placement="before-footer"
context={skillContext}
/>
{/* 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">
{t('createPosition.saveAsDraft')}
</button>
<button onClick={() => handleSave('active')} disabled={createJob.isPending || updateJob.isPending} className="bg-[#0838E0] hover:bg-[#062BAF] text-white font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
{t('createPosition.publishJobPosting')}
</button>
</div>
</div>
</div>
);
}