refactor: organize frontend by feature

Move the React application into app, api, feature, component, style, and type boundaries, and rebuild the interview scheduler with a responsive details-and-team layout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 22:12:10 +02:00
parent 75e3c73a61
commit afc8f0d5a7
17 changed files with 606 additions and 315 deletions

View file

@ -19,7 +19,7 @@ Focused tests:
```bash ```bash
cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$' cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$'
cd backend && go test -tags=integration ./internal/integration -run '^TestCompleteInterviewWorkflow$' cd backend && go test -tags=integration ./internal/integration -run '^TestCompleteInterviewWorkflow$'
cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title"
``` ```
The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
@ -37,9 +37,12 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
`go-guess.interview-notes` RabbitMQ fanout exchange. Each API instance consumes `go-guess.interview-notes` RabbitMQ fanout exchange. Each API instance consumes
through its own exclusive queue and distributes events to authenticated SSE through its own exclusive queue and distributes events to authenticated SSE
subscribers. subscribers.
- `frontend/src/App.tsx` owns routing/layout/screens, `src/api.ts` is the typed HTTP - `frontend/src/app` owns application routing and providers, `src/features` groups
boundary, and `src/types.ts` contains client domain types. Vite proxies `/api` to feature pages and components, `src/components` contains reusable UI,
the Go service in development. `src/api/client.ts` is the typed HTTP boundary, `src/types` contains client
domain types, and `src/styles/global.css` contains application-wide styles.
Keep feature-specific components inside their feature directory. Vite proxies
`/api` to the Go service in development.
Goose files in `backend/migrations` are schema-only and run in every environment. Goose files in `backend/migrations` are schema-only and run in every environment.
`internal/database/seed/common.sql` contains shared reference data; `internal/database/seed/common.sql` contains shared reference data;

View file

@ -56,7 +56,7 @@ cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercen
Run one frontend test: Run one frontend test:
```bash ```bash
cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title"
``` ```
Docker must be available for `make test`. The integration suite starts PostgreSQL Docker must be available for `make test`. The integration suite starts PostgreSQL

View file

