Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Implement_Changes

pull/64/head
ahmed.mujtaba 2026-09-03 16:25:22 +05:00
commit fb94db5f41
13 changed files with 1479 additions and 149 deletions

View File

@ -0,0 +1,24 @@
-- 026: The organisation runs four staff roles — system_administrator,
-- recruiter, hiring_manager, department_head. Soft-delete the unused seeded
-- staff roles (hr_administrator, interviewer, ceo) so they stop appearing in
-- the Access Control screen and every role picker (listings filter on
-- is_deleted).
--
-- NOT touched:
-- * candidate — not a staff role: every applicant account is a role-8 user
-- and the Candidates screen is keyed to it.
-- * any role that still has live members — pruning a role out from under a
-- user would strand their permissions; such a role keeps working until
-- the members are reassigned by hand, and this migration (idempotent)
-- picks it up on a later boot.
UPDATE app.roles r
SET is_deleted = TRUE,
is_active = FALSE,
updated_at = NOW()
WHERE r.role_name IN ('hr_administrator', 'interviewer', 'ceo')
AND r.is_deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM app.users u
WHERE u.role_id = r.id
AND COALESCE(u.is_deleted, FALSE) = FALSE
);

View File

@ -0,0 +1,29 @@
-- 027: The hand-made Access Control role "Manager" duplicates the seeded
-- hiring_manager, which has carried the manager_candidates bundle since 024.
-- Consolidate: move its live members onto hiring_manager, then soft-delete
-- it. Both steps are idempotent and guarded; the seeded hiring_manager row
-- is never matched (name check excludes it, and it is is_system).
UPDATE app.users u
SET role_id = hm.id,
updated_at = NOW()
FROM app.roles hm
WHERE hm.role_name = 'hiring_manager' AND hm.is_deleted = FALSE
AND u.role_id IN (
SELECT r.id FROM app.roles r
WHERE lower(r.role_name) = 'manager'
AND r.role_name <> 'hiring_manager'
AND r.is_deleted = FALSE
)
AND COALESCE(u.is_deleted, FALSE) = FALSE;
UPDATE app.roles r
SET is_deleted = TRUE,
is_active = FALSE,
updated_at = NOW()
WHERE lower(r.role_name) = 'manager'
AND r.role_name <> 'hiring_manager'
AND r.is_deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM app.users u
WHERE u.role_id = r.id AND COALESCE(u.is_deleted, FALSE) = FALSE
);

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"exceljs": "^4.4.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"

View File

