/* ============================================================ 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) => ( <>
{p.title}
{[p.location, p.employmentType].filter(Boolean).join(' · ') || '—'}
), }, { key: 'platform', label: 'Platform', sortable: true, render: (p) => {p.platform}, }, { key: 'status', label: 'Publish Status', sortable: true, sortValue: (p) => badgeFor(p.status).label, render: (p) => { const b = badgeFor(p.status) return ( <> {b.label} {p.error &&
{p.error}
} ) }, }, { key: 'sentAt', label: 'Sent', sortable: true, sortValue: (p) => (p.sentAt ? p.sentAt.getTime() : 0), render: (p) => {p.sentAt ? fmtShort(p.sentAt) : '—'}, }, { key: 'created', label: 'Created', sortable: true, sortValue: (p) => (p.created ? p.created.getTime() : 0), render: (p) => ( <>
{p.created ? fmtShort(p.created) : '—'}
{p.createdBy &&
{p.createdBy}
} ), }, { key: '_a', label: '', align: 'right', render: (p) => (
{p.link ? ( ) : ( )}
), }, ] return (

Job Board

Where each requisition was published, and whether it landed

Analytics

Destinations

Connected Buffer channels and known platforms
{channelsQuery.isPending && aliasQuery.isPending && ( Fetching connected channels. )} {channelsQuery.isError && ( {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. )} {!channelsQuery.isPending && !channelsQuery.isError && destinations.length === 0 && ( Connect a channel in Buffer, then set BUFFER_CHANNEL_ID so posts have a default destination. )} {destinations.length > 0 && (
{destinations.map((d) => (
{d.name}
{d.service || (d.connected ? 'channel' : 'not connected')}
{d.posts} post{d.posts === 1 ? '' : 's'} {d.connected ? 'Connected' : 'Available'}
))}
)}

Published Posts

{postsQuery.isSuccess ? `${rows.length} of ${posts.length}` : 'Every job post and its Buffer state'}
setQ(e.target.value)} placeholder="Search job or platform…" />
{postsQuery.isPending && (
Fetching job posts.
)} {postsQuery.isError && (
{friendlyAuthError(postsQuery.error, 'The server did not return job posts.')} {' '}This screen needs the job_board.view permission.
)} {!postsQuery.isPending && !postsQuery.isError && ( )}
) }