update position page design
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useJobPosting, useApplications, useScreenAllCandidates, useUpdateJobPosting, useHireCandidate, useUpdateApplication, useCreateApplication } from '@/lib/krowHooks';
|
||||
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';
|
||||
@@ -13,16 +16,120 @@ import { cn } from '@/lib/utils';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
|
||||
const STATUS_ACTIONS = [
|
||||
{ status: 'active', label: 'Hiring', color: 'bg-[#0A39DF] text-white shadow-sm' },
|
||||
{ status: 'paused', label: 'Paused', color: 'bg-[#0A39DF] text-white shadow-sm' },
|
||||
{ status: 'closed', label: 'Closed', color: 'bg-[#0A39DF] text-white shadow-sm' },
|
||||
{ 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();
|
||||
@@ -89,11 +196,8 @@ export default function PositionDetail() {
|
||||
|
||||
const jobApps = applications.filter(a => a.job_posting_id === id);
|
||||
const sortedApps = [...jobApps].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
|
||||
const stats = {
|
||||
applied: jobApps.length,
|
||||
screened: jobApps.filter(a => a.status !== 'applied').length,
|
||||
hired: jobApps.filter(a => a.status === 'hired').length,
|
||||
};
|
||||
/* 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 {
|
||||
@@ -153,8 +257,15 @@ export default function PositionDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
/* 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-[#0A39DF] rounded-full animate-spin" /></div>;
|
||||
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) {
|
||||
@@ -173,9 +284,23 @@ export default function PositionDetail() {
|
||||
<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">
|
||||
<h1 className="text-[22px] font-bold text-[#111827]">{job.title}</h1>
|
||||
{/* 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-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
|
||||
AI Built
|
||||
</span>
|
||||
)}
|
||||
@@ -191,7 +316,7 @@ export default function PositionDetail() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setInviteOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 bg-[#F9E547] text-[#0A39DF] text-[12px] font-bold px-4 py-1.5 rounded-full hover:scale-[1.03] transition-transform"
|
||||
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
|
||||
@@ -205,7 +330,7 @@ export default function PositionDetail() {
|
||||
'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-[#0A39DF] hover:text-[#0A39DF]'
|
||||
: 'border border-[#E5E7EB] bg-white text-[#6B7280] hover:border-[#0838E0] hover:text-[#0838E0]'
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
@@ -214,23 +339,66 @@ export default function PositionDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<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]">{stats.applied}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF] tracking-wide">APPLIED</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>
|
||||
<div className="text-2xl font-bold text-[#111827]">{stats.screened}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF] tracking-wide">SCREENED</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>
|
||||
<div className="text-2xl font-bold text-[#0A39DF]">{stats.hired}</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
|
||||
@@ -266,7 +434,7 @@ export default function PositionDetail() {
|
||||
<button
|
||||
onClick={handleScreenAll}
|
||||
disabled={screenAll.isPending}
|
||||
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user