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):
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

View File

@ -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"

View File

@ -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"))

View File

@ -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 } })
}

View File

@ -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. */

View File

@ -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) {
@ -205,7 +216,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 (
@ -272,7 +284,7 @@ export function PreviousApplications({ row, title = 'Total applications' }) {
)}
<div className="cell-sub">
{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>
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>

View File

@ -240,7 +240,7 @@ export default function Assessments() {
</select>
</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>

View File

@ -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 1160). 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 51150). 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

View File

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

View File

@ -371,7 +371,7 @@ export default function JobBoard() {
</div>
)}
{!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>

View File

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

View File

@ -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) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => (
o.status === 'failed' ? setCreating({ offerId: o.id }) : setViewing(o)
)}>
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
<Icon name="eye" />
</button>
<button
className="act-btn"
data-tip={o.status === 'failed' ? 'Edit and resend' : o.status === 'draft' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'failed' ? 'Edit and resend offer' : o.status === 'draft' ? 'Send offer' : 'Resend offer'}
data-tip={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend offer'}
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" />
</button>
@ -297,7 +291,7 @@ export default function Offers() {
</div>
)}
{!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>
@ -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 && (
<CreateOffer
offerId={creating.offerId || null}
busy={send.isPending}
busy={save.isPending}
onClose={() => setCreating(null)}
onSubmit={(body) => send.mutate(body)}
onSubmit={(body) => save.mutate(body)}
/>
)}
</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
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]}
</button>
))}
{o.status === 'failed' ? (
<button className="btn btn-primary" disabled={busy} onClick={onRetry}>
<Icon name="send" /> Edit and resend
</button>
) : (
<button
className="btn btn-primary"
disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={() => { onIssue(); onClose() }}
>
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
</button>
)}
<button className="btn btn-secondary" disabled={busy} onClick={onEdit}>
Edit
</button>
<button
className="btn btn-primary"
disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={onSend}
>
<Icon name="send" /> {o.status === 'draft' || o.status === 'failed' ? 'Send Offer' : 'Resend Offer'}
</button>
</>
}
>
@ -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 (
<Modal
title={offerId ? 'Resend Offer' : 'Create Offer'}
subtitle={offerId ? 'Edit compensation and send again' : 'Emails the candidate and moves them to Offer'}
title={offerId ? 'Edit Offer' : 'Create 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}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
<Icon name="send" /> {busy ? 'Sending…' : 'Send Offer'}
<Icon name="check" /> {busy ? 'Saving…' : 'Save Offer'}
</button>
</>
}
@ -781,7 +795,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
<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.
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>
</form>
</Modal>

View File

@ -515,7 +515,7 @@ export default function Reports() {
</EmptyState>
</div>
) : (
<DataTable columns={reportColumns} rows={reportsQuery.data} pageSize={8} />
<DataTable columns={reportColumns} rows={reportsQuery.data} pageSize={50} />
)}
</div>
@ -595,7 +595,7 @@ export default function Reports() {
</EmptyState>
</div>
) : (
<DataTable columns={deptColumns} rows={deptRows} pageSize={10} />
<DataTable columns={deptColumns} rows={deptRows} pageSize={50} />
)}
</div>
@ -631,7 +631,7 @@ export default function Reports() {
</EmptyState>
</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>
@ -664,7 +664,7 @@ export default function Reports() {
<DataTable
columns={sourceColumns}
rows={(sourcesQuery.data ?? []).map((r) => ({ ...r, id: r.id ?? r.source }))}
pageSize={10}
pageSize={50}
/>
)}
</div>
@ -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}
/>
) : (
<EmptyState icon="inbox" title="No rows in this window">

View File

@ -246,7 +246,7 @@ export default function Requisitions() {
</select>
</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>

View File

@ -7,7 +7,7 @@
useDataTable alone. The other six consumers use <DataTable/>.
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]