@ -10,6 +10,7 @@ import { useTheme } from '../theme/ThemeProvider'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { formatRole } from '../lib/format'
import * as notificationsApi from '../api/notifications'
function initialsFromName(name) {
@ -55,7 +56,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
const name = user?.name || 'Guest'
const email = user?.email || ''
const role = user?.role_name || user?.role || 'Member'
const role = formatRole(user?.role_name || user?.role) || 'Member'
function openNotif(n) {
if (n.unread) markOne.mutate(n.id)

View File

@ -0,0 +1,100 @@
/* ============================================================
exportXlsx.js brand-styled spreadsheet exports.
One shared exporter so every "Export" button produces the same
dashboard-flavoured file: TalentFlow green title band, mint meta row,
ink-teal header, zebra data rows, frozen header + autofilter.
exceljs is ~1MB, so it is imported dynamically Vite splits it into
its own chunk that only ever downloads when an export is clicked.
============================================================ */
/** Utopia Brands palette (ARGB, from styles.css brand constants). */
const BRAND = {
green: 'FF004D43', // deep green — title band, like the sidebar/action colour
ink: 'FF1A3134', // ink teal — header row
lime: 'FFCEFF71', // signature lime — title text accent
mint: 'FFEAFFF4', // mint white — meta row fill
zebra: 'FFF1F7F4', // app background — alternating data rows
border: 'FFDBE8E2', // hairline borders
text: 'FF10231F',
sub: 'FF4A625C',
white: 'FFFFFFFF',
}
const thin = { style: 'thin', color: { argb: BRAND.border } }
/**
* Build and download a styled .xlsx.
*
* @param {object} opts
* @param {string} opts.filename without extension
* @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox"
* @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026"
* @param {Array<{header: string, key: string, width?: number}>} opts.columns
* @param {Array<object>} opts.rows keyed by columns[].key; null/undefined print blank
*/
export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) {
const ExcelJS = (await import('exceljs')).default
const wb = new ExcelJS.Workbook()
wb.creator = 'TalentFlow ATS'
wb.created = new Date()
const ws = wb.addWorksheet(title.slice(0, 31) || 'Export', {
views: [{ state: 'frozen', ySplit: 3 }],
})
ws.columns = columns.map((c) => ({ key: c.key, width: c.width ?? 18 }))
const span = columns.length
// Row 1 — brand title band
const titleRow = ws.addRow([title])
ws.mergeCells(1, 1, 1, span)
titleRow.height = 30
const titleCell = ws.getCell(1, 1)
titleCell.font = { name: 'Calibri', size: 14, bold: true, color: { argb: BRAND.lime } }
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.green } }
titleCell.alignment = { vertical: 'middle', indent: 1 }
// Row 2 — meta line
const metaRow = ws.addRow([subtitle])
ws.mergeCells(2, 1, 2, span)
metaRow.height = 20
const metaCell = ws.getCell(2, 1)
metaCell.font = { name: 'Calibri', size: 10, color: { argb: BRAND.sub } }
metaCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.mint } }
metaCell.alignment = { vertical: 'middle', indent: 1 }
// Row 3 — column headers
const headRow = ws.addRow(columns.map((c) => c.header))
headRow.height = 22
headRow.eachCell((cell) => {
cell.font = { name: 'Calibri', size: 10, bold: true, color: { argb: BRAND.white } }
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.ink } }
cell.alignment = { vertical: 'middle' }
cell.border = { bottom: { style: 'medium', color: { argb: BRAND.lime } } }
})
// Data — zebra rows with hairline borders
for (const [i, r] of rows.entries()) {
const row = ws.addRow(columns.map((c) => r[c.key] ?? ''))
row.eachCell({ includeEmpty: true }, (cell) => {
cell.font = { name: 'Calibri', size: 10, color: { argb: BRAND.text } }
if (i % 2 === 1) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.zebra } }
cell.border = { bottom: thin, right: thin }
cell.alignment = { vertical: 'middle', wrapText: false }
})
}
ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: span } }
const buf = await wb.xlsx.writeBuffer()
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${filename}.xlsx`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(a.href)
}

View File

@ -0,0 +1,11 @@
/** Display formatting for identifiers stored snake_case in the DB. */
/** 'system_administrator' → 'System Administrator', 'INTERVIEW' → 'Interview'. */
export function formatRole(name) {
const s = String(name || '').trim()
if (!s) return s
return s
.split(/[_\s]+/)
.map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w))
.join(' ')
}

View File

@ -23,6 +23,8 @@ import { isHiringManager } from '../auth/permissions'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { formatRole } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
@ -484,7 +486,39 @@ function RecruiterCandidates() {
title="Candidates"
sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>}
actions={<>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<button
className="btn btn-secondary"
onClick={async () => {
if (!rows.length) {
toast('Nothing to export — current filters match no candidates', 'warning')
return
}
try {
await exportStyledXlsx({
filename: `candidates-${new Date().toISOString().slice(0, 10)}`,
title: 'Candidates',
subtitle: `${rows.length} candidate account${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 30 },
{ header: 'Role', key: 'role', width: 22 },
{ header: 'Applied', key: 'applied', width: 12 },
{ header: 'Source', key: 'source', width: 14 },
{ header: 'Account', key: 'account', width: 12 },
],
rows: rows.map((c) => ({
name: c.name, email: c.email, role: formatRole(c.roleName),
applied: c.applied ? c.applied.toLocaleDateString() : '',
source: c.source,
account: c.isActive ? 'Active' : 'Unconfirmed',
})),
})
toast(`Exported ${rows.length} candidate${rows.length === 1 ? '' : 's'}`, 'success')
} catch {
toast('Export failed', 'error')
}
}}
>
<Icon name="download" /> Export
</button>
<button className="btn btn-secondary" onClick={() => navigate('/import')}>
@ -603,7 +637,7 @@ function RecruiterCandidates() {
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
)}
</div>
<div className="cell-sub">{c.roleName ?? '—'}</div>
<div className="cell-sub">{formatRole(c.roleName) || '—'}</div>
</div>
</div>
</td>

View File

