PVR Tech Studio

Contacts

A two-pane applicant-review workspace with search, filters, tabs, quick actions, bulk select, ratings, tags, CSV export, keyboard nav, and deep links.

9 min read
Updated July 15, 2026

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

FileResponsibility
src/pages/apps/ContactsPage.tsxFull-bleed page. Renders the list pane + detail pane (responsive swap) and the two modals; wires everything to useApplicants.
src/components/contacts/useApplicants.tsThe 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.tsxLeft 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.tsxRight 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.tsxDocuments accordion — download / delete / fake-upload per category.
src/components/contacts/HistoryTab.tsxHistory timeline (tone-colored entries).
src/components/contacts/DetailsTab.tsxThe details grid (dob, languages, preferred start, salary, emergency contact, source).
src/components/contacts/InterviewsTab.tsxInterview cards + per-card menu (mark completed / delete).
src/components/contacts/InterviewModal.tsxSchedule-interview dialog.
src/components/contacts/AddApplicantModal.tsxCreate-application dialog (exports the NewApplicant type; name is the only required field).
src/components/contacts/StarRating.tsxThe 1–5 star control (click the current value to clear).
src/components/contacts/ContactsEmpty.tsxToken-var SVG empty states (ContactsEmptyState, variant).
src/data/contacts.tsTypes, the 12-applicant seed (relative epochs), persistence, STATUS_TONE / STATUS_KEYS, maxApplicantSeq / maxRefSeq, defaultDocuments(), and the Intl display formatters.
src/lib/csv.tsThe 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.

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:

KeyTypeDescription
visibleApplicant[]The filtered + sorted list (what the list renders and what CSV export uses).
countsRecord<ApplicantStatus, number>Live per-status counts (query-independent) for the filter chips.
allTagsstring[]Distinct tags (case-insensitive dedupe).
selected / selectedId / selectedIdxThe current applicant, its id, and its index in visible.
setSelectedId(id | null) => voidSelect / deselect (syncs ?id=).
move(dir: 1 | -1) => voidMove the selection within visible, clamped.
query / setQueryFree-text search (name / ref / email).
statusFilter / toggleStatus / tagFilter / toggleTag / clearFiltersFilter state.
sort / setSortSortKey'newest' | 'oldest' | 'name' | 'rating'.
selectMode / toggleSelectMode / checked / toggleChecked / checkAllVisibleBulk-select state.
bulkApprove / bulkReject / bulkDelete() => voidAct on the checked set.
interviewOpen / setInterviewOpen / addOpen / setAddOpenModal open state.
updateStatus(id, status, historyKey) => voidChange status + append history + toast.
deleteApplicant(id) => voidDelete (selects a fallback row).
addApplicant(NewApplicant) => voidCreate a pending applicant + select it.
addInterview / completeInterview / deleteInterviewInterview actions.
setRating(id, rating?) => voidSet / clear the 1–5 rating.
addTag / removeTag(id, tag) => voidCustom tags (case-insensitive dedupe).
addNote(id, text) => voidAppend a reviewer note to the history.
addFile / deleteFileFake-upload / remove a document file.
exportCsv() => voidDownload 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 keysposition_*, 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 fired

Export the current filtered list

const {exportCsv} = useApplicants()
exportCsv() // downloads applicants.csv of the visible (filtered + sorted) rows

Best practices

  • Keep everything in useApplicants. Selection, filtering, and mutation are tightly coupled (the delete fallbacks depend on the filtered visible list), 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 nextSubId counter (not Date.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

SymptomCause / fix
↑/↓ scrolls the page instead of navigating rowsThe handler preventDefaults and stands down while inputs/menus/dialogs are focused. Ensure focus isn't trapped in a field.
?id= deep link doesn't selectThe id must exist in the current data; an invalid id self-heals (is dropped from the URL).
Duplicate React key warnings after bulk actionsSub-entries must use nextSubId(...), not Date.now() — the counter avoids same-tick collisions.
New applicant / ref ids collide after reloadIds continue from maxApplicantSeq / maxRefSeq — don't hand-assign ids that skip the sequence.
Deleting the selected applicant clears the paneIt selects a fallback (next, then previous visible row); if none remain, the pane empties. Expected.
Bulk bar / select-all doesn't appearIt's only shown in select mode — toggle it from the list header.
Renamed a seed field but the app shows old dataThe 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_TONEBadge tones → 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.

Was this page helpful?