Compare commits

...

4 Commits

Author SHA1 Message Date
ahmed.mujtaba a04d75e482 Merge pull request 'SQS_BROKER' (#87) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 42s Details
Reviewed-on: #87
2026-09-09 15:20:52 +00:00
ahmed.mujtaba 4a33651082 Merge branch 'main' into SQS_BROKER 2026-09-09 15:19:25 +00:00
ahmed.mujtaba 5fdc6031aa listing corrections 2026-09-09 20:16:25 +05:00
ahmed.mujtaba c0e4a94d58 total applications removed 2026-09-09 19:42:01 +05:00
15 changed files with 272 additions and 142 deletions

View File

@ -14,9 +14,12 @@ router = APIRouter()
class OfferCreate(BaseModel): 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 job_post_id: str
candidate_user_id: str candidate_user_id: str | None = None
status: str | None = "draft" status: str | None = "draft"
base_salary: float | None = None base_salary: float | None = None
currency: str | None = None currency: str | None = None
@ -74,9 +77,9 @@ class OfferSent(BaseModel):
inbox_id: int | None = None inbox_id: int | None = None
manual_upload_candidate_id: str | None = None manual_upload_candidate_id: str | None = None
form_data_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 candidate_user_id: str | None = None
base_salary: float base_salary: float | None = None
currency: str | None = "USD" currency: str | None = "USD"
salary_period: str | None = "year" salary_period: str | None = "year"
signing_bonus: float | None = None signing_bonus: float | None = None

View File

@ -183,6 +183,20 @@ class Offers(SQLModel, table=True):
) )
return result.scalars().first() 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): class OfferStatusHistory(SQLModel, table=True):
__tablename__ = "offer_status_history" __tablename__ = "offer_status_history"

View File

@ -219,39 +219,75 @@ class Offer:
existing["name"]=item.get("name") existing["name"]=item.get("name")
async def create_offer(self,payload,current_user): 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) 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={ fields=_comp_fields(payload)
"inbox_id": int(payload["inbox_id"]), 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, "job_post_id": job_post_id,
"candidate_user_id": candidate_user_id, "candidate_user_id": candidate_user_id,
"created_by": created_by, "status": "draft",
"status": status, })
} if fields.get("equity_units") in (None,0):
for key in non_validation_values(): fields["equity_instrument"]=None
if key in payload and payload[key] is not None:
fields[key]=payload[key]
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) row=await Offers.insert_offer(self.session,fields)
history_data={ await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id, "offer_id": row.id,
"from_status": None, "from_status": None,
"to_status": status, "to_status": "draft",
"changed_by": created_by, "changed_by": created_by,
"actor_kind": "user", "actor_kind": "user",
"change_reason": payload.get("change_reason"), "change_reason": payload.get("change_reason"),
} })
await OfferStatusHistory.insert_history(self.session,history_data) return (await self._hydrate_offers([row]))[0]
return serialize_offer(row)
async def update_offer(self,offer_id,payload,current_user): async def update_offer(self,offer_id,payload,current_user):
row=await Offers.get_offer_by_id(self.session,offer_id) 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): async def send_offer(self,payload,current_user):
created_by=_user_id(current_user) created_by=_user_id(current_user)
payload=await self._payload_for_send(payload)
app=await self._resolve_application(payload) app=await self._resolve_application(payload)
await self._assert_offer_job(current_user,app["job_post_id"]) await self._assert_offer_job(current_user,app["job_post_id"])
candidate_user_id=app["candidate_user_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) 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")) 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") raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job")
fields=_comp_fields(payload) fields=_comp_fields(payload)
@ -341,8 +381,9 @@ class Offer:
"form_data_id": app.get("form_data_id"), "form_data_id": app.get("form_data_id"),
"job_post_id": job_post_id, "job_post_id": job_post_id,
"candidate_user_id": candidate_user_id, "candidate_user_id": candidate_user_id,
"status": "failed",
}) })
if not resend:
fields["status"]="draft"
if fields.get("equity_units") in (None,0): if fields.get("equity_units") in (None,0):
fields["equity_instrument"]=None fields["equity_instrument"]=None
@ -351,20 +392,23 @@ class Offer:
row=await Offers.get_offer_by_id(self.session,retry_id) row=await Offers.get_offer_by_id(self.session,retry_id)
if not row: if not row:
raise HTTPException(status_code=404,detail="Offer not found") raise HTTPException(status_code=404,detail="Offer not found")
if row.status in ("sent","negotiating"): if row.status in ("accepted","declined"):
raise HTTPException(status_code=409,detail="This offer has already been sent") raise HTTPException(status_code=409,detail="This offer is already closed")
row=await Offers.update_offer(self.session,retry_id,fields) row=await Offers.update_offer(self.session,retry_id,fields)
else: else:
failed=await Offers.get_failed_for_candidate_job(self.session,candidate_user_id,job_post_id) existing=await Offers.get_draft_or_failed_for_candidate_job(
if failed: self.session,candidate_user_id,job_post_id,
row=await Offers.update_offer(self.session,failed.id,fields) )
if existing:
row=await Offers.update_offer(self.session,existing.id,fields)
else: else:
fields["created_by"]=created_by fields["created_by"]=created_by
fields["status"]="draft"
row=await Offers.insert_offer(self.session,fields) row=await Offers.insert_offer(self.session,fields)
await OfferStatusHistory.insert_history(self.session,{ await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id, "offer_id": row.id,
"from_status": None, "from_status": None,
"to_status": "failed", "to_status": "draft",
"changed_by": created_by, "changed_by": created_by,
"actor_kind": "user", "actor_kind": "user",
"change_reason": payload.get("change_reason"), "change_reason": payload.get("change_reason"),
@ -383,6 +427,9 @@ class Offer:
raise RuntimeError("candidate has no email") raise RuntimeError("candidate has no email")
await send_offer_mail(email,subject,html) await send_offer_mail(email,subject,html)
except (httpx.HTTPError,RuntimeError) as e: 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) await self._notify_send_failed(created_by,name,job_title,row)
raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e
@ -416,6 +463,33 @@ class Offer:
) )
return (await self._hydrate_offers([updated]))[0] 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): async def _resolve_application(self,payload):
inbox_id=payload.get("inbox_id") inbox_id=payload.get("inbox_id")
manual_id=_as_uuid(payload.get("manual_upload_candidate_id")) manual_id=_as_uuid(payload.get("manual_upload_candidate_id"))

View File

@ -125,7 +125,7 @@ export function viewCvBankCv(id) {
* Needs candidates.view. `assigned` is tri-valued: omit for all, false for * 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. * 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', { return request('/candidate/matching/fetch', {
params: { search, top, skip, assigned }, 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', { return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId }, 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 * detail query refetches on every write in the modal. Fetched lazily when the
* History tab opens, paginated server-side. * 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 } }) return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
} }

View File

@ -8,7 +8,8 @@ import { toDate } from '../lib/format'
recruiter who can read offers still cannot issue one. recruiter who can read offers still cannot issue one.
List rows include candidate_name / created_by_name. Job title is still 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. */ /** `status` is a free-text column defaulting to "draft"; the vocabulary is decided here. */

View File

@ -64,7 +64,7 @@ export function candidateApplicationsOf(row) {
const self = syntheticCurrentApplication(row) const self = syntheticCurrentApplication(row)
if (self) items.push(self) 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 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 == null || value === '') return null
if (value instanceof Date) { if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.getTime() return Number.isNaN(value.getTime()) ? null : value
} }
const instant = toInstant(value) if (isUtcSource(item?.source)) {
if (instant) return instant.getTime() return toInstant(value) || toDate(value)
const wall = toDate(value) }
return wall ? wall.getTime() : null return toDate(value) || toInstant(value)
}
function appliedAtMs(item) {
const d = displayAppliedDate(item)
return d ? d.getTime() : null
} }
function currentRowIds(row) { function currentRowIds(row) {
@ -205,7 +216,8 @@ export function hrefForPreviousApplication(item) {
/** Full application list for profile / inbox / add-candidate. */ /** Full application list for profile / inbox / add-candidate. */
export function PreviousApplications({ row, title = 'Total applications' }) { export function PreviousApplications({ row, title = 'Total applications' }) {
const items = candidateApplicationsOf(row) 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 current = currentRowIds(row)
const heading = title === 'Total applications' ? `Total applications (${items.length})` : title const heading = title === 'Total applications' ? `Total applications (${items.length})` : title
return ( return (
@ -272,7 +284,7 @@ export function PreviousApplications({ row, title = 'Total applications' }) {
)} )}
<div className="cell-sub"> <div className="cell-sub">
{SOURCE_LABEL[item.source] || item.source || 'Application'} {SOURCE_LABEL[item.source] || item.source || 'Application'}
{item.applied_at ? ` · ${fmtDateTime(toInstant(item.applied_at) || item.applied_at)}` : ''} {item.applied_at ? ` · ${fmtDateTime(displayAppliedDate(item))}` : ''}
</div> </div>
</div> </div>
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge> <Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>

View File

@ -240,7 +240,7 @@ export default function Assessments() {
</select> </select>
</div> </div>
</div> </div>
<DataTable columns={columns} rows={rows} pageSize={8} empty="No assessments match these filters." /> <DataTable columns={columns} rows={rows} pageSize={50} empty="No assessments match these filters." />
</> </>
)} )}
</div> </div>

View File

@ -1,9 +1,9 @@
/* ============================================================ /* ============================================================
Recruitment Inbox application tabs over GET /inbox/all-applications. Recruitment Inbox application tabs over GET /inbox/all-applications.
Page size defaults to 10 (dropdown: 10 / 50 / 100). skip/offset is the Page size defaults to 50 (dropdown: 10 / 50 / 100). skip/offset is the
window start and is not recomputed when Per page changes: growing 10 to window start and is not recomputed when Per page changes: growing 50 to
50 on a window that started at row 11 requests skip=10, top=50 100 on a window that started at row 51 requests skip=50, top=100
(rows 1160). Clicking a (rows 51150). Clicking a
page number realigns skip = (page-1)*limit. Total comes from a count page number realigns skip = (page-1)*limit. Total comes from a count
endpoint called once when the page opens. 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 * qk.mailbox.all() the moment a run completes so refetching on every visit
* bought nothing and cost a full-width skeleton each time. * bought nothing and cost a full-width skeleton each time.
* *
* List fetches always send the UI page size (10 / 50 / 100), including All * List fetches send the UI page size (10 / 50 / 100). All channel is
* omitting limit used to dump the whole form_data table into the browser. * 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 * staleTime therefore covers a normal working stretch, and keepPreviousData
* means a tab switch, a page turn or a keystroke re-renders the rows already * 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 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. */ /** Same numeric gate as email `ats_score` / pipeline ScoreChip. */
function asAtsScore(value) { function asAtsScore(value) {
if (value == null || value === '') return null if (value == null || value === '') return null
@ -1227,12 +1243,12 @@ export default function Inbox() {
}, [deepOpen, deepKind, setSearchParams]) }, [deepOpen, deepKind, setSearchParams])
const isForms = channel === 'forms' 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 isAllChannel = channel === 'all'
const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS
const pageLimit = pageSize === 'all' ? undefined : pageSize 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 = [ const activeInboxFilters = [
inboxFilters.location, inboxFilters.location,
inboxFilters.source, inboxFilters.source,
@ -1244,14 +1260,13 @@ export default function Inbox() {
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({ const listParams = useMemo(() => ({
...tabFilter, ...tabFilter,
// Show-all omits top (no LIMIT). Otherwise send the pager size 10, 50, 100. top: fetchTop,
top: pageLimit, skip: fetchSkip,
skip: pageLimit == null ? 0 : skip,
...(search ? { search } : {}), ...(search ? { search } : {}),
...(city ? { city } : {}), ...(city ? { city } : {}),
...(source ? { source } : {}), ...(source ? { source } : {}),
...assignedParams, ...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, * 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(() => ({ const formParams = useMemo(() => ({
// All channel spans every sheet tab, not just the selected one. // All channel spans every sheet tab, not just the selected one.
sheet: isAllChannel ? undefined : (formSheet || undefined), sheet: isAllChannel ? undefined : (formSheet || undefined),
offset: pageLimit == null ? 0 : skip, offset: fetchSkip,
limit: pageLimit, limit: fetchTop,
...formTabFilter, ...formTabFilter,
...(search ? { search } : {}), ...(search ? { search } : {}),
...(city ? { city } : {}), ...(city ? { city } : {}),
...(source ? { source } : {}), ...(source ? { source } : {}),
...assignedParams, ...assignedParams,
...linkFilters, ...linkFilters,
}), [formSheet, skip, pageLimit, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters]) }), [formSheet, fetchSkip, fetchTop, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters])
const citiesQuery = useQuery({ const citiesQuery = useQuery({
queryKey: qk.mailbox.cities(), queryKey: qk.mailbox.cities(),
@ -1385,18 +1400,15 @@ export default function Inbox() {
} }
}, [isForms, formSheetsQuery.data, formSheet]) }, [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(() => { const mergedRows = useMemo(() => {
if (!isAllChannel) return null if (!isAllChannel) return null
const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : [] const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : []
const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : [] const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : []
const when = (row) => { const sorted = [...emails, ...forms].sort((a, b) => rowTimeMs(b) - rowTimeMs(a))
const d = row.createdAt || row.received if (pageSize === 'all') return sorted
const t = d instanceof Date ? d.getTime() : NaN return sorted.slice(skip, skip + pageSize)
return Number.isFinite(t) ? t : 0 }, [isAllChannel, applicationsQuery.data, formQuery.data, skip, pageSize])
}
return [...emails, ...forms].sort((a, b) => when(b) - when(a))
}, [isAllChannel, applicationsQuery.data, formQuery.data])
const activeQuery = isAllChannel const activeQuery = isAllChannel
? { ? {
@ -1470,9 +1482,9 @@ export default function Inbox() {
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize)) setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
}, [total, pageSize, skip, showAll]) }, [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 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. // Mixed rows: the row's own kind picks the detail endpoint, not the channel.
const selectedKind = inbox.find((i) => sameInboxId(i.id, selectedId))?.kind const selectedKind = inbox.find((i) => sameInboxId(i.id, selectedId))?.kind

View File

@ -322,7 +322,7 @@ export default function Interviews() {
<DataTable <DataTable
columns={columns} columns={columns}
rows={rows} rows={rows}
pageSize={8} pageSize={50}
empty="No interviews match these filters." empty="No interviews match these filters."
/> />
)} )}

View File

@ -371,7 +371,7 @@ export default function JobBoard() {
</div> </div>
)} )}
{!postsQuery.isPending && !postsQuery.isError && ( {!postsQuery.isPending && !postsQuery.isError && (
<DataTable columns={columns} rows={rows} pageSize={10} empty="No posts match these filters." /> <DataTable columns={columns} rows={rows} pageSize={50} empty="No posts match these filters." />
)} )}
</div> </div>
</div> </div>

View File

@ -379,7 +379,7 @@ export default function Jobs() {
<DataTable <DataTable
columns={columns} columns={columns}
rows={rows} rows={rows}
pageSize={8} pageSize={50}
empty="No requisitions match these filters." empty="No requisitions match these filters."
onRowClick={(j) => setViewing(j)} onRowClick={(j) => setViewing(j)}
/> />

View File

@ -1,14 +1,9 @@
/* ============================================================ /* ============================================================
Offers live on backend/offer/app.py. Offers live on backend/offer/app.py.
Read is GET /offers/fetch. Create Offer writes POST /offers/jobs/sent Create Offer writes POST /offers/create as a draft (no email). Send on the
(persist, email via Teams, then pipeline OFFER). Failed sends stay listed; list (and offer detail) writes POST /offers/jobs/sent: Teams mail, then
GET /offers/fetch?offer_id= prefills the form for retry. Drafts still use pipeline OFFER. Failed sends stay listed for retry from the row Send button.
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.
============================================================ */ ============================================================ */
import { useMemo, useState, useEffect, useRef } from 'react' import { useMemo, useState, useEffect, useRef } from 'react'
@ -144,13 +139,27 @@ export default function Offers() {
} }
}, [searchParams, setSearchParams]) }, [searchParams, setSearchParams])
const issue = useMutation({ const save = useMutation({
mutationFn: (offerId) => offersApi.issue(offerId), mutationFn: (body) => offersApi.create(body),
onSuccess: (_res, _id) => { onSuccess: () => {
invalidate() 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({ const setStatus = useMutation({
@ -171,20 +180,7 @@ export default function Offers() {
onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'),
}) })
const send = useMutation({ const busy = send.isPending || setStatus.isPending
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 columns = [ const columns = [
{ {
@ -225,17 +221,15 @@ export default function Offers() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (o) => ( render: (o) => (
<div className="row-actions"> <div className="row-actions">
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => ( <button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
o.status === 'failed' ? setCreating({ offerId: o.id }) : setViewing(o)
)}>
<Icon name="eye" /> <Icon name="eye" />
</button> </button>
<button <button
className="act-btn" className="act-btn"
data-tip={o.status === 'failed' ? 'Edit and resend' : o.status === 'draft' ? 'Send offer' : 'Resend'} data-tip={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'failed' ? 'Edit and resend offer' : o.status === 'draft' ? 'Send offer' : 'Resend offer'} aria-label={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend offer'}
disabled={busy || ['accepted', 'declined'].includes(o.status)} disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={() => (o.status === 'failed' ? setCreating({ offerId: o.id }) : issue.mutate(o.id))} onClick={() => send.mutate(sendPayloadFromOffer(o))}
> >
<Icon name="send" /> <Icon name="send" />
</button> </button>
@ -297,7 +291,7 @@ export default function Offers() {
</div> </div>
)} )}
{!offersQuery.isPending && !offersQuery.isError && ( {!offersQuery.isPending && !offersQuery.isError && (
<DataTable columns={columns} rows={rows} pageSize={8} empty="No offers match these filters." /> <DataTable columns={columns} rows={rows} pageSize={50} empty="No offers match these filters." />
)} )}
</div> </div>
@ -306,24 +300,24 @@ export default function Offers() {
offer={viewing} offer={viewing}
busy={busy} busy={busy}
onClose={() => setViewing(null)} onClose={() => setViewing(null)}
onIssue={() => issue.mutate(viewing.id)} onSend={() => send.mutate(sendPayloadFromOffer(viewing))}
onRetry={() => { setViewing(null); setCreating({ offerId: viewing.id }) }} onEdit={() => { setViewing(null); setCreating({ offerId: viewing.id }) }}
onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }} onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }}
/> />
)} )}
{creating && ( {creating && (
<CreateOffer <CreateOffer
offerId={creating.offerId || null} offerId={creating.offerId || null}
busy={send.isPending} busy={save.isPending}
onClose={() => setCreating(null)} onClose={() => setCreating(null)}
onSubmit={(body) => send.mutate(body)} onSubmit={(body) => save.mutate(body)}
/> />
)} )}
</div> </div>
) )
} }
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 /* 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 is a one-off and is shown separately rather than folded in, because adding
it would overstate year two. */ it would overstate year two. */
@ -346,19 +340,16 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) {
Mark {OFFER_STATUS_LABEL[s]} Mark {OFFER_STATUS_LABEL[s]}
</button> </button>
))} ))}
{o.status === 'failed' ? ( <button className="btn btn-secondary" disabled={busy} onClick={onEdit}>
<button className="btn btn-primary" disabled={busy} onClick={onRetry}> Edit
<Icon name="send" /> Edit and resend </button>
</button> <button
) : ( className="btn btn-primary"
<button disabled={busy || ['accepted', 'declined'].includes(o.status)}
className="btn btn-primary" onClick={onSend}
disabled={busy || ['accepted', 'declined'].includes(o.status)} >
onClick={() => { onIssue(); onClose() }} <Icon name="send" /> {o.status === 'draft' || o.status === 'failed' ? 'Send Offer' : 'Resend Offer'}
> </button>
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
</button>
)}
</> </>
} }
> >
@ -435,6 +426,28 @@ function applicationIdentity(c) {
return {} 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) { function localDateIso(date) {
if (!date) return null if (!date) return null
const d = new Date(`${date}T00:00`) const d = new Date(`${date}T00:00`)
@ -658,6 +671,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
...identity, ...identity,
job_post_id: selected.job_post_id, job_post_id: selected.job_post_id,
candidate_user_id: selected.user_id || undefined, candidate_user_id: selected.user_id || undefined,
status: 'draft',
base_salary: base, base_salary: base,
currency: form.currency, currency: form.currency,
salary_period: form.salaryPeriod, salary_period: form.salaryPeriod,
@ -676,14 +690,14 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
return ( return (
<Modal <Modal
title={offerId ? 'Resend Offer' : 'Create Offer'} title={offerId ? 'Edit Offer' : 'Create Offer'}
subtitle={offerId ? 'Edit compensation and send again' : 'Emails the candidate and moves them to Offer'} subtitle={offerId ? 'Update compensation and save — send from the list' : 'Saved as a draft — send from the Offers list after you review it'}
onClose={onClose} onClose={onClose}
footer={ footer={
<> <>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button> <button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={sendDisabled}> <button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
<Icon name="send" /> {busy ? 'Sending…' : 'Send Offer'} <Icon name="check" /> {busy ? 'Saving…' : 'Save Offer'}
</button> </button>
</> </>
} }
@ -781,7 +795,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}> <p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
Equity is stored as a unit count plus an instrument, so 20k RSU is entered as 20000 and RSU. 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.
</p> </p>
</form> </form>
</Modal> </Modal>

View File

@ -515,7 +515,7 @@ export default function Reports() {
</EmptyState> </EmptyState>
</div> </div>
) : ( ) : (
<DataTable columns={reportColumns} rows={reportsQuery.data} pageSize={8} /> <DataTable columns={reportColumns} rows={reportsQuery.data} pageSize={50} />
)} )}
</div> </div>
@ -595,7 +595,7 @@ export default function Reports() {
</EmptyState> </EmptyState>
</div> </div>
) : ( ) : (
<DataTable columns={deptColumns} rows={deptRows} pageSize={10} /> <DataTable columns={deptColumns} rows={deptRows} pageSize={50} />
)} )}
</div> </div>
@ -631,7 +631,7 @@ export default function Reports() {
</EmptyState> </EmptyState>
</div> </div>
) : ( ) : (
<DataTable columns={costColumns} rows={costTotals.map((r) => ({ id: r.type, ...r }))} pageSize={10} /> <DataTable columns={costColumns} rows={costTotals.map((r) => ({ id: r.type, ...r }))} pageSize={50} />
)} )}
</div> </div>
@ -664,7 +664,7 @@ export default function Reports() {
<DataTable <DataTable
columns={sourceColumns} columns={sourceColumns}
rows={(sourcesQuery.data ?? []).map((r) => ({ ...r, id: r.id ?? r.source }))} rows={(sourcesQuery.data ?? []).map((r) => ({ ...r, id: r.id ?? r.source }))}
pageSize={10} pageSize={50}
/> />
)} )}
</div> </div>
@ -722,7 +722,7 @@ export default function Reports() {
: String(row[c.key])), : String(row[c.key])),
}))} }))}
rows={runResult.rows.map((row, i) => ({ id: i, ...row }))} rows={runResult.rows.map((row, i) => ({ id: i, ...row }))}
pageSize={10} pageSize={50}
/> />
) : ( ) : (
<EmptyState icon="inbox" title="No rows in this window"> <EmptyState icon="inbox" title="No rows in this window">

View File

@ -246,7 +246,7 @@ export default function Requisitions() {
</select> </select>
</div> </div>
</div> </div>
<DataTable columns={columns} rows={rows} pageSize={8} empty="No requisitions match these filters." /> <DataTable columns={columns} rows={rows} pageSize={50} empty="No requisitions match these filters." />
</> </>
)} )}
</div> </div>

View File

@ -7,7 +7,7 @@
useDataTable alone. The other six consumers use <DataTable/>. useDataTable alone. The other six consumers use <DataTable/>.
Sort comparator and the ellipsis pager windowing are ported verbatim. 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. 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 Icon from './icons'
import { EmptyState } from './primitives' import { EmptyState } from './primitives'
/** Matches the backend Query(10) default on list GET endpoints. */ /** Default Per page value on every listing. 10 remains in PAGE_SIZE_OPTIONS. */
export const DEFAULT_PAGE_SIZE = 10 export const DEFAULT_PAGE_SIZE = 50
/** Fixed Per page choices — a dropdown, not a free-text box. */ /** Fixed Per page choices — a dropdown, not a free-text box. */
export const PAGE_SIZE_OPTIONS = [10, 50, 100] export const PAGE_SIZE_OPTIONS = [10, 50, 100]