@ -25,6 +25,8 @@ import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { formatRole } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import * as sheetApi from '../api/sheet'
@ -59,8 +61,9 @@ function storeSyncRunId(id) {
}
}
/** Inbox channel: Outlook email queue vs imported Google Form rows. */
/** Inbox channel: both sources combined (default), Outlook email, or Google Form rows. */
const CHANNELS = [
{ key: 'all', label: 'All', icon: 'inbox' },
{ key: 'email', label: 'Email', icon: 'mail' },
{ key: 'forms', label: 'Sheet Forms', icon: 'layers' },
]
@ -315,6 +318,7 @@ async function fetchMessageDetail(recordId) {
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
kind: 'email',
id: String(row.id),
name,
initials: initialsOf(name),
@ -368,6 +372,7 @@ async function fetchApplications(params) {
rows: rows.map((row) => {
const name = row.name || row.email || 'Unknown'
return {
kind: 'email',
id: String(row.id),
name,
initials: initialsOf(name),
@ -619,7 +624,7 @@ const READ_TICK_MS = 1000
* `rows` rather than a count, because the bar needs each row's read state, and
* `selectedIds` narrows it to what the selected pair of buttons would touch.
*/
function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel, showAll, onToggleShowAll, tabTotal }) {
const { selectedIds, toggleAll, clear, allSelected } = selection
const n = selectedIds.size
const total = rows.length
@ -670,6 +675,17 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
? `${n} selected`
: `${total}${unreadCount ? ` · ${unreadCount}` : ''}`}
</span>
{/* Same switch as the Per-page "All" entry below, surfaced at the top of
the queue where the eye actually is. */}
{onToggleShowAll && n === 0 && (
<button
className={`btn btn-sm ${showAll ? 'btn-primary' : 'btn-secondary'}`}
title={showAll ? 'Back to paged view' : `Show all ${tabTotal || ''} in one list`}
onClick={onToggleShowAll}
>
{showAll ? 'Show paged' : `Show all${tabTotal ? ` (${tabTotal})` : ''}`}
</button>
)}
<div className="inbox-bulk-actions">
{n > 0 ? (
<>
@ -727,7 +743,7 @@ export default function Inbox() {
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
const [channel, setChannel] = useState('email')
const [channel, setChannel] = useState('all')
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
const [tab, setTab] = useState('All Applications')
const [skip, setSkip] = useState(0)
@ -738,25 +754,31 @@ export default function Inbox() {
const [noting, setNoting] = useState(null)
const isForms = channel === 'forms'
const channelTabs = isForms ? FORM_TABS : TABS
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
// missing top/limit as no LIMIT), merged by date, and paged client-side
// per-source skip/top cannot compose into a correct global page.
const isAllChannel = channel === 'all'
const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS
const tabFilter = TAB_FILTERS[tab] ?? {}
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
// 'all' drops the param entirely the endpoint reads a missing top as unpaged.
top: pageSize === 'all' ? undefined : pageSize,
skip,
// 'all' page size and the All channel drop the param entirely the
// endpoint reads a missing top as unpaged.
top: pageSize === 'all' || isAllChannel ? undefined : pageSize,
skip: isAllChannel ? 0 : skip,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, skip, pageSize, q])
}), [tabFilter, skip, pageSize, q, isAllChannel])
const formParams = useMemo(() => ({
sheet: formSheet || undefined,
offset: skip,
limit: pageSize === 'all' ? undefined : pageSize,
// All channel spans every sheet tab, not just the selected one.
sheet: isAllChannel ? undefined : (formSheet || undefined),
offset: isAllChannel ? 0 : skip,
limit: pageSize === 'all' || isAllChannel ? undefined : pageSize,
...formTabFilter,
...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, skip, pageSize, q, formTabFilter])
}), [formSheet, skip, pageSize, q, formTabFilter, isAllChannel])
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
@ -777,7 +799,7 @@ export default function Inbox() {
const formQuery = useQuery({
queryKey: qk.mailbox.formData(formParams),
queryFn: () => fetchFormApplications(formParams),
enabled: isForms,
enabled: isForms || isAllChannel,
})
const countsQuery = useQuery({
@ -786,13 +808,14 @@ export default function Inbox() {
enabled: !isForms,
})
const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined)
const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts({ sheet: formSheet || undefined }),
queryKey: qk.mailbox.formCounts({ sheet: formCountsSheet }),
queryFn: async () => {
const res = await sheetApi.fetchFormCounts({ sheet: formSheet || undefined })
const res = await sheetApi.fetchFormCounts({ sheet: formCountsSheet })
return res?.data ?? {}
},
enabled: isForms,
enabled: isForms || isAllChannel,
})
const emailTotalQuery = useQuery({
@ -806,12 +829,12 @@ export default function Inbox() {
})
const formTotalQuery = useQuery({
queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }),
queryKey: qk.mailbox.formTotal({ sheet: formCountsSheet }),
queryFn: async () => {
const res = await sheetApi.countFormData({ sheet: formSheet || undefined })
const res = await sheetApi.countFormData({ sheet: formCountsSheet })
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
enabled: isForms,
enabled: isForms || isAllChannel,
staleTime: Infinity,
})
@ -830,27 +853,55 @@ export default function Inbox() {
}
}, [isForms, formSheetsQuery.data, formSheet])
const activeQuery = isForms ? formQuery : applicationsQuery
// All channel: both sources arrive unpaged; merge newest-first and let the
// pager slice the merged array below.
const mergedRows = useMemo(() => {
if (!isAllChannel) return null
const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : []
const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : []
return [...emails, ...forms].sort(
(a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0),
)
}, [isAllChannel, applicationsQuery.data, formQuery.data])
const activeQuery = isAllChannel
? {
isPending: applicationsQuery.isPending || formQuery.isPending,
// one healthy source still renders; error only when both are down
isError: applicationsQuery.isError && formQuery.isError,
isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess,
error: applicationsQuery.error ?? formQuery.error,
data: { rows: mergedRows ?? [], total: mergedRows?.length ?? 0 },
}
: (isForms ? formQuery : applicationsQuery)
const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : []
const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
const counts = useMemo(
() => {
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
const e = countsQuery.data ?? {}
const f = formCountsQuery.data ?? {}
const pick = (key) => (isAllChannel
? n(e[key]) + n(f[key])
: n((isForms ? f : e)[key]))
return {
'All Applications': n(serverCounts.all),
Unread: n(serverCounts.unread),
Processed: n(serverCounts.processed),
Rejected: n(serverCounts.rejected),
Duplicates: n(serverCounts.duplicates),
'All Applications': pick('all'),
Unread: pick('unread'),
Processed: pick('processed'),
Rejected: pick('rejected'),
Duplicates: pick('duplicates'),
}
},
[serverCounts],
[countsQuery.data, formCountsQuery.data, isForms, isAllChannel],
)
const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0)
const poolTotal = isAllChannel
? (emailTotalQuery.data ?? 0) + (formTotalQuery.data ?? 0)
: (isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0))
const tabTotal = counts[tab] ?? 0
const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess
const countsReady = isAllChannel
? countsQuery.isSuccess && formCountsQuery.isSuccess
: (isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess)
const total = q.trim()
? (activeQuery.data?.total ?? 0)
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
@ -868,11 +919,15 @@ export default function Inbox() {
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
}, [total, pageSize, skip, showAll])
const list = inbox
// All channel pages the merged array client-side; single channels page on the server.
const list = isAllChannel && !showAll ? inbox.slice(skip, skip + pageSize) : inbox
// Mixed rows: the row's own kind picks the detail endpoint, not the channel.
const selectedKind = inbox.find((i) => i.id === selectedId)?.kind
?? (isForms ? 'form' : 'email')
const detailQuery = useQuery({
queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)),
queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)),
enabled: Boolean(selectedId),
})
@ -915,8 +970,8 @@ export default function Inbox() {
setSkip(0)
setSelectedId(null)
setQ('')
// Unread is email-only; leave it behind when opening Sheet Forms.
if (next === 'forms' && tab === 'Unread') setTab('All Applications')
// Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
selection.clear()
}
@ -937,11 +992,12 @@ export default function Inbox() {
setReadAll.mutate({
read,
filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) },
ids: list.map((i) => i.id),
// sheet rows carry no mailbox read state email rows only
ids: list.filter((i) => i.kind !== 'form').map((i) => i.id),
})
return
}
const ids = list.map((i) => i.id)
const ids = list.filter((i) => i.kind !== 'form').map((i) => i.id)
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
@ -988,8 +1044,13 @@ export default function Inbox() {
function select(id) {
setSelectedId(id)
if (isForms) return
// Phones swap the list for the detail pane bring its top into view.
if (window.matchMedia?.('(max-width: 900px)').matches) {
window.scrollTo(0, 0)
document.querySelector('.content')?.scrollTo?.(0, 0)
}
const item = inbox.find((i) => i.id === id)
if (item?.kind === 'form') return // sheet rows have no mailbox read state
if (item?.unread) setRead.mutate({ ids: [id], read: true })
}
@ -1009,6 +1070,51 @@ export default function Inbox() {
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind })
}
/** Styled XLSX of the rows already loaded in this view the DB filtered
them when the list was fetched (channel, tab, search); no extra request.
Paged channels export the loaded page; the All channel and the "All"
page size hold the whole view, so those export everything. */
async function exportRows() {
const rows = inbox
if (!rows.length) {
toast('Nothing to export in this view', 'info')
return
}
try {
const channelLabel = CHANNELS.find((c) => c.key === channel)?.label ?? channel
await exportStyledXlsx({
filename: `inbox-${channel}-${tab.toLowerCase().replaceAll(' ', '-')}-${new Date().toISOString().slice(0, 10)}`,
title: 'Recruitment Inbox',
subtitle: `${tab} · ${channelLabel} channel · ${rows.length} row${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 28 },
{ header: 'Phone', key: 'phone', width: 15 },
{ header: 'Position', key: 'position', width: 34 },
{ header: 'Channel', key: 'channel', width: 12 },
{ header: 'Source', key: 'source', width: 20 },
{ header: 'Received', key: 'received', width: 12 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'City', key: 'city', width: 14 },
{ header: 'Notice period', key: 'notice', width: 13 },
{ header: 'ATS score', key: 'ats', width: 10 },
{ header: 'Assigned job', key: 'job', width: 24 },
],
rows: rows.map((r) => ({
name: r.name, email: r.email, phone: r.phone, position: r.position,
channel: r.kind === 'form' ? 'Sheet Form' : 'Email',
source: r.source,
received: r.received ? r.received.toISOString().slice(0, 10) : '',
status: r.processing, city: r.residingCity, notice: r.noticePeriod,
ats: r.atsScore, job: r.assignedPost?.title,
})),
})
toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success')
} catch {
toast('Export failed', 'error')
}
}
const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId())
const syncToastShown = useRef(null)
@ -1071,9 +1177,11 @@ export default function Inbox() {
<PageHeader
title="Recruitment Inbox"
sub={
isForms
? 'Google Form applicants — same queue energy, profile-first cards'
: 'Every candidate, every source — one unified queue'
isAllChannel
? 'Email and Sheet Forms together — one combined stream'
: isForms
? 'Google Form applicants — same queue energy, profile-first cards'
: 'Every candidate, every source — one unified queue'
}
actions={<>
<div className="pill-tabs" role="tablist" aria-label="Inbox channel">
@ -1090,12 +1198,6 @@ export default function Inbox() {
</button>
))}
</div>
{!isForms && (
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
)}
{isForms && (
<span className="integration-status"><span className="pulse" />Google Sheets · Form data</span>
)}
{!isForms && (
<SyncButton
run={syncRun.data}
@ -1104,6 +1206,9 @@ export default function Inbox() {
onClick={() => sync.mutate()}
/>
)}
<button className="btn btn-secondary" onClick={exportRows}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs
</button>
@ -1125,7 +1230,7 @@ export default function Inbox() {
/>
</div>
<div className="split inbox-split">
<div className={`split inbox-split${selected ? ' has-selection' : ''}`}>
<div className="split-list inbox-queue">
{!isForms && applicationsQuery.isSuccess && (
<BulkReadBar
@ -1136,6 +1241,14 @@ export default function Inbox() {
busy={setRead.isPending || setReadAll.isPending}
canEdit={canEdit}
scopeLabel={scopeLabel}
showAll={showAll}
tabTotal={total}
onToggleShowAll={() => {
setPageSize(showAll ? DEFAULT_PAGE_SIZE : 'all')
setSkip(0)
setSelectedId(null)
selection.clear()
}}
/>
)}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
@ -1173,11 +1286,31 @@ export default function Inbox() {
{activeQuery.isPending && (
<SkeletonRows rows={6} />
)}
{activeQuery.isError && (
{/* The big error state only when there is truly nothing to
show cached rows beat a scary banner over a working list. */}
{activeQuery.isError && list.length === 0 && (
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
{friendlyAuthError(activeQuery.error, 'Request failed')}
</EmptyState>
)}
{/* All channel with one source down: keep the healthy list,
note the gap in one line instead of a full error state. */}
{isAllChannel && applicationsQuery.isError !== formQuery.isError
&& (applicationsQuery.isError || formQuery.isError) && (
<div
className="cell-sub"
style={{
padding: '9px 16px',
borderBottom: '1px solid var(--border)',
background: 'var(--warning-soft)',
color: 'var(--warning)',
}}
>
{applicationsQuery.isError
? 'Email applications couldnt load right now — showing Sheet Forms only.'
: 'Sheet Form applications couldnt load right now — showing Email only.'}
</div>
)}
{activeQuery.isSuccess && list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
@ -1189,7 +1322,7 @@ export default function Inbox() {
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)}
>
{!isForms && (
{i.kind !== 'form' && (
<RowCheck
checked={selection.selectedIds.has(i.id)}
onToggle={() => selection.toggle(i.id)}
@ -1212,13 +1345,16 @@ export default function Inbox() {
{asAtsScore(i.atsScore) != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
{i.kind === 'form' && i.noticePeriod && (
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)}
</div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
<Badge>{formatRole(i.applicationStatus)}</Badge>
)}
{isForms && i.residingCity && (
{i.kind === 'form' && i.residingCity && (
<span className="cell-sub">{i.residingCity}</span>
)}
</div>
@ -1250,6 +1386,15 @@ export default function Inbox() {
</div>
<div className="split-detail">
{selected && (
<button
type="button"
className="btn btn-ghost btn-sm inbox-back"
onClick={() => setSelectedId(null)}
>
<Icon name="chevron-left" /> Back to list
</button>
)}
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Select an application">
@ -1264,7 +1409,7 @@ export default function Inbox() {
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : isForms ? (
) : selected?.kind === 'form' ? (
<FormApplicantDetail
item={selected}
loading={detailQuery.isPending}
@ -1558,65 +1703,47 @@ function FormApplicantDetail({
marginBottom: 20,
}}
>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Contact &amp; application</div>
<div
className="info-grid"
style={{
marginBottom: 20,
border: '1px solid var(--border)',
borderTop: 'none',
borderRadius: '0 0 10px 10px',
padding: '14px 16px',
background: 'var(--bg-elev)',
}}
>
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
<div style={{ flex: '1 1 320px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 18 }}>
<div className="card" style={{ boxShadow: 'none' }}>
<div className="card-head"><h3>Contact &amp; application</h3></div>
<div className="card-body">
<div className="info-grid">
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
</div>
</div>
</div>
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Profile</div>
<div
className="info-grid"
style={{
marginBottom: 20,
border: '1px solid var(--border)',
borderTop: 'none',
borderRadius: '0 0 10px 10px',
padding: '14px 16px',
background: 'var(--bg-elev)',
}}
>
<div className="info-item"><div className="il">Gender</div><div className="iv">{orDash(i.gender)}</div></div>
<div className="info-item"><div className="il">Date of birth</div><div className="iv">{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}</div></div>
<div className="info-item"><div className="il">CNIC</div><div className="iv">{orDash(i.cnic)}</div></div>
<div className="info-item"><div className="il">Marital status</div><div className="iv">{orDash(i.maritalStatus)}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(location)}</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(education)}</div></div>
<div className="info-item"><div className="il">Graduation</div><div className="iv">{orDash(i.graduationYear)}</div></div>
<div className="info-item"><div className="il">Other university</div><div className="iv">{orDash(i.universityOther)}</div></div>
<div className="info-item"><div className="il">Current salary</div><div className="iv">{orDash(i.currentSalary)}</div></div>
<div className="info-item"><div className="il">Expected salary</div><div className="iv">{orDash(i.expectedSalary)}</div></div>
<div className="card" style={{ boxShadow: 'none' }}>
<div className="card-head"><h3>Profile</h3></div>
<div className="card-body">
<div className="info-grid">
<div className="info-item"><div className="il">Gender</div><div className="iv">{orDash(i.gender)}</div></div>
<div className="info-item"><div className="il">Date of birth</div><div className="iv">{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}</div></div>
<div className="info-item"><div className="il">CNIC</div><div className="iv">{orDash(i.cnic)}</div></div>
<div className="info-item"><div className="il">Marital status</div><div className="iv">{orDash(i.maritalStatus)}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(location)}</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(education)}</div></div>
<div className="info-item"><div className="il">Graduation</div><div className="iv">{orDash(i.graduationYear)}</div></div>
<div className="info-item"><div className="il">Other university</div><div className="iv">{orDash(i.universityOther)}</div></div>
<div className="info-item"><div className="il">Current salary</div><div className="iv">{orDash(i.currentSalary)}</div></div>
<div className="info-item"><div className="il">Expected salary</div><div className="iv">{orDash(i.expectedSalary)}</div></div>
</div>
</div>
</div>
{i.hrComments && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 8 }}>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="fw-600" style={{ marginBottom: 6 }}>HR comment</div>
<p style={{ margin: 0 }}>{i.hrComments}</p>
</div>
</div>
)}
{i.sheet && (
<div className="cell-sub" style={{ marginTop: 12 }}>
Imported from {i.sheet}
</div>
)}
</div>
<div role="radiogroup" aria-label="Matching roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
@ -1681,10 +1808,84 @@ function FormApplicantDetail({
>
Assign
</button>
<div
role="radiogroup"
aria-label="Matching roles"
className="card"
style={{ flex: '1 1 300px', minWidth: 260, boxShadow: 'none', alignSelf: 'start' }}
>
<div className="card-head">
<div>
<h3>Matching roles</h3>
<span className="ch-sub">Matched by position: {orDash(i.position)}</span>
</div>
</div>
<div className="card-body">
{matchCards.length === 0 && !manualPost ? (
<EmptyState icon="target" title="No matching roles">
<p>No job post title matches this position pick one manually.</p>
</EmptyState>
) : (
<>
{matchCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
badge={`Match #${rank}`}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
/>
))}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
/>
)}
</>
)}
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 12 }}>
<button
className={`btn ${matchCards.length === 0 && !manualPost ? 'btn-primary' : 'btn-secondary'}`}
style={{ flex: '1 1 auto' }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
<Icon name="search" /> Browse roles
</button>
{/* Assign only renders once there is a selection to act on
a permanently disabled primary button read as broken UI. */}
{selection && selection !== i.assignedId && (
<button
className="btn btn-primary"
style={{ flex: '1 1 auto' }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
<Icon name="check" /> Assign{selectedPost?.title ? `${selectedPost.title}` : ''}
</button>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
{i.sheet && (
<div className="cell-sub" style={{ marginBottom: 14 }}>
Imported from {i.sheet}{i.rowNumber != null ? ` · row ${i.rowNumber}` : ''}
</div>
)}
<div className="flex gap-8" style={{ flexWrap: 'wrap', borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
</button>
@ -1841,7 +2042,7 @@ function ApplicationDetail({
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<><Badge>{i.applicationStatus}</Badge>{' '}</>
<><Badge>{formatRole(i.applicationStatus)}</Badge>{' '}</>
)}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
@ -2070,7 +2271,7 @@ function ApplicationDetail({
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<div className="flex gap-8" style={{ flexWrap: 'wrap', borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
</button>

View File

@ -29,6 +29,7 @@ import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { formatRole } from '../lib/format'
import * as rolesApi from '../api/roles'
const ROLE_COLORS = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']
@ -69,7 +70,9 @@ export default function Rbac() {
queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []),
})
const roles = rolesQuery.data ?? []
// `candidate` is the applicant account type, not a staff role it stays in
// the DB (every candidate user sits on it) but is not managed on this screen.
const roles = (rolesQuery.data ?? []).filter((r) => r.role_name !== 'candidate')
const tags = tagsQuery.data ?? []
const bundles = bundlesQuery.data ?? []
@ -178,7 +181,7 @@ export default function Rbac() {
<Icon name="shield" />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{r.role_name}</div>
<div className="fw-600 text-sm">{formatRole(r.role_name)}</div>
<div className="cell-sub">
{(r.effective_permissions?.length ?? 0)} permissions
{r.is_system ? ' · system' : ''}
@ -199,7 +202,7 @@ export default function Rbac() {
<Icon name="shield" />
</span>
<div>
<h3>{role.role_name}</h3>
<h3>{formatRole(role.role_name)}</h3>
<span className="ch-sub">{role.description || 'No description'}</span>
</div>
</div>
@ -300,7 +303,7 @@ export default function Rbac() {
{editing && (
<RoleForm
title="Edit Role"
subtitle={editing.role_name}
subtitle={formatRole(editing.role_name)}
role={editing}
bundles={bundles}
bundlesLoading={bundlesQuery.isPending}
@ -313,7 +316,7 @@ export default function Rbac() {
{confirmDelete && (
<Modal
title="Delete role"
subtitle={confirmDelete.role_name}
subtitle={formatRole(confirmDelete.role_name)}
onClose={() => setConfirmDelete(null)}
footer={
<>
@ -331,7 +334,7 @@ export default function Rbac() {
}
>
<p>
<b>{confirmDelete.role_name}</b> will be soft-deleted. Anyone currently holding it keeps the
<b>{formatRole(confirmDelete.role_name)}</b> will be soft-deleted. Anyone currently holding it keeps the
account but loses every permission the role granted, so reassign them first.
</p>
</Modal>

View File

@ -18,6 +18,7 @@ import { useFormState } from '../components/AuthLayout'
import { usePermission } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { formatRole } from '../lib/format'
import * as rolesApi from '../api/roles'
import * as usersApi from '../api/users'
import * as orgSettingsApi from '../api/orgSettings'
@ -324,7 +325,7 @@ function Users() {
</div>
</div>
</td>
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td>
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
<td>
<Badge className={!u.is_active ? undefined : u.is_approved ? 'b-green' : 'b-amber'}>
{!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'}
@ -425,7 +426,7 @@ function Approvals() {
</div>
</div>
</td>
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td>
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<button
@ -495,7 +496,7 @@ function AssignRoleModal({ user, users, onClose }) {
toast(
clearing
? `Role removed from ${target.name}`
: `${target.name} is now ${picked?.role_name ?? 'assigned'}`,
: `${target.name} is now ${formatRole(picked?.role_name) || 'assigned'}`,
'success',
)
onClose()
@ -558,7 +559,7 @@ function AssignRoleModal({ user, users, onClose }) {
>
<option value="">No role</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>{r.role_name}</option>
<option key={r.id} value={r.id}>{formatRole(r.role_name)}</option>
))}
</select>
<FieldError>
@ -641,7 +642,7 @@ function Permissions() {
for (const r of roles) {
for (const id of r.permissions ?? []) {
const list = map.get(Number(id)) ?? []
list.push(r.role_name)
list.push(formatRole(r.role_name))
map.set(Number(id), list)
}
}

View File

@ -41,6 +41,7 @@ import CandidateProfile from './CandidateProfile'
import { AtsMatch } from './Candidates'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
@ -248,31 +249,39 @@ export default function TalentPool() {
`pool` means the file always matches what the recruiter is looking at,
search, job, and department filter included. Company/skills are seed-overlay
values, same as the cards render. */
function exportCsv() {
async function exportCsv() {
if (!list.length) {
toast('Nothing to export — current filters match no candidates', 'warning')
return
}
const esc = (v) => {
const s = v == null ? '' : String(v)
return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
try {
await exportStyledXlsx({
filename: `talent-pool-${new Date().toISOString().slice(0, 10)}`,
title: 'Talent Pool',
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
columns: [
{ header: 'Name', key: 'name', width: 24 },
{ header: 'Email', key: 'email', width: 28 },
{ header: 'Current Title', key: 'title', width: 24 },
{ header: 'Company', key: 'company', width: 20 },
{ header: 'Departments', key: 'departments', width: 22 },
{ header: 'Stage', key: 'stage', width: 13 },
{ header: 'Experience (yrs)', key: 'experience', width: 14 },
{ header: 'Source', key: 'source', width: 16 },
{ header: 'AI Score', key: 'aiScore', width: 10 },
{ header: 'Skills', key: 'skills', width: 40 },
],
rows: list.map((c) => ({
name: c.name, email: c.email, title: c.currentTitle, company: c.currentCompany,
departments: (c.departments || []).join('; '), stage: c.stage,
experience: c.experience, source: c.source, aiScore: c.aiScore ?? '',
skills: (c.skills || []).join('; '),
})),
})
toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'}`, 'success')
} catch {
toast('Export failed', 'error')
}
const header = ['Name', 'Email', 'Current Title', 'Company', 'Departments', 'Stage', 'Experience (yrs)', 'Source', 'AI Score', 'Skills']
const lines = list.map((c) => [
c.name, c.email, c.currentTitle, c.currentCompany,
(c.departments || []).join('; '), c.stage, c.experience,
c.source, c.aiScore ?? '', (c.skills || []).join('; '),
].map(esc).join(','))
const blob = new Blob([[header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `talent-pool-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'} to CSV`, 'success')
}
return (

View File

@ -1027,6 +1027,18 @@ canvas { width: 100%; max-width: 100%; display: block; }
so the detail pane keeps a readable width in the 980-1200px band where the
split has not collapsed to one column yet. */
.inbox-split { grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); }
/* Desktop keeps the two-pane split; only the phone layout below shows it. */
.inbox-back { display: none; }
/* Phones: the stacked split nested two scroll wells inside the page scroll.
Instead: one page scroll, and a selected application takes over from the
list the Back button returns to it (standard master-detail collapse). */
@media (max-width: 900px) {
.inbox-split .split-list,
.inbox-split .split-detail { max-height: none; overflow: visible; }
.inbox-split.has-selection .split-list { display: none; }
.inbox-split:not(.has-selection) .split-detail { display: none; }
.inbox-back { display: inline-flex; margin: 10px 12px 0; }
}
.inbox-queue { overflow-x: hidden; min-width: 0; }
.inbox-queue .inbox-item { min-width: 0; }
/* Name + time on row 1, subject on row 2, chips span the full width under
@ -1072,19 +1084,37 @@ canvas { width: 100%; max-width: 100%; display: block; }
overflow: visible;
}
.inbox-queue .page-info { width: 100%; }
.inbox-queue .page-controls { flex-wrap: wrap; }
/* "of N" repeats the Showing line one row up — drop it in the queue column. */
.inbox-queue .page-size-total { display: none; }
.inbox-queue .page-size { margin-right: 0; }
.inbox-queue .page-controls {
flex-wrap: wrap;
justify-content: space-between;
row-gap: 8px;
}
.inbox-queue .page-nav {
justify-content: flex-start;
flex: 1 1 100%;
width: 100%;
flex: 1 1 auto;
width: auto;
min-width: 0;
flex-wrap: wrap;
gap: 3px;
row-gap: 4px;
}
.inbox-queue .page-nums { flex-wrap: wrap; gap: 3px; row-gap: 4px; }
/* Slimmer buttons: nine controls (4 chevrons + up to 5 numbers) fit the
~350px split column on one row. */
.inbox-queue .page-btn { min-width: 31px; height: 31px; padding: 0 6px; }
.inbox-bulk-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-wrap: nowrap;
/* wrap: the Show all toggle joined the row, and in the 34%-wide split
column the four controls no longer fit on one line at laptop widths. */
flex-wrap: wrap;
row-gap: 6px;
min-width: 0;
}
.inbox-bulk-count {
@ -1582,6 +1612,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* When the Ask button wraps under the input, let it take the full row. */
.ask-form .btn { flex: 1 1 auto; }
/* Find Talent: stack the toolbar controls edge to edge. */
.talent-controls .tc-job, .talent-controls .tc-loc,
.talent-controls .tc-custom, .talent-controls .btn {