diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 1cb7463..118f1f7 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,2434 +1,16 @@ -import { - ArrowLeft, - ArrowRight, - BriefcaseBusiness, - CalendarDays, - Check, - ChevronRight, - CircleHelp, - Clock3, - Copy, - FileText, - LayoutDashboard, - LogOut, - Menu, - Pencil, - Plus, - Search, - Send, - Sparkles, - Trash2, - Upload, - UserRound, - UsersRound, - X, -} from 'lucide-react' -import { - createContext, - type FormEvent, - type ReactNode, - useContext, - useEffect, - useRef, - useState, -} from 'react' -import { - Link, - NavLink, - Navigate, - Route, - Routes, - useLocation, - useNavigate, - useParams, -} from 'react-router-dom' -import { api, ApiError, authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../api/client' -import { - CalendarPage, - InboxPage, - ParticipantMeetingPage, - ScheduleInterviewForm, - ScheduledInterviewPage, -} from '../features/interviews' -import { UsersPage } from '../features/users' -import { ProfilePage } from '../features/users' -import { AppFooter } from '../components/layout/AppFooter' -import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities' -import type { - CandidateMatch, - CreateJobInput, - CreateParticipantInput, - CreateQuestionInput, - Interview, - Invitation, - Job, - JobStatus, - Participant, - Question, - QuestionType, - ScheduledInterview, -} from '../types' - -interface AppData { - jobs: Job[] - participants: Participant[] - loading: boolean - error: string - createJob: (input: CreateJobInput) => Promise - createParticipant: (input: CreateParticipantInput) => Promise - reload: () => Promise -} - -const DataContext = createContext(null) - -function useData() { - const value = useContext(DataContext) - if (!value) throw new Error('useData must be used within DataProvider') - return value -} - -function errorMessage(error: unknown) { - return error instanceof ApiError || error instanceof Error - ? error.message - : 'Something went wrong' -} - -function DataProvider({ children }: { children: ReactNode }) { - const [jobs, setJobs] = useState([]) - const [participants, setParticipants] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - async function reload() { - setLoading(true) - setError('') - try { - const [jobItems, participantItems] = await Promise.all([ - api.jobs.list(), - api.participants.list(), - ]) - setJobs(jobItems) - setParticipants(participantItems) - } catch (requestError) { - setError(errorMessage(requestError)) - } finally { - setLoading(false) - } - } - - useEffect(() => { - let active = true - void Promise.all([api.jobs.list(), api.participants.list()]) - .then(([jobItems, participantItems]) => { - if (!active) return - setJobs(jobItems) - setParticipants(participantItems) - }) - .catch((requestError: unknown) => { - if (active) setError(errorMessage(requestError)) - }) - .finally(() => { - if (active) setLoading(false) - }) - return () => { - active = false - } - }, []) - - async function createJob(input: CreateJobInput) { - const created = await api.jobs.create(input) - setJobs((current) => [created, ...current]) - return created - } - - async function createParticipant(input: CreateParticipantInput) { - const created = await api.participants.create(input) - setParticipants((current) => [created, ...current]) - return created - } - - return ( - - {children} - - ) -} - -function Logo() { - return ( - - - - GO GUESS - Talent workspace - - - ) -} - -function LoginPage({ onLogin, sessionExpired }: { onLogin: () => void; sessionExpired: boolean }) { - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - - async function submit(event: FormEvent) { - event.preventDefault() - setLoading(true) - setError('') - const form = new FormData(event.currentTarget) - try { - const auth = await api.login(String(form.get('email')), String(form.get('password'))) - authStorage.save(auth) - onLogin() - } catch (requestError) { - setError(errorMessage(requestError)) - } finally { - setLoading(false) - } - } - - return ( -
-
- -
- Better hiring, clearly connected -

Find the right person for every journey.

-

Bring roles, assessments, and candidate insight together in one workspace.

-
- - - Skills-first matching - Structured signals for confident decisions - -
-
-

Designed for fair, collaborative recruitment.

-
-
-
- Welcome back -

Sign in to your workspace

- {sessionExpired && ( -
- Your session is no longer active. Sign in again to continue. -
- )} -

Use your work account to continue.

-
- - - - - - - {error && } - - -

- Contact your administrator for access. -

-
-
-
- ) -} - -function AppLayout({ onLogout }: { onLogout: () => void }) { - const [open, setOpen] = useState(false) - const user = authStorage.user() - return ( -
-
- - -
- {open && ( - -
- -
- -
- -
- -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
- -
- - ) -} - -function NavItem({ - to, - label, - icon, - close, -}: { - to: string - label: string - icon: ReactNode - close: () => void -}) { - return ( - (isActive ? 'nav-link active' : 'nav-link')} - > - {icon} - {label} - - ) -} - -function PageHeader({ - eyebrow, - title, - description, - action, -}: { - eyebrow: string - title: string - description?: string - action?: ReactNode -}) { - return ( -
-
- {eyebrow} -

{title}

- {description &&

{description}

} -
- {action} -
- ) -} - -function JobsPage() { - const { jobs, loading, error, reload } = useData() - const [query, setQuery] = useState('') - const filtered = jobs.filter((job) => - `${job.title} ${job.position} ${job.seniority} ${job.labels.join(' ')}` - .toLowerCase() - .includes(query.toLowerCase()), - ) - return ( - <> - - Create job - - } - /> - {error && } -
- - - sum + job.questions.length, 0))} - /> -
-
-
- - {filtered.length} jobs -
- {loading ? ( - - ) : ( -
- - - - - - - - - - - - - {filtered.map((job) => ( - - - - - - - - - ))} - -
RoleSeniorityPositionStatusQuestions - View -
- - {job.title} - - {job.labels.join(' · ') || 'No labels'} - - - {job.position} - - {job.questions.length} - - - -
-
- )} - {!loading && !filtered.length && !error && ( - - )} -
- - ) -} - -function JobDetailPage() { - const id = Number(useParams().jobId) - const [job, setJob] = useState() - const [matches, setMatches] = useState([]) - const [invitations, setInvitations] = useState([]) - const [scheduledInterviews, setScheduledInterviews] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>( - 'overview', - ) - - async function load() { - setLoading(true) - setError('') - try { - const [jobValue, candidates, invitationItems, scheduledItems] = await Promise.all([ - api.jobs.get(id), - api.jobs.candidates(id), - api.jobs.invitations(id), - api.jobs.interviews(id), - ]) - setJob(jobValue) - setMatches(candidates) - setInvitations(invitationItems) - setScheduledInterviews(scheduledItems) - } catch (requestError) { - setError(errorMessage(requestError)) - } finally { - setLoading(false) - } - } - useEffect(() => { - let active = true - void Promise.all([ - api.jobs.get(id), - api.jobs.candidates(id), - api.jobs.invitations(id), - api.jobs.interviews(id), - ]) - .then(([jobValue, candidates, invitationItems, scheduledItems]) => { - if (!active) return - setJob(jobValue) - setMatches(candidates) - setInvitations(invitationItems) - setScheduledInterviews(scheduledItems) - }) - .catch((requestError: unknown) => { - if (active) setError(errorMessage(requestError)) - }) - .finally(() => { - if (active) setLoading(false) - }) - return () => { - active = false - } - }, [id]) - if (loading) return - if (error) return - if (!job) return - - return ( - <> - - All jobs - - -
- {(['overview', 'questions', 'candidates', 'invitations'] as const).map((item) => ( - - ))} -
- {tab === 'overview' && ( -
-
-

About the role

-

{job.description}

-

Required skills

- -

Additional skills

- -
- -
- )} - {tab === 'questions' && } - {tab === 'candidates' && } - {tab === 'invitations' && ( - setInvitations((current) => [invitation, ...current])} - onInvitationUpdated={(invitation) => - setInvitations((current) => - current.map((item) => (item.id === invitation.id ? invitation : item)), - ) - } - onScheduled={(interview) => setScheduledInterviews((current) => [interview, ...current])} - /> - )} - - ) -} - -function JobSettings({ job, onUpdated }: { job: Job; onUpdated: (job: Job) => void }) { - const [status, setStatus] = useState(job.status) - const [durationMinutes, setDurationMinutes] = useState(job.durationMinutes) - const [saving, setSaving] = useState(false) - const [error, setError] = useState('') - - async function save(event: FormEvent) { - event.preventDefault() - setSaving(true) - setError('') - try { - onUpdated(await api.jobs.update(job.id, { status, durationMinutes })) - } catch (requestError) { - setError(errorMessage(requestError)) - } finally { - setSaving(false) - } - } - - return ( -
- - - - - setDurationMinutes(Number(event.target.value))} - /> - - {error && } - - - ) -} - -function JobQuestionsView({ job, onUpdated }: { job: Job; onUpdated: (job: Job) => void }) { - const [query, setQuery] = useState('') - const [results, setResults] = useState([]) - const [error, setError] = useState('') - const attached = new Set(job.questions.map((question) => question.id)) - - useEffect(() => { - let active = true - const timeout = window.setTimeout(() => { - void api.questions - .list(query) - .then((questions) => { - if (active) setResults(questions) - }) - .catch((requestError) => { - if (active) setError(errorMessage(requestError)) - }) - }, 200) - return () => { - active = false - window.clearTimeout(timeout) - } - }, [query]) - - async function attach(question: Question) { - setError('') - try { - await api.jobs.attachQuestion(job.id, question.id) - onUpdated({ ...job, questions: [...job.questions, question] }) - } catch (requestError) { - setError(errorMessage(requestError)) - } - } - - async function detach(question: Question) { - setError('') - try { - await api.jobs.detachQuestion(job.id, question.id) - onUpdated({ - ...job, - questions: job.questions.filter((item) => item.id !== question.id), - }) - } catch (requestError) { - setError(errorMessage(requestError)) - } - } - - return ( -
-
-
-
-

Assessment questions

-

Detach removes only this job’s link to the library question.

-
-
-
- {job.questions.map((question, index) => ( - void detach(question)} - aria-label={`Detach ${question.text}`} - > - Detach - - } - /> - ))} - {!job.questions.length && ( - - )} -
-
-
-
-
-

