522 lines
23 KiB
JavaScript
522 lines
23 KiB
JavaScript
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 (
|
||
<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>
|
||
|
||
<SkillSurface page="positions" placement="after-header" context={{ position: job }} />
|
||
|
||
{/* 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">
|
||
{/* 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 && (
|
||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#0838E0]">{job.company}</p>
|
||
)}
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<h1 className="text-[22px] font-bold text-[#111827]">{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>}
|
||
{payLabel(job) && <span className="font-medium text-[#111827]">{payLabel(job)}</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>
|
||
|
||
{/* 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. */}
|
||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
|
||
<div>
|
||
<h2 className="text-[15px] font-bold text-[#111827]">Position details</h2>
|
||
<p className="text-[12px] text-[#6B7280] mt-0.5">The role as it was defined when this position was created.</p>
|
||
</div>
|
||
<PositionOverview position={job} />
|
||
{/* Role-specific, so present only when this role states them. */}
|
||
{hasRequirements(job) && (
|
||
<div>
|
||
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Requirements</h3>
|
||
<PositionRequirements position={job} />
|
||
</div>
|
||
)}
|
||
{customRequirementsText(job) && (
|
||
<div>
|
||
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Custom requirements</h3>
|
||
<PositionCustomRequirements position={job} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<SkillSurface page="positions" placement="after-position-summary" context={{ position: job }} />
|
||
|
||
<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>
|
||
)}
|
||
|
||
<SkillSurface page="positions" placement="before-candidates" context={{ position: job }} />
|
||
|
||
{/* 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>
|
||
|
||
<SkillSurface page="positions" placement="after-candidates" context={{ position: job }} />
|
||
<SkillSurface page="positions" placement="before-footer" context={{ position: job }} />
|
||
|
||
{/* 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>
|
||
);
|
||
} |