update position page design

This commit is contained in:
2026-08-13 21:41:14 +05:30
parent 6340962907
commit 1c653264b5
124 changed files with 5746 additions and 596 deletions

View File

@@ -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>