import React, { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useJobPosting, useApplications, useScreenAllCandidates, useUpdateJobPosting, useHireCandidate, useUpdateApplication, useCreateApplication, useWorkerProfiles, useCourses, useAssignments, useStaff } from '@/lib/krowHooks'; import { customRequirementsText, demandLabel, hasRequirements, payLabel, } from '@/lib/positionModel'; import { PositionCustomRequirements, PositionOverview, PositionRequirements, } from '@/components/krow/PositionDetails'; import { workforceStatusFor } from '@/lib/workforce'; import { SkillSurface } from '@/components/skills/SkillSurface'; import { MatchedCandidates } from '@/components/krow/MatchedCandidates'; import { base44 } from '@/api/base44Client'; import CandidateCard from '@/components/krow/CandidateCard'; import AIInterviewModal from '@/components/krow/AIInterviewModal'; import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal'; import MessageCandidateModal from '@/components/krow/MessageCandidateModal'; import InviteApplicantsModal from '@/components/krow/InviteApplicantsModal'; import SuggestedTalent from '@/components/krow/SuggestedTalent'; import { toast } from 'react-hot-toast'; import { cn } from '@/lib/utils'; import { UserPlus } from 'lucide-react'; const STATUS_ACTIONS = [ { status: 'active', label: 'Hiring', color: 'bg-[#0838E0] text-white shadow-sm' }, { status: 'paused', label: 'Paused', color: 'bg-[#0838E0] text-white shadow-sm' }, { status: 'closed', label: 'Closed', color: 'bg-[#0838E0] text-white shadow-sm' }, ]; /** * The workforce picture behind this position. * * Reads what `workforceStatusFor` already computed — availability, today's * arrivals, the pipeline as the application records actually stand, and the * interviews on file. Nothing is derived here; sections that have nothing to * report say so in one line rather than reserving a panel for it. */ function WorkforceStatus({ workforce, job }) { const { demand, existingAvailable, applicants, newToday, strong, interviewReady } = workforce; /* Coverage figures depend on a declared headcount; the rest are facts about this position either way, so they are always shown. */ const figures = [ { label: 'Available now', value: existingAvailable.length, hint: 'free when this starts' }, demand.declared && { label: 'Currently assigned', value: demand.assigned, hint: 'on the roster' }, { label: 'New applicants', value: newToday.length, hint: 'today' }, { label: 'Strong matches', value: strong.length, hint: 'qualified and free' }, { label: 'Interview ready', value: interviewReady.length, hint: 'at interview stage' }, demand.declared && { label: 'Remaining gap', value: workforce.gap, hint: 'after strong matches' }, ].filter(Boolean); /* The pipeline, counted off the application statuses that already exist — not a second status vocabulary invented for this panel. */ const pipeline = [ { label: 'Applied', value: applicants.filter((a) => a.application.status === 'applied').length }, { label: 'Screening', value: applicants.filter((a) => a.application.status === 'ai_screened').length }, { label: 'Interview', value: interviewReady.length }, { label: 'Hired', value: workforce.hired.length }, ]; return (

Workforce status

{/* `demandLabel` already states the start, so it is not repeated. */}

{demandLabel(job)}

{figures.map((f) => (
{f.label}
{f.value}

{f.hint}

))}

New today

{newToday.length ? (
    {newToday.slice(0, 5).map((row) => (
  • {row.name}

    Applied {new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })} {/* Availability is only known for people we hold a worker profile for; an external applicant has none, and the line simply stops rather than guessing at one. */} {row.match ? ` · ${row.match.availability.reason}` : ''}

    {row.match && ( {row.match.score}% )}
  • ))}
) : (

No applications today.

)}

