Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/Careers.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/screens/Careers.tsx |
| Original SHA-256 | 88a87831ae1bccdbaac055e5e9145a23448a5b5c20a29b42a8849c6d0c591664 |
| Git blob SHA | 316a3ff0f768e0b61d2d5f640977e794cd386e63 |
| Reading-copy SHA-256 | 88a87831ae1bccdbaac055e5e9145a23448a5b5c20a29b42a8849c6d0c591664 |
Recovered source transcript
Download complete source text ↓725 lines · 26.2 KB
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
CalendarClock,
Check,
Copy,
ExternalLink,
Globe,
Inbox,
Plus,
UserPlus,
X,
} from 'lucide-react'
import { Badge, Modal, Panel } from '../components/ui'
import { Empty, Labeled, Note, Select, btnGhost, btnPrimary, fieldCls } from '../components/form'
import { CAP, can } from '../lib/can'
import { useAuth } from '../lib/useAuth'
import { useOrgBranding } from '../lib/orgQueries'
import {
APPLICATION_STATUSES,
useConvertApplication,
useCreateOffer,
useCreatePosting,
useInboundApplications,
useInterviewOffers,
usePostings,
useReviewApplication,
useSetPostingStatus,
useWithdrawOffer,
type InboundApplication,
type PostingInput,
} from '../lib/careers'
import { useRequisitions } from '../tools/recruitmatch/requisitionQueries'
// CAREERS: what the outside world sees, and what it sends back.
//
// Three things on one screen because they are one flow: a posting goes public, applications arrive from it,
// and the ones worth pursuing get an interview. Splitting them into three screens would mean a recruiter
// checking three places to find out whether the job they published this morning worked.
//
// THE PUBLIC LINK IS THE PRODUCT HERE. A posting nobody can reach is a draft, so the address is shown, is
// copyable in one click, and is opened in a new tab from the same screen. A recruiter should be able to
// publish and then look at what a stranger sees, without asking anybody how.
export function Careers() {
const { principal } = useAuth()
const mayPublish = can(principal, CAP.requirementEdit)
const mayReview = can(principal, CAP.candidateWrite)
return (
<div className="space-y-4">
<PostingsPanel mayPublish={mayPublish} />
<InboundPanel mayReview={mayReview} />
<OffersPanel mayReview={mayReview} />
</div>
)
}
// publicCareersURL builds the address a stranger uses. It is derived from the current origin rather than
// configured, because the application is served from the same host as the API, and a configured base would
// be one more thing that can be wrong in staging and right in production.
function publicCareersURL(slug: string, token?: string): string {
const base = `${window.location.origin}/careers/${encodeURIComponent(slug)}`
return token ? `${base}/${encodeURIComponent(token)}` : base
}
function CopyLink({ url, label }: { url: string; label: string }) {
const [copied, setCopied] = useState(false)
return (
<span className="flex items-center gap-1">
<button
type="button"
className={btnGhost}
onClick={() => {
void navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? <Check size={12} /> : <Copy size={12} />} {copied ? 'Copied' : label}
</button>
<a href={url} target="_blank" rel="noreferrer" className={btnGhost} title="Open what a stranger sees">
<ExternalLink size={12} />
</a>
</span>
)
}
function PostingsPanel({ mayPublish }: { mayPublish: boolean }) {
const branding = useOrgBranding()
const postings = usePostings()
const setStatus = useSetPostingStatus()
const [newOpen, setNewOpen] = useState(false)
const slug = branding.data?.slug ?? ''
const list = postings.data?.postings ?? []
const published = list.filter((p) => p.status === 'published')
return (
<Panel
title="Job postings"
hint={published.length === 1 ? '1 live' : `${published.length} live`}
>
{!slug && (
<div className="mb-3">
<Note tone="warn">
This organization has no public handle set, so a posting has no address to publish to. An
operator sets it when the account is provisioned.
</Note>
</div>
)}
{slug && (
<div className="mb-3 flex flex-wrap items-center gap-2 text-[11px] text-muted">
<Globe size={12} />
Your careers page is at
<code className="rounded bg-navy-950/60 px-1.5 py-0.5 text-ink">{publicCareersURL(slug)}</code>
<CopyLink url={publicCareersURL(slug)} label="Copy" />
</div>
)}
{postings.isError ? (
<Note tone="risk">{(postings.error as Error).message}</Note>
) : list.length === 0 ? (
<Empty
title="Nothing published"
reason="A posting is the public face of a job order. It starts as a draft, so nothing goes live until you publish it."
/>
) : (
<ul className="space-y-2">
{list.map((p) => (
<li
key={p.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line/60 bg-navy-950/30 px-3 py-2"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-ink">{p.title}</span>
<Badge
color={
p.status === 'published'
? 'var(--color-ok)'
: p.status === 'closed'
? 'var(--color-faint)'
: 'var(--color-warn)'
}
subtle
>
{p.status}
</Badge>
</div>
<div className="text-[11px] text-faint">
{p.applications === 1 ? '1 application' : `${p.applications} applications`}
{p.published_at ? ` · live since ${new Date(p.published_at).toLocaleDateString()}` : ''}
{p.closes_at ? ` · closes ${new Date(p.closes_at).toLocaleDateString()}` : ''}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* The link is only offered for a LIVE posting. Handing somebody a link to a draft would be
handing them a 404, and the server is right to answer one. */}
{p.status === 'published' && slug && (
<CopyLink url={publicCareersURL(slug, p.public_token)} label="Copy the link" />
)}
{mayPublish && p.status === 'draft' && (
<button
type="button"
className={btnPrimary}
onClick={() => setStatus.mutate({ id: p.id, status: 'published' })}
>
Publish it
</button>
)}
{mayPublish && p.status === 'published' && (
<button
type="button"
className={btnGhost}
onClick={() => setStatus.mutate({ id: p.id, status: 'closed' })}
>
Close it
</button>
)}
</div>
</li>
))}
</ul>
)}
{setStatus.isError && (
<div className="mt-2">
<Note tone="risk">{(setStatus.error as Error).message}</Note>
</div>
)}
{mayPublish && (
<div className="mt-3 border-t border-line/40 pt-3">
<button type="button" className={btnPrimary} onClick={() => setNewOpen(true)}>
<Plus size={13} /> Write a posting
</button>
</div>
)}
<NewPostingModal open={newOpen} onClose={() => setNewOpen(false)} />
</Panel>
)
}
const emptyPosting: PostingInput = {
requisition_id: '',
title: '',
summary: '',
responsibilities: '',
qualifications: '',
location_text: '',
remote_allowed: false,
employment_type: 'contract',
clearance_text: '',
pay_range_text: '',
}
function NewPostingModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const create = useCreatePosting()
const reqs = useRequisitions({ status: 'open' })
const [v, setV] = useState<PostingInput>(emptyPosting)
const openReqs = reqs.data?.requisitions ?? []
return (
<Modal open={open} onClose={onClose} title="Write a job posting">
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault()
if (!v.requisition_id || !v.title.trim()) return
create.mutate(v, {
onSuccess: () => {
setV(emptyPosting)
onClose()
},
})
}}
>
<Note tone="muted">
This becomes a public page. Everything written here is read by people outside your organization, so
leave out anything about the client that is not yours to publish.
</Note>
<Labeled label="Which job order" hint="a posting is the public face of one, so it cannot stand alone">
<Select
value={v.requisition_id}
choices={[
{ value: '', label: 'Choose a job order' },
...openReqs.map((r) => ({ value: r.id, label: r.title })),
]}
onChange={(id) => {
const r = openReqs.find((x) => x.id === id)
// The title is seeded from the job order, because the internal name is usually the right
// public one, and a recruiter who wants a different one can simply type over it.
setV({ ...v, requisition_id: id, title: v.title || (r?.title ?? '') })
}}
className="w-full"
/>
</Labeled>
<Labeled label="Title">
<input
value={v.title}
maxLength={200}
onChange={(e) => setV({ ...v, title: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="Summary" hint="the paragraph somebody reads before deciding to keep reading">
<textarea
value={v.summary}
rows={3}
maxLength={4000}
onChange={(e) => setV({ ...v, summary: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="What they will do">
<textarea
value={v.responsibilities}
rows={4}
maxLength={8000}
onChange={(e) => setV({ ...v, responsibilities: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="What they need">
<textarea
value={v.qualifications}
rows={4}
maxLength={8000}
onChange={(e) => setV({ ...v, qualifications: e.target.value })}
className={fieldCls}
/>
</Labeled>
<div className="grid gap-2.5 sm:grid-cols-2">
<Labeled label="Location">
<input
value={v.location_text}
maxLength={200}
onChange={(e) => setV({ ...v, location_text: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="Employment type">
<Select
value={v.employment_type}
choices={[
{ value: 'contract', label: 'Contract' },
{ value: 'contract_to_hire', label: 'Contract to hire' },
{ value: 'direct_hire', label: 'Direct hire' },
]}
onChange={(t) => setV({ ...v, employment_type: t })}
className="w-full"
/>
</Labeled>
<Labeled
label="Clearance"
hint="written in your own words for the advert, and still a self reported indicator"
>
<input
value={v.clearance_text}
maxLength={200}
placeholder="Active TS/SCI required"
onChange={(e) => setV({ ...v, clearance_text: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="Pay range" hint="what you are willing to publish, in whatever form you publish it">
<input
value={v.pay_range_text}
maxLength={200}
placeholder="150,000 to 175,000"
onChange={(e) => setV({ ...v, pay_range_text: e.target.value })}
className={fieldCls}
/>
</Labeled>
</div>
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={v.remote_allowed}
onChange={(e) => setV({ ...v, remote_allowed: e.target.checked })}
/>
Remote is allowed
</label>
{create.isError && <Note tone="risk">{(create.error as Error).message}</Note>}
<button
type="submit"
disabled={!v.requisition_id || !v.title.trim() || create.isPending}
className={btnPrimary}
>
{create.isPending ? 'Saving' : 'Save it as a draft'}
</button>
<p className="text-[11px] text-faint">
It saves as a draft. Nothing is public until you publish it from the list.
</p>
</form>
</Modal>
)
}
function InboundPanel({ mayReview }: { mayReview: boolean }) {
const apps = useInboundApplications()
const [offerFor, setOfferFor] = useState<InboundApplication | null>(null)
const list = apps.data?.applications ?? []
const fresh = list.filter((a) => a.status === 'new')
return (
<Panel title="Applications" hint={fresh.length === 1 ? '1 new' : `${fresh.length} new`}>
{apps.isError ? (
<Note tone="risk">{(apps.error as Error).message}</Note>
) : list.length === 0 ? (
<Empty
title="Nothing has come in"
reason="Applications from your published postings land here. Nothing arrives until a posting is live."
/>
) : (
<ul className="space-y-2">
{list.map((a) => (
<ApplicationRow key={a.id} app={a} mayReview={mayReview} onOffer={() => setOfferFor(a)} />
))}
</ul>
)}
<NewOfferModal app={offerFor} onClose={() => setOfferFor(null)} />
</Panel>
)
}
function ApplicationRow({
app,
mayReview,
onOffer,
}: {
app: InboundApplication
mayReview: boolean
onOffer: () => void
}) {
const review = useReviewApplication()
const convert = useConvertApplication()
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('')
const status = APPLICATION_STATUSES.find((s) => s.value === app.status)
return (
<li className="rounded-lg border border-line/60 bg-navy-950/30 px-3 py-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setOpen(!open)}
className="text-sm font-semibold text-ink hover:underline"
>
{app.full_name}
</button>
<Badge color={app.status === 'new' ? 'var(--color-warn)' : 'var(--color-blue)'} subtle>
{status?.label ?? app.status}
</Badge>
{app.has_resume && <span className="text-[11px] text-faint">resume attached</span>}
</div>
<div className="text-[11px] text-faint">
{app.posting_title} · {app.email}
{app.location_text ? ` · ${app.location_text}` : ''} ·{' '}
{new Date(app.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* An application that BECAME a candidate links to the record rather than offering convert again.
Converting twice would create the duplicate this product has a whole screen for removing. */}
{app.candidate_id ? (
<Link to={`/bench/candidate/${app.candidate_id}`} className={btnGhost}>
Open the record
</Link>
) : (
mayReview && (
<>
<button
type="button"
className={btnPrimary}
disabled={convert.isPending}
onClick={() => convert.mutate(app.id)}
>
<UserPlus size={13} /> {convert.isPending ? 'Adding' : 'Add to the bench'}
</button>
<button type="button" className={btnGhost} onClick={() => setOpen(!open)}>
Review it
</button>
</>
)
)}
{app.candidate_id && mayReview && (
<button type="button" className={btnGhost} onClick={onOffer}>
<CalendarClock size={13} /> Offer times
</button>
)}
</div>
</div>
{open && (
<div className="mt-2 space-y-2 border-t border-line/40 pt-2">
{app.cover_note && (
<p className="whitespace-pre-wrap text-[11px] leading-relaxed text-muted">{app.cover_note}</p>
)}
<div className="flex flex-wrap gap-3 text-[11px] text-faint">
{app.phone && <span>{app.phone}</span>}
{app.clearance_self_reported && <span>clearance stated as {app.clearance_self_reported}</span>}
<span>came from {app.source}</span>
{/* Consent is shown because it is the legal basis for holding this person's details at all, and
an application without one is a fact somebody needs to see rather than a missing field. */}
{app.consented_at ? (
<span>consented {new Date(app.consented_at).toLocaleDateString()}</span>
) : (
<span className="text-warn">no consent recorded</span>
)}
</div>
{app.status_reason && <Note tone="muted">Recorded reason: {app.status_reason}</Note>}
{mayReview && !app.candidate_id && (
<div className="flex flex-wrap items-end gap-2">
<input
value={reason}
maxLength={240}
placeholder="Why, in a sentence"
onChange={(e) => setReason(e.target.value)}
className={`${fieldCls} max-w-sm`}
/>
{APPLICATION_STATUSES.filter((s) => s.value !== 'new' && s.value !== 'accepted').map((s) => (
<button
key={s.value}
type="button"
className={btnGhost}
title={s.hint}
onClick={() => review.mutate({ id: app.id, status: s.value, reason: reason.trim() })}
>
{s.label}
</button>
))}
</div>
)}
{review.isError && <Note tone="risk">{(review.error as Error).message}</Note>}
{convert.isError && <Note tone="risk">{(convert.error as Error).message}</Note>}
</div>
)}
</li>
)
}
function OffersPanel({ mayReview }: { mayReview: boolean }) {
const offers = useInterviewOffers()
const withdraw = useWithdrawOffer()
const branding = useOrgBranding()
const slug = branding.data?.slug ?? ''
const list = offers.data?.offers ?? []
const open = list.filter((o) => o.status === 'open')
return (
<Panel title="Interview scheduling" hint={open.length === 1 ? '1 waiting' : `${open.length} waiting`}>
<p className="pb-2 text-[11px] leading-relaxed text-faint">
Offer somebody a few times and send them the link. They pick one, and the interview is created when
they do, so nobody spends a morning trading messages about Thursday.
</p>
{offers.isError ? (
<Note tone="risk">{(offers.error as Error).message}</Note>
) : list.length === 0 ? (
<Empty
title="Nothing scheduled"
reason="Offer times from an application once it is on the bench, and the link appears here."
/>
) : (
<ul className="space-y-2">
{list.map((o) => (
<li
key={o.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line/60 bg-navy-950/30 px-3 py-2"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-ink">{o.candidate_name}</span>
<span className="text-faint">for {o.role}</span>
<Badge color={o.status === 'open' ? 'var(--color-warn)' : 'var(--color-ok)'} subtle>
{o.status}
</Badge>
</div>
<div className="text-[11px] text-faint">
{o.kind} ·{' '}
{o.times_available === 1 ? '1 time left' : `${o.times_available} times left`} · expires{' '}
{new Date(o.expires_at).toLocaleDateString()}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{o.status === 'open' && slug && (
<CopyLink
url={`${window.location.origin}/careers/${encodeURIComponent(slug)}/schedule/${encodeURIComponent(o.public_token)}`}
label="Copy the link"
/>
)}
{mayReview && o.status === 'open' && (
<button type="button" className={btnGhost} onClick={() => withdraw.mutate(o.id)}>
<X size={12} /> Withdraw
</button>
)}
</div>
</li>
))}
</ul>
)}
{withdraw.isError && (
<div className="mt-2">
<Note tone="risk">{(withdraw.error as Error).message}</Note>
</div>
)}
</Panel>
)
}
function NewOfferModal({ app, onClose }: { app: InboundApplication | null; onClose: () => void }) {
const create = useCreateOffer()
const [kind, setKind] = useState('phone')
const [duration, setDuration] = useState(30)
const [interviewer, setInterviewer] = useState('')
const [location, setLocation] = useState('')
const [message, setMessage] = useState('')
const [slots, setSlots] = useState<string[]>([''])
if (!app) return null
const usable = slots.map((s) => s.trim()).filter(Boolean)
return (
<Modal open={!!app} onClose={onClose} title={`Offer times to ${app.full_name}`}>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault()
if (usable.length === 0) return
create.mutate(
{
application_id: app.id,
kind,
round: 1,
duration_mins: duration,
location_text: location,
interviewer,
message,
expires_in_days: 7,
// The browser gives a local time with no zone. Converting to an instant here means the
// candidate is offered the moment that was meant rather than the same clock reading in
// whatever zone they happen to be in.
slots: usable.map((s) => new Date(s).toISOString()),
},
{
onSuccess: () => {
setSlots([''])
setMessage('')
onClose()
},
},
)
}}
>
<div className="grid gap-2.5 sm:grid-cols-2">
<Labeled label="Kind">
<Select
value={kind}
choices={[
{ value: 'phone', label: 'Phone' },
{ value: 'video', label: 'Video' },
{ value: 'onsite', label: 'On site' },
]}
onChange={setKind}
className="w-full"
/>
</Labeled>
<Labeled label="How long">
<Select
value={String(duration)}
choices={[
{ value: '15', label: '15 minutes' },
{ value: '30', label: '30 minutes' },
{ value: '45', label: '45 minutes' },
{ value: '60', label: '1 hour' },
]}
onChange={(v) => setDuration(Number(v))}
className="w-full"
/>
</Labeled>
<Labeled label="Who is interviewing">
<input
value={interviewer}
maxLength={200}
onChange={(e) => setInterviewer(e.target.value)}
className={fieldCls}
/>
</Labeled>
<Labeled label="Where" hint="a dial in number, a link, or an address">
<input
value={location}
maxLength={400}
onChange={(e) => setLocation(e.target.value)}
className={fieldCls}
/>
</Labeled>
</div>
<Labeled label="A note to them" hint="this is read by the candidate on the scheduling page">
<textarea
value={message}
rows={3}
maxLength={2000}
onChange={(e) => setMessage(e.target.value)}
className={fieldCls}
/>
</Labeled>
<fieldset className="space-y-2">
<legend className="text-[11px] font-bold uppercase tracking-wider text-muted">
Times to offer
</legend>
{slots.map((s, i) => (
<div key={i} className="flex gap-2">
<input
type="datetime-local"
value={s}
onChange={(e) => setSlots(slots.map((v, j) => (j === i ? e.target.value : v)))}
className={fieldCls}
/>
{slots.length > 1 && (
<button
type="button"
className={btnGhost}
onClick={() => setSlots(slots.filter((_, j) => j !== i))}
>
<X size={12} />
</button>
)}
</div>
))}
<button type="button" className={btnGhost} onClick={() => setSlots([...slots, ''])}>
<Plus size={12} /> Another time
</button>
<p className="text-[11px] text-faint">
Times are in your own time zone here, and are shown to the candidate in theirs.
</p>
</fieldset>
{create.isError && <Note tone="risk">{(create.error as Error).message}</Note>}
<button type="submit" disabled={usable.length === 0 || create.isPending} className={btnPrimary}>
<Inbox size={13} /> {create.isPending ? 'Creating' : 'Create the link'}
</button>
<p className="text-[11px] text-faint">
Nothing is sent for you. The link appears in the list above for you to send.
</p>
</form>
</Modal>
)
}