@ -18,9 +18,21 @@ npm run build
Run one test: Run one test:
```bash ```bash
npm test -- src/App.test.tsx -t "shows answer inputs" npm test -- src/app/App.test.tsx -t "filters jobs by title"
``` ```
## Source structure
- `src/app` contains the application shell, providers, routes, and route-level tests.
- `src/features` groups pages and components by business capability.
- `src/components` contains UI shared by multiple features.
- `src/api/client.ts` is the typed HTTP boundary.
- `src/types` contains API and domain types.
- `src/styles/global.css` contains shared tokens and layout styles.
Keep feature-only UI close to its feature instead of adding more screens to the
application shell.
## API integration ## API integration
Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api, authStorage } from './api' import { api, authStorage } from './client'
describe('API client', () => { describe('API client', () => {
beforeEach(() => { beforeEach(() => {

View file

@ -15,7 +15,7 @@ import type {
ScheduledInterview, ScheduledInterview,
User, User,
InterviewNote, InterviewNote,
} from './types' } from '../types'
const TOKEN_KEY = 'go-guess-token' const TOKEN_KEY = 'go-guess-token'
const USER_KEY = 'go-guess-user' const USER_KEY = 'go-guess-user'

View file

@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { BrowserRouter, MemoryRouter } from 'react-router-dom' import { BrowserRouter, MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App' import App from './App'
import type { Job, Participant } from './types' import type { Job, Participant } from '../types'
const question = { const question = {
id: 11, id: 11,
@ -680,6 +680,8 @@ describe('Go Guess frontend', () => {
renderApp('/jobs/7') renderApp('/jobs/7')
await user.click(await screen.findByRole('tab', { name: /invitations/i })) await user.click(await screen.findByRole('tab', { name: /invitations/i }))
await user.click(screen.getByRole('button', { name: 'Schedule Amélie Dubois' })) await user.click(screen.getByRole('button', { name: 'Schedule Amélie Dubois' }))
expect(screen.getByRole('heading', { name: 'Interview details' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Interview team' })).toBeInTheDocument()
await user.type(screen.getByLabelText('Date and time'), '2026-09-22T10:30') await user.type(screen.getByLabelText('Date and time'), '2026-09-22T10:30')
await user.type(screen.getByLabelText('Location or meeting link'), 'Brussels') await user.type(screen.getByLabelText('Location or meeting link'), 'Brussels')
await user.click(await screen.findByLabelText(/Alex Morgan/)) await user.click(await screen.findByLabelText(/Alex Morgan/))

View file

@ -43,15 +43,15 @@ import {
useNavigate, useNavigate,
useParams, useParams,
} from 'react-router-dom' } from 'react-router-dom'
import { api, ApiError, authStorage } from './api' import { api, ApiError, authStorage } from '../api/client'
import { import {
CalendarPage, CalendarPage,
InboxPage, InboxPage,
ParticipantMeetingPage, ParticipantMeetingPage,
ScheduleInterviewForm, ScheduleInterviewForm,
ScheduledInterviewPage, ScheduledInterviewPage,
UsersPage, } from '../features/interviews'
} from './interview-workflow' import { UsersPage } from '../features/users'
import type { import type {
CandidateMatch, CandidateMatch,
CreateJobInput, CreateJobInput,
@ -65,7 +65,7 @@ import type {
Question, Question,
QuestionType, QuestionType,
ScheduledInterview, ScheduledInterview,
} from './types' } from '../types'
interface AppData { interface AppData {
jobs: Job[] jobs: Job[]

View file

@ -0,0 +1,17 @@
export function ErrorAlert({ error }: { error: string }) {
return (
<div className="error-alert" role="alert">
<strong>Request failed</strong>
<span>{error}</span>
</div>
)
}
export function Loading() {
return (
<div className="empty-state" role="status">
<span className="loading-spinner" />
<p>Loading…</p>
</div>
)
}

View file

@ -0,0 +1,42 @@
import { CalendarDays, MapPin } from 'lucide-react'
import type { ReactNode } from 'react'
import type { ParticipantMeeting, ScheduledInterview } from '../../../types'
export function DateAndPlace({
interview,
}: {
interview: ScheduledInterview | ParticipantMeeting
}) {
return (
<div className="meeting-facts">
<span>
<CalendarDays size={18} aria-hidden="true" />
{new Date(interview.startsAt).toLocaleString()}
</span>
<span>
<MapPin size={18} aria-hidden="true" />
{interview.location}
</span>
</div>
)
}
export function InterviewCard({
interview,
children,
}: {
interview: ScheduledInterview
children?: ReactNode
}) {
return (
<article className="meeting-card">
<div>
<span className={`status status-${interview.status}`}>{interview.status}</span>
<h2>{interview.participantName}</h2>
<p>{interview.jobTitle}</p>
</div>
<DateAndPlace interview={interview} />
{children}
</article>
)
}

View file

@ -0,0 +1,203 @@
import { CalendarDays, FileText, MapPin, UsersRound, X } from 'lucide-react'
import { type FormEvent, useEffect, useState } from 'react'
import { api, ApiError, authStorage } from '../../../api/client'
import type { Invitation, ScheduledInterview, User } from '../../../types'
interface ScheduleInterviewFormProps {
jobId: number
invitation: Invitation
onCreated: (interview: ScheduledInterview) => void
onCancel: () => void
}
function errorMessage(error: unknown) {
return error instanceof ApiError || error instanceof Error
? error.message
: 'Something went wrong'
}
export function ScheduleInterviewForm({
jobId,
invitation,
onCreated,
onCancel,
}: ScheduleInterviewFormProps) {
const [users, setUsers] = useState<User[]>([])
const [selected, setSelected] = useState<number[]>([])
const [error, setError] = useState('')
const [loadingUsers, setLoadingUsers] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
let active = true
void api.users
.list()
.then((items) => {
if (active) {
const currentUserId = authStorage.user()?.id
setUsers(items.filter((user) => user.id !== currentUserId))
}
})
.catch((value) => {
if (active) setError(errorMessage(value))
})
.finally(() => {
if (active) setLoadingUsers(false)
})
return () => {
active = false
}
}, [])
function toggleInterviewer(userId: number, checked: boolean) {
setSelected((current) =>
checked ? [...new Set([...current, userId])] : current.filter((id) => id !== userId),
)
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const values = new FormData(event.currentTarget)
setSaving(true)
setError('')
try {
const startsAt = new Date(String(values.get('startsAt'))).toISOString()
const interview = await api.jobs.scheduleInterview(jobId, {
invitationId: invitation.id,
startsAt,
location: String(values.get('location')),
interviewerIds: selected,
sharedDocument: String(values.get('sharedDocument')),
})
onCreated(interview)
} catch (value) {
setError(errorMessage(value))
setSaving(false)
}
}
return (
<form
className="schedule-form"
onSubmit={submit}
aria-label={`Schedule ${invitation.participantName}`}
>
<header className="schedule-form-header">
<div>
<span className="eyebrow">Passed assessment</span>
<h2>Schedule interview</h2>
<p>
Set up the next conversation with <strong>{invitation.participantName}</strong>.
</p>
</div>
<button
className="icon-button"
type="button"
onClick={onCancel}
aria-label="Close scheduling form"
>
<X />
</button>
</header>
<div className="schedule-form-layout">
<section className="schedule-form-details" aria-labelledby="schedule-details-title">
<div className="schedule-form-section-heading">
<CalendarDays aria-hidden="true" />
<div>
<h3 id="schedule-details-title">Interview details</h3>
<p>Choose when and where the interview takes place.</p>
</div>
</div>
<div className="schedule-form-fields">
<label className="field">
<span className="field-label">Date and time</span>
<input name="startsAt" type="datetime-local" required />
</label>
<label className="field">
<span className="field-label">Location or meeting link</span>
<span className="input-with-icon">
<MapPin aria-hidden="true" />
<input name="location" required placeholder="Meeting room or https://…" />
</span>
</label>
</div>
<label className="field schedule-document-field">
<span className="schedule-field-heading">
<FileText aria-hidden="true" />
<span>
<strong>Shared documentation</strong>
<small>Visible to the candidate and all interviewers</small>
</span>
</span>
<textarea
name="sharedDocument"
aria-label="Shared documentation"
rows={8}
required
placeholder="Add the agenda, exercise, preparation links, and other shared information…"
/>
</label>
</section>
<aside className="schedule-team-panel" aria-labelledby="schedule-team-title">
<div className="schedule-form-section-heading">
<UsersRound aria-hidden="true" />
<div>
<h3 id="schedule-team-title">Interview team</h3>
<p>The organizer is included automatically.</p>
</div>
</div>
<fieldset className="interviewer-picker" disabled={loadingUsers}>
<legend className="sr-only">Select co-interviewers</legend>
{loadingUsers && <p className="muted">Loading team members…</p>}
{!loadingUsers && users.length === 0 && (
<p className="muted">No co-interviewers are available yet.</p>
)}
{users.map((user) => {
const checked = selected.includes(user.id)
return (
<label className={checked ? 'selected' : ''} key={user.id}>
<input
type="checkbox"
checked={checked}
onChange={(event) => toggleInterviewer(user.id, event.target.checked)}
/>
<span className="avatar" aria-hidden="true">
{user.displayName.slice(0, 2).toUpperCase()}
</span>
<span className="interviewer-details">
<strong>{user.displayName}</strong>
<small>{user.email}</small>
</span>
</label>
)
})}
</fieldset>
<p className="schedule-team-summary" aria-live="polite">
{selected.length === 0
? 'No additional interviewers selected'
: `${selected.length} co-interviewer${selected.length === 1 ? '' : 's'} selected`}
</p>
</aside>
</div>
{error && (
<div className="schedule-form-error" role="alert">
<strong>Could not schedule interview</strong>
<span>{error}</span>
</div>
)}
<footer className="schedule-form-actions">
<button className="button ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="button primary" type="submit" disabled={saving}>
<CalendarDays size={17} />
{saving ? 'Scheduling…' : 'Create interview'}
</button>
</footer>
</form>
)
}

View file

@ -0,0 +1,2 @@
export { CalendarPage, InboxPage, ParticipantMeetingPage, ScheduledInterviewPage } from './pages'
export { ScheduleInterviewForm } from './components/ScheduleInterviewForm'

View file

@ -1,8 +1,10 @@
import { CalendarDays, Check, MapPin, Plus, UsersRound, X } from 'lucide-react' import { Check, UsersRound, X } from 'lucide-react'
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { api, ApiError } from './api' import { api, ApiError } from '../../api/client'
import type { Invitation, ParticipantMeeting, ScheduledInterview, User } from './types' import type { ParticipantMeeting, ScheduledInterview } from '../../types'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import { DateAndPlace, InterviewCard } from './components/InterviewCard'
function message(error: unknown) { function message(error: unknown) {
return error instanceof ApiError || error instanceof Error return error instanceof ApiError || error instanceof Error
@ -10,281 +12,6 @@ function message(error: unknown) {
: 'Something went wrong' : 'Something went wrong'
} }
function ErrorAlert({ error }: { error: string }) {
return (
<div className="error-alert" role="alert">
<strong>Request failed</strong>
<span>{error}</span>
</div>
)
}
function Loading() {
return (
<div className="empty-state" role="status">
<span className="loading-spinner" />
<p>Loading…</p>
</div>
)
}
function DateAndPlace({ interview }: { interview: ScheduledInterview | ParticipantMeeting }) {
return (
<div className="meeting-facts">
<span>
<CalendarDays size={18} />
{new Date(interview.startsAt).toLocaleString()}
</span>
<span>
<MapPin size={18} />
{interview.location}
</span>
</div>
)
}
export function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
void api.users
.list()
.then(setUsers)
.catch((value) => setError(message(value)))
.finally(() => setLoading(false))
}, [])
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setError('')
const form = event.currentTarget
const values = new FormData(form)
try {
const created = await api.users.create({
email: String(values.get('email')),
password: String(values.get('password')),
displayName: String(values.get('displayName')),
})
setUsers((current) => [...current, created])
form.reset()
} catch (value) {
setError(message(value))
} finally {
setSaving(false)
}
}
return (
<>
<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} />}
<div className="library-grid">
<section className="card">
<div className="section-heading">
<div>
<h2>Interview team</h2>
<p>{users.length} workspace users</p>
</div>
</div>
{loading ? (
<Loading />
) : (
<div className="user-list">
{users.map((user) => (
<article className="user-row" key={user.id}>
<span className="avatar">{user.displayName.slice(0, 2).toUpperCase()}</span>
<div>
<strong>{user.displayName}</strong>
<small>{user.email}</small>
</div>
<span className={`status status-${user.role}`}>{user.role}</span>
</article>
))}
</div>
)}
</section>
<form className="card form-card library-form" onSubmit={create}>
<div className="form-section">
<h2>Create co-interviewer</h2>
<label className="field">
<span className="field-label">Display name</span>
<input name="displayName" required autoComplete="name" />
</label>
<label className="field">
<span className="field-label">Email address</span>
<input name="email" type="email" required autoComplete="email" />
</label>
<label className="field">
<span className="field-label">Temporary password</span>
<input
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
</label>
<button className="button primary wide" type="submit" disabled={saving}>
<Plus size={18} /> {saving ? 'Creating…' : 'Create co-interviewer'}
</button>
</div>
</form>
</div>
</>
)
}
export function ScheduleInterviewForm({
jobId,
invitation,
onCreated,
onCancel,
}: {
jobId: number
invitation: Invitation
onCreated: (interview: ScheduledInterview) => void
onCancel: () => void
}) {
const [users, setUsers] = useState<User[]>([])
const [selected, setSelected] = useState<number[]>([])
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
void api.users
.list()
.then(setUsers)
.catch((value) => setError(message(value)))
}, [])
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const values = new FormData(event.currentTarget)
setSaving(true)
setError('')
try {
const startsAt = new Date(String(values.get('startsAt'))).toISOString()
onCreated(
await api.jobs.scheduleInterview(jobId, {
invitationId: invitation.id,
startsAt,
location: String(values.get('location')),
interviewerIds: selected,
sharedDocument: String(values.get('sharedDocument')),
}),
)
} catch (value) {
setError(message(value))
setSaving(false)
}
}
return (
<form
className="schedule-form"
onSubmit={submit}
aria-label={`Schedule ${invitation.participantName}`}
>
<div className="section-heading">
<div>
<span className="eyebrow">Passed assessment</span>
<h2>Schedule interview with {invitation.participantName}</h2>
</div>
<button
className="icon-button"
type="button"
onClick={onCancel}
aria-label="Close scheduling form"
>
<X />
</button>
</div>
<div className="form-section">
<div className="form-grid">
<label className="field">
<span className="field-label">Date and time</span>
<input name="startsAt" type="datetime-local" required />
</label>
<label className="field">
<span className="field-label">Location or meeting link</span>
<input name="location" required />
</label>
</div>
<fieldset className="interviewer-picker">
<legend className="field-label">Co-interviewers</legend>
{users.map((user) => (
<label key={user.id}>
<input
type="checkbox"
checked={selected.includes(user.id)}
onChange={(event) =>
setSelected((current) =>
event.target.checked
? [...current, user.id]
: current.filter((id) => id !== user.id),
)
}
/>
<span>
<strong>{user.displayName}</strong>
<small>{user.email}</small>
</span>
</label>
))}
</fieldset>
<label className="field shared-document-field">
<span className="field-label">Shared documentation</span>
<textarea
name="sharedDocument"
rows={10}
required
placeholder="Add the agenda, exercise, links, and information shared with the candidate…"
/>
</label>
{error && <ErrorAlert error={error} />}
<div className="question-form-actions">
<button className="button ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="button primary" type="submit" disabled={saving}>
{saving ? 'Scheduling…' : 'Create interview'}
</button>
</div>
</div>
</form>
)
}
function InterviewCard({
interview,
children,
}: {
interview: ScheduledInterview
children?: React.ReactNode
}) {
return (
<article className="meeting-card">
<div>
<span className={`status status-${interview.status}`}>{interview.status}</span>
<h2>{interview.participantName}</h2>
<p>{interview.jobTitle}</p>
</div>
<DateAndPlace interview={interview} />
{children}
</article>
)
}
export function InboxPage() { export function InboxPage() {
const [items, setItems] = useState<ScheduledInterview[]>([]) const [items, setItems] = useState<ScheduledInterview[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')

View file

@ -0,0 +1,112 @@
import { Plus } from 'lucide-react'
import { type FormEvent, useEffect, useState } from 'react'
import { api, ApiError } from '../../api/client'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { User } from '../../types'
function errorMessage(error: unknown) {
return error instanceof ApiError || error instanceof Error
? error.message
: 'Something went wrong'
}
export function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
void api.users
.list()
.then(setUsers)
.catch((value) => setError(errorMessage(value)))
.finally(() => setLoading(false))
}, [])
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setError('')
const form = event.currentTarget
const values = new FormData(form)
try {
const created = await api.users.create({
email: String(values.get('email')),
password: String(values.get('password')),
displayName: String(values.get('displayName')),
})
setUsers((current) => [...current, created])
form.reset()
} catch (value) {
setError(errorMessage(value))
} finally {
setSaving(false)
}
}
return (
<>
<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} />}
<div className="library-grid">
<section className="card">
<div className="section-heading">
<div>
<h2>Interview team</h2>
<p>{users.length} workspace users</p>
</div>
</div>
{loading ? (
<Loading />
) : (
<div className="user-list">
{users.map((user) => (
<article className="user-row" key={user.id}>
<span className="avatar">{user.displayName.slice(0, 2).toUpperCase()}</span>
<div>
<strong>{user.displayName}</strong>
<small>{user.email}</small>
</div>
<span className={`status status-${user.role}`}>{user.role}</span>
</article>
))}
</div>
)}
</section>
<form className="card form-card library-form" onSubmit={create}>
<div className="form-section">
<h2>Create co-interviewer</h2>
<label className="field">
<span className="field-label">Display name</span>
<input name="displayName" required autoComplete="name" />
</label>
<label className="field">
<span className="field-label">Email address</span>
<input name="email" type="email" required autoComplete="email" />
</label>
<label className="field">
<span className="field-label">Temporary password</span>
<input
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
</label>
<button className="button primary wide" type="submit" disabled={saving}>
<Plus size={18} /> {saving ? 'Creating…' : 'Create co-interviewer'}
</button>
</div>
</form>
</div>
</>
)
}

View file

@ -0,0 +1 @@
export { UsersPage } from './UsersPage'

View file

@ -1,8 +1,8 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './app/App'
import './styles.css' import './styles/global.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View file

@ -953,10 +953,124 @@ td small {
} }
.schedule-form { .schedule-form {
margin: 1.5rem; margin: 1.5rem;
border: 2px solid var(--cyan); border: 1px solid #bfdde3;
border-radius: 14px; border-radius: 16px;
overflow: hidden; overflow: hidden;
background: white; background: white;
box-shadow: 0 14px 38px rgba(14, 42, 56, 0.1);
}
.schedule-form-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
padding: 1.5rem clamp(1.2rem, 3vw, 2rem);
color: white;
background: var(--navy);
}
.schedule-form-header .eyebrow {
color: var(--lime);
}
.schedule-form-header h2 {
margin: 0.25rem 0 0.35rem;
color: white;
}
.schedule-form-header p {
margin: 0;
color: #bcd0d8;
}
.schedule-form-header strong {
color: white;
}
.schedule-form-header .icon-button {
flex: 0 0 auto;
color: white;
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
}
.schedule-form-layout {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.85fr);
}
.schedule-form-details,
.schedule-team-panel {
min-width: 0;
padding: clamp(1.25rem, 3vw, 2rem);
}
.schedule-team-panel {
border-left: 1px solid var(--line);
background: #f7fafb;
}
.schedule-form-section-heading {
display: flex;
align-items: flex-start;
gap: 0.8rem;
margin-bottom: 1.25rem;
}
.schedule-form-section-heading > svg {
flex: 0 0 auto;
width: 36px;
height: 36px;
padding: 8px;
color: var(--cyan-dark);
border-radius: 9px;
background: #e8f7f9;
}
.schedule-form-section-heading h3 {
margin: 0;
}
.schedule-form-section-heading p {
margin: 0.2rem 0 0;
color: var(--muted);
font-size: 0.84rem;
}
.schedule-form-fields {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
gap: 1rem;
}
.input-with-icon {
position: relative;
display: block;
}
.input-with-icon > svg {
position: absolute;
z-index: 1;
top: 50%;
left: 0.8rem;
width: 17px;
color: #78909c;
transform: translateY(-50%);
pointer-events: none;
}
.input-with-icon > input {
padding-left: 2.35rem;
}
.schedule-document-field {
display: block;
margin: 0.3rem 0 0;
padding: 1rem;
border: 1px solid #b9dce2;
border-radius: 12px;
background: #f2fbfc;
}
.schedule-field-heading {
display: flex;
align-items: center;
gap: 0.65rem;
margin-bottom: 0.75rem;
}
.schedule-field-heading > svg {
color: var(--cyan-dark);
}
.schedule-field-heading > span {
display: flex;
flex-direction: column;
}
.schedule-field-heading small {
margin-top: 0.1rem;
color: var(--muted);
font-weight: 400;
} }
.scheduled-list { .scheduled-list {
display: grid; display: grid;
@ -984,37 +1098,76 @@ td small {
margin-top: 0.2rem; margin-top: 0.2rem;
} }
.interviewer-picker { .interviewer-picker {
display: grid; display: flex;
grid-template-columns: repeat(2, minmax(0, 1fr)); flex-direction: column;
gap: 0.65rem; gap: 0.55rem;
margin-bottom: 1.2rem;
}
.interviewer-picker legend {
margin-bottom: 0.5rem;
} }
.interviewer-picker label { .interviewer-picker label {
display: flex; display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.7rem;
padding: 0.75rem; padding: 0.7rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: 10px;
background: white;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s,
box-shadow 0.2s;
}
.interviewer-picker label:hover {
border-color: #99cad3;
}
.interviewer-picker label.selected {
border-color: var(--cyan);
background: #edf9fa;
box-shadow: 0 0 0 2px rgba(0, 169, 200, 0.1);
} }
.interviewer-picker input { .interviewer-picker input {
width: auto; width: auto;
margin: 0;
} }
.interviewer-picker span { .interviewer-picker .avatar {
width: 32px;
height: 32px;
font-size: 0.7rem;
}
.interviewer-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
} }
.interviewer-picker small { .interviewer-details small {
overflow: hidden;
color: var(--muted); color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
} }
.shared-document-field { .schedule-team-summary {
padding: 1rem; margin: 1rem 0 0;
border: 2px solid var(--cyan); color: var(--muted);
border-radius: 12px; font-size: 0.8rem;
background: #f2fbfc; }
.schedule-form-error {
display: flex;
gap: 0.4rem;
padding: 0.85rem clamp(1.2rem, 3vw, 2rem);
color: #8f2929;
border-top: 1px solid #f3cccc;
background: #fff2f2;
}
.schedule-form-error span {
color: #a44545;
}
.schedule-form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem clamp(1.2rem, 3vw, 2rem);
border-top: 1px solid var(--line);
background: white;
} }
.user-list { .user-list {
@ -1868,6 +2021,13 @@ fieldset {
.people-grid { .people-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.schedule-form-layout {
grid-template-columns: 1fr;
}
.schedule-team-panel {
border-top: 1px solid var(--line);
border-left: 0;
}
} }
@media (max-width: 800px) { @media (max-width: 800px) {
@ -1981,6 +2141,7 @@ fieldset {
width: 100%; width: 100%;
} }
.form-grid, .form-grid,
.schedule-form-fields,
.type-grid { .type-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -2040,8 +2201,17 @@ fieldset {
flex-direction: column; flex-direction: column;
display: flex; display: flex;
} }
.interviewer-picker { .schedule-form {
grid-template-columns: 1fr; margin: 1rem;
}
.schedule-form-header {
gap: 0.75rem;
}
.schedule-form-actions {
flex-direction: column-reverse;
}
.schedule-form-actions .button {
width: 100%;
} }
} }