Question library

-

Search reusable, active questions.

-
- - Manage library - -
-
- - {error && } -
-
- {results - .filter((question) => !question.deprecated && !attached.has(question.id)) - .map((question) => ( - void attach(question)} - > - Attach - - } - /> - ))} -
-
-
- ) -} - -function InvitationsView({ - job, - matches, - invitations, - scheduledInterviews, - onInvited, - onInvitationUpdated, - onScheduled, -}: { - job: Job - matches: CandidateMatch[] - invitations: Invitation[] - scheduledInterviews: ScheduledInterview[] - onInvited: (invitation: Invitation) => void - onInvitationUpdated: (invitation: Invitation) => void - onScheduled: (interview: ScheduledInterview) => void -}) { - const [sendingId, setSendingId] = useState() - const [review, setReview] = useState() - const [reviewingId, setReviewingId] = useState() - const [error, setError] = useState('') - const [scheduling, setScheduling] = useState() - const invitedIds = new Set(invitations.map((invitation) => invitation.participantId)) - const eligible = matches.filter((match) => !invitedIds.has(match.participant.id)) - - async function send(participantId: number) { - setSendingId(participantId) - setError('') - try { - onInvited(await api.jobs.invite(job.id, participantId)) - } catch (requestError) { - setError(errorMessage(requestError)) - } finally { - setSendingId(undefined) - } - } - - async function copy(url: string) { - try { - await navigator.clipboard.writeText(url) - } catch { - setError('Could not copy the participant URL') - } - } - - async function reviewAnswers(invitationId: number) { - setReviewingId(invitationId) - setError('') - try { - setReview(await api.jobs.invitation(job.id, invitationId)) - } catch (requestError) { - setError(errorMessage(requestError)) - } finally { - setReviewingId(undefined) - } - } - - return ( -
-
-
-

Interview invitations

-

Invite an eligible candidate and share their private participant URL.

-
-
-
- {eligible.map(({ participant }) => ( -
- -
- - {participant.firstName} {participant.lastName} - - {participant.email} -
- -
- ))} - {!eligible.length &&

All eligible candidates have been invited.

} -
- {error && } -
- {invitations.map((invitation) => ( -
-
- {invitation.participantName} - {invitation.participantEmail} -
- - {invitation.participantUrl} - - {['accepted', 'completed'].includes(invitation.status) && ( - - )} -
- ))} - {!invitations.length && ( - - )} -
- {review && ( - setReview(undefined)} - onOutcome={(invitation) => { - onInvitationUpdated(invitation) - setReview((current) => current && { ...current, invitation }) - }} - /> - )} - {scheduling && ( - setScheduling(undefined)} - onCreated={(interview) => { - onScheduled(interview) - setScheduling(undefined) - }} - /> - )} -
- {invitations - .filter((invitation) => invitation.outcome === 'passed') - .filter( - (invitation) => - !scheduledInterviews.some( - (interview) => interview.participantId === invitation.participantId, - ), - ) - .map((invitation) => ( - - ))} - {scheduledInterviews.map((interview) => ( - - - {interview.participantName} - - {new Date(interview.startsAt).toLocaleString()} · {interview.location} - - - - - ))} -
-
- ) -} - -function InvitationAnswerReview({ - interview, - onClose, - onOutcome, -}: { - interview: Interview - onClose: () => void - onOutcome: (invitation: Invitation) => void -}) { - const [saving, setSaving] = useState(false) - const [error, setError] = useState('') - - async function decide(outcome: 'passed' | 'failed') { - setSaving(true) - setError('') - try { - onOutcome( - await api.jobs.setInvitationOutcome( - interview.invitation.jobId, - interview.invitation.id, - outcome, - ), - ) - } catch (requestError) { - setError(errorMessage(requestError)) - } finally { - setSaving(false) - } - } - return ( -
-
-
- Participant submission -

{interview.invitation.participantName}

-

{interview.invitation.participantEmail}

-
- {interview.invitation.status === 'completed' && ( -
-
- Assessment decision -

Record whether this candidate should proceed to a scheduled interview.

-
- - - {interview.invitation.outcome && } -
- )} - {error && } -
- - -
-
-
- {interview.job.questions.map((question, index) => { - const answer = interview.answers[String(question.id)]?.trim() - return ( -
- {index + 1} -
- {question.type.replace('_', ' ')} -

{question.text}

- {question.type === 'code_review' && question.codeSnippet && ( - - )} - {(question.type === 'multiple_choice' || question.type === 'radio') && - question.options.length > 0 && ( -
- Possible options - {question.options.join(' · ')} -
- )} - {(question.type === 'open' || question.type === 'code_review') && - question.referenceAnswer && ( -
- Reference answer -

{question.referenceAnswer}

-
- )} -
- Participant answer -

{answer || 'Unanswered'}

-
-
-
- ) - })} -
-
- ) -} - -function QuestionRow({ - question, - number, - action, -}: { - question: Question - number?: number - action?: ReactNode -}) { - return ( -
- {number ? {number} : } -
-
-

{question.type.replace('_', ' ')}

- {question.deprecated && } -
-

{question.text}

- {question.options.length > 0 && {question.options.join(' · ')}} - {question.type === 'code_review' && question.codeSnippet && ( -
-            {question.codeSnippet}
-          
- )} - {question.referenceAnswer && ( -
- Reference answer -

{question.referenceAnswer}

-
- )} -
- {action &&
{action}
} -
- ) -} - -function CandidateMatchView({ matches }: { matches: CandidateMatch[] }) { - return ( -
-
-
-

Candidate match

-

Trait alignment calculated by the API.

-
- - Match insight - -
-
- {matches.map((match, index) => ( -
- {index + 1} - -
- - {match.participant.firstName} {match.participant.lastName} - - {match.participant.email} -
-
-
- Matched - {match.matchedTraits.length} -
- - - -
-
-
- Missing - {match.missingTraits.length} -
-
-
- {Math.round(match.score)}% - Overall match -
-
- ))} - {!matches.length && ( - - )} -
-
- ) -} - -function CreateJobPage() { - const { createJob } = useData() - const navigate = useNavigate() - const [saving, setSaving] = useState(false) - const [error, setError] = useState('') - async function submit(event: FormEvent) { - event.preventDefault() - setSaving(true) - setError('') - const form = new FormData(event.currentTarget) - const split = (name: string) => - String(form.get(name)) - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - try { - const job = await createJob({ - title: String(form.get('title')), - description: String(form.get('description')), - seniority: String(form.get('seniority')), - position: String(form.get('position')), - labels: split('labels'), - requiredSkills: split('requiredSkills'), - additionalSkills: split('additionalSkills'), - }) - navigate(`/jobs/${job.id}`) - } catch (requestError) { - setError(errorMessage(requestError)) - setSaving(false) - } - } - return ( - <> - - Cancel - - -
-
-

Role information

-
- - - - - - - - - - - - -
-
-
-

Role profile

- -