pull/79/head
ahmed.mujtaba 2026-09-07 20:10:45 +05:00
parent 86531c77db
commit c520b2d9c2
3 changed files with 266 additions and 93 deletions

View File

@ -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

View File

@ -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'}

View File

@ -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')