Hiring pipeline

    {pipeline.map((stage) => (
  • {stage.value}

    {stage.label}

  • ))}
); } export default function PositionDetail() { const { id } = useParams(); const navigate = useNavigate(); const { data: job, isLoading } = useJobPosting(id); const { data: applications = [] } = useApplications(id); /* Matching reads the workforce and the training library — the same two collections Forge reads, so a level earned there is a match here with no copy of the number in between. */ const { data: profiles = [] } = useWorkerProfiles(); const { data: courses = [] } = useCourses(); /* Assignments and hired staff are what make "assigned" a real number rather than a count of hires — the same two collections the Positions grid reads. */ const { data: assignments = [] } = useAssignments(); const { data: staff = [] } = useStaff(); const screenAll = useScreenAllCandidates(); const updateJob = useUpdateJobPosting(); const hireCandidate = useHireCandidate(); const updateApp = useUpdateApplication(); const createApp = useCreateApplication(); const [interviewApp, setInterviewApp] = useState(null); const [scheduleApp, setScheduleApp] = useState(null); const [messageApp, setMessageApp] = useState(null); const [inviteOpen, setInviteOpen] = useState(false); // Turn a matched pool talent into an applicant so they enter the pipeline const admitTalent = async (profile, status = 'applied') => { const existing = jobApps.find((a) => a.email && profile.email && a.email.toLowerCase() === profile.email.toLowerCase()); if (existing) return existing; return createApp.mutateAsync({ applicant_name: profile.full_name, email: profile.email || '', phone: profile.phone || '', years_experience: profile.experience_years || 0, skills: profile.skills || [], availability: profile.availability || [], certifications: profile.certifications || [], companies_worked: (profile.experience || []).map((e) => e.company).filter(Boolean), selfie_url: profile.selfie_url || '', professional_summary: profile.personality || profile.career_goals || '', cover_letter: '', job_posting_id: id, job_title: job.title, status, ai_score: profile.krow_score || 0, }); }; const handleSuggestedContact = async (profile) => { try { const app = await admitTalent(profile); setMessageApp(app); } catch (e) { toast.error('Could not open talent'); } }; const handleSuggestedSchedule = async (profile) => { try { const app = await admitTalent(profile, 'interview'); setScheduleApp(app); } catch (e) { toast.error('Could not schedule talent'); } }; const handleSuggestedHire = async (profile) => { try { const app = await admitTalent(profile, 'interview'); await hireCandidate.mutateAsync({ application: app, job }); toast.success(`${profile.full_name} hired from the talent pool!`); } catch (e) { toast.error('Failed to hire'); } }; const handleSuggestedHireWrapped = (profile) => handleSuggestedHire(profile); const jobApps = applications.filter(a => a.job_posting_id === id); const sortedApps = [...jobApps].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0)); /* The header's counts now come from the workforce engine rather than being recounted here — one source, so the card and this page agree. */ const handleStatusChange = async (newStatus) => { try { await updateJob.mutateAsync({ id, data: { status: newStatus } }); toast.success(`Position ${newStatus === 'active' ? 'is now hiring' : newStatus}`); } catch (e) { toast.error('Failed to update status'); } }; const handleScreenAll = async () => { const unscreened = jobApps.filter(a => a.status === 'applied'); if (unscreened.length === 0) { toast('All applicants have been screened'); return; } try { await screenAll.mutateAsync({ applications: unscreened, job }); toast.success(`Screened ${unscreened.length} applicants`); } catch (e) { toast.error('Screening failed'); } }; const handleHire = async (app) => { try { await hireCandidate.mutateAsync({ application: app, job }); toast.success(`${app.applicant_name} hired! Staff record created.`); } catch (e) { toast.error('Failed to hire'); } }; const handleAction = (app) => { if (app.status === 'interview') { setInterviewApp(app); } else { setScheduleApp(app); } }; const handleDecline = async (app) => { try { await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } }); toast.success(`${app.applicant_name} declined`); } catch (e) { toast.error('Failed to decline candidate'); } }; const handleDelete = async (app) => { try { await base44.entities.JobApplication.delete(app.id); toast.success(`${app.applicant_name} deleted`); } catch (e) { toast.error('Failed to delete candidate'); } }; /* One engine call, shared by the demand row and the workforce panel. Computed after the loading guards below would run, so it is guarded for a missing job rather than assuming one. */ const workforce = job ? workforceStatusFor(job, { profiles, applications, assignments, courses, staff }) : null; if (isLoading) { return
; } if (!job) { return

Position not found

; } return (
{/* Back */} {/* Header Card */}
{/* Company leads: on a workforce page the client asking for the people is as much the identity of the role as its title — above it rather than fused into it, so the stored job title is always read as the title. */} {job.company && (

{job.company}

)}

{job.title}

{job.priority && job.priority !== 'normal' && ( {job.priority} )} {job.ai_generated && ( AI Built )}
{job.role_category} {job.location && {job.location}} {payLabel(job) && {payLabel(job)}}
{/* Status controls */}
{STATUS_ACTIONS.map(s => ( ))}
{/* Demand — required, assigned, remaining. The same three numbers the position card leads with, from the same engine call, so the card and the page it opens can never disagree. Shown only when this position states a headcount; otherwise the pipeline below is what there is. */} {workforce.demand.declared ? (
{workforce.demand.required}
REQUIRED
{workforce.demand.assigned}
ASSIGNED
{workforce.demand.remaining}
REMAINING
) : (
{workforce.applicants.length}
APPLICANTS
{workforce.strong.length}
STRONG MATCHES
{workforce.hired.length}
HIRED
)}
{/* Everything Create Position collected about the role itself. The same two components the Positions drawer renders, so the page and the panel state the record identically. */}

Position details

The role as it was defined when this position was created.

{/* Role-specific, so present only when this role states them. */} {hasRequirements(job) && (

Requirements

)} {customRequirementsText(job) && (

Custom requirements

)}
{/* Extension points. A skill definition naming `positions` and one of these placements renders here; with none registered they render nothing at all. No page code changes when a skill is added. Every placement the vocabulary offers for this surface is mounted, so a valid definition can never point at a slot that does not exist. */} {/* Which people can actually do this work, scored against the position's own skill requirements. Above the applicant list on purpose: the best match for a role is often somebody who has not applied yet. */} { try { await updateApp.mutateAsync({ id: app.id, data: { status: 'interview' } }); toast.success(`${app.applicant_name} moved to interview`); } catch { toast.error('Could not move this candidate'); } }} /> {/* Suggested Talent — proactive matching, no waiting for applications */} {job.status === 'active' && ( )} {/* AI Description */} {job.description && (

Job Description

{job.description}

{job.responsibilities?.length > 0 && (

Responsibilities

    {job.responsibilities.map((r, i) =>
  • {r}
  • )}
)}
)} {/* Applicants */}

Applicants ({jobApps.length})

{sortedApps.length === 0 ? (

No applicants yet

Candidates can apply from the Apply page

) : (
{sortedApps.map((app, idx) => ( ))}
)}
{/* Modals */} {interviewApp && setInterviewApp(null)} application={interviewApp} job={job} />} {scheduleApp && setScheduleApp(null)} application={scheduleApp} />} {messageApp && setMessageApp(null)} application={messageApp} />} {inviteOpen && setInviteOpen(false)} job={job} />}
); }