pull/73/head
parent
74ad8dd7e1
commit
ac49a903ae
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue