A recruitment dashboard that allows recruiters to view, filter, and reclassify job candidates. Each candidate carries a rejection reason field: an empty string means the candidate is approved; a non-empty comma-separated string means they are rejected. Recruiters can approve or reject candidates inline from the table, selecting one or more structured reasons when rejecting. The dashboard supports search, status filtering, sortable columns, pagination, column visibility toggles, and CSV export of the current filtered view.
| Layer | Technology | Version |
|---|---|---|
| Backend runtime | Node.js | 22.x |
| Backend framework | Express | 4.18 |
| Backend test runner | Jest + Supertest | 29.x / 7.x |
| Frontend framework | React | 18.2 |
| Frontend build tool | Vite | 5.0 |
| Frontend styling | Tailwind CSS | 3.4 |
| Server state | TanStack React Query | 5.96 |
| Client state | React Context + useReducer | — |
| Frontend test runner | Vitest + React Testing Library | 4.x / 16.x |
- Node.js 18 or higher (developed on v22.17.1)
- npm 9 or higher
git clone <repository-url>
cd emi-labs-challengecd backend
npm install
npm run devThe API server starts at http://localhost:3001.
Open a second terminal:
cd frontend
npm install
npm run devThe app opens at http://localhost:5173.
Both servers must be running simultaneously. The Vite dev server proxies
/apirequests tolocalhost:3001, so no CORS configuration is needed in development.
- Dynamic table — columns rendered from the
/api/columnsconfig; only visible columns are shown - Status badges — green for approved, red for rejected; driven by the
reasonfield - Inline status select — a controlled
<select>in each row lets recruiters switch status without leaving the table - Approve flow — selecting "Approved" on a rejected candidate fires a PATCH immediately, no modal required
- Reject flow — selecting "Rejected" on an approved candidate opens a modal to choose one or more structured reasons; cancelling leaves the candidate unchanged
- Reclassify modal — pre-populates existing reasons when editing a rejected candidate's reasons; confirms before submitting
- Search bar — filters by candidate name, email, or document number; debounced to avoid excessive requests
- Status filter — tabs for All / Approved / Rejected
- Sortable columns — click any column header to sort ascending; click again for descending; resets on new search
- Pagination — 20 candidates per page, rendered above and below the table with a sliding page window
- Column toggle — dropdown to show or hide individual columns; state held in the React Query cache
- CSV export — downloads all candidates matching the current filters (unpaginated) as a valid RFC 4180 CSV file
- Candidate count summary — live counts for Total / Approved / Rejected in the header, consistent with active filters
- Error boundary — catches render errors in the table and shows an inline retry button
| Method | Endpoint | Description | Query params |
|---|---|---|---|
GET |
/api/candidates |
Paginated candidate list | search, status, page, limit |
GET |
/api/candidates/export |
Full filtered list as CSV | search, status |
PATCH |
/api/candidates/:id |
Update candidate status | — |
GET |
/api/columns |
Column visibility config | — |
GET |
/api/reasons |
Available rejection reasons | — |
Query params for GET /api/candidates:
| Param | Type | Default | Description |
|---|---|---|---|
search |
string | — | Filter by name, email, or document |
status |
approved | rejected |
— | Filter by classification |
page |
integer | 1 |
Page number (non-numeric values fall back to 1) |
limit |
integer | 20 |
Results per page (capped at 100) |
PATCH request body:
{ "reason": "" }reason must be a string. An empty string approves the candidate. A comma-separated string (e.g. "Too old, Wrong career") rejects them with the given reasons.
GET /api/candidates response shape:
{
"data": [ ...candidates ],
"pagination": {
"page": 1,
"limit": 20,
"total": 142,
"totalPages": 8,
"approvedCount": 57,
"rejectedCount": 85
}
}cd frontend
npm test # run all suites once and exit
npm run test:ui # open the Vitest browser UIcd backend
npm test| Suite | File | Tests |
|---|---|---|
| Utility functions | src/utils/candidateHelpers.test.js |
16 |
| Reducer | src/context/CandidatesContext.test.js |
10 |
| StatusBadge component | src/components/StatusBadge/StatusBadge.test.jsx |
4 |
| ReclassifyModal component | src/components/ReclassifyModal/ReclassifyModal.test.jsx |
3 |
| API routes (integration) | src/__tests__/candidates.test.js |
11 |
| Total | 44 |
emi-labs-challenge/
├── CLAUDE.md # Project spec and coding guidelines
├── README.md
├── backend/
│ ├── jest.config.js # Jest configuration (node environment)
│ ├── package.json
│ └── src/
│ ├── index.js # Express server entry point, port 3001
│ ├── data/
│ │ ├── candidates.json # Source data, mutated in memory
│ │ └── columns.json # Column visibility config (field → bool)
│ ├── routes/
│ │ ├── candidates.js # GET /, GET /export, PATCH /:id
│ │ ├── columns.js # GET /api/columns
│ │ └── reasons.js # GET /api/reasons
│ └── __tests__/
│ └── candidates.test.js # Integration tests for candidate routes
└── frontend/
├── vitest.config.js # Vitest configuration (jsdom environment)
├── vite.config.js # Vite build config and /api proxy
├── tailwind.config.js
├── package.json
└── src/
├── main.jsx # React root, QueryClientProvider
├── App.jsx # Dashboard layout, header, export button
├── api/
│ └── client.js # Axios wrapper and exportCandidates helper
├── context/
│ └── CandidatesContext.jsx # Client state: filters, page, sort, modal
├── hooks/
│ ├── useCandidates.js # useCandidatesQuery, useReasonsQuery, useUpdateCandidate
│ ├── useColumns.js # useColumnsQuery, useToggleColumn
│ └── useOnClickOutside.js # Shared click-outside detection hook
├── components/
│ ├── CandidateTable/
│ │ ├── CandidateTable.jsx # Table shell, loading skeleton, error state
│ │ ├── TableHeader.jsx # Sortable column headers
│ │ └── TableRow.jsx # Row with inline status select and cell renderer
│ ├── ReclassifyModal/
│ │ └── ReclassifyModal.jsx # Approve/reject modal with reason checkboxes
│ ├── StatusBadge/
│ │ └── StatusBadge.jsx # Pure display badge (approved / rejected)
│ ├── SearchBar/
│ │ └── SearchBar.jsx # Debounced search input and status filter tabs
│ ├── Pagination/
│ │ └── Pagination.jsx # Page controls with sliding window
│ ├── ColumnToggle/
│ │ └── ColumnToggle.jsx # Dropdown to show/hide columns
│ └── ErrorBoundary/
│ └── ErrorBoundary.jsx # Class component boundary with retry button
├── utils/
│ └── candidateHelpers.js # Pure helpers: isApproved, parseReasons, sort, format
└── test/
└── setup.js # @testing-library/jest-dom import for Vitest
This project was built with extensive assistance from Claude Code (Anthropic's CLI) and Claude.ai. AI was used across all phases: architecture planning, initial scaffolding, React Query migration, backend and frontend bug fixing, feature implementation, and test generation. All sessions are documented in detail — including the exact prompts used, what was generated versus manually written, and the rationale behind key technical decisions — in logs/AI_LOG.md.