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 { demandLabel } from '@/lib/positionModel';
import { workforceStatusFor } from '@/lib/workforce';
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. */}
{/* 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
)}
{/* 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 && (