Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Implement_Changes
commit
fb94db5f41
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(' ')
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 couldn’t load right now — showing Sheet Forms only.'
|
||||
: 'Sheet Form applications couldn’t 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 & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue