Contacts
A two-pane applicant-review workspace with search, filters, tabs, quick actions, bulk select, ratings, tags, CSV export, keyboard nav, and deep links.
Overview
Contact Applications is a hiring-style applicant-review app modelled on a two-pane inbox: a left list of applicants (search, status filter, sort, bulk select) and a right detail pane showing one applicant's full application. It is a working app — reviewers can approve / reject / schedule interviews, upload and remove documents, add reviewer notes, rate applicants, tag them, add new applicants, and export the filtered list to CSV. Every mutation appends to the applicant's history timeline and fires a toast.
The page is full-bleed (see Layout System — full-bleed routes): it
renders no PageHeader and sizes itself to the viewport minus the app header and (measured) footer.
On mobile the two panes swap (list ↔ detail with a back button).
All state and behavior live in the useApplicants view-model hook — selection, search / filter /
sort, bulk-select, ↑/↓ keyboard navigation, ?id= deep-linking, and every mutator. The page
(ContactsPage) is layout + wiring only. Applicant chrome (statuses, positions, document categories,
history titles, sources) is stored as i18n keys (the contacts namespace); proper names, emails,
phone numbers, references, and skills stay literal.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/apps/ContactsPage.tsx | Full-bleed page. Renders the list pane + detail pane (responsive swap) and the two modals; wires everything to useApplicants. |
src/components/contacts/useApplicants.ts | The view-model: persisted applicants, selection, visible (filtered + sorted) list, live status counts, distinct tags, ↑/↓ keyboard nav, ?id= URL sync, and every mutator (status / bulk / add / delete / interviews / rating / tags / notes / files / CSV). |
src/components/contacts/ApplicantList.tsx | Left list: search input, status-filter Popover (with count badges), sort Dropdown, tag filter, select-mode toggle + select-all strip + floating bulk action bar, and staggered ApplicantRows. Exports the SortKey type. |
src/components/contacts/ApplicantDetail.tsx | Right detail: header (avatar, status, prev/next + back), a three-dot menu (copy email / print / delete), Approve/Schedule/Reject quick actions, star rating, tags (add via Popover), and the Tabs (key={id}) hosting the four tab bodies. |
src/components/contacts/DocumentsTab.tsx | Documents accordion — download / delete / fake-upload per category. |
src/components/contacts/HistoryTab.tsx | History timeline (tone-colored entries). |
src/components/contacts/DetailsTab.tsx | The details grid (dob, languages, preferred start, salary, emergency contact, source). |
src/components/contacts/InterviewsTab.tsx | Interview cards + per-card menu (mark completed / delete). |
src/components/contacts/InterviewModal.tsx | Schedule-interview dialog. |
src/components/contacts/AddApplicantModal.tsx | Create-application dialog (exports the NewApplicant type; name is the only required field). |
src/components/contacts/StarRating.tsx | The 1–5 star control (click the current value to clear). |
src/components/contacts/ContactsEmpty.tsx | Token-var SVG empty states (ContactsEmptyState, variant). |
src/data/contacts.ts | Types, the 12-applicant seed (relative epochs), persistence, STATUS_TONE / STATUS_KEYS, maxApplicantSeq / maxRefSeq, defaultDocuments(), and the Intl display formatters. |
src/lib/csv.ts | The CSV writer + download helper used by export (toCsv / downloadCsv). |
Data model
export type ApplicantStatus = 'pending' | 'docsRequired' | 'approved' | 'rejected'
export interface Applicant {
id: string
name: string // literal
avatar?: string // omitted → Avatar initials fallback
status: ApplicantStatus
appliedAt: number // epoch ms
ref: string // literal, e.g. "APP-2417"
location: string
email: string
phone: string
positionKey: string // i18n key, position_*
employmentKey: string // i18n key, emp_*
experienceYears: number
skills: string[]
verified: boolean
rating?: number // 1–5, unset = unrated
tags?: string[] // literal free-form labels
details: {dob; languages; preferredStart; expectedSalary; emergencyContact; sourceKey}
documents: ApplicantDocument[] // 5 standard categories
history: HistoryEntry[] // timeline (append-only)
interviews: Interview[]
}STATUS_TONE maps each status to a Badge tone (single source for list rows, detail info, and
filters); STATUS_KEYS maps each status to its i18n label key. Documents follow five standard
categories (doc_resume, doc_id, doc_portfolio, doc_certificates, doc_background — the last is
always pending review).
Persistence
useApplicants loads via loadApplicants() on mount and saves on every change via an effect
(saveApplicants({applicants})).
export const STORAGE_KEY = 'contacts-state-v1'Registered in src/lib/appStorage.ts so "Reset to defaults" wipes it. New applicant ids continue
the a<seq> sequence (maxApplicantSeq) and references continue APP-<seq> (maxRefSeq) past the
persisted maximum, so both survive reloads without collision.
Info
Seed timestamps are relative to module load (the chat/calendar idiom) so a fresh session looks lively; once persisted they freeze at first run and age naturally — correct for a log.
Usage
Navigate to /apps/contacts (Applications → Contacts). The page is self-contained:
import {ContactsPage} from '@/pages/apps/ContactsPage'
;<ContactsPage />Registered full-bleed in src/routes.tsx:
{path: '/apps/contacts', element: <ContactsPage />, fullBleed: true},Deep-link straight to an applicant with ?id=:
/apps/contacts?id=a8
An invalid ?id self-heals (the sync uses replace: true so ↑/↓ navigation doesn't spam history).
API / Props
useApplicants()
Returns the view-model. Selected keys:
| Key | Type | Description |
|---|---|---|
visible | Applicant[] | The filtered + sorted list (what the list renders and what CSV export uses). |
counts | Record<ApplicantStatus, number> | Live per-status counts (query-independent) for the filter chips. |
allTags | string[] | Distinct tags (case-insensitive dedupe). |
selected / selectedId / selectedIdx | — | The current applicant, its id, and its index in visible. |
setSelectedId | (id | null) => void | Select / deselect (syncs ?id=). |
move | (dir: 1 | -1) => void | Move the selection within visible, clamped. |
query / setQuery | — | Free-text search (name / ref / email). |
statusFilter / toggleStatus / tagFilter / toggleTag / clearFilters | — | Filter state. |
sort / setSort | SortKey | 'newest' | 'oldest' | 'name' | 'rating'. |
selectMode / toggleSelectMode / checked / toggleChecked / checkAllVisible | — | Bulk-select state. |
bulkApprove / bulkReject / bulkDelete | () => void | Act on the checked set. |
interviewOpen / setInterviewOpen / addOpen / setAddOpen | — | Modal open state. |
updateStatus | (id, status, historyKey) => void | Change status + append history + toast. |
deleteApplicant | (id) => void | Delete (selects a fallback row). |
addApplicant | (NewApplicant) => void | Create a pending applicant + select it. |
addInterview / completeInterview / deleteInterview | — | Interview actions. |
setRating | (id, rating?) => void | Set / clear the 1–5 rating. |
addTag / removeTag | (id, tag) => void | Custom tags (case-insensitive dedupe). |
addNote | (id, text) => void | Append a reviewer note to the history. |
addFile / deleteFile | — | Fake-upload / remove a document file. |
exportCsv | () => void | Download applicants.csv of the visible list. |
SortKey (ApplicantList.tsx)
export type SortKey = 'newest' | 'oldest' | 'name' | 'rating'NewApplicant (AddApplicantModal.tsx)
export interface NewApplicant {
name: string // the only required field
positionKey: string
email: string
phone: string
location: string
employmentKey: string
}Keyboard
↑ / ↓ move the selection over the filtered list; Esc exits select mode first, then deselects.
The handler is inert while typing in an input/textarea/select or while a [role="menu"] /
[role="dialog"] is open, and while the interview/add modals are open.
Display formatters (src/data/contacts.ts)
formatShortDate(at) (history/file stamps), formatListTime(at) (list rows — clock today, weekday
within a week, else short date), formatDateOnly(value) (plain dates like dob/preferred start). All
native Intl, locale-aware.
Configuration & customization
Statuses
ApplicantStatus is a fixed four-value union. To adjust the tone or label:
// src/data/contacts.ts
export const STATUS_TONE: Record<ApplicantStatus, /* Badge tone */> = {
pending: 'warning',
docsRequired: 'danger',
approved: 'success',
rejected: 'neutral',
}
export const STATUS_KEYS: Record<ApplicantStatus, string> = {
pending: 'statusPending',
/* … */
}Adding a new status means extending the union, STATUS_TONE, STATUS_KEYS, the STATUS_DOT map and
STATUSES array in ApplicantList.tsx, and the counts initializer in useApplicants.ts.
Positions, employment types, document categories, sources
These are all i18n keys — position_*, emp_*, doc_*, src_*. The pickers in
AddApplicantModal.tsx (POSITION_KEYS, EMPLOYMENT_KEYS) and the standard document set
(defaultDocuments() in src/data/contacts.ts) list the available keys. Add a value = add the key to
the list + a label in src/locales/<lng>/contacts.json.
Seed applicants
Edit the applicants array in src/data/contacts.ts. Timestamps use the relative helpers
(minsAgo / hoursAgo / daysAgo / daysAhead). Avatars are self-hosted absolute paths, or omit
avatar to get the Avatar initials fallback.
CSV export columns
The export header + row mapping live in exportCsv inside useApplicants.ts. Add or reorder columns
there; header labels are pulled from i18n keys and the file is written with toCsv + downloadCsv
(BOM + RFC-4180 quoting — see Utilities & API Client).
Examples
Reuse the view-model in a compact widget
import {useApplicants} from '@/components/contacts/useApplicants'
function PendingApplicantsWidget() {
const {visible, counts, setSelectedId} = useApplicants()
const pending = visible.filter((a) => a.status === 'pending')
return (
<ul>
<li>Pending: {counts.pending}</li>
{pending.map((a) => (
<li key={a.id}>
<button onClick={() => setSelectedId(a.id)}>{a.name}</button>
</li>
))}
</ul>
)
}Approve an applicant from code
const {updateStatus} = useApplicants()
updateStatus('a1', 'approved', 'h_approved')
// → status set, an 'h_approved' (success-tone) history entry appended, success toast firedExport the current filtered list
const {exportCsv} = useApplicants()
exportCsv() // downloads applicants.csv of the visible (filtered + sorted) rowsBest practices
- Keep everything in
useApplicants. Selection, filtering, and mutation are tightly coupled (the delete fallbacks depend on the filteredvisiblelist), which is exactly why the whole view-model is one hook — the page stays pure rendering. - Append to history, never rewrite it. Every status change / interview / note / upload pushes a
new
HistoryEntry; the timeline is an audit log. - Use monotonic sub-ids. History / interview / file ids come from the
nextSubIdcounter (notDate.now()) so rapid or bulk actions never produce duplicate React keys. - Store keys, keep proper nouns literal. Statuses / positions / document categories / sources are i18n keys; names, emails, refs, and skills are literal.
- Register new persisted keys in
src/lib/appStorage.ts.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| ↑/↓ scrolls the page instead of navigating rows | The handler preventDefaults and stands down while inputs/menus/dialogs are focused. Ensure focus isn't trapped in a field. |
?id= deep link doesn't select | The id must exist in the current data; an invalid id self-heals (is dropped from the URL). |
| Duplicate React key warnings after bulk actions | Sub-entries must use nextSubId(...), not Date.now() — the counter avoids same-tick collisions. |
| New applicant / ref ids collide after reload | Ids continue from maxApplicantSeq / maxRefSeq — don't hand-assign ids that skip the sequence. |
| Deleting the selected applicant clears the pane | It selects a fallback (next, then previous visible row); if none remain, the pane empties. Expected. |
| Bulk bar / select-all doesn't appear | It's only shown in select mode — toggle it from the list header. |
| Renamed a seed field but the app shows old data | The persisted state (contacts-state-v1) froze at first run. Clear it or "Reset to defaults". |
FAQ
Is this connected to the live API? No — it is a fully client-side, localStorage-backed demo
module (like Chat, Calendar, and Scrumboard). It demonstrates a complete review workflow that you can
later wire to the api client (see Utilities & API Client).
Can I upload real files? The upload is a fake stub (addFile appends a placeholder file entry) so
the demo needs no backend. Replace addFile with a real upload to the API.
How is search scoped? Free-text matches name, reference, and email; the status filter, tag filter, and sort compose on top. Counts are computed over the whole data set, independent of the query.
How do I clear all applicants? clearApplicants() removes the persisted key; "Reset to defaults"
clears it and reloads to the 12-applicant seed.
Why do the "applied" times look recent on a fresh install? Seed timestamps are relative to module load; they freeze once persisted.
Notes for designers & content editors
- All chrome copy (status labels, position names, employment types, document categories, history
titles, sources, button/toast text) is i18n keys in
src/locales/<lng>/contacts.json. Edit there. - Status colors come from
STATUS_TONE→Badgetones → semantic tokens, so skins and dark mode re-color the list dots, badges, and detail chips automatically. - Applicant content (names, emails, phone numbers, skills, tags) is literal demo data in
src/data/contacts.ts— safe to swap for a client preview. - Empty states are token-var SVGs (
ContactsEmpty.tsx), so they re-skin and dark-mode with the app. - Star ratings & tags are review annotations; they persist and drive the rating sort and tag filter.
Related
Was this page helpful?
