Merge pull request 'SQS_BROKER' (#87) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 42s
Details
Deploy to S3 / deploy (push) Successful in 42s
Details
Reviewed-on: #87INTERGRATE_UI-
commit
a04d75e482
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ export default function Interviews() {
|
|||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
pageSize={8}
|
||||
pageSize={50}
|
||||
empty="No interviews match these filters."
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 className="btn btn-secondary" disabled={busy} onClick={onEdit}>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||||
onClick={() => { onIssue(); onClose() }}
|
||||
onClick={onSend}
|
||||
>
|
||||
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue