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=` 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) .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 (
{/* Header */} {!embedded && (

{t('createPosition.createAPosition')}

{t('createPosition.setHiringCriteriaOnceKrowInterviews')}

{/* `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. */}
)} {/* Card */}
{/* Section Header */}

{t('createPosition.createJobPosting')}

{/* 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. */} {/* Row 1 */}
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" />
{/* 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. */}
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" />
{/* Add new category */}
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" />
{/* Requirements */}