chore(ts-migration): migrate the app shell, routing and the last non-agent pages
Phase 10: `App`, `main`, `index.html`, and the four remaining `src/pages` files. ROUTING IS PROVEN UNCHANGED, not assumed. The 49 `<Route>` elements were fingerprinted before the rename and compared after: byte-identical, all 44 paths, all 13 `<Navigate>` redirects, the same nesting under `ProtectedRoute` and `AdminRoute`, and the same provider order - AuthProvider, then QueryClientProvider, then Router, with ScrollToTop and AuthenticatedApp inside and both toasters as siblings. There is no lazy loading to preserve; there never was any. Two coordinated edits the rename forced. `main` imported `@/App.jsx` by explicit extension, which stops resolving the moment `App` is `.tsx`; it is now extensionless `@/App`. `index.html` pointed its module script at `/src/main.jsx`; it points at `/src/main.tsx`. Both are required, and missing either would have been a blank page rather than a type error. ONE GENUINE SEMANTIC DIFFERENCE, INVESTIGATED AND ACCEPTED. esbuild elides unused imports under the TypeScript loader but keeps them under the JavaScript one, so `App` drops thirteen imports: `Layout`, `Overview`, `Positions`, `Candidates`, `HiredHistory`, `TalentPool`, `UserTracking`, `Analytics`, `Profile`, `WorkerProfile`, `KrowIdentity`, `Owliver` and `DesignSystem`. All thirteen are dead - each has zero JSX uses in `App`, because every route that once rendered them now `<Navigate>`s to an `/admin/*` equivalent. Before accepting it I checked that none of the thirteen modules can do anything when evaluated: no bare side-effect imports, no top-level calls, and every top-level binding a literal or a function declaration. The consequence is 942 fewer bytes in the index chunk and thirteen modules no longer evaluated at startup. Nothing observable changes, and the routes those pages are reached through are unaffected - they are reached through the `/admin` tree, which is untouched. That is the first time in this migration the production bundle has changed for a reason other than a comment, so it is recorded here rather than left to be noticed later. `CreatePosition` needed the only real typing. `vetting_criteria` is a `jsonb` column holding the five weighting percentages the page edits, typed `unknown` by the registry, and both the total and the three render sites read through it. `onDone` is called with the created id, so it takes arguments - the generator had classed it as zero-arg, and that rule is now narrowed to `onClose` alone. The generator's entity-import rule was narrowed first, as instructed: it now counts a type as used only when it appears in a type position inside a generated interface, rather than anywhere in the file text. That is what produced four unused-import lint errors in the previous batch. Verified: tsc 21 -> 20, set-difference showing one removed and none added; zero errors in any of the six files; all four pages emit byte-identical JavaScript; route fingerprint identical; npm test 1684/1691 with the same seven failures; Owliver baseline 59/59; lint 0 errors; no deferred agent-region file touched; baseline artifacts untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
455
src/pages/CreatePosition.tsx
Normal file
455
src/pages/CreatePosition.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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 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]">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>
|
||||
{/* `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]">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]">Create Job Posting</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]">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-[#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]">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-[#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]">Client / Company</label>
|
||||
<input
|
||||
value={form.company}
|
||||
onChange={(e) => update('company', e.target.value)}
|
||||
placeholder="e.g. Fairmont San Jose"
|
||||
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="Create a new role category (saved for reuse)..." 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"
|
||||
>
|
||||
+ 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-[#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]">
|
||||
Physical Requirements <span className="font-normal text-[#9CA3AF]">— optional</span>
|
||||
</label>
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Only if this role genuinely has them.</p>
|
||||
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Extended standing, lifting or climbing" 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]">
|
||||
Leadership Expectations <span className="font-normal text-[#9CA3AF]">— optional</span>
|
||||
</label>
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Only if this role leads people.</p>
|
||||
<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-[#0838E0] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">
|
||||
Attendance Expectations <span className="font-normal text-[#9CA3AF]">— optional</span>
|
||||
</label>
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Shift pattern or availability, if it matters.</p>
|
||||
<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-[#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]">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-[#0838E0] 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-[#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]">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-[#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]">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-[#0838E0] 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-[#0838E0] 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-[#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" /> 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-[#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" /> 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>
|
||||
)}
|
||||
|
||||
<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]">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 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]">
|
||||
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">
|
||||
Save as Draft
|
||||
</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">
|
||||
Publish Job Posting
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user