TalentFlow - Complete Implementation Guide
TalentFlow - Complete Implementation Guide
typescript
import { useState, useEffect, useCallback } from 'react';
import { Candidate, TimelineEvent } from '../types';
import { api } from '../services/api';
interface UseCandidatesParams {
search?: string;
stage?: string;
page?: number;
pageSize?: number;
}
interface UseCandidatesReturn {
candidates: Candidate[];
loading: boolean;
error: string | null;
total: number;
totalPages: number;
currentPage: number;
refetch: () => Promise<void>;
updateCandidate: (id: string, updates: Partial<Candidate>) => Promise<void>;
moveCandidateStage: (candidateId: string, newStage: Candidate['stage']) => Promise<void>;
getCandidateTimeline: (id: string) => Promise<TimelineEvent[]>;
hasNextPage: boolean;
fetchNextPage: () => Promise<void>;
}
if (append) {
setCandidates(prev => [...prev, ...response.data]);
} else {
setCandidates(response.data);
}
setTotal(response.total);
setTotalPages(response.totalPages);
setCurrentPage(page);
setHasNextPage(page < response.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch candidates');
} finally {
setLoading(false);
}
}, [params]);
useEffect(() => {
fetchCandidates(1, false);
}, [fetchCandidates]);
// Optimistic update
setCandidates(prev =>
prev.map(candidate =>
candidate.id === id ? { ...candidate, ...updates } : candidate
)
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update candidate');
await fetchCandidates(1, false); // Revert optimistic update
throw err;
}
};
return {
candidates,
loading,
error,
total,
totalPages,
currentPage,
refetch: () => fetchCandidates(1, false),
updateCandidate,
moveCandidateStage,
getCandidateTimeline,
hasNextPage,
fetchNextPage
};
};
typescript
import React from 'react';
import { Candidate } from '../../types';
import { Mail, Calendar, User } from 'lucide-react';
import { formatDate, getInitials } from '../../utils/helpers';
import clsx from 'clsx';
interface CandidateCardProps {
candidate: Candidate;
onClick?: (candidate: Candidate) => void;
isDragging?: boolean;
showStage?: boolean;
}
const stageColors = {
applied: 'bg-blue-100 text-blue-800',
screen: 'bg-yellow-100 text-yellow-800',
tech: 'bg-purple-100 text-purple-800',
offer: 'bg-green-100 text-green-800',
hired: 'bg-emerald-100 text-emerald-800',
rejected: 'bg-red-100 text-red-800'
};
const stageLabels = {
applied: 'Applied',
screen: 'Screening',
tech: 'Technical',
offer: 'Offer',
hired: 'Hired',
rejected: 'Rejected'
};
typescript
import React, { useCallback, useMemo } from 'react';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { Candidate } from '../../types';
import { CandidateCard } from './CandidateCard';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
import { Search, Users } from 'lucide-react';
interface CandidatesListProps {
candidates: Candidate[];
loading: boolean;
hasNextPage: boolean;
onCandidateClick: (candidate: Candidate) => void;
onLoadMore: () => Promise<void>;
search: string;
stage: string;
onSearchChange: (search: string) => void;
onStageChange: (stage: string) => void;
}
if (!candidate) {
return (
<div style={style} className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div style={style} className="px-4 py-2">
<CandidateCard
candidate={candidate}
onClick={onCandidateClick}
/>
</div>
);
};
const stageOptions = [
{ value: '', label: 'All Stages' },
{ value: 'applied', label: 'Applied' },
{ value: 'screen', label: 'Screening' },
{ value: 'tech', label: 'Technical' },
{ value: 'offer', label: 'Offer' },
{ value: 'hired', label: 'Hired' },
{ value: 'rejected', label: 'Rejected' }
];
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Filters */}
<div className="p-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Search candidates by name or email..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select
value={stage}
onChange={(e) => onStageChange(e.target.value)}
options={stageOptions}
className="w-full sm:w-48"
/>
</div>
typescript
import React, { useState, useMemo } from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Candidate } from '../../types';
import { CandidateCard } from './CandidateCard';
import clsx from 'clsx';
interface KanbanColumnProps {
title: string;
stage: Candidate['stage'];
candidates: Candidate[];
onCandidateClick: (candidate: Candidate) => void;
count: number;
}
return (
<div className="flex-1 min-w-80">
<div className={clsx(
'rounded-lg border-2 border-dashed h-full min-h-[600px]',
stageColors[stage]
)}>
<div className="p-4 border-b border-gray-200 bg-white rounded-t-lg">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">{title}</h3>
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-sm">
{count}
</span>
</div>
</div>
interface SortableCandidateCardProps {
candidate: Candidate;
onClick: (candidate: Candidate) => void;
}
const SortableCandidateCard: React.FC<SortableCandidateCardProps> = ({
candidate,
onClick
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: candidate.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<CandidateCard
candidate={candidate}
onClick={onClick}
isDragging={isDragging}
showStage={false}
/>
</div>
);
};
interface KanbanBoardProps {
candidates: Candidate[];
onCandidateClick: (candidate: Candidate) => void;
onMoveCandidate: (candidateId: string, newStage: Candidate['stage']) => Promise<void>;
}
if (!over) return;
try {
await onMoveCandidate(candidateId, newStage);
} catch (error) {
console.error('Failed to move candidate:', error);
}
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-6 overflow-x-auto pb-4">
{stages.map(({ stage, title }) => (
<KanbanColumn
key={stage}
title={title}
stage={stage}
candidates={candidatesByStage[stage]}
onCandidateClick={onCandidateClick}
count={candidatesByStage[stage].length}
/>
))}
</div>
<DragOverlay>
{activeCandidate && (
<CandidateCard
candidate={activeCandidate}
isDragging
showStage={false}
/>
)}
</DragOverlay>
</DndContext>
);
};
typescript
import React from 'react';
import { TimelineEvent } from '../../types';
import { formatDateTime } from '../../utils/helpers';
import {
ArrowRight,
MessageSquare,
FileText,
User,
CheckCircle,
XCircle,
Clock
} from 'lucide-react';
import clsx from 'clsx';
interface TimelineProps {
events: TimelineEvent[];
loading?: boolean;
}
if (events.length === 0) {
return (
<div className="text-center py-8">
<Clock className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No activity yet</h3>
<p className="mt-1 text-sm text-gray-500">
Timeline events will appear here as the candidate progresses.
</p>
</div>
);
}
return (
<div className="flow-root">
<ul className="-mb-8">
{events.map((event, eventIdx) => {
const Icon = getEventIcon(event.type);
const colorClasses = getEventColor(event.type, event.data);
const isLast = eventIdx === events.length - 1;
return (
<li key={event.id}>
<div className="relative pb-8">
{!isLast && (
<span
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
)}
<div className="relative flex space-x-3">
<div>
<span className={clsx(
'h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white',
colorClasses
)}>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>
<p className="text-sm text-gray-900">
{formatEventDescription(event)}
</p>
</div>
<div className="whitespace-nowrap text-right text-sm text-gray-500">
<time dateTime={event.createdAt.toISOString()}>
{formatDateTime(event.createdAt)}
</time>
</div>
</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
);
};
typescript
import React, { useState } from 'react';
import { Candidate, TimelineEvent } from '../../types';
import { Timeline } from './Timeline';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
import {
Mail,
Calendar,
User,
MessageSquare,
ArrowLeft,
Save,
Plus
} from 'lucide-react';
import { formatDate, getInitials } from '../../utils/helpers';
import clsx from 'clsx';
interface CandidateProfileProps {
candidate: Candidate;
timeline: TimelineEvent[];
timelineLoading: boolean;
onBack: () => void;
onUpdateStage: (newStage: Candidate['stage']) => Promise<void>;
onAddNote: (note: string) => Promise<void>;
}
const stageOptions = [
{ value: 'applied', label: 'Applied' },
{ value: 'screen', label: 'Screening' },
{ value: 'tech', label: 'Technical' },
{ value: 'offer', label: 'Offer' },
{ value: 'hired', label: 'Hired' },
{ value: 'rejected', label: 'Rejected' }
];
const stageColors = {
applied: 'bg-blue-100 text-blue-800 border-blue-200',
screen: 'bg-yellow-100 text-yellow-800 border-yellow-200',
tech: 'bg-purple-100 text-purple-800 border-purple-200',
offer: 'bg-green-100 text-green-800 border-green-200',
hired: 'bg-emerald-100 text-emerald-800 border-emerald-200',
rejected: 'bg-red-100 text-red-800 border-red-200'
};
try {
setIsUpdatingStage(true);
await onUpdateStage(selectedStage);
} catch (error) {
console.error('Failed to update stage:', error);
setSelectedStage(candidate.stage); // Reset on error
} finally {
setIsUpdatingStage(false);
}
};
try {
setIsAddingNote(true);
await onAddNote(newNote.trim());
setNewNote('');
} catch (error) {
console.error('Failed to add note:', error);
} finally {
setIsAddingNote(false);
}
};
<div className={clsx(
'px-4 py-2 rounded-full text-sm font-medium border',
stageColors[candidate.stage]
)}>
{stageOptions.find(opt => opt.value === candidate.stage)?.label}
</div>
</div>
</div>
</div>
<div className="space-y-4">
<Select
label="Current Stage"
value={selectedStage}
onChange={(e) => setSelectedStage(e.target.value as Candidate['stage'])}
options={stageOptions}
/>
{stageChanged && (
<Button
onClick={handleStageUpdate}
loading={isUpdatingStage}
className="w-full"
>
<Save className="h-4 w-4 mr-2" />
Update Stage
</Button>
)}
</div>
</div>
{/* Add Note */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Add Note</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Note Content
</label>
<textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
rows={4}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-no
placeholder="Add a note about this candidate..."
/>
</div>
<Button
onClick={handleAddNote}
loading={isAddingNote}
disabled={!newNote.trim()}
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
Add Note
</Button>
</div>
</div>
</div>
typescript
import React, { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { CandidatesList } from '../components/candidates/CandidatesList';
import { KanbanBoard } from '../components/candidates/KanbanBoard';
import { Button } from '../components/ui/Button';
import { useCandidates } from '../hooks/useCandidates';
import { Candidate } from '../types';
import { List, Kanban, AlertCircle } from 'lucide-react';
import { debounce } from '../utils/helpers';
const {
candidates,
loading,
error,
moveCandidateStage,
hasNextPage,
fetchNextPage,
refetch
} = useCandidates({
search: search.length >= 2 ? search : '', // Only search if 2+ characters
stage,
pageSize: 50
});
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Candidates</h1>
<p className="mt-1 text-sm text-gray-500">
Manage candidate applications and track their progress
</p>
</div>
1. Project Setup
Prerequisites
Node.js (v16 or higher)
npm or yarn
Git
Initial Setup
bash
js
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
Add to src/index.css :
css
@tailwind base;
@tailwind components;
@tailwind utilities;
2. Project Structure
src/
├── components/
│ ├── ui/
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ ├── Modal.tsx
│ │ ├── Select.tsx
│ │ └── Pagination.tsx
│ ├── jobs/
│ │ ├── JobsList.tsx
│ │ ├── JobCard.tsx
│ │ ├── JobForm.tsx
│ │ └── JobFilters.tsx
│ ├── candidates/
│ │ ├── CandidatesList.tsx
│ │ ├── CandidateCard.tsx
│ │ ├── CandidateProfile.tsx
│ │ ├── KanbanBoard.tsx
│ │ └── Timeline.tsx
│ ├── assessments/
│ │ ├── AssessmentBuilder.tsx
│ │ ├── AssessmentPreview.tsx
│ │ ├── QuestionEditor.tsx
│ │ └── AssessmentForm.tsx
│ └── layout/
│ ├── Header.tsx
│ ├── Sidebar.tsx
│ └── Layout.tsx
├── hooks/
│ ├── useJobs.ts
│ ├── useCandidates.ts
│ ├── useAssessments.ts
│ └── useLocalStorage.ts
├── services/
│ ├── api.ts
│ ├── database.ts
│ └── mockData.ts
├── types/
│ └── index.ts
├── pages/
│ ├── JobsPage.tsx
│ ├── CandidatesPage.tsx
│ ├── CandidateDetailPage.tsx
│ └── AssessmentsPage.tsx
├── utils/
│ ├── validation.ts
│ └── helpers.ts
├── mocks/
│ ├── handlers.ts
│ └── browser.ts
├── App.tsx
├── index.tsx
└── index.css
typescript
export interface Job {
id: string;
title: string;
slug: string;
status: 'active' | 'archived';
tags: string[];
order: number;
description?: string;
createdAt: Date;
updatedAt: Date;
}
typescript
constructor() {
super('TalentFlowDB');
this.version(1).stores({
jobs: 'id, title, slug, status, order, createdAt',
candidates: 'id, name, email, stage, jobId, createdAt',
assessments: 'id, jobId, createdAt',
assessmentResponses: 'id, assessmentId, candidateId, createdAt',
timelineEvents: 'id, candidateId, type, createdAt'
});
}
}
typescript
import { Job, Candidate, Assessment, AssessmentSection, Question } from '../types';
import { db } from './database';
const jobTitles = [
'Frontend Developer', 'Backend Developer', 'Full Stack Developer',
'Product Manager', 'UI/UX Designer', 'Data Scientist', 'DevOps Engineer',
'QA Engineer', 'Mobile Developer', 'Technical Writer', 'Sales Manager',
'Marketing Specialist', 'HR Manager', 'Business Analyst', 'System Administrator'
];
const tags = ['React', 'Node.js', 'Python', 'JavaScript', 'TypeScript', 'AWS', 'Docker', 'Kubernetes', 'MongoDB', 'Postgre
const firstNames = ['John', 'Jane', 'Mike', 'Sarah', 'David', 'Emma', 'Chris', 'Lisa', 'Tom', 'Anna'];
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez'];
// Generate Jobs
const jobs: Job[] = [];
for (let i = 0; i < 25; i++) {
const title = jobTitles[Math.floor(Math.random() * jobTitles.length)];
const job: Job = {
id: `job-${i + 1}`,
title: `${title} ${i + 1}`,
slug: `${title.toLowerCase().replace(/\s+/g, '-')}-${i + 1}`,
status: Math.random() > 0.3 ? 'active' : 'archived',
tags: tags.slice(0, Math.floor(Math.random() * 4) + 1),
order: i,
description: `Job description for ${title}`,
createdAt: new Date(Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000),
updatedAt: new Date()
};
jobs.push(job);
}
await db.jobs.bulkAdd(jobs);
// Generate Candidates
const candidates: Candidate[] = [];
for (let i = 0; i < 1000; i++) {
const firstName = firstNames[Math.floor(Math.random() * firstNames.length)];
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)];
const candidate: Candidate = {
id: `candidate-${i + 1}`,
name: `${firstName} ${lastName}`,
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}${i}@example.com`,
stage: stages[Math.floor(Math.random() * stages.length)],
jobId: jobs[Math.floor(Math.random() * jobs.length)].id,
createdAt: new Date(Date.now() - Math.random() * 60 * 24 * 60 * 60 * 1000),
updatedAt: new Date()
};
candidates.push(candidate);
}
await db.candidates.bulkAdd(candidates);
// Generate Assessments
const assessments: Assessment[] = [];
const activeJobs = jobs.filter(job => job.status === 'active').slice(0, 3);
await db.assessments.bulkAdd(assessments);
};
return questions;
};
typescript
import { rest } from 'msw';
import { db } from '../services/database';
if (status) {
query = query.filter(job => job.status === status);
}
if (search) {
jobs = jobs.filter(job =>
job.title.toLowerCase().includes(search.toLowerCase()) ||
job.tags.some(tag => tag.toLowerCase().includes(search.toLowerCase()))
);
}
return res(
ctx.json({
data: paginatedJobs,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
})
);
}),
await db.jobs.add(job);
return res(ctx.json(job));
}),
const { id } = req.params;
const updates = await req.json();
return res(ctx.json(job));
}),
// Candidates endpoints
rest.get('/api/candidates', async (req, res, ctx) => {
await delay();
if (search) {
candidates = candidates.filter(candidate =>
candidate.name.toLowerCase().includes(search.toLowerCase()) ||
candidate.email.toLowerCase().includes(search.toLowerCase())
);
}
if (stage) {
candidates = candidates.filter(candidate => candidate.stage === stage);
}
return res(
ctx.json({
data: paginatedCandidates,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
})
);
}),
rest.patch('/api/candidates/:id', async (req, res, ctx) => {
await delay();
if (shouldError()) return res(ctx.status(500));
const { id } = req.params;
const updates = await req.json();
return res(ctx.json(candidate));
}),
const { id } = req.params;
const events = await db.timelineEvents.where('candidateId').equals(id as string).toArray();
return res(ctx.json(events));
}),
// Assessments endpoints
rest.get('/api/assessments/:jobId', async (req, res, ctx) => {
await delay();
return res(ctx.json(assessment));
}),
return res(ctx.json(assessment));
}),
rest.post('/api/assessments/:jobId/submit', async (req, res, ctx) => {
await delay();
if (shouldError()) return res(ctx.status(500));
typescript
typescript
export class ApiService {
private baseURL = '/api';
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Jobs API
async getJobs(params: {
search?: string;
status?: string;
page?: number;
pageSize?: number;
sort?: string;
}) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) searchParams.set(key, String(value));
});
return this.request(`/jobs?${searchParams}`);
}
// Candidates API
async getCandidates(params: {
search?: string;
stage?: string;
page?: number;
pageSize?: number;
}) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) searchParams.set(key, String(value));
});
return this.request(`/candidates?${searchParams}`);
}
// Assessments API
async getAssessment(jobId: string) {
return this.request(`/assessments/${jobId}`);
}
async saveAssessment(jobId: string, assessment: any) {
return this.request(`/assessments/${jobId}`, {
method: 'PUT',
body: JSON.stringify(assessment),
});
}
4. UI Components
typescript
import React from 'react';
import clsx from 'clsx';
// Sizes
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
'px-6 py-3 text-base': size === 'lg',
// States
'opacity-50 cursor-not-allowed': disabled || loading,
},
className
)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.29
</svg>
)}
{children}
</button>
);
};
typescript
import React, { forwardRef } from 'react';
import clsx from 'clsx';
Input.displayName = 'Input';
Modal Component ( src/components/ui/Modal.tsx )
typescript
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import clsx from 'clsx';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
return createPortal(
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div
className={clsx(
'relative bg-white rounded-lg shadow-xl w-full transform transition-all',
{
'max-w-sm': size === 'sm',
'max-w-md': size === 'md',
'max-w-2xl': size === 'lg',
'max-w-4xl': size === 'xl',
}
)}
>
{/* Header */}
{title && (
<div className="flex items-center justify-between p-6 border-b">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X className="h-5 w-5" />
</button>
</div>
)}
typescript
import React, { forwardRef } from 'react';
import clsx from 'clsx';
interface SelectOption {
value: string;
label: string;
}
Select.displayName = 'Select';
typescript
import React from 'react';
import { Button } from './Button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
showInfo?: boolean;
total?: number;
pageSize?: number;
}
rangeWithDots.push(...range);
return rangeWithDots;
};
return (
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
{showInfo && (
<div className="flex-1 flex justify-between sm:hidden">
<p className="text-sm text-gray-700">
Showing {startItem} to {endItem} of {total} results
</p>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white tex
>
<ChevronRight className="h-4 w-4" />
</Button>
</nav>
</div>
</div>
);
};
5. Jobs Module
typescript
import { useState, useEffect, useCallback } from 'react';
import { Job } from '../types';
import { api } from '../services/api';
interface UseJobsParams {
search?: string;
status?: string;
page?: number;
pageSize?: number;
sort?: string;
}
interface UseJobsReturn {
jobs: Job[];
loading: boolean;
error: string | null;
total: number;
totalPages: number;
currentPage: number;
refetch: () => Promise<void>;
createJob: (job: Partial<Job>) => Promise<void>;
updateJob: (id: string, updates: Partial<Job>) => Promise<void>;
reorderJobs: (fromIndex: number, toIndex: number) => Promise<void>;
}
setJobs(response.data);
setTotal(response.total);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch jobs');
} finally {
setLoading(false);
}
}, [params, currentPage]);
useEffect(() => {
fetchJobs();
}, [fetchJobs]);
// Optimistic update
setJobs(prevJobs =>
prevJobs.map(job =>
job.id === id ? { ...job, ...updates } : job
)
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update job');
await fetchJobs(); // Revert optimistic update
throw err;
}
};
// Optimistic update
setJobs(reorderedJobs);
try {
await api.reorderJob(movedJob.id, fromIndex, toIndex);
} catch (err) {
// Rollback on failure
setJobs(jobs);
setError(err instanceof Error ? err.message : 'Failed to reorder jobs');
throw err;
}
};
return {
jobs,
loading,
error,
total,
totalPages,
currentPage,
refetch: fetchJobs,
createJob,
updateJob,
reorderJobs
};
};
typescript
import React from 'react';
import { Job } from '../../types';
import { Button } from '../ui/Button';
import { Edit, Archive, ArchiveRestore, MoreVertical } from 'lucide-react';
import clsx from 'clsx';
interface JobCardProps {
job: Job;
onEdit: (job: Job) => void;
onArchive: (job: Job) => void;
onUnarchive: (job: Job) => void;
isDragging?: boolean;
}
{job.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{job.description}
</p>
)}
typescript
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Job } from '../../types';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
import { Button } from '../ui/Button';
interface JobFormData {
title: string;
slug: string;
status: 'active' | 'archived';
tags: string;
description: string;
}
interface JobFormProps {
job?: Job;
onSubmit: (data: JobFormData) => Promise<void>;
onCancel: () => void;
loading?: boolean;
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
<Input
label="Job Title"
{...register('title', {
required: 'Job title is required',
minLength: {
value: 3,
message: 'Job title must be at least 3 characters'
}
})}
error={errors.title?.message}
placeholder="e.g., Frontend Developer"
/>
<Input
label="Slug"
{...register('slug', {
required: 'Slug is required',
pattern: {
value: /^[a-z0-9-]+$/,
message: 'Slug can only contain lowercase letters, numbers, and hyphens'
}
})}
error={errors.slug?.message}
placeholder="e.g., frontend-developer"
helperText="URL-friendly version of the job title"
/>
<Select
label="Status"
{...register('status', { required: 'Status is required' })}
options={[
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' }
]}
error={errors.status?.message}
/>
<Input
label="Tags"
{...register('tags')}
error={errors.tags?.message}
placeholder="e.g., React, TypeScript, Remote"
helperText="Comma-separated tags"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
{...register('description')}
rows={4}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none foc
placeholder="Job description..."
/>
</div>
typescript
import React from 'react';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
import { Button } from '../ui/Button';
import { Search, Filter, X } from 'lucide-react';
interface JobFiltersProps {
search: string;
status: string;
onSearchChange: (search: string) => void;
onStatusChange: (status: string) => void;
onClearFilters: () => void;
hasActiveFilters: boolean;
}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="flex items-center gap-1"
>
<X className="h-4 w-4" />
Clear
</Button>
)}
typescript
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import {
useSortable
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Job } from '../../types';
import { JobCard } from './JobCard';
import { Button } from '../ui/Button';
import { GripVertical } from 'lucide-react';
interface SortableJobCardProps {
job: Job;
onEdit: (job: Job) => void;
onArchive: (job: Job) => void;
onUnarchive: (job: Job) => void;
}
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className="relative group">
<div
{...attributes}
{...listeners}
className="absolute left-2 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-
>
<GripVertical className="h-5 w-5 text-gray-400" />
</div>
<div className="ml-8">
<JobCard
job={job}
onEdit={onEdit}
onArchive={onArchive}
onUnarchive={onUnarchive}
isDragging={isDragging}
/>
</div>
</div>
);
};
interface JobsListProps {
jobs: Job[];
onEdit: (job: Job) => void;
onArchive: (job: Job) => void;
onUnarchive: (job: Job) => void;
onReorder: (fromIndex: number, toIndex: number) => Promise<void>;
loading?: boolean;
}
try {
await onReorder(oldIndex, newIndex);
} catch (error) {
console.error('Reorder failed:', error);
}
}
};
if (loading) {
return (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-white rounded-lg border border-gray-200 p-4 animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3 mb-3"></div>
<div className="flex gap-2">
<div className="h-6 bg-gray-200 rounded w-16"></div>
<div className="h-6 bg-gray-200 rounded w-16"></div>
</div>
</div>
))}
</div>
);
}
if (jobs.length === 0) {
return (
<div className="text-center py-12">
<div className="mx-auto h-12 w-12 text-gray-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 01
</svg>
</div>
<h3 className="mt-2 text-sm font-medium text-gray-900">No jobs found</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating a new job.</p>
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={jobs.map(job => job.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-4">
{jobs.map(job => (
<SortableJobCard
key={job.id}
job={job}
onEdit={onEdit}
onArchive={onArchive}
onUnarchive={onUnarchive}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeJob && (
<div className="ml-8">
<JobCard
job={activeJob}
onEdit={onEdit}
onArchive={onArchive}
onUnarchive={onUnarchive}
isDragging
/>
</div>
)}
</DragOverlay>
</DndContext>
);
};
typescript
import React, { useState, useMemo } from 'react';
import { JobsList } from '../components/jobs/JobsList';
import { JobFilters } from '../components/jobs/JobFilters';
import { JobForm } from '../components/jobs/JobForm';
import { Modal } from '../components/ui/Modal';
import { Button } from '../components/ui/Button';
import { Pagination } from '../components/ui/Pagination';
import { useJobs } from '../hooks/useJobs';
import { Job } from '../types';
import { Plus, AlertCircle } from 'lucide-react';
const {
jobs,
loading,
error,
total,
totalPages,
currentPage,
refetch,
createJob,
updateJob,
reorderJobs
} = useJobs({
search,
status,
page,
pageSize: 10,
sort: 'order'
});
await createJob(jobData);
setIsCreateModalOpen(false);
};
const jobData = {
...data,
tags: data.tags ? data.tags.split(',').map((tag: string) => tag.trim()) : []
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Jobs</h1>
<p className="mt-1 text-sm text-gray-500">
Manage job postings and track applications
</p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Job
</Button>
</div>
</div>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 m-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
<button
onClick={refetch}
className="mt-2 text-sm text-red-600 hover:text-red-500 underline"
>
Try again
</button>
</div>
</div>
</div>
)}
<div className="p-6">
<JobsList
jobs={jobs}
onEdit={setEditingJob}
onArchive={handleArchiveJob}
onUnarchive={handleUnarchiveJob}
onReorder={reorderJobs}
loading={loading}
/>
</div>
6. Layout Components
typescript
import React from 'react';
import { useLocation, Link } from 'react-router-dom';
import { Briefcase, Users, ClipboardList } from 'lucide-react';
import clsx from 'clsx';
const navigation = [
{ name: 'Jobs', href: '/jobs', icon: Briefcase },
{ name: 'Candidates', href: '/candidates', icon: Users },
{ name: 'Assessments', href: '/assessments', icon: ClipboardList },
];
return (
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex items-center">
<Link to="/jobs" className="flex items-center">
<div className="flex-shrink-0">
<Briefcase className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-3">
<h1 className="text-xl font-bold text-gray-900">TalentFlow</h1>
</div>
</Link>
</div>
return (
<Link
key={item.name}
to={item.href}
className={clsx(
'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors',
{
'border-blue-500 text-gray-900': isActive,
'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700': !isActive,
}
)}
>
<Icon className="h-4 w-4 mr-2" />
{item.name}
</Link>
);
})}
</nav>
typescript
import React from 'react';
import { Header } from './Header';
interface LayoutProps {
children: React.ReactNode;
}
typescript
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { JobsPage } from './pages/JobsPage';
import { CandidatesPage } from './pages/CandidatesPage';
import { CandidateDetailPage } from './pages/CandidateDetailPage';
import { AssessmentsPage } from './pages/AssessmentsPage';
import { generateMockData } from './services/mockData';
import { db } from './services/database';
function App() {
useEffect(() => {
const initializeData = async () => {
try {
// Check if data already exists
const jobCount = await db.jobs.count();
initializeData();
}, []);
return (
<Router>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/:jobId" element={<JobsPage />} />
<Route path="/candidates" element={<CandidatesPage />} />
<Route path="/candidates/:id" element={<CandidateDetailPage />} />
<Route path="/assessments" element={<AssessmentsPage />} />
<Route path="/assessments/:jobId" element={<AssessmentsPage />} />
<Route path="*" element={<Navigate to="/jobs" replace />} />
</Routes>
</Layout>
</Router>
);
}
typescript
return worker.start({
onUnhandledRequest: 'bypass',
});
}
enableMocking().then(() => {
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
});
8. Utility Functions
typescript
export const validateNumericRange = (value: number, min?: number, max?: number): boolean => {
if (min !== undefined && value < min) return false;
if (max !== undefined && value > max) return false;
return true;
};
typescript
export const formatDate = (date: Date | string): string => {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
export const classNames = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
};
9. Package.json Scripts
Add to your package.json :
json
{
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
"type-check": "tsc --noEmit"
}
}
This completes the UI components and Jobs module implementation. The Jobs module includes:
✅ Complete CRUD operations with optimistic updates ✅ Drag & drop reordering with rollback on
failure ✅ Server-like pagination & filtering ✅ Form validation with React Hook Form ✅ Deep
linking support (/jobs/:jobId) ✅ Responsive design with Tailwind CSS ✅ Loading states and error
handling ✅ MSW integration with artificial latency