Files
krow_talent_app/src/pages/PositionDetail.jsx
2026-08-13 21:41:14 +05:30

475 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
<div>
<h2 className="text-[15px] font-bold text-[#111827]">Workforce status</h2>
{/* `demandLabel` already states the start, so it is not repeated. */}
<p className="text-[12px] text-[#6B7280] mt-0.5">{demandLabel(job)}</p>
</div>
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{figures.map((f) => (
<div key={f.label} className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-[#9CA3AF]">{f.label}</dt>
<dd className="mt-1 text-[18px] font-bold tabular-nums text-[#111827]">{f.value}</dd>
<p className="text-[10px] text-[#9CA3AF]">{f.hint}</p>
</div>
))}
</dl>
<div className="grid gap-5 lg:grid-cols-2">
<section>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">New today</h3>
{newToday.length ? (
<ul className="divide-y divide-[#F3F4F6] rounded-xl border border-[#E5E7EB]">
{newToday.slice(0, 5).map((row) => (
<li key={row.application.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-[#111827]">{row.name}</p>
<p className="truncate text-[11px] text-[#9CA3AF]">
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}` : ''}
</p>
</div>
{row.match && (
<span className="shrink-0 text-[12px] font-semibold tabular-nums text-[#0838E0]">
{row.match.score}%
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-[13px] text-[#6B7280]">No applications today.</p>
)}
</section>
<section>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Hiring pipeline</h3>
<ul className="grid grid-cols-4 gap-2">
{pipeline.map((stage) => (
<li key={stage.label} className="rounded-xl border border-[#E5E7EB] px-2 py-2 text-center">
<p className="text-[16px] font-bold tabular-nums text-[#111827]">{stage.value}</p>
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF]">{stage.label}</p>
</li>
))}
</ul>
</section>
</div>
</div>
);
}
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 <div className="flex items-center justify-center py-20"><div className="w-6 h-6 border-3 border-[#E5E7EB] border-t-[#0838E0] rounded-full animate-spin" /></div>;
}
if (!job) {
return <div className="text-center py-20"><p className="text-[14px] text-[#6B7280]">Position not found</p></div>;
}
return (
<div className="space-y-5">
{/* Back */}
<button onClick={() => navigate('/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium">
‹ Back to Positions
</button>
{/* Header Card */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{/* Company leads: on a workforce page the client asking for the
people is as much the identity of the role as its title. */}
<h1 className="text-[22px] font-bold text-[#111827]">
{job.company ? `${job.company} — ${job.title}` : job.title}
</h1>
{job.priority && job.priority !== 'normal' && (
<span className={cn(
'px-2 py-0.5 rounded-full text-[11px] font-bold capitalize',
job.priority === 'urgent'
? 'bg-[#FDECEC] text-[#D64545]'
: 'bg-[#FEF6E0] text-[#B45309]'
)}>
{job.priority}
</span>
)}
{job.ai_generated && (
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
AI Built
</span>
)}
</div>
<div className="flex items-center gap-4 text-[13px] text-[#6B7280] flex-wrap">
<span className="capitalize">{job.role_category}</span>
{job.location && <span>{job.location}</span>}
{job.pay_range_min != null && <span className="font-medium text-[#111827]">${job.pay_range_min}–{job.pay_range_max}/hr</span>}
</div>
</div>
{/* Status controls */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setInviteOpen(true)}
className="inline-flex items-center gap-1.5 bg-[#F9E547] text-[#0838E0] text-[12px] font-bold px-4 py-1.5 rounded-full hover:scale-[1.03] transition-transform"
>
<UserPlus className="w-4 h-4" />
Invite Applicants
</button>
{STATUS_ACTIONS.map(s => (
<button
key={s.status}
onClick={() => handleStatusChange(s.status)}
disabled={updateJob.isPending}
className={cn(
'px-4 py-1.5 rounded-full text-[12px] font-semibold transition-all disabled:opacity-50',
job.status === s.status
? s.color
: 'border border-[#E5E7EB] bg-white text-[#6B7280] hover:border-[#0838E0] hover:text-[#0838E0]'
)}
>
{s.label}
</button>
))}
</div>
</div>
{/* 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 ? (
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-[#F3F4F6]">
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.demand.required}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">REQUIRED</div>
</div>
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.demand.assigned}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">ASSIGNED</div>
</div>
<div>
<div className={cn('text-2xl font-bold', workforce.demand.remaining ? 'text-[#D64545]' : 'text-[#16A765]')}>
{workforce.demand.remaining}
</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">REMAINING</div>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-[#F3F4F6]">
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.applicants.length}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">APPLICANTS</div>
</div>
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.strong.length}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">STRONG MATCHES</div>
</div>
<div>
<div className="text-2xl font-bold text-[#0838E0]">{workforce.hired.length}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">HIRED</div>
</div>
</div>
)}
</div>
<WorkforceStatus workforce={workforce} job={job} />
{/* 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. */}
<MatchedCandidates
job={job}
profiles={profiles}
courses={courses}
applications={applications}
busy={updateApp.isPending}
onInterview={async (app) => {
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' && (
<SuggestedTalent
job={job}
onContact={handleSuggestedContact}
onSchedule={handleSuggestedSchedule}
onHire={handleSuggestedHireWrapped}
/>
)}
{/* AI Description */}
{job.description && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<h3 className="text-[14px] font-semibold text-[#111827] mb-2">
Job Description
</h3>
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
{job.responsibilities?.length > 0 && (
<div className="mt-4">
<p className="text-[12px] font-semibold text-[#374151] mb-1.5">Responsibilities</p>
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-1">
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
)}
{/* Applicants */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[15px] font-semibold text-[#111827]">Applicants ({jobApps.length})</h3>
<button
onClick={handleScreenAll}
disabled={screenAll.isPending}
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-50 text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors"
>
{screenAll.isPending ? 'Screening...' : 'AI Screen All'}
</button>
</div>
{sortedApps.length === 0 ? (
<div className="text-center py-12">
<p className="text-[14px] text-[#6B7280] mb-1">No applicants yet</p>
<p className="text-[12px] text-[#9CA3AF]">Candidates can apply from the Apply page</p>
</div>
) : (
<div className="space-y-3">
{sortedApps.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
)}
</div>
{/* Modals */}
{interviewApp && <AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={job} />}
{scheduleApp && <ScheduleInterviewModal open={true} onClose={() => setScheduleApp(null)} application={scheduleApp} />}
{messageApp && <MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />}
{inviteOpen && <InviteApplicantsModal open={true} onClose={() => setInviteOpen(false)} job={job} />}
</div>
);
}