diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index d0cdc73..390b001 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -1751,7 +1751,11 @@ class CandidateView: ) 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) records=[payloads] if single else list(payloads or []) emails=[_payload_email(p) for p in records] @@ -1761,15 +1765,15 @@ class CandidateView: continue email=_payload_email(payload) pack=history.get(email) or {"present_in":[],"user":None,"applications":[]} - previous=[] + items=[] + reapplied=False for row in pack.get("applications") or []: - if _is_current_application(row,payload): - continue - if not _is_earlier_application(row,payload): - continue - previous.append(serialize_application_history_item(row)) - previous.sort(key=lambda r: r.get("applied_at") or "",reverse=True) + item=serialize_application_history_item(row) + items.append(item) + if is_assigned_application(item) and not _is_current_application(row,payload): + reapplied=True + items.sort(key=lambda r: r.get("applied_at") or "",reverse=True) payload["present_in"]=list(pack.get("present_in") or []) - payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous) - payload["previous_applications"]=previous + payload["is_reapplicant"]=reapplied + payload["previous_applications"]=items return records[0] if single else records diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index c25cd41..a37d583 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -48,26 +48,62 @@ export function applicationStatusLabel(status, item) { 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) { if (!row) return [] - const raw = Array.isArray(row.previousApplications) - ? row.previousApplications - : Array.isArray(row.previous_applications) - ? row.previous_applications - : [] const current = currentRowIds(row) - const currentTs = appliedAtMs( - row.received || row.applied_at || row.entry_date || row.when || row.sentAt, - ) - const items = raw.filter((item) => { - if (current.size && isSameApplication(item, current)) return false - const t = appliedAtMs(item?.applied_at) - if (currentTs == null) return true - if (t == null) return false - return t < currentTs - }) - items.sort((a, b) => (appliedAtMs(a?.applied_at) ?? 0) - (appliedAtMs(b?.applied_at) ?? 0)) - return items + return candidateApplicationsOf(row).filter((item) => !isSameApplication(item, current)) +} + +function syntheticCurrentApplication(row) { + const assigned = row.assignedPost || row.assigned_job_post + const position = row.kind !== 'email' && row.position && row.position !== '—' ? row.position : null + const jobTitle = assigned?.title || row.job_title || row.jobTitle || row.currentTitle || position || null + const kind = row.kind + let source = row.source + if (kind === 'form') source = 'form' + else if (kind === 'email') source = 'inbox' + 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) { @@ -152,7 +188,7 @@ export function hrefForPreviousApplication(item) { if (item.source === 'form' && item.form_data_id) { 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` } if (item.source === 'manual') { @@ -161,13 +197,22 @@ export function hrefForPreviousApplication(item) { 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 } -/** Full prior-job list for profile / inbox / add-candidate. */ -export function PreviousApplications({ row, title = 'Previous applications' }) { - const items = previousApplicationsOf(row) +/** Full application list for profile / inbox / add-candidate. */ +export function PreviousApplications({ row, title = 'Total applications' }) { + const items = candidateApplicationsOf(row) if (!items.length) return null + const current = currentRowIds(row) + const heading = title === 'Total applications' ? `Total applications (${items.length})` : title return (
- {title} + {heading}
{items.map((item, idx) => { const stage = applicationStatusLabel(item.status, item) const job = item.job_title || item.jobTitle || 'No job assigned' const href = hrefForPreviousApplication(item) + const isCurrent = current.size > 0 && isSameApplication(item, current) const key = [ item.source, item.inbox_id, @@ -204,6 +250,12 @@ export function PreviousApplications({ row, title = 'Previous applications' }) { item.job_post_id, idx, ].filter(Boolean).join(':') + const jobLabel = ( + <> + {job} + {isCurrent ? ' (Current)' : ''} + + ) return (
e.stopPropagation()} > - {job} + {jobLabel} ) : ( -
{job}
+
{jobLabel}
)}
{SOURCE_LABEL[item.source] || item.source || 'Application'} diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 8b74e19..1faf315 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -105,65 +105,144 @@ function fmtWhen(iso) { return fmtShort(iso) || '—' } -const PERIOD_SHEET_NAMES = { - week: 'Weekly', - month: 'Monthly', - quarter: 'Quarterly', - year: 'Yearly', +function dashExportValue(value) { + if (value == null || value === '') return '' + return value } -const PERIOD_METRICS = [ - { label: 'Open Jobs', key: 'open_jobs' }, - { label: 'Applications', key: 'total_candidates' }, - { label: 'Hires', key: 'hires' }, - { label: 'Offers Sent', key: 'offers_sent' }, - { label: 'Offers Accepted', key: 'offers_accepted' }, - { label: 'Cost per Hire', key: 'cost_per_hire', round: true }, -] - -function kpiValue(kpis, key, { round = false } = {}) { - if (!kpis || kpis[key] == null || kpis[key] === '') return '' - const n = Number(kpis[key]) - 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 }) { +function buildDashboardSheets({ + department, + rangeKey, + span, + exportedAt, + kpis, + jobApps, + pipeRows, + offerCounts, + attentionRows, + starvingCount, + activity, +}) { const deptLabel = department || 'All Departments' const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10) - const columns = [ - { header: 'Metric', key: 'metric', width: 22 }, - { header: 'Value', key: 'value', width: 16 }, + 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 snapshots.map((s) => { - const name = PERIOD_SHEET_NAMES[s.key] || s.key - const from = fmtDate(s.fromDate) || s.fromDate - const to = fmtDate(s.toDate) || s.toDate - return { - name, - title: name, - subtitle: `${from} – ${to} · ${deptLabel} · exported ${stamp}`, - columns, - rows: PERIOD_METRICS.map((spec) => ({ - metric: spec.label, - value: kpiValue(s.kpis, spec.key, { round: spec.round }), + + return [ + { + name: 'Summary', + title: 'Dashboard', + subtitle, + columns: [ + { header: 'Metric', key: 'metric', width: 22 }, + { header: 'Value', key: 'value', width: 16 }, + { header: 'Vs prior period', key: 'trend', width: 18 }, + ], + rows: summaryRows, + }, + { + name: 'Applications per Job', + title: 'Applications per Job', + subtitle, + columns: [ + { header: 'Job', key: 'title', width: 36 }, + { header: 'Department', key: 'department', width: 22 }, + { header: 'Status', key: 'status', width: 14 }, + { 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 }) { @@ -423,16 +502,54 @@ function DashboardHome() { if (exporting) return setExporting(true) try { - const snapshots = await Promise.all( - RANGES.map((r) => fetchRangeSnapshot(r.key, department)), - ) + const [kpisRes, jobAppsRes, funnelRes, offersRes, countsRes, activityRes] = await Promise.all([ + 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' await exportStyledWorkbook({ filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`, - sheets: buildPeriodSheets({ + sheets: buildDashboardSheets({ department, - snapshots, + rangeKey, + span, exportedAt: new Date(), + kpis, + jobApps, + pipeRows: exportPipeRows, + offerCounts: exportOfferCounts, + attentionRows: exportAttention, + starvingCount: exportStarving, + activity: asList(activityRes?.data), }), }) toast('Dashboard exported to Excel', 'success')