diff --git a/frontend/src/app/AuthenticatedApp.tsx b/frontend/src/app/AuthenticatedApp.tsx index aeaa4bc..c6df1d8 100644 --- a/frontend/src/app/AuthenticatedApp.tsx +++ b/frontend/src/app/AuthenticatedApp.tsx @@ -5,7 +5,7 @@ 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 { InboxPage, ScheduledInterviewPage } from '../features/interviews' import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs' import { CreateParticipantPage, @@ -13,7 +13,7 @@ import { ParticipantsPage, } from '../features/participants' import { QuestionLibraryPage, QuestionPage } from '../features/questions' -import { ProfilePage, UsersPage } from '../features/users' +import {CalendarPage, ProfilePage, UsersPage} from '../features/users' export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) { const [open, setOpen] = useState(false) diff --git a/frontend/src/components/actions/errorMessage.ts b/frontend/src/components/actions/errorMessage.ts index c2d7205..e2a6b9b 100644 --- a/frontend/src/components/actions/errorMessage.ts +++ b/frontend/src/components/actions/errorMessage.ts @@ -5,3 +5,9 @@ export function errorMessage(error: unknown) { ? error.message : 'Something went wrong' } + +export function message(error: unknown) { + return error instanceof ApiError || error instanceof Error + ? error.message + : 'Something went wrong' +} \ No newline at end of file diff --git a/frontend/src/components/actions/index.ts b/frontend/src/components/actions/index.ts index 0965917..e1d29bd 100644 --- a/frontend/src/components/actions/index.ts +++ b/frontend/src/components/actions/index.ts @@ -1,4 +1,4 @@ export { useAuthSession } from './Account' export { ErrorAlert } from './ErrorAlert' -export { errorMessage } from './errorMessage' +export { errorMessage, message } from './errorMessage' export { Loading } from './Loading' diff --git a/frontend/src/components/layout/SideBar.tsx b/frontend/src/components/layout/SideBar.tsx index 16aa999..964bf78 100644 --- a/frontend/src/components/layout/SideBar.tsx +++ b/frontend/src/components/layout/SideBar.tsx @@ -43,7 +43,6 @@ export function SideBar({ icon={} close={close} /> - } close={close} /> } close={close} />
diff --git a/frontend/src/components/layout/WorkspaceUtilities.tsx b/frontend/src/components/layout/WorkspaceUtilities.tsx index fcdd84a..e9b3815 100644 --- a/frontend/src/components/layout/WorkspaceUtilities.tsx +++ b/frontend/src/components/layout/WorkspaceUtilities.tsx @@ -1,21 +1,25 @@ -import { Inbox } from 'lucide-react' -import { NavLink } from 'react-router-dom' -import type { User } from '../../types' +import {CalendarDays, Inbox, Mail} from 'lucide-react' +import {NavLink} from 'react-router-dom' +import type {User} from '../../types' -export function WorkspaceUtilities({ user }: { user: User | null }) { - const profileLabel = user?.displayName || user?.email || 'Profile' - return ( - - ) +export function WorkspaceUtilities({user}: { user: User | null }) { + const profileLabel = user?.displayName || user?.email || 'Profile' + return ( + + ) } diff --git a/frontend/src/components/ui/PageHeader.tsx b/frontend/src/components/ui/PageHeader.tsx index 0358c41..6333a21 100644 --- a/frontend/src/components/ui/PageHeader.tsx +++ b/frontend/src/components/ui/PageHeader.tsx @@ -15,7 +15,6 @@ export function PageHeader({
{eyebrow} -

{title}

{description &&

{description}

}
{action} diff --git a/frontend/src/config/navigation.ts b/frontend/src/config/navigation.ts index 1b608ae..a3457ae 100644 --- a/frontend/src/config/navigation.ts +++ b/frontend/src/config/navigation.ts @@ -15,13 +15,13 @@ export const footerLinkGroups: FooterLinkGroup[] = [ { label: 'Jobs', href: '/jobs' }, { label: 'Participants', href: '/participants' }, { label: 'Question library', href: '/questions' }, - { label: 'Calendar', href: '/calendar' }, ], }, { title: 'Useful links', links: [ { label: 'Your inbox', href: '/inbox' }, + { label: 'Calendar', href: '/calendar' }, { label: 'Your profile', href: '/profile' }, { label: 'Interview team', href: '/users' }, ], diff --git a/frontend/src/features/interviews/index.ts b/frontend/src/features/interviews/index.ts index f1f3b98..db7eaa8 100644 --- a/frontend/src/features/interviews/index.ts +++ b/frontend/src/features/interviews/index.ts @@ -1,2 +1,2 @@ -export { CalendarPage, InboxPage, ParticipantMeetingPage, ScheduledInterviewPage } from './pages' +export { InboxPage, ParticipantMeetingPage, ScheduledInterviewPage } from './pages' export { ScheduleInterviewForm } from './components/ScheduleInterviewForm' diff --git a/frontend/src/features/interviews/pages.tsx b/frontend/src/features/interviews/pages.tsx index 5feab26..3fdadf0 100644 --- a/frontend/src/features/interviews/pages.tsx +++ b/frontend/src/features/interviews/pages.tsx @@ -1,453 +1,392 @@ -import { Check, Copy, UsersRound, X } from 'lucide-react' -import { type FormEvent, useEffect, useMemo, useState } from 'react' -import { Link, useParams } from 'react-router-dom' -import { api, ApiError } from '../../api/client' -import type { ParticipantMeeting, ScheduledInterview } from '../../types' -import { ErrorAlert, Loading } from '../../components/ui/AsyncState' -import { DateAndPlace, InterviewCard } from './components/InterviewCard' - -function message(error: unknown) { - return error instanceof ApiError || error instanceof Error - ? error.message - : 'Something went wrong' -} +import {Check, Copy, UsersRound, X} from 'lucide-react' +import {type FormEvent, useEffect, useState} from 'react' +import {Link, useParams} from 'react-router-dom' +import {api} from '../../api/client' +import type {ParticipantMeeting, ScheduledInterview} from '../../types' +import {ErrorAlert, Loading} from '../../components/ui/AsyncState' +import {DateAndPlace, InterviewCard} from './components/InterviewCard' +import {message} from "../../components/actions"; export function InboxPage() { - const [items, setItems] = useState([]) - const [error, setError] = useState('') - const [loading, setLoading] = useState(true) + const [items, setItems] = useState([]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) - useEffect(() => { - void api.inbox - .list() - .then(setItems) - .catch((value) => setError(message(value))) - .finally(() => setLoading(false)) - }, []) + useEffect(() => { + void api.inbox + .list() + .then(setItems) + .catch((value) => setError(message(value))) + .finally(() => setLoading(false)) + }, []) - async function respond(id: number, status: 'accepted' | 'declined') { - setError('') - try { - const updated = await api.inbox.respond(id, status) - setItems((current) => current.map((item) => (item.id === id ? updated : item))) - } catch (value) { - setError(message(value)) + async function respond(id: number, status: 'accepted' | 'declined') { + setError('') + try { + const updated = await api.inbox.respond(id, status) + setItems((current) => current.map((item) => (item.id === id ? updated : item))) + } catch (value) { + setError(message(value)) + } } - } - return ( - <> -
-
- Invitations -

Inbox

-

Respond to interview invitations assigned to you.

-
-
- {error && } - {loading ? ( - - ) : ( -
- {items.map((item) => ( - -
- - -
-
- ))} - {!items.length && ( -
-

Inbox zero

-

You have no interview invitations.

-
- )} -
- )} - - ) + return ( + <> +
+
+ Invitations +

Inbox

+

Respond to interview invitations assigned to you.

+
+
+ {error && } + {loading ? ( + + ) : ( +
+ {items.map((item) => ( + +
+ + +
+
+ ))} + {!items.length && ( +
+

Inbox zero

+

You have no interview invitations.

+
+ )} +
+ )} + + ) } -export function CalendarPage() { - const [items, setItems] = useState([]) - const [error, setError] = useState('') - const [loading, setLoading] = useState(true) - useEffect(() => { - void api - .calendar() - .then(setItems) - .catch((value) => setError(message(value))) - .finally(() => setLoading(false)) - }, []) - const groups = useMemo(() => { - return [...items] - .sort((a, b) => a.startsAt.localeCompare(b.startsAt)) - .reduce>((result, item) => { - const key = new Date(item.startsAt).toLocaleDateString(undefined, { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric', - }) - ;(result[key] ??= []).push(item) - return result - }, {}) - }, [items]) - return ( - <> -
-
- Schedule -

Calendar

-

Your accepted and organized interviews, grouped by date.

-
-
- {error && } - {loading ? ( - - ) : ( - Object.entries(groups).map(([date, interviews]) => ( -
-

{date}

-
- {interviews.map((item) => ( - - - Open session - - - ))} -
-
- )) - )} - - ) -} export function ScheduledInterviewPage() { - const id = Number(useParams().id) - const [interview, setInterview] = useState() - const [document, setDocument] = useState('') - const [notes, setNotes] = useState([]) - const [error, setError] = useState('') - const [saving, setSaving] = useState(false) - const [linkCopied, setLinkCopied] = useState(false) - const loadedInterviewID = interview?.id + const id = Number(useParams().id) + const [interview, setInterview] = useState() + const [document, setDocument] = useState('') + const [notes, setNotes] = useState([]) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + const [linkCopied, setLinkCopied] = useState(false) + const loadedInterviewID = interview?.id - useEffect(() => { - let active = true - void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)]) - .then(([item, loadedNotes]) => { - if (!active) return - setInterview(item) - setDocument(item.sharedDocument) - setNotes(loadedNotes) - }) - .catch((value) => active && setError(message(value))) - return () => { - active = false + useEffect(() => { + let active = true + void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)]) + .then(([item, loadedNotes]) => { + if (!active) return + setInterview(item) + setDocument(item.sharedDocument) + setNotes(loadedNotes) + }) + .catch((value) => active && setError(message(value))) + return () => { + active = false + } + }, [id]) + + useEffect(() => { + if (!loadedInterviewID) return + const controller = new AbortController() + void api.scheduledInterviews + .subscribe( + id, + (event) => { + if (event.type === 'note.created') { + setNotes((current) => + current.some((item) => item.id === event.note.id) + ? current + : [...current, event.note], + ) + } + if (event.type === 'document.updated') { + setDocument(event.sharedDocument) + setInterview((current) => + current ? {...current, sharedDocument: event.sharedDocument} : current, + ) + } + }, + controller.signal, + (value) => setError(message(value)), + ) + .catch((value) => { + if (!controller.signal.aborted) setError(message(value)) + }) + return () => controller.abort() + }, [id, loadedInterviewID]) + + async function setStatus(status: string) { + setError('') + try { + setInterview(await api.scheduledInterviews.setStatus(id, status)) + } catch (value) { + setError(message(value)) + } } - }, [id]) - useEffect(() => { - if (!loadedInterviewID) return - const controller = new AbortController() - void api.scheduledInterviews - .subscribe( - id, - (event) => { - if (event.type === 'note.created') { + async function saveDocument(event: FormEvent) { + event.preventDefault() + setSaving(true) + setError('') + try { + setInterview(await api.scheduledInterviews.updateDocument(id, document)) + } catch (value) { + setError(message(value)) + } finally { + setSaving(false) + } + } + + async function addNote(event: FormEvent) { + event.preventDefault() + const form = event.currentTarget + const body = String(new FormData(form).get('body')).trim() + if (!body) return + try { + const note = await api.scheduledInterviews.addNote(id, body) setNotes((current) => - current.some((item) => item.id === event.note.id) - ? current - : [...current, event.note], + current.some((item) => item.id === note.id) ? current : [...current, note], ) - } - if (event.type === 'document.updated') { - setDocument(event.sharedDocument) - setInterview((current) => - current ? { ...current, sharedDocument: event.sharedDocument } : current, - ) - } - }, - controller.signal, - (value) => setError(message(value)), - ) - .catch((value) => { - if (!controller.signal.aborted) setError(message(value)) - }) - return () => controller.abort() - }, [id, loadedInterviewID]) - - async function setStatus(status: string) { - setError('') - try { - setInterview(await api.scheduledInterviews.setStatus(id, status)) - } catch (value) { - setError(message(value)) + form.reset() + } catch (value) { + setError(message(value)) + } } - } - async function saveDocument(event: FormEvent) { - event.preventDefault() - setSaving(true) - setError('') - try { - setInterview(await api.scheduledInterviews.updateDocument(id, document)) - } catch (value) { - setError(message(value)) - } finally { - setSaving(false) + async function copyParticipantLink(url: string) { + setError('') + try { + await navigator.clipboard.writeText(url) + setLinkCopied(true) + } catch { + setError('Could not copy the participant interview link') + } } - } - async function addNote(event: FormEvent) { - event.preventDefault() - const form = event.currentTarget - const body = String(new FormData(form).get('body')).trim() - if (!body) return - try { - const note = await api.scheduledInterviews.addNote(id, body) - setNotes((current) => - current.some((item) => item.id === note.id) ? current : [...current, note], - ) - form.reset() - } catch (value) { - setError(message(value)) - } - } - - async function copyParticipantLink(url: string) { - setError('') - try { - await navigator.clipboard.writeText(url) - setLinkCopied(true) - } catch { - setError('Could not copy the participant interview link') - } - } - - if (!interview && !error) return - if (!interview) return - return ( - <> - - ← Calendar - -
-
- Interviewer session -

{interview.participantName}

-

{interview.jobTitle}

-
- {interview.status} -
- {error && } - -
- - {interview.status !== 'started' && interview.status !== 'completed' && ( - - )} - {interview.status === 'started' && ( - - )} -
-
-
-
-
- Visible to candidate -

Shared documentation

+ if (!interview && !error) return + if (!interview) return + return ( + <> + + ← Calendar + +
+
+ Interviewer session +

{interview.participantName}

+

{interview.jobTitle}

+
+ {interview.status} +
+ {error && } + +
+ + {interview.status !== 'started' && interview.status !== 'completed' && ( + + )} + {interview.status === 'started' && ( + + )}
-
-
+
+ +
+
+ Visible to candidate +

Shared documentation

+
+
+