ordered
parent
86531c77db
commit
c520b2d9c2
|
|
@ -1751,7 +1751,11 @@ class CandidateView:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def attach_application_history(self,payloads):
|
async def attach_application_history(self,payloads):
|
||||||
"""Stamp is_reapplicant + previous_applications onto list/detail dicts."""
|
"""Stamp is_reapplicant + previous_applications onto list/detail dicts.
|
||||||
|
|
||||||
|
``previous_applications`` is every application for that email, including
|
||||||
|
the open row. ``is_reapplicant`` still means a *different* assigned job.
|
||||||
|
"""
|
||||||
single=not isinstance(payloads,list)
|
single=not isinstance(payloads,list)
|
||||||
records=[payloads] if single else list(payloads or [])
|
records=[payloads] if single else list(payloads or [])
|
||||||
emails=[_payload_email(p) for p in records]
|
emails=[_payload_email(p) for p in records]
|
||||||
|
|
@ -1761,15 +1765,15 @@ class CandidateView:
|
||||||
continue
|
continue
|
||||||
email=_payload_email(payload)
|
email=_payload_email(payload)
|
||||||
pack=history.get(email) or {"present_in":[],"user":None,"applications":[]}
|
pack=history.get(email) or {"present_in":[],"user":None,"applications":[]}
|
||||||
previous=[]
|
items=[]
|
||||||
|
reapplied=False
|
||||||
for row in pack.get("applications") or []:
|
for row in pack.get("applications") or []:
|
||||||
if _is_current_application(row,payload):
|
item=serialize_application_history_item(row)
|
||||||
continue
|
items.append(item)
|
||||||
if not _is_earlier_application(row,payload):
|
if is_assigned_application(item) and not _is_current_application(row,payload):
|
||||||
continue
|
reapplied=True
|
||||||
previous.append(serialize_application_history_item(row))
|
items.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||||
previous.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
|
||||||
payload["present_in"]=list(pack.get("present_in") or [])
|
payload["present_in"]=list(pack.get("present_in") or [])
|
||||||
payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous)
|
payload["is_reapplicant"]=reapplied
|
||||||
payload["previous_applications"]=previous
|
payload["previous_applications"]=items
|
||||||
return records[0] if single else records
|
return records[0] if single else records
|
||||||
|
|
|
||||||
|
|
@ -48,26 +48,62 @@ export function applicationStatusLabel(status, item) {
|
||||||
return key.charAt(0) + key.slice(1).toLowerCase()
|
return key.charAt(0) + key.slice(1).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function historyItemsOf(row) {
|
||||||
|
if (!row) return []
|
||||||
|
if (Array.isArray(row.previousApplications)) return row.previousApplications
|
||||||
|
if (Array.isArray(row.previous_applications)) return row.previous_applications
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every application for this candidate, including the one currently open. */
|
||||||
|
export function candidateApplicationsOf(row) {
|
||||||
|
if (!row) return []
|
||||||
|
const current = currentRowIds(row)
|
||||||
|
const items = [...historyItemsOf(row)]
|
||||||
|
if (current.size && !items.some((item) => isSameApplication(item, current))) {
|
||||||
|
const self = syntheticCurrentApplication(row)
|
||||||
|
if (self) items.push(self)
|
||||||
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const ac = isSameApplication(a, current) ? 1 : 0
|
||||||
|
const bc = isSameApplication(b, current) ? 1 : 0
|
||||||
|
if (ac !== bc) return bc - ac
|
||||||
|
return (appliedAtMs(b?.applied_at) ?? 0) - (appliedAtMs(a?.applied_at) ?? 0)
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applications other than the open row — used by the Reapplied chip. */
|
||||||
export function previousApplicationsOf(row) {
|
export function previousApplicationsOf(row) {
|
||||||
if (!row) return []
|
if (!row) return []
|
||||||
const raw = Array.isArray(row.previousApplications)
|
|
||||||
? row.previousApplications
|
|
||||||
: Array.isArray(row.previous_applications)
|
|
||||||
? row.previous_applications
|
|
||||||
: []
|
|
||||||
const current = currentRowIds(row)
|
const current = currentRowIds(row)
|
||||||
const currentTs = appliedAtMs(
|
return candidateApplicationsOf(row).filter((item) => !isSameApplication(item, current))
|
||||||
row.received || row.applied_at || row.entry_date || row.when || row.sentAt,
|
}
|
||||||
)
|
|
||||||
const items = raw.filter((item) => {
|
function syntheticCurrentApplication(row) {
|
||||||
if (current.size && isSameApplication(item, current)) return false
|
const assigned = row.assignedPost || row.assigned_job_post
|
||||||
const t = appliedAtMs(item?.applied_at)
|
const position = row.kind !== 'email' && row.position && row.position !== '—' ? row.position : null
|
||||||
if (currentTs == null) return true
|
const jobTitle = assigned?.title || row.job_title || row.jobTitle || row.currentTitle || position || null
|
||||||
if (t == null) return false
|
const kind = row.kind
|
||||||
return t < currentTs
|
let source = row.source
|
||||||
})
|
if (kind === 'form') source = 'form'
|
||||||
items.sort((a, b) => (appliedAtMs(a?.applied_at) ?? 0) - (appliedAtMs(b?.applied_at) ?? 0))
|
else if (kind === 'email') source = 'inbox'
|
||||||
return items
|
else if (row.manualUploadId || row.manual_upload_candidate_id) source = 'manual'
|
||||||
|
if (source && String(source).includes('@')) source = 'inbox'
|
||||||
|
const id = row.id != null && row.id !== '' ? String(row.id) : null
|
||||||
|
return {
|
||||||
|
source: source || 'inbox',
|
||||||
|
inbox_id: row.inboxId || row.inbox_id || null,
|
||||||
|
message_id: kind === 'email' ? id : (row.message_id || null),
|
||||||
|
form_data_id: kind === 'form' ? id : (row.form_data_id || null),
|
||||||
|
manual_upload_candidate_id: row.manualUploadId || row.manual_upload_candidate_id || null,
|
||||||
|
candidate_id: row.candidate_id || (kind == null && row.jobId ? row.id : null) || null,
|
||||||
|
user_id: row.userId || row.user_id || null,
|
||||||
|
job_post_id: row.assignedId || row.jobId || row.job_post_id || null,
|
||||||
|
job_title: jobTitle,
|
||||||
|
status: row.applicationStatus || row.processingState || row.status || null,
|
||||||
|
applied_at: row.received || row.applied || row.applied_at || row.entry_date || row.when || null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appliedAtMs(value) {
|
function appliedAtMs(value) {
|
||||||
|
|
@ -152,7 +188,7 @@ export function hrefForPreviousApplication(item) {
|
||||||
if (item.source === 'form' && item.form_data_id) {
|
if (item.source === 'form' && item.form_data_id) {
|
||||||
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
|
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
|
||||||
}
|
}
|
||||||
if (item.source === 'inbox' && item.message_id) {
|
if ((item.source === 'inbox' || item.source === 'filtered') && item.message_id) {
|
||||||
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
|
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
|
||||||
}
|
}
|
||||||
if (item.source === 'manual') {
|
if (item.source === 'manual') {
|
||||||
|
|
@ -161,13 +197,22 @@ export function hrefForPreviousApplication(item) {
|
||||||
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
|
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
|
||||||
|
if (item.form_data_id) {
|
||||||
|
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
|
||||||
|
}
|
||||||
|
if (item.message_id) {
|
||||||
|
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full prior-job list for profile / inbox / add-candidate. */
|
/** Full application list for profile / inbox / add-candidate. */
|
||||||
export function PreviousApplications({ row, title = 'Previous applications' }) {
|
export function PreviousApplications({ row, title = 'Total applications' }) {
|
||||||
const items = previousApplicationsOf(row)
|
const items = candidateApplicationsOf(row)
|
||||||
if (!items.length) return null
|
if (!items.length) return null
|
||||||
|
const current = currentRowIds(row)
|
||||||
|
const heading = title === 'Total applications' ? `Total applications (${items.length})` : title
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="card"
|
className="card"
|
||||||
|
|
@ -188,13 +233,14 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{heading}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{items.map((item, idx) => {
|
{items.map((item, idx) => {
|
||||||
const stage = applicationStatusLabel(item.status, item)
|
const stage = applicationStatusLabel(item.status, item)
|
||||||
const job = item.job_title || item.jobTitle || 'No job assigned'
|
const job = item.job_title || item.jobTitle || 'No job assigned'
|
||||||
const href = hrefForPreviousApplication(item)
|
const href = hrefForPreviousApplication(item)
|
||||||
|
const isCurrent = current.size > 0 && isSameApplication(item, current)
|
||||||
const key = [
|
const key = [
|
||||||
item.source,
|
item.source,
|
||||||
item.inbox_id,
|
item.inbox_id,
|
||||||
|
|
@ -204,6 +250,12 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||||
item.job_post_id,
|
item.job_post_id,
|
||||||
idx,
|
idx,
|
||||||
].filter(Boolean).join(':')
|
].filter(Boolean).join(':')
|
||||||
|
const jobLabel = (
|
||||||
|
<>
|
||||||
|
{job}
|
||||||
|
{isCurrent ? ' (Current)' : ''}
|
||||||
|
</>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
|
|
@ -215,13 +267,13 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
className="reapplicant-job-link"
|
className="reapplicant-job-link"
|
||||||
title="Open this previous application"
|
title={isCurrent ? 'This application' : 'Open this application'}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{job}
|
{jobLabel}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="fw-600" style={{ fontSize: 13 }}>{job}</div>
|
<div className="fw-600" style={{ fontSize: 13 }}>{jobLabel}</div>
|
||||||
)}
|
)}
|
||||||
<div className="cell-sub">
|
<div className="cell-sub">
|
||||||
{SOURCE_LABEL[item.source] || item.source || 'Application'}
|
{SOURCE_LABEL[item.source] || item.source || 'Application'}
|
||||||
|
|
|
||||||
|
|
@ -105,65 +105,144 @@ function fmtWhen(iso) {
|
||||||
return fmtShort(iso) || '—'
|
return fmtShort(iso) || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIOD_SHEET_NAMES = {
|
function dashExportValue(value) {
|
||||||
week: 'Weekly',
|
if (value == null || value === '') return ''
|
||||||
month: 'Monthly',
|
return value
|
||||||
quarter: 'Quarterly',
|
|
||||||
year: 'Yearly',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIOD_METRICS = [
|
function buildDashboardSheets({
|
||||||
{ label: 'Open Jobs', key: 'open_jobs' },
|
department,
|
||||||
{ label: 'Applications', key: 'total_candidates' },
|
rangeKey,
|
||||||
{ label: 'Hires', key: 'hires' },
|
span,
|
||||||
{ label: 'Offers Sent', key: 'offers_sent' },
|
exportedAt,
|
||||||
{ label: 'Offers Accepted', key: 'offers_accepted' },
|
kpis,
|
||||||
{ label: 'Cost per Hire', key: 'cost_per_hire', round: true },
|
jobApps,
|
||||||
]
|
pipeRows,
|
||||||
|
offerCounts,
|
||||||
function kpiValue(kpis, key, { round = false } = {}) {
|
attentionRows,
|
||||||
if (!kpis || kpis[key] == null || kpis[key] === '') return ''
|
starvingCount,
|
||||||
const n = Number(kpis[key])
|
activity,
|
||||||
if (!Number.isFinite(n)) return kpis[key]
|
}) {
|
||||||
return round ? Math.round(n) : n
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchRangeSnapshot(rangeKey, department) {
|
|
||||||
const span = rangeWindow(rangeKey)
|
|
||||||
const kpisRes = await analyticsApi.kpis({
|
|
||||||
...span,
|
|
||||||
department: department || undefined,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
key: rangeKey,
|
|
||||||
fromDate: span.fromDate,
|
|
||||||
toDate: span.toDate,
|
|
||||||
kpis: asObject(kpisRes?.data),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPeriodSheets({ department, snapshots, exportedAt }) {
|
|
||||||
const deptLabel = department || 'All Departments'
|
const deptLabel = department || 'All Departments'
|
||||||
const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10)
|
const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10)
|
||||||
const columns = [
|
const from = fmtDate(span.fromDate) || span.fromDate
|
||||||
|
const to = fmtDate(span.toDate) || span.toDate
|
||||||
|
const subtitle = `${rangeLabel(rangeKey)} · ${from} – ${to} · ${deptLabel} · exported ${stamp}`
|
||||||
|
const k = kpis || {}
|
||||||
|
|
||||||
|
const summaryRows = [
|
||||||
|
{ metric: 'Open Jobs', value: dashExportValue(k.open_jobs), trend: pctDelta(k.open_jobs, k.open_jobs_prior) || '' },
|
||||||
|
{ metric: 'Applications', value: dashExportValue(k.total_candidates), trend: pctDelta(k.total_candidates, k.total_candidates_prior) || '' },
|
||||||
|
{ metric: 'Hires', value: dashExportValue(k.hires), trend: pctDelta(k.hires, k.hires_prior) || '' },
|
||||||
|
{ metric: 'Offers Sent', value: dashExportValue(k.offers_sent), trend: pctDelta(k.offers_sent, k.offers_sent_prior) || '' },
|
||||||
|
{ metric: 'Offers Accepted', value: dashExportValue(k.offers_accepted), trend: pctDelta(k.offers_accepted, k.offers_accepted_prior) || '' },
|
||||||
|
{
|
||||||
|
metric: 'Interviews Today',
|
||||||
|
value: dashExportValue(k.interviews_today),
|
||||||
|
trend: k.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: 'Time to Hire',
|
||||||
|
value: k.time_to_hire != null ? `${Math.round(k.time_to_hire)} days` : '',
|
||||||
|
trend: dayDelta(k.time_to_hire, k.time_to_hire_prior) || '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: 'Cost per Hire',
|
||||||
|
value: k.cost_per_hire != null ? money(Math.round(k.cost_per_hire)) : '',
|
||||||
|
trend: pctDelta(k.cost_per_hire, k.cost_per_hire_prior) || '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Summary',
|
||||||
|
title: 'Dashboard',
|
||||||
|
subtitle,
|
||||||
|
columns: [
|
||||||
{ header: 'Metric', key: 'metric', width: 22 },
|
{ header: 'Metric', key: 'metric', width: 22 },
|
||||||
{ header: 'Value', key: 'value', width: 16 },
|
{ header: 'Value', key: 'value', width: 16 },
|
||||||
]
|
{ header: 'Vs prior period', key: 'trend', width: 18 },
|
||||||
return snapshots.map((s) => {
|
],
|
||||||
const name = PERIOD_SHEET_NAMES[s.key] || s.key
|
rows: summaryRows,
|
||||||
const from = fmtDate(s.fromDate) || s.fromDate
|
},
|
||||||
const to = fmtDate(s.toDate) || s.toDate
|
{
|
||||||
return {
|
name: 'Applications per Job',
|
||||||
name,
|
title: 'Applications per Job',
|
||||||
title: name,
|
subtitle,
|
||||||
subtitle: `${from} – ${to} · ${deptLabel} · exported ${stamp}`,
|
columns: [
|
||||||
columns,
|
{ header: 'Job', key: 'title', width: 36 },
|
||||||
rows: PERIOD_METRICS.map((spec) => ({
|
{ header: 'Department', key: 'department', width: 22 },
|
||||||
metric: spec.label,
|
{ header: 'Status', key: 'status', width: 14 },
|
||||||
value: kpiValue(s.kpis, spec.key, { round: spec.round }),
|
{ header: 'Vacancies', key: 'vacancies', width: 12 },
|
||||||
|
{ header: 'Applications', key: 'count', width: 14 },
|
||||||
|
],
|
||||||
|
rows: jobApps.map((j) => ({
|
||||||
|
title: j.title || '',
|
||||||
|
department: j.department || '',
|
||||||
|
status: jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status || '',
|
||||||
|
vacancies: j.vacancies ?? '',
|
||||||
|
count: j.count ?? 0,
|
||||||
})),
|
})),
|
||||||
}
|
},
|
||||||
})
|
{
|
||||||
|
name: 'Pipeline',
|
||||||
|
title: 'Candidate Pipeline',
|
||||||
|
subtitle,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Stage', key: 'stage', width: 18 },
|
||||||
|
{ header: 'Count', key: 'count', width: 12 },
|
||||||
|
{ header: 'Share', key: 'share', width: 12 },
|
||||||
|
],
|
||||||
|
rows: pipeRows.map((r) => ({
|
||||||
|
stage: r.stage,
|
||||||
|
count: r.count,
|
||||||
|
share: r.pct != null ? `${r.pct}%` : '',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Offer Book',
|
||||||
|
title: 'Offer Book',
|
||||||
|
subtitle: `All offers by status · point-in-time · exported ${stamp}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Status', key: 'status', width: 18 },
|
||||||
|
{ header: 'Count', key: 'count', width: 12 },
|
||||||
|
],
|
||||||
|
rows: offersApi.OFFER_STATUSES.map((s) => ({
|
||||||
|
status: offersApi.OFFER_STATUS_LABEL[s],
|
||||||
|
count: offerCounts[s] ?? 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Needs Attention',
|
||||||
|
title: 'Needs Attention',
|
||||||
|
subtitle: `Work queued right now · exported ${stamp}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Item', key: 'title', width: 36 },
|
||||||
|
{ header: 'Count', key: 'count', width: 12 },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...attentionRows.map((row) => ({ title: row.title, count: row.count ?? 0 })),
|
||||||
|
{ title: 'Open jobs with no applications', count: starvingCount },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Recent Activity',
|
||||||
|
title: 'Recent Activity',
|
||||||
|
subtitle: `Latest across the org · exported ${stamp}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'When', key: 'when', width: 20 },
|
||||||
|
{ header: 'Activity', key: 'activity', width: 28 },
|
||||||
|
{ header: 'Actor', key: 'actor', width: 22 },
|
||||||
|
{ header: 'Detail', key: 'detail', width: 40 },
|
||||||
|
],
|
||||||
|
rows: activity.map((a) => ({
|
||||||
|
when: fmtWhen(a.activity_date),
|
||||||
|
activity: a.activity_type || 'Activity',
|
||||||
|
actor: a.actor_name || '',
|
||||||
|
detail: a.description || a.activity_status || '',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
||||||
|
|
@ -423,16 +502,54 @@ function DashboardHome() {
|
||||||
if (exporting) return
|
if (exporting) return
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
try {
|
try {
|
||||||
const snapshots = await Promise.all(
|
const [kpisRes, jobAppsRes, funnelRes, offersRes, countsRes, activityRes] = await Promise.all([
|
||||||
RANGES.map((r) => fetchRangeSnapshot(r.key, department)),
|
analyticsApi.kpis(filters),
|
||||||
)
|
analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }),
|
||||||
|
analyticsApi.funnel(filters),
|
||||||
|
offersApi.list({ top: 500 }).catch(() => null),
|
||||||
|
inboxApi.fetchCounts().catch(() => ({})),
|
||||||
|
activityApi.feed({ top: 8 }).catch(() => null),
|
||||||
|
])
|
||||||
|
const kpis = asObject(kpisRes?.data)
|
||||||
|
const jobApps = asList(jobAppsRes?.data)
|
||||||
|
const funnel = asList(funnelRes?.data)
|
||||||
|
const exportPipeRows = (() => {
|
||||||
|
const rows = analyticsApi
|
||||||
|
.toBoardStageRows(funnel, { includeRejected: true })
|
||||||
|
.filter((r) => r.count > 0)
|
||||||
|
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
pct: total ? Math.round((r.count / total) * 100) : 0,
|
||||||
|
}))
|
||||||
|
})()
|
||||||
|
const offers = asList(offersRes?.data)
|
||||||
|
const exportOfferCounts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0]))
|
||||||
|
for (const o of offers) {
|
||||||
|
if (o.status in exportOfferCounts) exportOfferCounts[o.status] += 1
|
||||||
|
}
|
||||||
|
const counts = countsRes && typeof countsRes === 'object' ? countsRes : {}
|
||||||
|
const exportAttention = [
|
||||||
|
{ title: 'Unread applications', count: counts.unread },
|
||||||
|
{ title: 'Not assigned to a job', count: counts.unassigned },
|
||||||
|
{ title: 'Flagged duplicates', count: counts.duplicates },
|
||||||
|
]
|
||||||
|
const exportStarving = jobApps.filter((r) => r.requisition_status === 'open' && !r.count).length
|
||||||
const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all'
|
const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all'
|
||||||
await exportStyledWorkbook({
|
await exportStyledWorkbook({
|
||||||
filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`,
|
filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`,
|
||||||
sheets: buildPeriodSheets({
|
sheets: buildDashboardSheets({
|
||||||
department,
|
department,
|
||||||
snapshots,
|
rangeKey,
|
||||||
|
span,
|
||||||
exportedAt: new Date(),
|
exportedAt: new Date(),
|
||||||
|
kpis,
|
||||||
|
jobApps,
|
||||||
|
pipeRows: exportPipeRows,
|
||||||
|
offerCounts: exportOfferCounts,
|
||||||
|
attentionRows: exportAttention,
|
||||||
|
starvingCount: exportStarving,
|
||||||
|
activity: asList(activityRes?.data),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
toast('Dashboard exported to Excel', 'success')
|
toast('Dashboard exported to Excel', 'success')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue