refactor: split frontend application features
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
83d14d23a2
commit
5d7a57fd00
32 changed files with 2563 additions and 2433 deletions
File diff suppressed because it is too large
Load diff
49
frontend/src/app/AuthenticatedApp.tsx
Normal file
49
frontend/src/app/AuthenticatedApp.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useState } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { authStorage } from '../api/client'
|
||||
import { AppFooter } from '../components/layout/AppFooter'
|
||||
import { GoGuessHeader } from '../components/layout/GoGuessHeader'
|
||||
import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities'
|
||||
import { NotFound } from '../components/ui/NotFound'
|
||||
import { CalendarPage, InboxPage, ScheduledInterviewPage } from '../features/interviews'
|
||||
import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs'
|
||||
import {
|
||||
CreateParticipantPage,
|
||||
ParticipantDetailPage,
|
||||
ParticipantsPage,
|
||||
} from '../features/participants'
|
||||
import { QuestionLibraryPage, QuestionPage } from '../features/questions'
|
||||
import { ProfilePage, UsersPage } from '../features/users'
|
||||
|
||||
export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const user = authStorage.user()
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout} />
|
||||
<main className="main-content">
|
||||
<WorkspaceUtilities user={user} />
|
||||
<div className="workspace-content">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/jobs" replace />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/jobs/new" element={<CreateJobPage />} />
|
||||
<Route path="/jobs/:jobId" element={<JobDetailPage />} />
|
||||
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage />} />
|
||||
<Route path="/questions" element={<QuestionLibraryPage />} />
|
||||
<Route path="/participants" element={<ParticipantsPage />} />
|
||||
<Route path="/participants/new" element={<CreateParticipantPage />} />
|
||||
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<AppFooter />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
frontend/src/app/DataContext.tsx
Normal file
68
frontend/src/app/DataContext.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { errorMessage } from '../components/actions'
|
||||
import type { CreateJobInput, CreateParticipantInput, Job, Participant } from '../types'
|
||||
import { DataContext } from './dataState'
|
||||
|
||||
export function DataProvider({ children }: { children: ReactNode }) {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
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 (
|
||||
<DataContext.Provider
|
||||
value={{ jobs, participants, loading, error, createJob, createParticipant, reload }}
|
||||
>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
)
|
||||
}
|
||||
14
frontend/src/app/dataState.ts
Normal file
14
frontend/src/app/dataState.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createContext } from 'react'
|
||||
import type { CreateJobInput, CreateParticipantInput, Job, Participant } from '../types'
|
||||
|
||||
export interface AppData {
|
||||
jobs: Job[]
|
||||
participants: Participant[]
|
||||
loading: boolean
|
||||
error: string
|
||||
createJob: (input: CreateJobInput) => Promise<Job>
|
||||
createParticipant: (input: CreateParticipantInput) => Promise<Participant>
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
export const DataContext = createContext<AppData | null>(null)
|
||||
8
frontend/src/app/useData.ts
Normal file
8
frontend/src/app/useData.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { useContext } from 'react'
|
||||
import { DataContext } from './dataState'
|
||||
|
||||
export function useData() {
|
||||
const value = useContext(DataContext)
|
||||
if (!value) throw new Error('useData must be used within DataProvider')
|
||||
return value
|
||||
}
|
||||
47
frontend/src/components/actions/Account.tsx
Normal file
47
frontend/src/components/actions/Account.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
|
||||
|
||||
export function useAuthSession() {
|
||||
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
||||
const [sessionExpired, setSessionExpired] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function handleSessionExpired() {
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(true)
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) return
|
||||
const expiresAt = authStorage.expiresAt()
|
||||
if (expiresAt === null) return
|
||||
const remaining = expiresAt - Date.now()
|
||||
if (remaining <= 0) {
|
||||
invalidateSession()
|
||||
return
|
||||
}
|
||||
const timeout = window.setTimeout(invalidateSession, remaining)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [authenticated])
|
||||
|
||||
function completeLogin() {
|
||||
setAuthenticated(true)
|
||||
setSessionExpired(false)
|
||||
navigate('/jobs', { replace: true })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStorage.clear()
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(false)
|
||||
}
|
||||
|
||||
return { authenticated, sessionExpired, completeLogin, logout }
|
||||
}
|
||||
19
frontend/src/components/actions/ErrorAlert.tsx
Normal file
19
frontend/src/components/actions/ErrorAlert.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function ErrorAlert({
|
||||
message,
|
||||
retry,
|
||||
}: {
|
||||
message: string
|
||||
retry?: () => void | Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<div className="error-alert" role="alert">
|
||||
<strong>Request failed</strong>
|
||||
<span>{message}</span>
|
||||
{retry && (
|
||||
<button type="button" onClick={() => void retry()}>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
frontend/src/components/actions/Loading.tsx
Normal file
8
frontend/src/components/actions/Loading.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export function Loading() {
|
||||
return (
|
||||
<div className="empty-state" role="status">
|
||||
<span className="loading-spinner" />
|
||||
<p>Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/actions/errorMessage.ts
Normal file
7
frontend/src/components/actions/errorMessage.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ApiError } from '../../api/client'
|
||||
|
||||
export function errorMessage(error: unknown) {
|
||||
return error instanceof ApiError || error instanceof Error
|
||||
? error.message
|
||||
: 'Something went wrong'
|
||||
}
|
||||
4
frontend/src/components/actions/index.ts
Normal file
4
frontend/src/components/actions/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { useAuthSession } from './Account'
|
||||
export { ErrorAlert } from './ErrorAlert'
|
||||
export { errorMessage } from './errorMessage'
|
||||
export { Loading } from './Loading'
|
||||
24
frontend/src/components/form/FormActions.tsx
Normal file
24
frontend/src/components/form/FormActions.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
export function FormActions({
|
||||
saving,
|
||||
cancelTo,
|
||||
label,
|
||||
}: {
|
||||
saving: boolean
|
||||
cancelTo: string
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className="form-actions">
|
||||
<Link className="button ghost" to={cancelTo}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button className="button primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : label}
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontend/src/components/form/FormField.tsx
Normal file
23
frontend/src/components/form/FormField.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
htmlFor: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <small>{hint}</small>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/form/index.ts
Normal file
2
frontend/src/components/form/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { FormActions } from './FormActions'
|
||||
export { FormField } from './FormField'
|
||||
32
frontend/src/components/layout/GoGuessHeader.tsx
Normal file
32
frontend/src/components/layout/GoGuessHeader.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Menu } from 'lucide-react'
|
||||
import Logo from './Logo'
|
||||
import { SideBar } from './SideBar'
|
||||
|
||||
export function GoGuessHeader({
|
||||
open,
|
||||
setOpen,
|
||||
onLogout,
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: (value: boolean) => void
|
||||
onLogout: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<header className="mobile-header">
|
||||
<Logo />
|
||||
<button className="icon-button" onClick={() => setOpen(true)} aria-label="Open navigation">
|
||||
<Menu />
|
||||
</button>
|
||||
</header>
|
||||
{open && (
|
||||
<button
|
||||
className="nav-backdrop"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close navigation"
|
||||
/>
|
||||
)}
|
||||
<SideBar open={open} setOpen={setOpen} onLogout={onLogout} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/layout/Logo.tsx
Normal file
17
frontend/src/components/layout/Logo.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
function Logo() {
|
||||
return (
|
||||
<Link className="logo" to="/jobs" aria-label="Go Guess home">
|
||||
<span className="logo-mark" aria-hidden="true">
|
||||
G
|
||||
</span>
|
||||
<span>
|
||||
<strong>GO GUESS</strong>
|
||||
<small>Talent workspace</small>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logo
|
||||
25
frontend/src/components/layout/NavItem.tsx
Normal file
25
frontend/src/components/layout/NavItem.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export function NavItem({
|
||||
to,
|
||||
label,
|
||||
icon,
|
||||
close,
|
||||
}: {
|
||||
to: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
close: () => void
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={close}
|
||||
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/layout/SideBar.tsx
Normal file
57
frontend/src/components/layout/SideBar.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
BriefcaseBusiness,
|
||||
CalendarDays,
|
||||
CircleHelp,
|
||||
LogOut,
|
||||
UserRound,
|
||||
UsersRound,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import Logo from './Logo'
|
||||
import { NavItem } from './NavItem'
|
||||
|
||||
export function SideBar({
|
||||
open,
|
||||
setOpen,
|
||||
onLogout,
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: (value: boolean) => void
|
||||
onLogout: () => void
|
||||
}) {
|
||||
const close = () => setOpen(false)
|
||||
return (
|
||||
<aside className={`sidebar ${open ? 'open' : ''}`}>
|
||||
<div className="sidebar-top">
|
||||
<Logo />
|
||||
<button className="icon-button mobile-only" onClick={close} aria-label="Close navigation">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label="Primary navigation">
|
||||
<p className="nav-label">Workspace</p>
|
||||
<NavItem to="/jobs" label="Jobs" icon={<BriefcaseBusiness size={20} />} close={close} />
|
||||
<NavItem
|
||||
to="/participants"
|
||||
label="Participants"
|
||||
icon={<UsersRound size={20} />}
|
||||
close={close}
|
||||
/>
|
||||
<NavItem
|
||||
to="/questions"
|
||||
label="Question library"
|
||||
icon={<CircleHelp size={20} />}
|
||||
close={close}
|
||||
/>
|
||||
<NavItem to="/calendar" label="Calendar" icon={<CalendarDays size={20} />} close={close} />
|
||||
<NavItem to="/users" label="Users" icon={<UserRound size={20} />} close={close} />
|
||||
</nav>
|
||||
<div className="sidebar-bottom">
|
||||
<button className="sidebar-signout" onClick={onLogout}>
|
||||
<LogOut size={18} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/ui/Display.tsx
Normal file
33
frontend/src/components/ui/Display.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { LayoutDashboard } from 'lucide-react'
|
||||
|
||||
export function StatusBadge({ value }: { value: string }) {
|
||||
return <span className={`status status-${value.toLowerCase()}`}>{value}</span>
|
||||
}
|
||||
|
||||
export function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<article className="metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="fact">
|
||||
<small>{label}</small>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<LayoutDashboard />
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
frontend/src/components/ui/NotFound.tsx
Normal file
14
frontend/src/components/ui/NotFound.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<section className="empty-page">
|
||||
<span className="eyebrow">404</span>
|
||||
<h1>Page not found</h1>
|
||||
<p>The page you requested is not available.</p>
|
||||
<Link className="button primary" to="/jobs">
|
||||
Back to jobs
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/ui/PageHeader.tsx
Normal file
24
frontend/src/components/ui/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
51
frontend/src/features/assessments/CodeReview.tsx
Normal file
51
frontend/src/features/assessments/CodeReview.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { FileText } from 'lucide-react'
|
||||
import { FormField } from '../../components/form'
|
||||
|
||||
export function CodePanel({ codeSnippet }: { codeSnippet: string }) {
|
||||
return (
|
||||
<section className="code-review-panel" aria-label="Code changes">
|
||||
<header>
|
||||
<FileText size={17} />
|
||||
<strong>Code changes</strong>
|
||||
</header>
|
||||
<div className="code-lines">
|
||||
{codeSnippet.split('\n').map((line, index) => {
|
||||
const added = line.startsWith('+') && !line.startsWith('+++')
|
||||
return (
|
||||
<div className={added ? 'code-line added' : 'code-line'} key={index}>
|
||||
<span className="line-number" aria-hidden="true">
|
||||
{index + 1}
|
||||
</span>
|
||||
<code>{line || ' '}</code>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeReviewAnswer({
|
||||
codeSnippet,
|
||||
answer,
|
||||
onChange,
|
||||
}: {
|
||||
codeSnippet: string
|
||||
answer: string
|
||||
onChange: (answer: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="code-review-answer">
|
||||
<CodePanel codeSnippet={codeSnippet} />
|
||||
<FormField label="Review comment" htmlFor="review-comment">
|
||||
<textarea
|
||||
id="review-comment"
|
||||
rows={7}
|
||||
value={answer}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Describe issues, suggestions, and approval notes…"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
343
frontend/src/features/assessments/ParticipantInterviewPage.tsx
Normal file
343
frontend/src/features/assessments/ParticipantInterviewPage.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import { ArrowLeft, ArrowRight, Check, CircleHelp, Clock3 } from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
|
||||
import Logo from '../../components/layout/Logo'
|
||||
import type { Interview } from '../../types'
|
||||
import { CodeReviewAnswer } from './CodeReview'
|
||||
import { NotFound } from '../../components/ui/NotFound'
|
||||
|
||||
export function ParticipantInterviewPage() {
|
||||
const token = useParams().invitationId ?? ''
|
||||
const [interview, setInterview] = useState<Interview>()
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({})
|
||||
const [index, setIndex] = useState(0)
|
||||
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [working, setWorking] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const finishing = useRef(false)
|
||||
const saveQueues = useRef<Record<string, Promise<void>>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void api.interviews
|
||||
.get(token)
|
||||
.then((value) => {
|
||||
if (!active) return
|
||||
setInterview(value)
|
||||
setAnswers(value.answers ?? {})
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (active) setError(errorMessage(requestError))
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const acceptedAt = interview?.invitation.acceptedAt
|
||||
const durationMinutes = interview?.job.durationMinutes
|
||||
useEffect(() => {
|
||||
if (!acceptedAt || !durationMinutes || interview?.invitation.completedAt) return
|
||||
const update = () => {
|
||||
const deadline = new Date(acceptedAt).getTime() + durationMinutes * 60_000
|
||||
setRemainingSeconds(Math.max(0, Math.ceil((deadline - Date.now()) / 1000)))
|
||||
}
|
||||
update()
|
||||
const interval = window.setInterval(update, 1000)
|
||||
return () => window.clearInterval(interval)
|
||||
}, [acceptedAt, durationMinutes, interview?.invitation.completedAt])
|
||||
|
||||
async function accept() {
|
||||
setWorking(true)
|
||||
setError('')
|
||||
try {
|
||||
const accepted = await api.interviews.accept(token)
|
||||
setInterview(accepted)
|
||||
setAnswers(accepted.answers ?? {})
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const questions = interview?.job.questions ?? []
|
||||
const currentQuestion = questions[index]
|
||||
|
||||
function updateAnswer(questionId: number, answer: string) {
|
||||
const key = String(questionId)
|
||||
setAnswers((current) => ({ ...current, [key]: answer }))
|
||||
setError('')
|
||||
const previous = saveQueues.current[key] ?? Promise.resolve()
|
||||
saveQueues.current[key] = previous
|
||||
.then(() => api.interviews.answer(token, questionId, answer))
|
||||
.catch((requestError) => {
|
||||
setError(errorMessage(requestError))
|
||||
})
|
||||
}
|
||||
|
||||
async function persistCurrent() {
|
||||
if (!currentQuestion) return
|
||||
await saveQueues.current[String(currentQuestion.id)]
|
||||
}
|
||||
|
||||
async function move(target: number) {
|
||||
setWorking(true)
|
||||
setError('')
|
||||
try {
|
||||
await persistCurrent()
|
||||
setIndex(Math.max(0, Math.min(target, questions.length - 1)))
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
if (finishing.current) return
|
||||
finishing.current = true
|
||||
setWorking(true)
|
||||
setError('')
|
||||
try {
|
||||
await persistCurrent()
|
||||
setInterview(await api.interviews.finish(token))
|
||||
} catch (requestError) {
|
||||
finishing.current = false
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<PublicInterviewShell>
|
||||
<Loading />
|
||||
</PublicInterviewShell>
|
||||
)
|
||||
if (error && !interview)
|
||||
return (
|
||||
<PublicInterviewShell>
|
||||
<ErrorAlert message={error} />
|
||||
</PublicInterviewShell>
|
||||
)
|
||||
if (!interview)
|
||||
return (
|
||||
<PublicInterviewShell>
|
||||
<NotFound />
|
||||
</PublicInterviewShell>
|
||||
)
|
||||
|
||||
if (interview.invitation.completedAt || interview.invitation.status === 'completed') {
|
||||
return (
|
||||
<PublicInterviewShell>
|
||||
<section className="interview-card completion-card">
|
||||
<span className="completion-mark">
|
||||
<Check />
|
||||
</span>
|
||||
<span className="eyebrow">Interview complete</span>
|
||||
<h1>Thank you, {interview.invitation.participantName}.</h1>
|
||||
<p>Your answers have been submitted. You may now close this page.</p>
|
||||
</section>
|
||||
</PublicInterviewShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!interview.invitation.acceptedAt) {
|
||||
return (
|
||||
<PublicInterviewShell>
|
||||
<section className="interview-card welcome-card">
|
||||
<span className="eyebrow">Interview invitation</span>
|
||||
<h1>Welcome, {interview.invitation.participantName}</h1>
|
||||
<h2>{interview.job.title}</h2>
|
||||
<p>{interview.job.description}</p>
|
||||
<p className="agreement-copy">
|
||||
By accepting, you agree to begin the timed interview and submit your responses.
|
||||
</p>
|
||||
<div className="welcome-facts">
|
||||
<span>
|
||||
<Clock3 size={18} /> {interview.job.durationMinutes} minutes
|
||||
</span>
|
||||
<span>
|
||||
<CircleHelp size={18} /> {questions.length} questions
|
||||
</span>
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<button className="button primary" type="button" disabled={working} onClick={accept}>
|
||||
{working ? 'Accepting…' : 'Accept'}
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</section>
|
||||
</PublicInterviewShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentQuestion) {
|
||||
return (
|
||||
<PublicInterviewShell>
|
||||
<section className="interview-card welcome-card">
|
||||
<h1>No interview questions</h1>
|
||||
<p>This interview does not contain any questions.</p>
|
||||
<button className="button primary" type="button" onClick={() => void finish()}>
|
||||
Finish
|
||||
</button>
|
||||
</section>
|
||||
</PublicInterviewShell>
|
||||
)
|
||||
}
|
||||
|
||||
const answer = answers[String(currentQuestion.id)] ?? ''
|
||||
const selectedOptions = answer ? answer.split('\n') : []
|
||||
const countdown = remainingSeconds ?? interview.job.durationMinutes * 60
|
||||
const minutes = Math.floor(countdown / 60)
|
||||
const seconds = countdown % 60
|
||||
const progress = ((index + 1) / questions.length) * 100
|
||||
|
||||
return (
|
||||
<PublicInterviewShell>
|
||||
<section className="interview-card question-stage">
|
||||
<header className="interview-header">
|
||||
<div>
|
||||
<span className="eyebrow">{interview.job.title}</span>
|
||||
<strong>
|
||||
Question {index + 1} of {questions.length}
|
||||
</strong>
|
||||
</div>
|
||||
<time className={countdown < 60 ? 'timer urgent' : 'timer'}>
|
||||
<Clock3 size={18} />
|
||||
<span data-testid="countdown">
|
||||
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
|
||||
</span>
|
||||
</time>
|
||||
</header>
|
||||
<div
|
||||
className="progress-track"
|
||||
role="progressbar"
|
||||
aria-label="Interview progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress)}
|
||||
>
|
||||
<span style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="interview-question">
|
||||
<span className="question-type">{currentQuestion.type.replace('_', ' ')}</span>
|
||||
<h1>{currentQuestion.text}</h1>
|
||||
{currentQuestion.type === 'radio' ? (
|
||||
<fieldset className="participant-options">
|
||||
<legend className="sr-only">Choose one answer</legend>
|
||||
{currentQuestion.options.map((option) => (
|
||||
<label key={option}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`question-${currentQuestion.id}`}
|
||||
checked={answer === option}
|
||||
onChange={() => updateAnswer(currentQuestion.id, option)}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : currentQuestion.type === 'multiple_choice' ? (
|
||||
<fieldset className="participant-options">
|
||||
<legend className="sr-only">Choose one or more answers</legend>
|
||||
{currentQuestion.options.map((option) => (
|
||||
<label key={option}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOptions.includes(option)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? [...selectedOptions, option]
|
||||
: selectedOptions.filter((item) => item !== option)
|
||||
updateAnswer(currentQuestion.id, next.join('\n'))
|
||||
}}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : currentQuestion.type === 'code_review' ? (
|
||||
<CodeReviewAnswer
|
||||
codeSnippet={currentQuestion.codeSnippet}
|
||||
answer={answer}
|
||||
onChange={(value) => updateAnswer(currentQuestion.id, value)}
|
||||
/>
|
||||
) : (
|
||||
<label>
|
||||
<span className="sr-only">Your answer</span>
|
||||
<textarea
|
||||
aria-label="Your answer"
|
||||
rows={7}
|
||||
value={answer}
|
||||
onChange={(event) => updateAnswer(currentQuestion.id, event.target.value)}
|
||||
placeholder="Type your answer…"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<footer className="interview-controls">
|
||||
<button
|
||||
className="button ghost"
|
||||
type="button"
|
||||
disabled={working || index === 0}
|
||||
onClick={() => void move(0)}
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
disabled={working || index === 0}
|
||||
onClick={() => void move(index - 1)}
|
||||
>
|
||||
<ArrowLeft size={17} /> Previous
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
disabled={working || index === questions.length - 1}
|
||||
onClick={() => void move(index + 1)}
|
||||
>
|
||||
Next <ArrowRight size={17} />
|
||||
</button>
|
||||
<button
|
||||
className="button ghost"
|
||||
type="button"
|
||||
disabled={working || index === questions.length - 1}
|
||||
onClick={() => void move(questions.length - 1)}
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={working}
|
||||
onClick={() => void finish()}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</PublicInterviewShell>
|
||||
)
|
||||
}
|
||||
|
||||
function PublicInterviewShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="public-interview">
|
||||
<header>
|
||||
<Logo />
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
1
frontend/src/features/assessments/index.ts
Normal file
1
frontend/src/features/assessments/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ParticipantInterviewPage } from './ParticipantInterviewPage'
|
||||
1
frontend/src/features/jobs/index.ts
Normal file
1
frontend/src/features/jobs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CreateJobPage, JobDetailPage, JobsPage, QuestionRow } from './pages'
|
||||
909
frontend/src/features/jobs/pages.tsx
Normal file
909
frontend/src/features/jobs/pages.tsx
Normal file
|
|
@ -0,0 +1,909 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Copy,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
|
||||
import { FormActions, FormField } from '../../components/form'
|
||||
import { EmptyState, Fact, Metric, StatusBadge } from '../../components/ui/Display'
|
||||
import { NotFound } from '../../components/ui/NotFound'
|
||||
import { PageHeader } from '../../components/ui/PageHeader'
|
||||
import { useData } from '../../app/useData'
|
||||
import { CodePanel } from '../assessments/CodeReview'
|
||||
import { ScheduleInterviewForm } from '../interviews'
|
||||
import { Avatar, SkillList } from '../participants'
|
||||
import type {
|
||||
CandidateMatch,
|
||||
Interview,
|
||||
Invitation,
|
||||
Job,
|
||||
JobStatus,
|
||||
Question,
|
||||
ScheduledInterview,
|
||||
} from '../../types'
|
||||
|
||||
export 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 (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Recruitment"
|
||||
title="Job postings"
|
||||
description="Create roles, build assessments, and compare candidate fit."
|
||||
action={
|
||||
<Link to="/jobs/new" className="button primary">
|
||||
<Plus size={18} /> Create job
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{error && <ErrorAlert message={error} retry={reload} />}
|
||||
<section className="metrics">
|
||||
<Metric label="Job postings" value={String(jobs.length)} />
|
||||
<Metric label="Participants" value="—" />
|
||||
<Metric
|
||||
label="Assessments"
|
||||
value={String(jobs.reduce((sum, job) => sum + job.questions.length, 0))}
|
||||
/>
|
||||
</section>
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<label className="search-field">
|
||||
<Search size={18} />
|
||||
<span className="sr-only">Search jobs</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search jobs…"
|
||||
/>
|
||||
</label>
|
||||
<span className="result-count">{filtered.length} jobs</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>Seniority</th>
|
||||
<th>Position</th>
|
||||
<th>Status</th>
|
||||
<th>Questions</th>
|
||||
<th>
|
||||
<span className="sr-only">View</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((job) => (
|
||||
<tr key={job.id}>
|
||||
<td>
|
||||
<Link className="table-title" to={`/jobs/${job.id}`}>
|
||||
{job.title}
|
||||
</Link>
|
||||
<small>{job.labels.join(' · ') || 'No labels'}</small>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge value={job.seniority} />
|
||||
</td>
|
||||
<td>{job.position}</td>
|
||||
<td>
|
||||
<StatusBadge value={job.status} />
|
||||
</td>
|
||||
<td>{job.questions.length}</td>
|
||||
<td>
|
||||
<Link
|
||||
className="icon-link"
|
||||
to={`/jobs/${job.id}`}
|
||||
aria-label={`View ${job.title}`}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !filtered.length && !error && (
|
||||
<EmptyState title="No jobs found" description="Create a job or adjust your search." />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function JobDetailPage() {
|
||||
const id = Number(useParams().jobId)
|
||||
const [job, setJob] = useState<Job>()
|
||||
const [matches, setMatches] = useState<CandidateMatch[]>([])
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([])
|
||||
const [scheduledInterviews, setScheduledInterviews] = useState<ScheduledInterview[]>([])
|
||||
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 <Loading />
|
||||
if (error) return <ErrorAlert message={error} retry={load} />
|
||||
if (!job) return <NotFound />
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/jobs">
|
||||
<ArrowLeft size={17} /> All jobs
|
||||
</Link>
|
||||
<PageHeader
|
||||
eyebrow={`${job.seniority} · ${job.position}`}
|
||||
title={job.title}
|
||||
description={job.description}
|
||||
/>
|
||||
<div className="tabs" role="tablist">
|
||||
{(['overview', 'questions', 'candidates', 'invitations'] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
role="tab"
|
||||
aria-selected={tab === item}
|
||||
className={tab === item ? 'active' : ''}
|
||||
onClick={() => setTab(item)}
|
||||
>
|
||||
{item === 'overview'
|
||||
? 'Overview'
|
||||
: item === 'questions'
|
||||
? `Questions (${job.questions.length})`
|
||||
: item === 'candidates'
|
||||
? `Candidate match (${matches.length})`
|
||||
: `Invitations (${invitations.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'overview' && (
|
||||
<div className="detail-grid">
|
||||
<section className="card prose">
|
||||
<h2>About the role</h2>
|
||||
<p>{job.description}</p>
|
||||
<h3>Required skills</h3>
|
||||
<SkillList skills={job.requiredSkills} />
|
||||
<h3>Additional skills</h3>
|
||||
<SkillList skills={job.additionalSkills} />
|
||||
</section>
|
||||
<aside className="card facts">
|
||||
<h2>Job details</h2>
|
||||
<Fact label="Seniority" value={job.seniority} />
|
||||
<Fact label="Position" value={job.position} />
|
||||
<Fact label="Status" value={job.status} />
|
||||
<Fact label="Duration" value={`${job.durationMinutes} minutes`} />
|
||||
<Fact label="Labels" value={job.labels.join(', ') || 'None'} />
|
||||
<Fact label="Created" value={new Date(job.createdAt).toLocaleDateString()} />
|
||||
<JobSettings job={job} onUpdated={setJob} />
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'questions' && <JobQuestionsView job={job} onUpdated={setJob} />}
|
||||
{tab === 'candidates' && <CandidateMatchView matches={matches} />}
|
||||
{tab === 'invitations' && (
|
||||
<InvitationsView
|
||||
job={job}
|
||||
matches={matches}
|
||||
invitations={invitations}
|
||||
scheduledInterviews={scheduledInterviews}
|
||||
onInvited={(invitation) => 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<JobStatus>(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 (
|
||||
<form className="inline-settings" onSubmit={save}>
|
||||
<FormField label="Interview status" htmlFor="job-status">
|
||||
<select
|
||||
id="job-status"
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as JobStatus)}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Duration (minutes)" htmlFor="job-duration">
|
||||
<input
|
||||
id="job-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={durationMinutes}
|
||||
onChange={(event) => setDurationMinutes(Number(event.target.value))}
|
||||
/>
|
||||
</FormField>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<button className="button secondary wide" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save interview settings'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function JobQuestionsView({ job, onUpdated }: { job: Job; onUpdated: (job: Job) => void }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<Question[]>([])
|
||||
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 (
|
||||
<div className="split-panels">
|
||||
<section className="card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Assessment questions</h2>
|
||||
<p>Detach removes only this job’s link to the library question.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="question-list">
|
||||
{job.questions.map((question, index) => (
|
||||
<QuestionRow
|
||||
key={question.id}
|
||||
question={question}
|
||||
number={index + 1}
|
||||
action={
|
||||
<button
|
||||
className="button ghost danger"
|
||||
type="button"
|
||||
onClick={() => void detach(question)}
|
||||
aria-label={`Detach ${question.text}`}
|
||||
>
|
||||
<Trash2 size={16} /> Detach
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!job.questions.length && (
|
||||
<EmptyState
|
||||
title="No questions yet"
|
||||
description="Search the library to attach a question."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Question library</h2>
|
||||
<p>Search reusable, active questions.</p>
|
||||
</div>
|
||||
<Link className="button secondary" to="/questions">
|
||||
Manage library
|
||||
</Link>
|
||||
</div>
|
||||
<div className="panel-padding">
|
||||
<label className="search-field">
|
||||
<Search size={18} />
|
||||
<span className="sr-only">Search question library</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search question library…"
|
||||
/>
|
||||
</label>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
</div>
|
||||
<div className="question-list compact">
|
||||
{results
|
||||
.filter((question) => !question.deprecated && !attached.has(question.id))
|
||||
.map((question) => (
|
||||
<QuestionRow
|
||||
key={question.id}
|
||||
question={question}
|
||||
action={
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
onClick={() => void attach(question)}
|
||||
>
|
||||
<Plus size={16} /> Attach
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<number>()
|
||||
const [review, setReview] = useState<Interview>()
|
||||
const [reviewingId, setReviewingId] = useState<number>()
|
||||
const [error, setError] = useState('')
|
||||
const [scheduling, setScheduling] = useState<Invitation>()
|
||||
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 (
|
||||
<section className="card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Interview invitations</h2>
|
||||
<p>Invite an eligible candidate and share their private participant URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="eligible-list">
|
||||
{eligible.map(({ participant }) => (
|
||||
<article className="eligible-row" key={participant.id}>
|
||||
<Avatar person={participant} />
|
||||
<div>
|
||||
<strong>
|
||||
{participant.firstName} {participant.lastName}
|
||||
</strong>
|
||||
<small>{participant.email}</small>
|
||||
</div>
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={sendingId === participant.id}
|
||||
onClick={() => void send(participant.id)}
|
||||
>
|
||||
<Send size={16} />{' '}
|
||||
{sendingId === participant.id ? 'Generating…' : 'Generate invitation'}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
{!eligible.length && <p className="muted">All eligible candidates have been invited.</p>}
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<div className="invitation-list">
|
||||
{invitations.map((invitation) => (
|
||||
<article className="invitation-row" key={invitation.id}>
|
||||
<div>
|
||||
<strong>{invitation.participantName}</strong>
|
||||
<small>{invitation.participantEmail}</small>
|
||||
</div>
|
||||
<StatusBadge value={invitation.status} />
|
||||
<code>{invitation.participantUrl}</code>
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
onClick={() => void copy(invitation.participantUrl)}
|
||||
aria-label={`Copy interview URL for ${invitation.participantName}`}
|
||||
>
|
||||
<Copy size={16} /> Copy URL
|
||||
</button>
|
||||
{['accepted', 'completed'].includes(invitation.status) && (
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
disabled={reviewingId === invitation.id}
|
||||
onClick={() => void reviewAnswers(invitation.id)}
|
||||
>
|
||||
{reviewingId === invitation.id ? 'Loading…' : 'Review answers'}
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{!invitations.length && (
|
||||
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
|
||||
)}
|
||||
</div>
|
||||
{review && (
|
||||
<InvitationAnswerReview
|
||||
interview={review}
|
||||
onClose={() => setReview(undefined)}
|
||||
onOutcome={(invitation) => {
|
||||
onInvitationUpdated(invitation)
|
||||
setReview((current) => current && { ...current, invitation })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{scheduling && (
|
||||
<ScheduleInterviewForm
|
||||
jobId={job.id}
|
||||
invitation={scheduling}
|
||||
onCancel={() => setScheduling(undefined)}
|
||||
onCreated={(interview) => {
|
||||
onScheduled(interview)
|
||||
setScheduling(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="scheduled-list">
|
||||
{invitations
|
||||
.filter((invitation) => invitation.outcome === 'passed')
|
||||
.filter(
|
||||
(invitation) =>
|
||||
!scheduledInterviews.some(
|
||||
(interview) => interview.participantId === invitation.participantId,
|
||||
),
|
||||
)
|
||||
.map((invitation) => (
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
key={invitation.id}
|
||||
onClick={() => setScheduling(invitation)}
|
||||
>
|
||||
<CalendarDays size={17} /> Schedule {invitation.participantName}
|
||||
</button>
|
||||
))}
|
||||
{scheduledInterviews.map((interview) => (
|
||||
<Link
|
||||
className="scheduled-summary"
|
||||
to={`/scheduled-interviews/${interview.id}`}
|
||||
key={interview.id}
|
||||
>
|
||||
<span>
|
||||
<strong>{interview.participantName}</strong>
|
||||
<small>
|
||||
{new Date(interview.startsAt).toLocaleString()} · {interview.location}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge value={interview.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="answer-review" aria-labelledby="answer-review-title">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Participant submission</span>
|
||||
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
|
||||
<p>{interview.invitation.participantEmail}</p>
|
||||
</div>
|
||||
{interview.invitation.status === 'completed' && (
|
||||
<div className="outcome-actions" aria-label="Candidate outcome">
|
||||
<div>
|
||||
<strong>Assessment decision</strong>
|
||||
<p>Record whether this candidate should proceed to a scheduled interview.</p>
|
||||
</div>
|
||||
<button
|
||||
className="button pass"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void decide('passed')}
|
||||
>
|
||||
<Check size={18} /> Pass candidate
|
||||
</button>
|
||||
<button
|
||||
className="button fail"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void decide('failed')}
|
||||
>
|
||||
<X size={18} /> Fail candidate
|
||||
</button>
|
||||
{interview.invitation.outcome && <StatusBadge value={interview.invitation.outcome} />}
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<div className="review-heading-actions">
|
||||
<StatusBadge value={interview.invitation.status} />
|
||||
<button className="button ghost" type="button" onClick={onClose}>
|
||||
Close review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-question-list">
|
||||
{interview.job.questions.map((question, index) => {
|
||||
const answer = interview.answers[String(question.id)]?.trim()
|
||||
return (
|
||||
<article className="review-question" key={question.id}>
|
||||
<span className="question-number">{index + 1}</span>
|
||||
<div>
|
||||
<span className="question-type">{question.type.replace('_', ' ')}</span>
|
||||
<h3>{question.text}</h3>
|
||||
{question.type === 'code_review' && question.codeSnippet && (
|
||||
<CodePanel codeSnippet={question.codeSnippet} />
|
||||
)}
|
||||
{(question.type === 'multiple_choice' || question.type === 'radio') &&
|
||||
question.options.length > 0 && (
|
||||
<div className="review-options">
|
||||
<strong>Possible options</strong>
|
||||
<span>{question.options.join(' · ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(question.type === 'open' || question.type === 'code_review') &&
|
||||
question.referenceAnswer && (
|
||||
<div className="reference-answer">
|
||||
<strong>Reference answer</strong>
|
||||
<p>{question.referenceAnswer}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={answer ? 'participant-answer' : 'participant-answer unanswered'}>
|
||||
<strong>Participant answer</strong>
|
||||
<p>{answer || 'Unanswered'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuestionRow({
|
||||
question,
|
||||
number,
|
||||
action,
|
||||
}: {
|
||||
question: Question
|
||||
number?: number
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<article className="question-row">
|
||||
{number ? <span className="question-number">{number}</span> : <CircleHelp size={22} />}
|
||||
<div>
|
||||
<div className="question-meta">
|
||||
<h3>{question.type.replace('_', ' ')}</h3>
|
||||
{question.deprecated && <StatusBadge value="deprecated" />}
|
||||
</div>
|
||||
<p>{question.text}</p>
|
||||
{question.options.length > 0 && <small>{question.options.join(' · ')}</small>}
|
||||
{question.type === 'code_review' && question.codeSnippet && (
|
||||
<pre className="interviewer-code-snippet">
|
||||
<code>{question.codeSnippet}</code>
|
||||
</pre>
|
||||
)}
|
||||
{question.referenceAnswer && (
|
||||
<div className="reference-answer">
|
||||
<strong>Reference answer</strong>
|
||||
<p>{question.referenceAnswer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="question-action">{action}</div>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function CandidateMatchView({ matches }: { matches: CandidateMatch[] }) {
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Candidate match</h2>
|
||||
<p>Trait alignment calculated by the API.</p>
|
||||
</div>
|
||||
<span className="ai-label">
|
||||
<Sparkles size={16} /> Match insight
|
||||
</span>
|
||||
</div>
|
||||
<div className="match-list">
|
||||
{matches.map((match, index) => (
|
||||
<article className="match-card" key={match.participant.id}>
|
||||
<span className="rank">{index + 1}</span>
|
||||
<Avatar person={match.participant} />
|
||||
<div className="match-person">
|
||||
<Link to={`/participants/${match.participant.id}`}>
|
||||
{match.participant.firstName} {match.participant.lastName}
|
||||
</Link>
|
||||
<small>{match.participant.email}</small>
|
||||
</div>
|
||||
<div className="score">
|
||||
<div>
|
||||
<span>Matched</span>
|
||||
<strong>{match.matchedTraits.length}</strong>
|
||||
</div>
|
||||
<span className="score-track">
|
||||
<span style={{ width: `${Math.min(match.score, 100)}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="score">
|
||||
<div>
|
||||
<span>Missing</span>
|
||||
<strong>{match.missingTraits.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="match-total">
|
||||
<strong>{Math.round(match.score)}%</strong>
|
||||
<small>Overall match</small>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!matches.length && (
|
||||
<EmptyState
|
||||
title="No candidate matches"
|
||||
description="Matches appear when participants have extractable CV traits."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreateJobPage() {
|
||||
const { createJob } = useData()
|
||||
const navigate = useNavigate()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<>
|
||||
<Link className="back-link" to="/jobs">
|
||||
<ArrowLeft size={17} /> Cancel
|
||||
</Link>
|
||||
<PageHeader
|
||||
eyebrow="New posting"
|
||||
title="Create a job"
|
||||
description="Define the role and skills used for candidate matching."
|
||||
/>
|
||||
<form className="card form-card" onSubmit={submit}>
|
||||
<div className="form-section">
|
||||
<h2>Role information</h2>
|
||||
<div className="form-grid">
|
||||
<FormField label="Job title" htmlFor="title">
|
||||
<input id="title" name="title" required />
|
||||
</FormField>
|
||||
<FormField label="Position" htmlFor="position">
|
||||
<input id="position" name="position" required placeholder="e.g. Backend Engineer" />
|
||||
</FormField>
|
||||
<FormField label="Seniority" htmlFor="seniority">
|
||||
<input id="seniority" name="seniority" required placeholder="e.g. Senior" />
|
||||
</FormField>
|
||||
<FormField label="Labels" htmlFor="labels" hint="Comma separated">
|
||||
<input id="labels" name="labels" />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<h2>Role profile</h2>
|
||||
<FormField label="Description" htmlFor="description">
|
||||
<textarea id="description" name="description" required rows={7} />
|
||||
</FormField>
|
||||
<FormField label="Required skills" htmlFor="requiredSkills" hint="Comma separated">
|
||||
<input id="requiredSkills" name="requiredSkills" required />
|
||||
</FormField>
|
||||
<FormField label="Additional skills" htmlFor="additionalSkills" hint="Comma separated">
|
||||
<input id="additionalSkills" name="additionalSkills" />
|
||||
</FormField>
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<FormActions saving={saving} cancelTo="/jobs" label="Create job" />
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
87
frontend/src/features/login/LoginPage.tsx
Normal file
87
frontend/src/features/login/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { ArrowRight, CircleHelp, Sparkles } from 'lucide-react'
|
||||
import { SyntheticEvent, useState } from 'react'
|
||||
import Logo from '../../components/layout/Logo'
|
||||
import { FormField } from '../../components/form'
|
||||
import { ErrorAlert, errorMessage } from '../../components/actions'
|
||||
import { api, authStorage } from '../../api/client'
|
||||
|
||||
export function LoginPage({
|
||||
onLogin,
|
||||
sessionExpired,
|
||||
}: {
|
||||
onLogin: () => void
|
||||
sessionExpired: boolean
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(event: SyntheticEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<main className="login-shell">
|
||||
<section className="login-story">
|
||||
<Logo />
|
||||
<div className="story-copy">
|
||||
<span className="eyebrow">Better hiring, clearly connected</span>
|
||||
<h1>Find the right person for every journey.</h1>
|
||||
<p>Bring roles, assessments, and candidate insight together in one workspace.</p>
|
||||
<div className="story-stat">
|
||||
<Sparkles />
|
||||
<span>
|
||||
<strong>Skills-first matching</strong>
|
||||
<small>Structured signals for confident decisions</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="login-foot">Designed for fair, collaborative recruitment.</p>
|
||||
</section>
|
||||
<section className="login-panel" aria-labelledby="login-title">
|
||||
<div className="login-card">
|
||||
<span className="eyebrow">Welcome back</span>
|
||||
<h2 id="login-title">Sign in to your workspace</h2>
|
||||
{sessionExpired && (
|
||||
<div className="session-notice" role="status">
|
||||
Your session is no longer active. Sign in again to continue.
|
||||
</div>
|
||||
)}
|
||||
<p className="muted">Use your work account to continue.</p>
|
||||
<form onSubmit={submit} className="stack-form">
|
||||
<FormField label="Email address" htmlFor="email">
|
||||
<input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="password">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<button className="button primary wide" type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="demo-note">
|
||||
<CircleHelp size={16} /> Contact your administrator for access.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
7
frontend/src/features/participants/index.ts
Normal file
7
frontend/src/features/participants/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export {
|
||||
Avatar,
|
||||
CreateParticipantPage,
|
||||
ParticipantDetailPage,
|
||||
ParticipantsPage,
|
||||
SkillList,
|
||||
} from './pages'
|
||||
316
frontend/src/features/participants/pages.tsx
Normal file
316
frontend/src/features/participants/pages.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
Upload,
|
||||
UserRound,
|
||||
} from 'lucide-react'
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { useData } from '../../app/useData'
|
||||
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
|
||||
import { FormActions, FormField } from '../../components/form'
|
||||
import { EmptyState, Fact } from '../../components/ui/Display'
|
||||
import { PageHeader } from '../../components/ui/PageHeader'
|
||||
import type { Participant } from '../../types'
|
||||
|
||||
export function ParticipantsPage() {
|
||||
const { participants, loading, error, reload } = useData()
|
||||
const [query, setQuery] = useState('')
|
||||
const filtered = participants.filter((person) =>
|
||||
`${person.firstName} ${person.lastName} ${person.email} ${person.traits.join(' ')}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase()),
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Talent pool"
|
||||
title="Participants"
|
||||
description="Manage candidate profiles and application documents."
|
||||
action={
|
||||
<Link to="/participants/new" className="button primary">
|
||||
<Plus size={18} /> Add participant
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{error && <ErrorAlert message={error} retry={reload} />}
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<label className="search-field">
|
||||
<Search size={18} />
|
||||
<span className="sr-only">Search participants</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search people or traits…"
|
||||
/>
|
||||
</label>
|
||||
<span className="result-count">{filtered.length} participants</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="people-grid">
|
||||
{filtered.map((person) => (
|
||||
<Link className="person-card" to={`/participants/${person.id}`} key={person.id}>
|
||||
<Avatar person={person} large />
|
||||
<div>
|
||||
<h2>
|
||||
{person.firstName} {person.lastName}
|
||||
</h2>
|
||||
<p>{person.email}</p>
|
||||
<small>{person.contactInfo}</small>
|
||||
</div>
|
||||
<div className="tag-list">
|
||||
{person.traits.slice(0, 3).map((trait) => (
|
||||
<span key={trait}>{trait}</span>
|
||||
))}
|
||||
</div>
|
||||
<ChevronRight className="person-arrow" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !filtered.length && !error && (
|
||||
<EmptyState
|
||||
title="No participants found"
|
||||
description="Add a participant or adjust your search."
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreateParticipantPage() {
|
||||
const { createParticipant } = useData()
|
||||
const navigate = useNavigate()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [photoPreview, setPhotoPreview] = useState('')
|
||||
const [photo, setPhoto] = useState<File>()
|
||||
const [cv, setCv] = useState<File>()
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
const form = new FormData(event.currentTarget)
|
||||
try {
|
||||
const person = await createParticipant({
|
||||
firstName: String(form.get('firstName')),
|
||||
lastName: String(form.get('lastName')),
|
||||
birthday: String(form.get('birthday')),
|
||||
email: String(form.get('email')),
|
||||
contactInfo: String(form.get('contactInfo')),
|
||||
photo,
|
||||
cv,
|
||||
})
|
||||
navigate(`/participants/${person.id}`)
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/participants">
|
||||
<ArrowLeft size={17} /> Cancel
|
||||
</Link>
|
||||
<PageHeader
|
||||
eyebrow="New profile"
|
||||
title="Add a participant"
|
||||
description="Create a candidate profile and attach application documents."
|
||||
/>
|
||||
<form className="card form-card" onSubmit={submit}>
|
||||
<div className="form-section">
|
||||
<h2>Profile photo</h2>
|
||||
<div className="photo-upload">
|
||||
<span className="avatar upload-avatar">
|
||||
{photoPreview ? (
|
||||
<img src={photoPreview} alt="Selected profile preview" />
|
||||
) : (
|
||||
<UserRound />
|
||||
)}
|
||||
</span>
|
||||
<label className="button secondary file-button">
|
||||
<Upload size={17} /> Choose photo
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
setPhoto(file)
|
||||
if (file) setPhotoPreview(URL.createObjectURL(file))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<small>JPG, PNG or WebP.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<h2>Personal information</h2>
|
||||
<div className="form-grid">
|
||||
<FormField label="First name" htmlFor="firstName">
|
||||
<input id="firstName" name="firstName" required />
|
||||
</FormField>
|
||||
<FormField label="Last name" htmlFor="lastName">
|
||||
<input id="lastName" name="lastName" required />
|
||||
</FormField>
|
||||
<FormField label="Birthday" htmlFor="birthday">
|
||||
<input id="birthday" name="birthday" type="date" />
|
||||
</FormField>
|
||||
<FormField label="Email address" htmlFor="email">
|
||||
<input id="email" name="email" type="email" required />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="Contact information"
|
||||
htmlFor="contactInfo"
|
||||
hint="Phone, location, or preferred contact details."
|
||||
>
|
||||
<textarea id="contactInfo" name="contactInfo" rows={3} required />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<h2>Curriculum vitae</h2>
|
||||
<label className="dropzone">
|
||||
<FileText size={28} />
|
||||
<span>
|
||||
<strong>{cv?.name ?? 'Upload CV'}</strong>
|
||||
<small>PDF or Word document</small>
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx"
|
||||
onChange={(event) => setCv(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<FormActions saving={saving} cancelTo="/participants" label="Create participant" />
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ParticipantDetailPage() {
|
||||
const id = Number(useParams().participantId)
|
||||
const [person, setPerson] = useState<Participant>()
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
void api.participants
|
||||
.get(id)
|
||||
.then(setPerson)
|
||||
.catch((value) => setError(errorMessage(value)))
|
||||
}, [id])
|
||||
if (error) return <ErrorAlert message={error} />
|
||||
if (!person) return <Loading />
|
||||
|
||||
async function downloadCV() {
|
||||
try {
|
||||
const blob = await api.participants.cv(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = person?.cvFilename ?? 'cv'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/participants">
|
||||
<ArrowLeft size={17} /> All participants
|
||||
</Link>
|
||||
<section className="profile-hero">
|
||||
<Avatar person={person} large />
|
||||
<div>
|
||||
<span className="eyebrow">Candidate profile</span>
|
||||
<h1>
|
||||
{person.firstName} {person.lastName}
|
||||
</h1>
|
||||
<p>{person.email}</p>
|
||||
</div>
|
||||
<a className="button secondary" href={`mailto:${person.email}`}>
|
||||
Contact candidate
|
||||
</a>
|
||||
</section>
|
||||
<div className="detail-grid">
|
||||
<section className="card prose">
|
||||
<h2>Extracted traits</h2>
|
||||
<div className="tag-list large">
|
||||
{person.traits.map((trait) => (
|
||||
<span key={trait}>{trait}</span>
|
||||
))}
|
||||
</div>
|
||||
{!person.traits.length && <p className="muted">No traits extracted.</p>}
|
||||
</section>
|
||||
<aside className="card facts">
|
||||
<h2>Contact & documents</h2>
|
||||
<Fact label="Email" value={person.email} />
|
||||
<Fact label="Contact information" value={person.contactInfo} />
|
||||
<Fact label="Birthday" value={person.birthday ?? 'Not provided'} />
|
||||
<Fact label="Created" value={new Date(person.createdAt).toLocaleDateString()} />
|
||||
{person.cvUrl && (
|
||||
<button className="document-row" type="button" onClick={downloadCV}>
|
||||
<FileText />
|
||||
<span>
|
||||
<strong>{person.cvFilename}</strong>
|
||||
<small>Download candidate CV</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Avatar({ person, large = false }: { person: Participant; large?: boolean }) {
|
||||
const [source, setSource] = useState('')
|
||||
useEffect(() => {
|
||||
if (!person.photoUrl) return
|
||||
let active = true
|
||||
let objectUrl = ''
|
||||
void api.participants
|
||||
.photo(person.id)
|
||||
.then((blob) => {
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
if (active) setSource(objectUrl)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
active = false
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [person.id, person.photoUrl])
|
||||
return (
|
||||
<span className={`avatar ${large ? 'large' : ''}`}>
|
||||
{source ? <img src={source} alt="" /> : `${person.firstName[0]}${person.lastName[0]}`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillList({ skills }: { skills: string[] }) {
|
||||
return skills.length ? (
|
||||
<ul className="check-list">
|
||||
{skills.map((skill) => (
|
||||
<li key={skill}>
|
||||
<Check size={17} /> {skill}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">None specified.</p>
|
||||
)
|
||||
}
|
||||
1
frontend/src/features/questions/index.ts
Normal file
1
frontend/src/features/questions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { QuestionLibraryPage, QuestionPage } from './pages'
|
||||
361
frontend/src/features/questions/pages.tsx
Normal file
361
frontend/src/features/questions/pages.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import { ArrowLeft, Pencil, Plus, Search, X } from 'lucide-react'
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
|
||||
import { FormField } from '../../components/form'
|
||||
import { EmptyState } from '../../components/ui/Display'
|
||||
import { PageHeader } from '../../components/ui/PageHeader'
|
||||
import { QuestionRow } from '../jobs'
|
||||
import type { CreateQuestionInput, Job, Question, QuestionType } from '../../types'
|
||||
|
||||
export function QuestionPage() {
|
||||
const id = Number(useParams().jobId)
|
||||
const navigate = useNavigate()
|
||||
const [job, setJob] = useState<Job>()
|
||||
const [loadError, setLoadError] = useState('')
|
||||
useEffect(() => {
|
||||
void api.jobs
|
||||
.get(id)
|
||||
.then(setJob)
|
||||
.catch((value) => setLoadError(errorMessage(value)))
|
||||
}, [id])
|
||||
if (loadError) return <ErrorAlert message={loadError} />
|
||||
if (!job) return <Loading />
|
||||
|
||||
async function createAndAttach(input: CreateQuestionInput) {
|
||||
const question = await api.questions.create(input)
|
||||
await api.jobs.attachQuestion(id, question.id)
|
||||
navigate(`/jobs/${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to={`/jobs/${id}`}>
|
||||
<ArrowLeft size={17} /> {job.title}
|
||||
</Link>
|
||||
<PageHeader
|
||||
eyebrow="Assessment builder"
|
||||
title="Create a question"
|
||||
description="Create a reusable library question and attach it to this job."
|
||||
/>
|
||||
<QuestionForm
|
||||
submitLabel="Create and attach question"
|
||||
onSubmit={createAndAttach}
|
||||
onCancel={() => navigate(`/jobs/${id}`)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionForm({
|
||||
initialQuestion,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initialQuestion?: Question
|
||||
submitLabel: string
|
||||
onSubmit: (input: CreateQuestionInput) => Promise<void>
|
||||
onCancel?: () => void
|
||||
}) {
|
||||
const [text, setText] = useState(initialQuestion?.text ?? '')
|
||||
const [type, setType] = useState<QuestionType>(initialQuestion?.type ?? 'open')
|
||||
const [options, setOptions] = useState(
|
||||
initialQuestion?.type === 'multiple_choice' || initialQuestion?.type === 'radio'
|
||||
? initialQuestion.options
|
||||
: [],
|
||||
)
|
||||
const [referenceAnswer, setReferenceAnswer] = useState(initialQuestion?.referenceAnswer ?? '')
|
||||
const [codeSnippet, setCodeSnippet] = useState(
|
||||
initialQuestion?.type === 'code_review' ? initialQuestion.codeSnippet : '',
|
||||
)
|
||||
const [optionDraft, setOptionDraft] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const choiceType = type === 'multiple_choice' || type === 'radio'
|
||||
|
||||
function changeType(nextType: QuestionType) {
|
||||
setType(nextType)
|
||||
setError('')
|
||||
if (nextType === 'open' || nextType === 'code_review') {
|
||||
setOptions([])
|
||||
setOptionDraft('')
|
||||
} else {
|
||||
setReferenceAnswer('')
|
||||
}
|
||||
if (nextType !== 'code_review') setCodeSnippet('')
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
const option = optionDraft.trim()
|
||||
if (!option) return
|
||||
setOptions((current) => [...current, option])
|
||||
setOptionDraft('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (choiceType && options.length < 2) {
|
||||
setError('Add at least two options before saving this question.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit({
|
||||
text: text.trim(),
|
||||
type,
|
||||
options: choiceType ? options : [],
|
||||
referenceAnswer: choiceType ? '' : referenceAnswer.trim(),
|
||||
codeSnippet: type === 'code_review' ? codeSnippet : '',
|
||||
})
|
||||
if (!initialQuestion) {
|
||||
setText('')
|
||||
setType('open')
|
||||
setOptions([])
|
||||
setOptionDraft('')
|
||||
setReferenceAnswer('')
|
||||
setCodeSnippet('')
|
||||
}
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card form-card library-form" onSubmit={submit}>
|
||||
<div className="form-section">
|
||||
<h2>{initialQuestion ? 'Edit question' : 'Create question'}</h2>
|
||||
<FormField label="Question prompt" htmlFor="question-form-text">
|
||||
<textarea
|
||||
id="question-form-text"
|
||||
name="text"
|
||||
rows={type === 'code_review' ? 10 : 5}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Response type" htmlFor="question-form-type">
|
||||
<select
|
||||
id="question-form-type"
|
||||
value={type}
|
||||
onChange={(event) => changeType(event.target.value as QuestionType)}
|
||||
>
|
||||
<option value="open">Open answer</option>
|
||||
<option value="multiple_choice">Multiple choice</option>
|
||||
<option value="radio">Single choice</option>
|
||||
<option value="code_review">Code review</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{type === 'code_review' && (
|
||||
<FormField label="Code snippet" htmlFor="question-form-code-snippet">
|
||||
<textarea
|
||||
id="question-form-code-snippet"
|
||||
rows={12}
|
||||
value={codeSnippet}
|
||||
onChange={(event) => setCodeSnippet(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{!choiceType && (
|
||||
<FormField
|
||||
label={type === 'code_review' ? 'Expected answer' : 'Reference answer'}
|
||||
htmlFor="question-form-reference-answer"
|
||||
>
|
||||
<textarea
|
||||
id="question-form-reference-answer"
|
||||
rows={5}
|
||||
value={referenceAnswer}
|
||||
onChange={(event) => setReferenceAnswer(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{choiceType && (
|
||||
<fieldset className="option-builder">
|
||||
<legend className="field-label">Answer options</legend>
|
||||
<div className="option-entry">
|
||||
<input
|
||||
aria-label="New option"
|
||||
value={optionDraft}
|
||||
onChange={(event) => setOptionDraft(event.target.value)}
|
||||
placeholder="Type an option"
|
||||
/>
|
||||
<button
|
||||
className="button secondary option-add"
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={!optionDraft.trim()}
|
||||
aria-label="Add option"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="option-list" aria-live="polite">
|
||||
{options.map((option, index) => (
|
||||
<div className="option-item" key={`${option}-${index}`}>
|
||||
<span>{option}</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOptions((current) => current.filter((_, itemIndex) => itemIndex !== index))
|
||||
}
|
||||
aria-label={`Remove option ${option}`}
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<small className="field-hint">Add at least two options.</small>
|
||||
</fieldset>
|
||||
)}
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<div className="question-form-actions">
|
||||
{onCancel && (
|
||||
<button className="button ghost" type="button" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button className="button primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuestionLibraryPage() {
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [editing, setEditing] = useState<Question>()
|
||||
const [formVersion, setFormVersion] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function load(search = query) {
|
||||
setError('')
|
||||
try {
|
||||
setQuestions(await api.questions.list(search))
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const timeout = window.setTimeout(() => {
|
||||
void api.questions
|
||||
.list(query)
|
||||
.then((items) => {
|
||||
if (active) setQuestions(items)
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (active) setError(errorMessage(requestError))
|
||||
})
|
||||
}, 200)
|
||||
return () => {
|
||||
active = false
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
async function create(input: CreateQuestionInput) {
|
||||
const created = await api.questions.create(input)
|
||||
setQuestions((current) => [created, ...current])
|
||||
setFormVersion((current) => current + 1)
|
||||
}
|
||||
|
||||
async function update(input: CreateQuestionInput) {
|
||||
if (!editing) return
|
||||
const updated = await api.questions.update(editing.id, input)
|
||||
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||
setEditing(undefined)
|
||||
}
|
||||
|
||||
async function toggle(question: Question) {
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.questions.setDeprecated(question.id, !question.deprecated)
|
||||
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||
if (editing?.id === updated.id) setEditing(updated)
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Assessment builder"
|
||||
title="Question library"
|
||||
description="Create reusable interview questions and retire questions without deleting them."
|
||||
/>
|
||||
{error && <ErrorAlert message={error} retry={load} />}
|
||||
<div className="library-grid">
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<label className="search-field">
|
||||
<Search size={18} />
|
||||
<span className="sr-only">Search questions</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search questions…"
|
||||
/>
|
||||
</label>
|
||||
<span className="result-count">{questions.length} questions</span>
|
||||
</div>
|
||||
<div className="question-list">
|
||||
{questions.map((question) => (
|
||||
<QuestionRow
|
||||
key={question.id}
|
||||
question={question}
|
||||
action={
|
||||
<div className="library-question-actions">
|
||||
<button
|
||||
className="button ghost"
|
||||
type="button"
|
||||
onClick={() => setEditing(question)}
|
||||
aria-label={`Edit ${question.text}`}
|
||||
>
|
||||
<Pencil size={16} /> Edit
|
||||
</button>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={question.deprecated}
|
||||
onChange={() => void toggle(question)}
|
||||
aria-label={`Deprecated: ${question.text}`}
|
||||
/>
|
||||
<span>Deprecated</span>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!questions.length && (
|
||||
<EmptyState
|
||||
title="No questions found"
|
||||
description="Create a question or adjust your search."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<QuestionForm
|
||||
key={editing ? `edit-${editing.id}` : `create-${formVersion}`}
|
||||
initialQuestion={editing}
|
||||
submitLabel={editing ? 'Save changes' : 'Create question'}
|
||||
onSubmit={editing ? update : create}
|
||||
onCancel={editing ? () => setEditing(undefined) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,8 +10,6 @@ export function ProfilePage() {
|
|||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Your account</span>
|
||||
<h1>{user.displayName}</h1>
|
||||
<p>Your signed-in identity and workspace access.</p>
|
||||
</div>
|
||||
</header>
|
||||
<section className="card profile-card">
|
||||
|
|
@ -20,7 +18,7 @@ export function ProfilePage() {
|
|||
{user.displayName.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<div>
|
||||
<h2>{user.displayName}</h2>
|
||||
<h1>{user.displayName}</h1>
|
||||
<span className={`status status-${user.role}`}>{user.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,8 +50,6 @@ export function UsersPage() {
|
|||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Team access</span>
|
||||
<h1>Users</h1>
|
||||
<p>Create co-interviewers and manage the people who collaborate on interviews.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue