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; }
}