356 lines
14 KiB
JavaScript
356 lines
14 KiB
JavaScript
/* ============================================================
|
||
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="Couldn’t 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="Couldn’t 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>
|
||
)
|
||
}
|