Coorect_Order_by #35
|
|
@ -818,11 +818,9 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
async def get_inbox_messages(
|
||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None
|
||||
):
|
||||
# Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is
|
||||
# (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first
|
||||
# via created_at.
|
||||
|
||||
statement = cls._apply_filters(
|
||||
select(cls).order_by(cls.created_at.desc()),
|
||||
select(cls).order_by(cls.created_at.desc(),cls.id.desc()),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state,
|
||||
)
|
||||
|
|
@ -1213,22 +1211,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
|
||||
|
||||
class Inbox_Message_Triage(SQLModel, table=True):
|
||||
"""One intake verdict per upstream message id — the gate before inbox_messages.
|
||||
|
||||
Rows land here for BOTH outcomes. Rejections are the point: inbox_messages stays
|
||||
application-only, and a repeated /email/fetch never re-pays for the same
|
||||
classification. Acceptances are recorded too, so a round that classified and then
|
||||
failed to insert does not pay twice either.
|
||||
|
||||
Deliberately no message_body column: the body is what this feature keeps out of the
|
||||
database, and the override route re-reads the mail from upstream by message_id.
|
||||
message_subject is kept (capped in inbox_classifier.decorators.triage_fields)
|
||||
because a review screen without it is unusable — it is stored, never logged.
|
||||
|
||||
server_default is load-bearing on every NOT NULL column: alembic_setup runs with
|
||||
compare_server_default=True, so a model default without a matching server default
|
||||
autogenerates a drift revision on every boot.
|
||||
"""
|
||||
|
||||
__tablename__ = "inbox_message_triage"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
/* ============================================================
|
||||
Recruitment Inbox — application tabs over GET /inbox/all-applications.
|
||||
Page size defaults to 10. Pagination (skip/offset) is independent of the
|
||||
limit control except that page 2 uses the current limit: skip = (page-1)*limit.
|
||||
Total comes from a count endpoint called once when the page opens.
|
||||
Page size defaults to 10. skip/offset is the window start and is not
|
||||
recomputed when Per page changes: growing 10 to 30 on a window that
|
||||
started at row 11 requests skip=10, top=30 (rows 11–40). Clicking a
|
||||
page number realigns skip = (page-1)*limit. Total comes from a count
|
||||
endpoint called once when the page opens.
|
||||
============================================================ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -14,7 +16,7 @@ import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
|||
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
|
||||
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -690,7 +692,7 @@ export default function Inbox() {
|
|||
const [channel, setChannel] = useState('email')
|
||||
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
|
||||
const [tab, setTab] = useState('All Applications')
|
||||
const [page, setPage] = useState(1)
|
||||
const [skip, setSkip] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [q, setQ] = useState('')
|
||||
|
|
@ -705,17 +707,17 @@ export default function Inbox() {
|
|||
const listParams = useMemo(() => ({
|
||||
...tabFilter,
|
||||
top: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
skip,
|
||||
...(q.trim() ? { search: q.trim() } : {}),
|
||||
}), [tabFilter, page, pageSize, q])
|
||||
}), [tabFilter, skip, pageSize, q])
|
||||
|
||||
const formParams = useMemo(() => ({
|
||||
sheet: formSheet || undefined,
|
||||
offset: (page - 1) * pageSize,
|
||||
offset: skip,
|
||||
limit: pageSize,
|
||||
...formTabFilter,
|
||||
...(q.trim() ? { search: q.trim() } : {}),
|
||||
}), [formSheet, page, pageSize, q, formTabFilter])
|
||||
}), [formSheet, skip, pageSize, q, formTabFilter])
|
||||
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: qk.mailbox.applications(listParams),
|
||||
|
|
@ -814,7 +816,14 @@ export default function Inbox() {
|
|||
? (activeQuery.data?.total ?? 0)
|
||||
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
const from = total ? skip + 1 : 0
|
||||
const to = Math.min(skip + pageSize, total)
|
||||
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
|
||||
|
||||
useEffect(() => {
|
||||
if (total <= 0 || skip < total) return
|
||||
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
|
||||
}, [total, pageSize, skip])
|
||||
|
||||
const list = inbox
|
||||
|
||||
|
|
@ -848,7 +857,7 @@ export default function Inbox() {
|
|||
function switchChannel(next) {
|
||||
if (next === channel) return
|
||||
setChannel(next)
|
||||
setPage(1)
|
||||
setSkip(0)
|
||||
setSelectedId(null)
|
||||
setQ('')
|
||||
// Unread is email-only; leave it behind when opening Sheet Forms.
|
||||
|
|
@ -945,24 +954,6 @@ export default function Inbox() {
|
|||
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind })
|
||||
}
|
||||
|
||||
const sync = useMutation({
|
||||
mutationFn: () => inboxApi.syncMailbox(),
|
||||
onSuccess: async (res) => {
|
||||
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
// /email/fetch reports what the intake gate did with the page. The key is
|
||||
// additive and absent when the gate is disabled, so fall back to the old
|
||||
// message rather than rendering "undefined filtered out".
|
||||
const t = res?.triage
|
||||
toast(
|
||||
t
|
||||
? `Mailbox synced — ${t.ingested} imported, ${t.skipped} filtered out`
|
||||
: 'Mailbox synced',
|
||||
'success',
|
||||
)
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
|
|
@ -993,18 +984,6 @@ export default function Inbox() {
|
|||
{isForms && (
|
||||
<span className="integration-status"><span className="pulse" />Google Sheets · Form data</span>
|
||||
)}
|
||||
{!isForms && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={sync.isPending}
|
||||
onClick={() => {
|
||||
toast('Fetching from Outlook…', 'info')
|
||||
sync.mutate()
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||
<Icon name="upload" /> Upload CVs
|
||||
</button>
|
||||
|
|
@ -1017,7 +996,7 @@ export default function Inbox() {
|
|||
value={tab}
|
||||
onChange={(t) => {
|
||||
setTab(t)
|
||||
setPage(1)
|
||||
setSkip(0)
|
||||
setSelectedId(null)
|
||||
selection.clear()
|
||||
}}
|
||||
|
|
@ -1049,7 +1028,7 @@ export default function Inbox() {
|
|||
value={formSheet}
|
||||
onChange={(e) => {
|
||||
setFormSheet(e.target.value)
|
||||
setPage(1)
|
||||
setSkip(0)
|
||||
setSelectedId(null)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
|
|
@ -1064,7 +1043,7 @@ export default function Inbox() {
|
|||
<Icon name="search" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setPage(1) }}
|
||||
onChange={(e) => { setQ(e.target.value); setSkip(0) }}
|
||||
placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1132,18 +1111,17 @@ export default function Inbox() {
|
|||
</div>
|
||||
{activeQuery.isSuccess && total > 0 && (
|
||||
<Pagination
|
||||
from={total ? (currentPage - 1) * pageSize + 1 : 0}
|
||||
to={Math.min(currentPage * pageSize, total)}
|
||||
from={from}
|
||||
to={to}
|
||||
total={total}
|
||||
page={currentPage}
|
||||
pages={pages}
|
||||
setPage={(p) => { setPage(p); setSelectedId(null); selection.clear() }}
|
||||
setPage={(p) => { setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
|
||||
pageButtons={pageWindow(currentPage, pages)}
|
||||
pageSize={pageSize}
|
||||
pageSizeMax={PAGE_SIZE_MAX}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage((p) => pageAfterSizeChange(p, total, n))
|
||||
setSelectedId(null)
|
||||
selection.clear()
|
||||
}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue