pull/73/head
ahmed.mujtaba 2026-09-04 21:00:54 +05:00
parent 74ad8dd7e1
commit ac49a903ae
5 changed files with 55 additions and 11 deletions

View File

@ -270,7 +270,8 @@ class JobPosts(SQLModel, table=True):
rows. Duplicate emails (case-insensitive) count once per job the
furthest pipeline stage is kept. Flagged is_duplicate rows are skipped.
Rows with no email still count, each as themselves. Jobs with zero
applicants still appear (LEFT JOIN).
applicants still appear (LEFT JOIN). `reapplied` is how many unique
applicants on the job also applied to at least one other job.
"""
from g_sheet.models import FormData
from inbox.models import Inbox_Messages
@ -299,7 +300,7 @@ class JobPosts(SQLModel, table=True):
func.concat("noid:", cast(row_id, String)),
)
inbox_q = (
inbox_base = (
select(
Inbox_Messages.assigned_job_post_id.label("job_post_id"),
dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"),
@ -316,7 +317,7 @@ class JobPosts(SQLModel, table=True):
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""),
Users.email,
)
manual_q = (
manual_base = (
select(
Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"),
dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"),
@ -332,7 +333,7 @@ class JobPosts(SQLModel, table=True):
(FormData.processing_state == "rejected", "REJECTED"),
else_="PENDING",
)
form_q = (
form_base = (
select(
FormData.job_post_id.label("job_post_id"),
dup_key(FormData.candidate_email, FormData.id).label("dup_key"),
@ -342,12 +343,23 @@ class JobPosts(SQLModel, table=True):
.where(FormData.manual_upload_candidate_id.is_(None))
.where(FormData.is_duplicate == False) # noqa: E712
)
# Unfiltered: an applicant on this page who also applied to a job
# not in the current page still counts as a reapplicant.
all_apps = union_all(inbox_base, manual_base, form_base).subquery("all_applications")
inbox_q, manual_q, form_q = inbox_base, manual_base, form_base
if job_uids:
inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
form_q = form_q.where(FormData.job_post_id.in_(job_uids))
inbox_q = inbox_base.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
manual_q = manual_base.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
form_q = form_base.where(FormData.job_post_id.in_(job_uids))
apps = union_all(inbox_q, manual_q, form_q).subquery("applications")
repeat_keys = (
select(all_apps.c.dup_key)
.where(~all_apps.c.dup_key.like("noid:%"))
.group_by(all_apps.c.dup_key)
.having(func.count(func.distinct(all_apps.c.job_post_id)) > 1)
.subquery("repeat_emails")
)
stage_rank = case(
(apps.c.stage == "HIRED", 9),
(apps.c.stage == "APPROVED", 8),
@ -389,6 +401,16 @@ class JobPosts(SQLModel, table=True):
.group_by(unique_apps.c.job_post_id)
.subquery("job_stage_stats")
)
reapplied_stats = (
select(
unique_apps.c.job_post_id,
func.count().label("reapplied"),
)
.select_from(unique_apps)
.join(repeat_keys, repeat_keys.c.dup_key == unique_apps.c.dup_key)
.group_by(unique_apps.c.job_post_id)
.subquery("job_reapplied")
)
# Alias so this join does not collide with the Users join inside
# the manual-upload subquery above.
@ -413,9 +435,11 @@ class JobPosts(SQLModel, table=True):
func.coalesce(stats.c.rejected, 0).label("rejected"),
func.coalesce(stats.c.approved, 0).label("approved"),
func.coalesce(stats.c.hired, 0).label("hired"),
func.coalesce(reapplied_stats.c.reapplied, 0).label("reapplied"),
)
.select_from(cls)
.outerjoin(stats, stats.c.job_post_id == cls.id)
.outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
.where(cls.is_deleted == False) # noqa: E712
)

View File

@ -107,6 +107,7 @@ def serialize_job_stats(row) -> dict:
"rejected": int(row["rejected"] or 0),
"approved": int(row["approved"] or 0),
"hired": int(row["hired"] or 0),
"reapplied": int(row.get("reapplied") or 0),
}

View File

@ -60,5 +60,6 @@ export function toJobStatsView(row) {
rejected: Number(row.rejected) || 0,
approved: Number(row.approved) || 0,
hired: Number(row.hired) || 0,
reapplied: Number(row.reapplied) || 0,
}
}

View File

@ -118,9 +118,15 @@ function JobDetail({ job }) {
)}
</p>
</div>
<div className="progress-selected-total">
<strong>{job.total.toLocaleString()}</strong>
<span>unique applicants</span>
<div className="progress-selected-totals">
<div className="progress-selected-total">
<strong>{job.total.toLocaleString()}</strong>
<span>unique applicants</span>
</div>
<div className="progress-selected-total">
<strong className={job.reapplied ? 'accent' : undefined}>{job.reapplied.toLocaleString()}</strong>
<span>reapplied</span>
</div>
</div>
</div>
@ -162,6 +168,11 @@ function JobDetail({ job }) {
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
<Metric value={job.offered.toLocaleString()} label="Offers made" accent />
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
<Metric
value={job.reapplied.toLocaleString()}
label={job.total ? `Reapplied (${Math.round((job.reapplied / job.total) * 100)}%)` : 'Reapplied'}
accent={job.reapplied > 0}
/>
</div>
<div className="progress-bottom-grid">
@ -172,6 +183,10 @@ function JobDetail({ job }) {
<span>Candidates on hold</span>
<strong className="text-warning">{job.onHold}</strong>
</div>
<div className="progress-attention-item">
<span>Reapplied</span>
<strong className={job.reapplied ? 'text-warning' : undefined}>{job.reapplied}</strong>
</div>
<div className="progress-attention-item">
<span>Job aging</span>
<strong>

View File

@ -1930,6 +1930,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
color: var(--text-2); font-size: var(--fs-sm); margin: 0;
}
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
.progress-selected-totals { display: flex; gap: 22px; align-items: flex-start; }
.progress-selected-total {
display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 110px;
}
@ -1937,6 +1938,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
display: block; font-family: var(--font-display); font-size: 28px; font-weight: 600;
letter-spacing: -.02em; line-height: 1; color: var(--text);
}
.progress-selected-total strong.accent { color: var(--warning); }
.progress-selected-total span {
display: block; font-size: var(--fs-xs); color: var(--text-2); margin-top: 4px;
}
@ -2045,7 +2047,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.progress-stage-row-pct { text-align: right; font-size: var(--fs-sm); color: var(--text-2); }
.progress-metric-grid {
display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr));
display: grid; grid-template-columns: repeat(5, minmax(90px, 1fr));
gap: 1px; background: var(--border); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
}
@ -2084,6 +2086,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.progress-header-stats { width: 100%; justify-content: space-between; }
.progress-stage-row { grid-template-columns: 96px minmax(80px, 1fr) 44px 36px; gap: 8px; }
.progress-selected-total { align-items: flex-start; text-align: left; }
.progress-selected-totals { width: 100%; justify-content: flex-start; }
.progress-sidebar-controls { flex-wrap: wrap; }
.progress-sort-chip { width: 100%; text-align: center; }
}