HR-ATS-Portal/frontend/src/screens/JobBoard.jsx

356 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
Job Board — live on the publishing side of job posts.
Reads: GET /job/fetch (every post with its Buffer state), GET
/job/buffer/channels (the connected destinations) and GET /jobs/alias (the
platform vocabulary the backend will resolve). Publishing a new post is
POST /job/post-job, which is Create Job on the Jobs screen — this page links
there rather than duplicating a 15-field form.
THIS IS NOW A PUBLISHING BOARD, NOT AN ANALYTICS BOARD. The prototype's
views / clicks / applications / conversion columns had no source anywhere:
Buffer posts go out, and nothing reads engagement back. Inventing four
metrics per row is exactly the failure mode the Jobs and Candidates screens
already refused, so those columns are gone. What replaced them is the state
the backend genuinely tracks — publish status, the channel, when Buffer
accepted it, the external permalink and the error text on failure — which is
what a recruiter actually needs when a post did not appear.
============================================================ */
import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import { Badge, EmptyState, Icon, KpiCard } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobPostsApi from '../api/jobPosts'
import { fmtShort } from '../data/seed'
const POST_LIMIT = 300
/* Buffer publishing lifecycle (job_posts.status) -> badge. NOT the hiring
lifecycle: requisition_status is open/closed/on_hold and lives on the Jobs
screen. Conflating the two is the single easiest mistake to make here. */
const PUBLISH_BADGE = {
published: { label: 'Published', cls: 'b-green' },
sent: { label: 'Published', cls: 'b-green' },
scheduled: { label: 'Scheduled', cls: 'b-blue' },
queued: { label: 'Queued', cls: 'b-indigo' },
draft: { label: 'Draft', cls: 'b-gray' },
failed: { label: 'Failed', cls: 'b-red' },
}
function badgeFor(status) {
return PUBLISH_BADGE[String(status || '').toLowerCase()] ?? { label: status || 'Unknown', cls: 'b-gray' }
}
export default function JobBoard() {
const navigate = useNavigate()
const [q, setQ] = useState('')
const [platform, setPlatform] = useState('')
const [status, setStatus] = useState('')
/* active_only false: a post that failed or was taken down still belongs on a
publishing board — that is precisely the row someone came here to find. */
const postsQuery = useQuery({
queryKey: qk.jobPosts.list({ top: POST_LIMIT, scope: 'board' }),
queryFn: async () => {
const res = await jobPostsApi.list({ top: POST_LIMIT, activeOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({
id: row.id,
title: row.title,
platform: row.platform || 'unknown',
channelId: row.channel_id || null,
status: row.status || 'draft',
link: row.buffer_external_link || null,
postId: row.buffer_post_id || null,
sentAt: row.buffer_sent_at ? new Date(row.buffer_sent_at) : null,
error: row.buffer_error || null,
isActive: row.is_active,
created: row.created_at ? new Date(row.created_at) : null,
createdBy: row.created_by_name || null,
location: row.location || null,
employmentType: row.employment_type || null,
}))
},
})
const channelsQuery = useQuery({
queryKey: qk.jobPosts.all(),
queryFn: async () => {
const res = await jobPostsApi.listChannels()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
/* The alias list is the vocabulary the backend can resolve a platform name
against. A destination the org has connected is a channel; an alias with no
channel is a platform the backend understands but nobody has hooked up. */
const aliasQuery = useQuery({
queryKey: ['jobPosts', 'aliases'],
queryFn: async () => {
const res = await jobPostsApi.listAliases()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
const posts = postsQuery.data ?? []
const channels = channelsQuery.data ?? []
const aliases = aliasQuery.data ?? []
const stats = useMemo(() => {
const by = (s) => posts.filter((p) => badgeFor(p.status).label === s).length
return {
total: posts.length,
published: by('Published'),
pending: by('Scheduled') + by('Queued'),
failed: by('Failed'),
}
}, [posts])
const platformOptions = useMemo(
() => [...new Set(posts.map((p) => p.platform).filter(Boolean))].sort(),
[posts],
)
const statusOptions = useMemo(
() => [...new Set(posts.map((p) => badgeFor(p.status).label))].sort(),
[posts],
)
const rows = useMemo(
() => posts.filter((p) => {
if (platform && p.platform !== platform) return false
if (status && badgeFor(p.status).label !== status) return false
if (q) {
const hay = `${p.title} ${p.platform} ${p.location ?? ''}`.toLowerCase()
if (!hay.includes(q.toLowerCase())) return false
}
return true
}),
[posts, q, platform, status],
)
/* Connected destinations first, then aliases the backend knows about that
have no channel behind them. `service` is Buffer's own name for the
network, which is what the alias list is keyed on. */
const destinations = useMemo(() => {
const connected = channels.map((ch) => ({
key: String(ch.id),
name: ch.displayName || ch.name || String(ch.id),
service: ch.service ? String(ch.service) : null,
connected: true,
posts: posts.filter((p) => String(p.channelId) === String(ch.id)).length,
}))
const known = new Set(connected.map((c) => (c.service || c.name || '').toLowerCase()))
const unconnected = aliases
.filter((a) => !known.has(String(a).toLowerCase()))
.map((a) => ({
key: `alias:${a}`,
name: String(a),
service: null,
connected: false,
posts: posts.filter((p) => String(p.platform).toLowerCase() === String(a).toLowerCase()).length,
}))
return [...connected, ...unconnected]
}, [channels, aliases, posts])
const columns = [
{
key: 'title', label: 'Job', sortable: true,
render: (p) => (
<>
<div className="cell-primary">{p.title}</div>
<div className="cell-sub">
{[p.location, p.employmentType].filter(Boolean).join(' · ') || '—'}
</div>
</>
),
},
{
key: 'platform', label: 'Platform', sortable: true,
render: (p) => <Badge className="b-gray">{p.platform}</Badge>,
},
{
key: 'status', label: 'Publish Status', sortable: true,
sortValue: (p) => badgeFor(p.status).label,
render: (p) => {
const b = badgeFor(p.status)
return (
<>
<Badge className={b.cls}>{b.label}</Badge>
{p.error && <div className="cell-sub" style={{ color: 'var(--danger)' }}>{p.error}</div>}
</>
)
},
},
{
key: 'sentAt', label: 'Sent', sortable: true,
sortValue: (p) => (p.sentAt ? p.sentAt.getTime() : 0),
render: (p) => <span className="text-muted">{p.sentAt ? fmtShort(p.sentAt) : '—'}</span>,
},
{
key: 'created', label: 'Created', sortable: true,
sortValue: (p) => (p.created ? p.created.getTime() : 0),
render: (p) => (
<>
<div className="text-muted">{p.created ? fmtShort(p.created) : '—'}</div>
{p.createdBy && <div className="cell-sub">{p.createdBy}</div>}
</>
),
},
{
key: '_a', label: '', align: 'right',
render: (p) => (
<div className="row-actions">
{p.link ? (
<a
className="act-btn"
href={p.link}
target="_blank"
rel="noreferrer noopener"
data-tip="Open live post"
>
<Icon name="external" />
</a>
) : (
<button className="act-btn" data-tip="Not published yet" disabled>
<Icon name="external" />
</button>
)}
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Job Board</h1>
<p className="page-sub">Where each requisition was published, and whether it landed</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/analytics"><Icon name="trending-up" /> Analytics</Link>
<button
className="btn btn-primary"
onClick={() => navigate('/jobs', { state: { openCreate: true } })}
>
<Icon name="send" /> Publish a Job
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Total Posts" value={postsQuery.isPending ? '—' : stats.total} icon="layers" tone="i-indigo" />
<KpiCard label="Published" value={postsQuery.isPending ? '—' : stats.published} icon="check-circle" tone="i-green" foot="live on a channel" />
<KpiCard label="Queued / Scheduled" value={postsQuery.isPending ? '—' : stats.pending} icon="clock" tone="i-amber" foot="awaiting Buffer" />
<KpiCard label="Failed" value={postsQuery.isPending ? '—' : stats.failed} icon="alert" tone="i-red" foot="needs a retry" />
</div>
<div className="card mb-18">
<div className="card-head">
<div>
<h3>Destinations</h3>
<span className="ch-sub">Connected Buffer channels and known platforms</span>
</div>
</div>
<div className="card-body">
{channelsQuery.isPending && aliasQuery.isPending && (
<EmptyState icon="clock" title="Loading…">Fetching connected channels.</EmptyState>
)}
{channelsQuery.isError && (
<EmptyState icon="alert" title="Couldnt reach Buffer">
{friendlyAuthError(channelsQuery.error, 'The channel list did not load.')}
{' '}A 502 here means the Buffer credentials are missing or rejected, not that
publishing is disabled.
</EmptyState>
)}
{!channelsQuery.isPending && !channelsQuery.isError && destinations.length === 0 && (
<EmptyState icon="layers" title="No channels connected">
Connect a channel in Buffer, then set <code>BUFFER_CHANNEL_ID</code> so posts have a default destination.
</EmptyState>
)}
{destinations.length > 0 && (
<div className="grid g-3">
{destinations.map((d) => (
<div
key={d.key}
className="card"
style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}
>
<div className="card-body">
<div className="flex items-center gap-8" style={{ marginBottom: 10 }}>
<span className={`kpi-icn ${d.connected ? 'i-green' : 'i-indigo'}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name={d.connected ? 'check-circle' : 'layers'} />
</span>
<div style={{ minWidth: 0 }}>
<div className="lr-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.name}</div>
<div className="lr-sub">{d.service || (d.connected ? 'channel' : 'not connected')}</div>
</div>
</div>
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<span className="cell-sub">{d.posts} post{d.posts === 1 ? '' : 's'}</span>
<Badge className={d.connected ? 'b-green' : 'b-gray'}>
{d.connected ? 'Connected' : 'Available'}
</Badge>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Published Posts</h3>
<span className="ch-sub">
{postsQuery.isSuccess ? `${rows.length} of ${posts.length}` : 'Every job post and its Buffer state'}
</span>
</div>
</div>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search job or platform…" />
</div>
<select className="select" value={platform} onChange={(e) => setPlatform(e.target.value)}>
<option value="">All Platforms</option>
{platformOptions.map((p) => <option key={p}>{p}</option>)}
</select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Statuses</option>
{statusOptions.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
</div>
{postsQuery.isPending && (
<div className="card-body">
<EmptyState icon="layers" title="Loading…">Fetching job posts.</EmptyState>
</div>
)}
{postsQuery.isError && (
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load job posts">
{friendlyAuthError(postsQuery.error, 'The server did not return job posts.')}
{' '}This screen needs the <code>job_board.view</code> permission.
</EmptyState>
</div>
)}
{!postsQuery.isPending && !postsQuery.isError && (
<DataTable columns={columns} rows={rows} pageSize={10} empty="No posts match these filters." />
)}
</div>
</div>
)
}