From c0e4a94d589f006e6f76c65ccfc3bdbb44475f7a Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 9 Sep 2026 19:42:01 +0500 Subject: [PATCH 1/2] total applications removed --- backend/offer/app.py | 11 +- backend/offer/models.py | 14 ++ backend/offer/views.py | 134 ++++++++++++++---- frontend/src/api/offers.js | 3 +- .../src/components/ReapplicantHistory.jsx | 3 +- frontend/src/screens/Offers.jsx | 124 +++++++++------- 6 files changed, 198 insertions(+), 91 deletions(-) diff --git a/backend/offer/app.py b/backend/offer/app.py index 7444b2b..addfc41 100644 --- a/backend/offer/app.py +++ b/backend/offer/app.py @@ -14,9 +14,12 @@ router = APIRouter() class OfferCreate(BaseModel): - inbox_id: int + offer_id: str | None = None + inbox_id: int | None = None + manual_upload_candidate_id: str | None = None + form_data_id: str | None = None job_post_id: str - candidate_user_id: str + candidate_user_id: str | None = None status: str | None = "draft" base_salary: float | None = None currency: str | None = None @@ -74,9 +77,9 @@ class OfferSent(BaseModel): inbox_id: int | None = None manual_upload_candidate_id: str | None = None form_data_id: str | None = None - job_post_id: str + job_post_id: str | None = None candidate_user_id: str | None = None - base_salary: float + base_salary: float | None = None currency: str | None = "USD" salary_period: str | None = "year" signing_bonus: float | None = None diff --git a/backend/offer/models.py b/backend/offer/models.py index 604dd60..6cd8870 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -183,6 +183,20 @@ class Offers(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_draft_or_failed_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id): + uid = cls._as_uuid(candidate_user_id) + jid = cls._as_uuid(job_post_id) + if uid is None or jid is None: + return None + result = await session.execute( + select(cls) + .where(cls.candidate_user_id == uid, cls.job_post_id == jid) + .where(cls.status.in_(("draft", "failed"))) + .order_by(cls.created_at.desc()) + ) + return result.scalars().first() + class OfferStatusHistory(SQLModel, table=True): __tablename__ = "offer_status_history" diff --git a/backend/offer/views.py b/backend/offer/views.py index bfea53d..e8c5dc2 100644 --- a/backend/offer/views.py +++ b/backend/offer/views.py @@ -219,39 +219,75 @@ class Offer: existing["name"]=item.get("name") async def create_offer(self,payload,current_user): - if not payload.get("inbox_id"): - raise HTTPException(status_code=422,detail="inbox_id is required") - job_post_id=_as_uuid(payload.get("job_post_id")) - if job_post_id is None: - raise HTTPException(status_code=422,detail="job_post_id is required") - candidate_user_id=_as_uuid(payload.get("candidate_user_id")) - if candidate_user_id is None: - raise HTTPException(status_code=422,detail="candidate_user_id is required") created_by=_user_id(current_user) - status=payload.get("status") or "draft" + app=await self._resolve_application(payload) + await self._assert_offer_job(current_user,app["job_post_id"]) + candidate_user_id=app["candidate_user_id"] + job_post_id=app["job_post_id"] + open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id) + retry_id=_as_uuid(payload.get("offer_id")) + if open_row and (retry_id is None or open_row.id!=retry_id): + raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job") - fields={ - "inbox_id": int(payload["inbox_id"]), + fields=_comp_fields(payload) + fields.update({ + "inbox_id": app.get("inbox_id"), + "manual_upload_candidate_id": app.get("manual_upload_id"), + "form_data_id": app.get("form_data_id"), "job_post_id": job_post_id, "candidate_user_id": candidate_user_id, - "created_by": created_by, - "status": status, - } - for key in non_validation_values(): - if key in payload and payload[key] is not None: - fields[key]=payload[key] + "status": "draft", + }) + if fields.get("equity_units") in (None,0): + fields["equity_instrument"]=None + if retry_id is not None: + row=await Offers.get_offer_by_id(self.session,retry_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + if row.status in ("sent","negotiating"): + raise HTTPException(status_code=409,detail="This offer has already been sent") + from_status=row.status + row=await Offers.update_offer(self.session,retry_id,fields) + if from_status!="draft": + await OfferStatusHistory.insert_history(self.session,{ + "offer_id": row.id, + "from_status": from_status, + "to_status": "draft", + "changed_by": created_by, + "actor_kind": "user", + "change_reason": payload.get("change_reason") or "saved", + }) + return (await self._hydrate_offers([row]))[0] + + existing=await Offers.get_draft_or_failed_for_candidate_job( + self.session,candidate_user_id,job_post_id, + ) + if existing: + from_status=existing.status + row=await Offers.update_offer(self.session,existing.id,fields) + if from_status!="draft": + await OfferStatusHistory.insert_history(self.session,{ + "offer_id": row.id, + "from_status": from_status, + "to_status": "draft", + "changed_by": created_by, + "actor_kind": "user", + "change_reason": payload.get("change_reason") or "saved", + }) + return (await self._hydrate_offers([row]))[0] + + fields["created_by"]=created_by row=await Offers.insert_offer(self.session,fields) - history_data={ + await OfferStatusHistory.insert_history(self.session,{ "offer_id": row.id, "from_status": None, - "to_status": status, + "to_status": "draft", "changed_by": created_by, "actor_kind": "user", "change_reason": payload.get("change_reason"), - } - await OfferStatusHistory.insert_history(self.session,history_data) - return serialize_offer(row) + }) + return (await self._hydrate_offers([row]))[0] async def update_offer(self,offer_id,payload,current_user): row=await Offers.get_offer_by_id(self.session,offer_id) @@ -322,6 +358,7 @@ class Offer: async def send_offer(self,payload,current_user): created_by=_user_id(current_user) + payload=await self._payload_for_send(payload) app=await self._resolve_application(payload) await self._assert_offer_job(current_user,app["job_post_id"]) candidate_user_id=app["candidate_user_id"] @@ -331,7 +368,10 @@ class Offer: open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id) retry_id=_as_uuid(payload.get("offer_id")) - if open_row and (retry_id is None or open_row.id!=retry_id): + resend=False + if open_row and retry_id is not None and open_row.id==retry_id: + resend=open_row.status in ("sent","negotiating") + elif open_row and (retry_id is None or open_row.id!=retry_id): raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job") fields=_comp_fields(payload) @@ -341,8 +381,9 @@ class Offer: "form_data_id": app.get("form_data_id"), "job_post_id": job_post_id, "candidate_user_id": candidate_user_id, - "status": "failed", }) + if not resend: + fields["status"]="draft" if fields.get("equity_units") in (None,0): fields["equity_instrument"]=None @@ -351,20 +392,23 @@ class Offer: row=await Offers.get_offer_by_id(self.session,retry_id) if not row: raise HTTPException(status_code=404,detail="Offer not found") - if row.status in ("sent","negotiating"): - raise HTTPException(status_code=409,detail="This offer has already been sent") + if row.status in ("accepted","declined"): + raise HTTPException(status_code=409,detail="This offer is already closed") row=await Offers.update_offer(self.session,retry_id,fields) else: - failed=await Offers.get_failed_for_candidate_job(self.session,candidate_user_id,job_post_id) - if failed: - row=await Offers.update_offer(self.session,failed.id,fields) + existing=await Offers.get_draft_or_failed_for_candidate_job( + self.session,candidate_user_id,job_post_id, + ) + if existing: + row=await Offers.update_offer(self.session,existing.id,fields) else: fields["created_by"]=created_by + fields["status"]="draft" row=await Offers.insert_offer(self.session,fields) await OfferStatusHistory.insert_history(self.session,{ "offer_id": row.id, "from_status": None, - "to_status": "failed", + "to_status": "draft", "changed_by": created_by, "actor_kind": "user", "change_reason": payload.get("change_reason"), @@ -383,6 +427,9 @@ class Offer: raise RuntimeError("candidate has no email") await send_offer_mail(email,subject,html) except (httpx.HTTPError,RuntimeError) as e: + if row.status not in ("sent","negotiating"): + await Offers.update_offer(self.session,row.id,{"status":"failed"}) + row=await Offers.get_offer_by_id(self.session,row.id) await self._notify_send_failed(created_by,name,job_title,row) raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e @@ -416,6 +463,33 @@ class Offer: ) return (await self._hydrate_offers([updated]))[0] + async def _payload_for_send(self,payload): + data=dict(payload or {}) + offer_id=_as_uuid(data.get("offer_id")) + if offer_id is None: + return data + row=await Offers.get_offer_by_id(self.session,offer_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + if data.get("inbox_id") in (None,"") and not data.get("manual_upload_candidate_id") and not data.get("form_data_id"): + if row.inbox_id is not None: + data["inbox_id"]=row.inbox_id + elif row.manual_upload_candidate_id is not None: + data["manual_upload_candidate_id"]=str(row.manual_upload_candidate_id) + elif row.form_data_id is not None: + data["form_data_id"]=str(row.form_data_id) + if data.get("job_post_id") in (None,""): + data["job_post_id"]=str(row.job_post_id) if row.job_post_id else None + if data.get("candidate_user_id") in (None,""): + data["candidate_user_id"]=str(row.candidate_user_id) if row.candidate_user_id else None + if data.get("base_salary") in (None,""): + data["base_salary"]=row.base_salary + for key in ("currency","salary_period","signing_bonus","annual_bonus_pct", + "equity_units","equity_instrument","start_date","expiry_date"): + if key not in data or data.get(key) in (None,""): + data[key]=getattr(row,key) + return data + async def _resolve_application(self,payload): inbox_id=payload.get("inbox_id") manual_id=_as_uuid(payload.get("manual_upload_candidate_id")) diff --git a/frontend/src/api/offers.js b/frontend/src/api/offers.js index 5a8da5f..e377b1d 100644 --- a/frontend/src/api/offers.js +++ b/frontend/src/api/offers.js @@ -8,7 +8,8 @@ import { toDate } from '../lib/format' recruiter who can read offers still cannot issue one. List rows include candidate_name / created_by_name. Job title is still - hydrated from /job/fetch?ids=. Create Offer uses POST /offers/jobs/sent. + hydrated from /job/fetch?ids=. Create Offer saves POST /offers/create + (draft). Send on the list is POST /offers/jobs/sent. ============================================================ */ /** `status` is a free-text column defaulting to "draft"; the vocabulary is decided here. */ diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index 22a41fa..8eaeffd 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -205,7 +205,8 @@ export function hrefForPreviousApplication(item) { /** Full application list for profile / inbox / add-candidate. */ export function PreviousApplications({ row, title = 'Total applications' }) { const items = candidateApplicationsOf(row) - if (!items.length) return null + // One row is the application already on screen — do not show a history card. + if (items.length < 2) return null const current = currentRowIds(row) const heading = title === 'Total applications' ? `Total applications (${items.length})` : title return ( diff --git a/frontend/src/screens/Offers.jsx b/frontend/src/screens/Offers.jsx index 3ad180e..cda2328 100644 --- a/frontend/src/screens/Offers.jsx +++ b/frontend/src/screens/Offers.jsx @@ -1,14 +1,9 @@ /* ============================================================ Offers — live on backend/offer/app.py. - Read is GET /offers/fetch. Create Offer writes POST /offers/jobs/sent - (persist, email via Teams, then pipeline OFFER). Failed sends stay listed; - GET /offers/fetch?offer_id= prefills the form for retry. Drafts still use - POST /offers/create (Candidate Forms) and POST /offers/issue. - - HYDRATION, NOT N+1. serialize_offer now includes candidate_name when listing; - job titles still come from /job/fetch?ids=. Equity is `equity_units` + - `equity_instrument` server-side. + Create Offer writes POST /offers/create as a draft (no email). Send on the + list (and offer detail) writes POST /offers/jobs/sent: Teams mail, then + pipeline OFFER. Failed sends stay listed for retry from the row Send button. ============================================================ */ import { useMemo, useState, useEffect, useRef } from 'react' @@ -144,13 +139,27 @@ export default function Offers() { } }, [searchParams, setSearchParams]) - const issue = useMutation({ - mutationFn: (offerId) => offersApi.issue(offerId), - onSuccess: (_res, _id) => { + const save = useMutation({ + mutationFn: (body) => offersApi.create(body), + onSuccess: () => { invalidate() - toast('Offer issued and marked sent', 'success') + setCreating(null) + toast('Offer saved — review it on the list, then Send', 'success') + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not save the offer.'), 'error'), + }) + + const send = useMutation({ + mutationFn: (body) => offersApi.send(body), + onSuccess: () => { + invalidate() + setViewing(null) + toast('Offer emailed and moved to Offer stage', 'success') + }, + onError: (err) => { + invalidate() + toast(friendlyAuthError(err, 'Failed: the offer could not be sent'), 'error') }, - onError: (err) => toast(friendlyAuthError(err, 'Could not issue the offer.'), 'error'), }) const setStatus = useMutation({ @@ -171,20 +180,7 @@ export default function Offers() { onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'), }) - const send = useMutation({ - mutationFn: (body) => offersApi.send(body), - onSuccess: () => { - invalidate() - setCreating(null) - toast('Offer emailed and moved to Offer stage', 'success') - }, - onError: (err) => { - invalidate() - toast(friendlyAuthError(err, 'Failed: the offer could not be sent'), 'error') - }, - }) - - const busy = issue.isPending || setStatus.isPending + const busy = send.isPending || setStatus.isPending const columns = [ { @@ -225,17 +221,15 @@ export default function Offers() { key: '_a', label: 'Actions', align: 'right', render: (o) => (
- @@ -306,24 +300,24 @@ export default function Offers() { offer={viewing} busy={busy} onClose={() => setViewing(null)} - onIssue={() => issue.mutate(viewing.id)} - onRetry={() => { setViewing(null); setCreating({ offerId: viewing.id }) }} + onSend={() => send.mutate(sendPayloadFromOffer(viewing))} + onEdit={() => { setViewing(null); setCreating({ offerId: viewing.id }) }} onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }} /> )} {creating && ( setCreating(null)} - onSubmit={(body) => send.mutate(body)} + onSubmit={(body) => save.mutate(body)} /> )}
) } -function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) { +function OfferDetail({ offer: o, busy, onClose, onSend, onEdit, onStatus }) { /* Est. total cash = base + the bonus percentage applied to it. Signing bonus is a one-off and is shown separately rather than folded in, because adding it would overstate year two. */ @@ -346,19 +340,16 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) { Mark {OFFER_STATUS_LABEL[s]} ))} - {o.status === 'failed' ? ( - - ) : ( - - )} + + } > @@ -435,6 +426,28 @@ function applicationIdentity(c) { return {} } +function sendPayloadFromOffer(o) { + return { + offer_id: o.id, + ...applicationIdentity({ + inbox_id: o.inboxId, + manual_upload_candidate_id: o.manualUploadId, + form_data_id: o.formDataId, + }), + job_post_id: o.jobPostId, + candidate_user_id: o.candidateUserId || undefined, + base_salary: o.base, + currency: o.currency, + salary_period: o.salaryPeriod, + annual_bonus_pct: o.bonusPct, + signing_bonus: o.signingBonus, + equity_units: o.equityUnits, + equity_instrument: o.equityInstrument, + start_date: localDateIso(toDateInput(o.startDate)), + expiry_date: localDateIso(toDateInput(o.expiry)), + } +} + function localDateIso(date) { if (!date) return null const d = new Date(`${date}T00:00`) @@ -658,6 +671,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) { ...identity, job_post_id: selected.job_post_id, candidate_user_id: selected.user_id || undefined, + status: 'draft', base_salary: base, currency: form.currency, salary_period: form.salaryPeriod, @@ -676,14 +690,14 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) { return ( } @@ -781,7 +795,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {

Equity is stored as a unit count plus an instrument, so “20k RSU” is entered as 20000 and RSU. - Sending emails the candidate and moves the pipeline to Offer only after the mail succeeds. + This saves a draft only. Use Send on the Offers list after you review it — that emails the candidate and moves them to Offer.

-- 2.40.1 From 5fdc6031aa9ddc6ea5a52f2859a4ce21167f1804 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 9 Sep 2026 20:16:25 +0500 Subject: [PATCH 2/2] listing corrections --- frontend/src/api/candidates.js | 6 +- .../src/components/ReapplicantHistory.jsx | 27 +++++--- frontend/src/screens/Assessments.jsx | 2 +- frontend/src/screens/Inbox.jsx | 64 +++++++++++-------- frontend/src/screens/Interviews.jsx | 2 +- frontend/src/screens/JobBoard.jsx | 2 +- frontend/src/screens/Jobs.jsx | 2 +- frontend/src/screens/Offers.jsx | 2 +- frontend/src/screens/Reports.jsx | 10 +-- frontend/src/screens/Requisitions.jsx | 2 +- frontend/src/ui/DataTable.jsx | 6 +- 11 files changed, 74 insertions(+), 51 deletions(-) diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 92d83ec..989dc76 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -125,7 +125,7 @@ export function viewCvBankCv(id) { * Needs candidates.view. `assigned` is tri-valued: omit for all, false for * still in the bank, true for rows that already have a job_post_id. */ -export function listMatching({ search, top = 10, skip = 0, assigned } = {}) { +export function listMatching({ search, top = 50, skip = 0, assigned } = {}) { return request('/candidate/matching/fetch', { params: { search, top, skip, assigned }, }) @@ -192,7 +192,7 @@ export function toCandidateView(row) { } -export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) { +export function listCandidateUsers({ roleId = 8, top = 50, skip = 0, assignedJobPostId } = {}) { return request('/candidate/fetch/users', { params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId }, }) @@ -536,7 +536,7 @@ export function createActivity({ inboxId, type, status, description }) { * detail query refetches on every write in the modal. Fetched lazily when the * History tab opens, paginated server-side. */ -export function listHistory(userId, { limit = 10, offset = 0 } = {}) { +export function listHistory(userId, { limit = 50, offset = 0 } = {}) { return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } }) } diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index 8eaeffd..7fd159a 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -64,7 +64,7 @@ export function candidateApplicationsOf(row) { const self = syntheticCurrentApplication(row) if (self) items.push(self) } - items.sort((a, b) => (appliedAtMs(b?.applied_at) ?? 0) - (appliedAtMs(a?.applied_at) ?? 0)) + items.sort((a, b) => (appliedAtMs(b) ?? 0) - (appliedAtMs(a) ?? 0)) return items } @@ -101,15 +101,26 @@ function syntheticCurrentApplication(row) { } } -function appliedAtMs(value) { +function isUtcSource(source) { + return source === 'inbox' || source === 'filtered' +} + +/** Email Graph stamps are UTC; sheet/manual stamps are wall-clock digits. */ +function displayAppliedDate(item) { + const value = item?.applied_at if (value == null || value === '') return null if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? null : value.getTime() + return Number.isNaN(value.getTime()) ? null : value } - const instant = toInstant(value) - if (instant) return instant.getTime() - const wall = toDate(value) - return wall ? wall.getTime() : null + if (isUtcSource(item?.source)) { + return toInstant(value) || toDate(value) + } + return toDate(value) || toInstant(value) +} + +function appliedAtMs(item) { + const d = displayAppliedDate(item) + return d ? d.getTime() : null } function currentRowIds(row) { @@ -273,7 +284,7 @@ export function PreviousApplications({ row, title = 'Total applications' }) { )}
{SOURCE_LABEL[item.source] || item.source || 'Application'} - {item.applied_at ? ` · ${fmtDateTime(toInstant(item.applied_at) || item.applied_at)}` : ''} + {item.applied_at ? ` · ${fmtDateTime(displayAppliedDate(item))}` : ''}
{stage} diff --git a/frontend/src/screens/Assessments.jsx b/frontend/src/screens/Assessments.jsx index 067f1b5..e314cbc 100644 --- a/frontend/src/screens/Assessments.jsx +++ b/frontend/src/screens/Assessments.jsx @@ -240,7 +240,7 @@ export default function Assessments() { - + )} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index f773393..13d7dcd 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1,9 +1,9 @@ /* ============================================================ Recruitment Inbox — application tabs over GET /inbox/all-applications. - Page size defaults to 10 (dropdown: 10 / 50 / 100). skip/offset is the - window start and is not recomputed when Per page changes: growing 10 to - 50 on a window that started at row 11 requests skip=10, top=50 - (rows 11–60). Clicking a + Page size defaults to 50 (dropdown: 10 / 50 / 100). skip/offset is the + window start and is not recomputed when Per page changes: growing 50 to + 100 on a window that started at row 51 requests skip=50, top=100 + (rows 51–150). Clicking a page number realigns skip = (page-1)*limit. Total comes from a count endpoint called once when the page opens. ============================================================ */ @@ -51,8 +51,10 @@ const PAGE_SIZE_MAX = 500 * qk.mailbox.all() the moment a run completes — so refetching on every visit * bought nothing and cost a full-width skeleton each time. * - * List fetches always send the UI page size (10 / 50 / 100), including All — - * omitting limit used to dump the whole form_data table into the browser. + * List fetches send the UI page size (10 / 50 / 100). All channel is + * different: email and sheet are fetched as two pools (up to PAGE_SIZE_MAX), + * sorted by received time descending, then sliced to the page so a page is + * not "half inbox, half form". * * staleTime therefore covers a normal working stretch, and keepPreviousData * means a tab switch, a page turn or a keystroke re-renders the rows already @@ -301,6 +303,20 @@ function formReceivedAt(entryDate, entryTime, timestampRaw) { return d } +/** Newest-first clock for All-channel merge. Prefer applied/received, not import time. */ +function rowTimeMs(row) { + const values = [row?.received, row?.applied, row?.applied_at, row?.createdAt] + for (const v of values) { + if (v instanceof Date && !Number.isNaN(v.getTime())) return v.getTime() + if (typeof v === 'number' && Number.isFinite(v)) return v + if (typeof v === 'string' && v.trim()) { + const d = row?.kind === 'form' ? (toDate(v) || toInstant(v)) : (toInstant(v) || toDate(v)) + if (d) return d.getTime() + } + } + return 0 +} + /** Same numeric gate as email `ats_score` / pipeline ScoreChip. */ function asAtsScore(value) { if (value == null || value === '') return null @@ -1227,12 +1243,12 @@ export default function Inbox() { }, [deepOpen, deepKind, setSearchParams]) const isForms = channel === 'forms' - // Combined channel: email and form lists both arrive newest created_at - // first; the merge uses that same clock so a June form cannot sit above a - // later email just because it was on the first sheet page. const isAllChannel = channel === 'all' const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS const pageLimit = pageSize === 'all' ? undefined : pageSize + // All: pull a merge pool from offset 0, sort desc by received, then slice. + const fetchTop = isAllChannel && pageLimit != null ? PAGE_SIZE_MAX : pageLimit + const fetchSkip = isAllChannel ? 0 : (pageLimit == null ? 0 : skip) const activeInboxFilters = [ inboxFilters.location, inboxFilters.source, @@ -1244,14 +1260,13 @@ export default function Inbox() { const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} const listParams = useMemo(() => ({ ...tabFilter, - // Show-all omits top (no LIMIT). Otherwise send the pager size — 10, 50, 100. - top: pageLimit, - skip: pageLimit == null ? 0 : skip, + top: fetchTop, + skip: fetchSkip, ...(search ? { search } : {}), ...(city ? { city } : {}), ...(source ? { source } : {}), ...assignedParams, - }), [tabFilter, skip, pageLimit, search, city, source, assignedParams]) + }), [tabFilter, fetchSkip, fetchTop, search, city, source, assignedParams]) /** * Sheet Forms only. On the All channel these rows are merged with email ones, @@ -1268,15 +1283,15 @@ export default function Inbox() { const formParams = useMemo(() => ({ // All channel spans every sheet tab, not just the selected one. sheet: isAllChannel ? undefined : (formSheet || undefined), - offset: pageLimit == null ? 0 : skip, - limit: pageLimit, + offset: fetchSkip, + limit: fetchTop, ...formTabFilter, ...(search ? { search } : {}), ...(city ? { city } : {}), ...(source ? { source } : {}), ...assignedParams, ...linkFilters, - }), [formSheet, skip, pageLimit, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters]) + }), [formSheet, fetchSkip, fetchTop, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters]) const citiesQuery = useQuery({ queryKey: qk.mailbox.cities(), @@ -1385,18 +1400,15 @@ export default function Inbox() { } }, [isForms, formSheetsQuery.data, formSheet]) - // All channel: one page from each source, newest created_at first, then merged. + // All channel: merge email + sheet pools, newest received first, then page. const mergedRows = useMemo(() => { if (!isAllChannel) return null const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : [] const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : [] - const when = (row) => { - const d = row.createdAt || row.received - const t = d instanceof Date ? d.getTime() : NaN - return Number.isFinite(t) ? t : 0 - } - return [...emails, ...forms].sort((a, b) => when(b) - when(a)) - }, [isAllChannel, applicationsQuery.data, formQuery.data]) + const sorted = [...emails, ...forms].sort((a, b) => rowTimeMs(b) - rowTimeMs(a)) + if (pageSize === 'all') return sorted + return sorted.slice(skip, skip + pageSize) + }, [isAllChannel, applicationsQuery.data, formQuery.data, skip, pageSize]) const activeQuery = isAllChannel ? { @@ -1470,9 +1482,9 @@ export default function Inbox() { setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize)) }, [total, pageSize, skip, showAll]) - // Server already applied skip/limit (or the whole tab when Show all). + // Email / Forms: server already applied skip/limit. All: client slice after merge. const list = inbox - const to = showAll ? total : Math.min(skip + (isAllChannel ? list.length : pageSize), total) + const to = showAll ? total : Math.min(skip + list.length, total) // Mixed rows: the row's own kind picks the detail endpoint, not the channel. const selectedKind = inbox.find((i) => sameInboxId(i.id, selectedId))?.kind diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index 00a8084..5137dd5 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -322,7 +322,7 @@ export default function Interviews() { )} diff --git a/frontend/src/screens/JobBoard.jsx b/frontend/src/screens/JobBoard.jsx index 51a835e..d51518e 100644 --- a/frontend/src/screens/JobBoard.jsx +++ b/frontend/src/screens/JobBoard.jsx @@ -371,7 +371,7 @@ export default function JobBoard() { )} {!postsQuery.isPending && !postsQuery.isError && ( - + )} diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 8bc066e..27f5ea2 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -379,7 +379,7 @@ export default function Jobs() { setViewing(j)} /> diff --git a/frontend/src/screens/Offers.jsx b/frontend/src/screens/Offers.jsx index cda2328..b05af5b 100644 --- a/frontend/src/screens/Offers.jsx +++ b/frontend/src/screens/Offers.jsx @@ -291,7 +291,7 @@ export default function Offers() { )} {!offersQuery.isPending && !offersQuery.isError && ( - + )} diff --git a/frontend/src/screens/Reports.jsx b/frontend/src/screens/Reports.jsx index 82938f6..0f51bf5 100644 --- a/frontend/src/screens/Reports.jsx +++ b/frontend/src/screens/Reports.jsx @@ -515,7 +515,7 @@ export default function Reports() { ) : ( - + )} @@ -595,7 +595,7 @@ export default function Reports() { ) : ( - + )} @@ -631,7 +631,7 @@ export default function Reports() { ) : ( - ({ id: r.type, ...r }))} pageSize={10} /> + ({ id: r.type, ...r }))} pageSize={50} /> )} @@ -664,7 +664,7 @@ export default function Reports() { ({ ...r, id: r.id ?? r.source }))} - pageSize={10} + pageSize={50} /> )} @@ -722,7 +722,7 @@ export default function Reports() { : String(row[c.key])), }))} rows={runResult.rows.map((row, i) => ({ id: i, ...row }))} - pageSize={10} + pageSize={50} /> ) : ( diff --git a/frontend/src/screens/Requisitions.jsx b/frontend/src/screens/Requisitions.jsx index b2ed6ec..4300761 100644 --- a/frontend/src/screens/Requisitions.jsx +++ b/frontend/src/screens/Requisitions.jsx @@ -246,7 +246,7 @@ export default function Requisitions() { - + )} diff --git a/frontend/src/ui/DataTable.jsx b/frontend/src/ui/DataTable.jsx index 7cbf90a..a64778d 100644 --- a/frontend/src/ui/DataTable.jsx +++ b/frontend/src/ui/DataTable.jsx @@ -7,7 +7,7 @@ useDataTable alone. The other six consumers use . Sort comparator and the ellipsis pager windowing are ported verbatim. - Page size defaults to 10 (the GET `top`/`limit` default) and is user-settable; + Page size defaults to 50 and is user-settable (10 / 50 / 100); screens that paginate on the server pass the same value as the query param. ============================================================ */ @@ -15,8 +15,8 @@ import { useEffect, useMemo, useState } from 'react' import Icon from './icons' import { EmptyState } from './primitives' -/** Matches the backend Query(10) default on list GET endpoints. */ -export const DEFAULT_PAGE_SIZE = 10 +/** Default Per page value on every listing. 10 remains in PAGE_SIZE_OPTIONS. */ +export const DEFAULT_PAGE_SIZE = 50 /** Fixed Per page choices — a dropdown, not a free-text box. */ export const PAGE_SIZE_OPTIONS = [10, 50, 100] -- 2.40.1