From ac49a903aeac369dd36c68b5ac1533616d405406 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 4 Sep 2026 21:00:54 +0500 Subject: [PATCH] . --- backend/job/job_post/models.py | 38 +++++++++++++++++++++++------ backend/job/job_post/serializers.py | 1 + frontend/src/api/jobStats.js | 1 + frontend/src/screens/Progress.jsx | 21 +++++++++++++--- frontend/src/styles/styles.css | 5 +++- 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index a8ca4fe..c2982ae 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -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 ) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index cfd756d..5cf3ef2 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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), } diff --git a/frontend/src/api/jobStats.js b/frontend/src/api/jobStats.js index cad5c6b..04f1d54 100644 --- a/frontend/src/api/jobStats.js +++ b/frontend/src/api/jobStats.js @@ -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, } } diff --git a/frontend/src/screens/Progress.jsx b/frontend/src/screens/Progress.jsx index c668def..360501e 100644 --- a/frontend/src/screens/Progress.jsx +++ b/frontend/src/screens/Progress.jsx @@ -118,9 +118,15 @@ function JobDetail({ job }) { )}

-
- {job.total.toLocaleString()} - unique applicants +
+
+ {job.total.toLocaleString()} + unique applicants +
+
+ {job.reapplied.toLocaleString()} + reapplied +
@@ -162,6 +168,11 @@ function JobDetail({ job }) { + 0} + />
@@ -172,6 +183,10 @@ function JobDetail({ job }) { Candidates on hold {job.onHold}
+
+ Reapplied + {job.reapplied} +
Job aging diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 8feaef6..f8bf530 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -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; } }