283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
|
import Modal from '../ui/Modal'
|
|
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
|
import { useToast } from '../ui/Toast'
|
|
import { useFormState } from '../components/AuthLayout'
|
|
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
|
import { fmtDate, fmtShort, getCandidate, savedSearches, TODAY } from '../data/seed'
|
|
|
|
const FILTERS = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']
|
|
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
|
|
|
|
export default function Tasks() {
|
|
const { toast } = useToast()
|
|
const navigate = useNavigate()
|
|
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
|
|
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
|
const updateTasks = useSeedMutation('tasks')
|
|
|
|
const [filter, setFilter] = useState('All')
|
|
const [detail, setDetail] = useState(null)
|
|
const [adding, setAdding] = useState(false)
|
|
|
|
const isOverdue = (t) => !t.done && t.due < TODAY
|
|
|
|
const list = useMemo(() => {
|
|
if (filter === 'Open') return tasks.filter((t) => !t.done)
|
|
if (filter === 'Completed') return tasks.filter((t) => t.done)
|
|
if (filter === 'Overdue') return tasks.filter(isOverdue)
|
|
if (['High', 'Medium', 'Low'].includes(filter)) return tasks.filter((t) => t.priority === filter)
|
|
return tasks
|
|
}, [tasks, filter])
|
|
|
|
const openCount = tasks.filter((t) => !t.done).length
|
|
const overdueCount = tasks.filter(isOverdue).length
|
|
|
|
// Writing to the cache is what makes the sidebar badge update — the prototype
|
|
// had to remember to call App.updateBadges() at each of these call sites.
|
|
function toggle(id) {
|
|
let nowDone = false
|
|
updateTasks((ts) =>
|
|
ts.map((t) => {
|
|
if (t.id !== id) return t
|
|
nowDone = !t.done
|
|
return { ...t, done: nowDone }
|
|
}),
|
|
)
|
|
toast(nowDone ? 'Task completed' : 'Task reopened', nowDone ? 'success' : 'info')
|
|
}
|
|
|
|
function complete(id) {
|
|
updateTasks((ts) => ts.map((t) => (t.id === id ? { ...t, done: true } : t)))
|
|
toast('Task completed', 'success')
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<div>
|
|
<h1 className="page-title">Tasks</h1>
|
|
<p className="page-sub">{openCount} open · {overdueCount} overdue</p>
|
|
</div>
|
|
<div className="page-head-actions">
|
|
<button className="btn btn-primary" onClick={() => setAdding(true)}>
|
|
<Icon name="plus" /> New Task
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid g-2-1">
|
|
<div className="card">
|
|
<div className="card-body" style={{ paddingBottom: 0 }}>
|
|
<div className="seg">
|
|
{FILTERS.map((f) => (
|
|
<button key={f} className={f === filter ? 'active' : ''} onClick={() => setFilter(f)}>
|
|
{f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="list-tight">
|
|
{list.length === 0 ? (
|
|
<EmptyState icon="check-square" title="All caught up">No tasks in this view.</EmptyState>
|
|
) : (
|
|
list.map((t) => {
|
|
const overdue = isOverdue(t)
|
|
return (
|
|
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
|
|
<span
|
|
className={`checkbox ${t.done ? 'on' : ''}`}
|
|
onClick={(e) => { e.stopPropagation(); toggle(t.id) }}
|
|
role="checkbox"
|
|
aria-checked={t.done}
|
|
tabIndex={0}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(t.id) } }}
|
|
>
|
|
<Icon name="check" />
|
|
</span>
|
|
<div className="lr-main" style={{ cursor: 'pointer' }} onClick={() => setDetail(t)}>
|
|
<div
|
|
className="lr-title"
|
|
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
|
|
>
|
|
{t.title}
|
|
</div>
|
|
<div className="lr-sub"><Icon name="users" /> {t.assignee} · {t.type}</div>
|
|
</div>
|
|
<div className="lr-right">
|
|
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
|
|
<div
|
|
className="lr-sub"
|
|
style={{ marginTop: 4, ...(overdue ? { color: 'var(--danger)', fontWeight: 600 } : {}) }}
|
|
>
|
|
{overdue ? 'Overdue · ' : 'Due '}{fmtShort(t.due)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card" style={{ alignSelf: 'start' }}>
|
|
<div className="card-head">
|
|
<div><h3>Saved Searches</h3><span className="ch-sub">Quick candidate filters</span></div>
|
|
<button className="act-btn" onClick={() => toast('New saved search', 'info')}><Icon name="plus" /></button>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="list-tight">
|
|
{savedSearches.map((s) => (
|
|
<div key={s.name} className="list-row" style={{ cursor: 'pointer' }} onClick={() => navigate('/candidates')}>
|
|
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
|
|
<Icon name="bookmark" />
|
|
</span>
|
|
<div className="lr-main">
|
|
<div className="lr-title">{s.name}</div>
|
|
<div className="lr-sub">{s.filters}</div>
|
|
</div>
|
|
<span className="badge b-gray badge-plain">{s.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{detail && (
|
|
<TaskDetail
|
|
task={detail}
|
|
onClose={() => setDetail(null)}
|
|
onComplete={() => { complete(detail.id); setDetail(null) }}
|
|
onViewCandidate={(id) => { setDetail(null); navigate('/candidates', { state: { openCandidate: id } }) }}
|
|
/>
|
|
)}
|
|
|
|
{adding && (
|
|
<AddTask
|
|
recruiters={recruiters}
|
|
count={tasks.length}
|
|
onClose={() => setAdding(false)}
|
|
onSave={(task) => {
|
|
updateTasks((ts) => [task, ...ts])
|
|
setAdding(false)
|
|
toast('Task created', 'success')
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TaskDetail({ task: t, onClose, onComplete, onViewCandidate }) {
|
|
const c = t.candidateId ? getCandidate(t.candidateId) : null
|
|
return (
|
|
<Modal
|
|
title={t.title}
|
|
subtitle={`${t.id} · ${t.type}`}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
|
{c && (
|
|
<button className="btn btn-secondary" onClick={() => onViewCandidate(c.id)}>View Candidate</button>
|
|
)}
|
|
<button className="btn btn-primary" onClick={onComplete}><Icon name="check" /> Mark Complete</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="info-grid" style={{ marginBottom: 16 }}>
|
|
<div className="info-item"><div className="il">Assignee</div><div className="iv">{t.assignee}</div></div>
|
|
<div className="info-item"><div className="il">Priority</div><div className="iv">{t.priority}</div></div>
|
|
<div className="info-item"><div className="il">Due Date</div><div className="iv">{fmtDate(t.due)}</div></div>
|
|
<div className="info-item"><div className="il">Status</div><div className="iv">{t.done ? 'Completed' : 'Open'}</div></div>
|
|
{c && <div className="info-item"><div className="il">Candidate</div><div className="iv">{c.name}</div></div>}
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Notes</label>
|
|
<textarea placeholder="Add task notes…" />
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
function AddTask({ recruiters, count, onClose, onSave }) {
|
|
const form = useFormState({
|
|
title: '', priority: 'Medium', type: 'Interview',
|
|
assignee: recruiters[0]?.name ?? '', due: '',
|
|
})
|
|
|
|
function submit() {
|
|
if (!form.values.title.trim()) {
|
|
form.setErrors({ title: 'Required' })
|
|
return
|
|
}
|
|
onSave({
|
|
id: `TSK-${50001 + count}`,
|
|
title: form.values.title,
|
|
candidateId: null,
|
|
priority: form.values.priority,
|
|
due: form.values.due ? new Date(form.values.due) : new Date('2026-07-16'),
|
|
assignee: form.values.assignee,
|
|
done: false,
|
|
type: form.values.type,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
title="New Task"
|
|
subtitle="Create a recruitment task"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
|
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Create Task</button>
|
|
</>
|
|
}
|
|
>
|
|
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
|
<div className="form-grid">
|
|
<div className="form-field col-span-2">
|
|
<label>Task Title <span className="req">*</span></label>
|
|
<input
|
|
placeholder="e.g. Screen candidate"
|
|
className={form.errors.title ? 'err' : ''}
|
|
value={form.values.title}
|
|
onChange={(e) => form.setField('title', e.target.value)}
|
|
/>
|
|
<FieldError>{form.errors.title}</FieldError>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Priority</label>
|
|
<select value={form.values.priority} onChange={(e) => form.setField('priority', e.target.value)}>
|
|
<option>High</option><option>Medium</option><option>Low</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Type</label>
|
|
<select value={form.values.type} onChange={(e) => form.setField('type', e.target.value)}>
|
|
{['Interview', 'Review', 'Offer', 'Admin'].map((o) => <option key={o}>{o}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Assignee</label>
|
|
<select value={form.values.assignee} onChange={(e) => form.setField('assignee', e.target.value)}>
|
|
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Due Date</label>
|
|
<input type="date" value={form.values.due} onChange={(e) => form.setField('due', e.target.value)} />
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|