dashboiard sheet export
parent
41f46c2c39
commit
ded1b9053d
|
|
@ -346,9 +346,10 @@ class FormData(SQLModel, table=True):
|
||||||
async def fetch_form_data(
|
async def fetch_form_data(
|
||||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||||
processing_state=None, is_duplicate=None, has_linkedin=None,
|
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||||
has_resume=None, city=None, no_suggestions=None, inbox_filter=None, offset=0, limit=None,
|
has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
|
||||||
|
offset=0, limit=None,
|
||||||
):
|
):
|
||||||
statement = select(cls).order_by(cls.sheet, cls.row_number)
|
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
for clause in cls._filters(
|
for clause in cls._filters(
|
||||||
sheet=sheet, search=search,
|
sheet=sheet, search=search,
|
||||||
processing_state=processing_state, is_duplicate=is_duplicate,
|
processing_state=processing_state, is_duplicate=is_duplicate,
|
||||||
|
|
@ -360,7 +361,6 @@ class FormData(SQLModel, table=True):
|
||||||
statement = statement.offset(offset)
|
statement = statement.offset(offset)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
statement = statement.limit(limit)
|
statement = statement.limit(limit)
|
||||||
statement = statement.order_by(cls.row_number)
|
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1078,9 +1078,8 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
async def get_inbox_messages(
|
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, city=None, light: bool=False
|
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, city=None, light: bool=False
|
||||||
):
|
):
|
||||||
|
|
||||||
statement = cls._apply_filters(
|
statement = cls._apply_filters(
|
||||||
select(cls).order_by(cls.message_received_time.desc(),cls.id.desc()),
|
select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
|
||||||
search, isread, application_status, assigned, is_duplicate,
|
search, isread, application_status, assigned, is_duplicate,
|
||||||
no_suggestions, processing_state, city,
|
no_suggestions, processing_state, city,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
||||||
"body": message.message_body,
|
"body": message.message_body,
|
||||||
"when": message.message_received_time,
|
"when": message.message_received_time,
|
||||||
"received": message.message_received_time,
|
"received": message.message_received_time,
|
||||||
|
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||||
"unread": not message.message_read,
|
"unread": not message.message_read,
|
||||||
"attachment": message.attachment,
|
"attachment": message.attachment,
|
||||||
"attachment_name": attachment_name,
|
"attachment_name": attachment_name,
|
||||||
|
|
@ -120,6 +121,7 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light:
|
||||||
"position": message.message_subject,
|
"position": message.message_subject,
|
||||||
"source": message.message_to,
|
"source": message.message_to,
|
||||||
"received": message.message_received_time,
|
"received": message.message_received_time,
|
||||||
|
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||||
"unread": not message.message_read,
|
"unread": not message.message_read,
|
||||||
"processing": processing,
|
"processing": processing,
|
||||||
"application_status": message.application_status,
|
"application_status": message.application_status,
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,22 @@ function fmtWhen(iso) {
|
||||||
return fmtShort(iso) || '—'
|
return fmtShort(iso) || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
function starvingCountOf(jobs) {
|
const PERIOD_SHEET_NAMES = {
|
||||||
return asList(jobs).filter((r) => r.requisition_status === 'open' && !r.count).length
|
week: 'Weekly',
|
||||||
|
month: 'Monthly',
|
||||||
|
quarter: 'Quarterly',
|
||||||
|
year: 'Yearly',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERIOD_METRICS = [
|
||||||
|
{ label: 'Open Jobs', key: 'open_jobs' },
|
||||||
|
{ label: 'Applications', key: 'total_candidates' },
|
||||||
|
{ label: 'Hires', key: 'hires' },
|
||||||
|
{ label: 'Offers Sent', key: 'offers_sent' },
|
||||||
|
{ label: 'Offers Accepted', key: 'offers_accepted' },
|
||||||
|
{ label: 'Cost per Hire', key: 'cost_per_hire', round: true },
|
||||||
|
]
|
||||||
|
|
||||||
function kpiValue(kpis, key, { round = false } = {}) {
|
function kpiValue(kpis, key, { round = false } = {}) {
|
||||||
if (!kpis || kpis[key] == null || kpis[key] === '') return ''
|
if (!kpis || kpis[key] == null || kpis[key] === '') return ''
|
||||||
const n = Number(kpis[key])
|
const n = Number(kpis[key])
|
||||||
|
|
@ -116,248 +128,42 @@ function kpiValue(kpis, key, { round = false } = {}) {
|
||||||
return round ? Math.round(n) : n
|
return round ? Math.round(n) : n
|
||||||
}
|
}
|
||||||
|
|
||||||
function kpiChange(kpis, key, priorKey, kind) {
|
|
||||||
if (!kpis) return ''
|
|
||||||
const text = kind === 'days'
|
|
||||||
? dayDelta(kpis[key], kpis[priorKey])
|
|
||||||
: pctDelta(kpis[key], kpis[priorKey])
|
|
||||||
return text || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function settle(promise) {
|
|
||||||
try {
|
|
||||||
return await promise
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const KPI_EXPORT_ROWS = [
|
|
||||||
{ label: 'Open Jobs', key: 'open_jobs', prior: 'open_jobs_prior', delta: 'pct' },
|
|
||||||
{ label: 'Applications', key: 'total_candidates', prior: 'total_candidates_prior', delta: 'pct' },
|
|
||||||
{ label: 'Hires', key: 'hires', prior: 'hires_prior', delta: 'pct' },
|
|
||||||
{ label: 'Offers Sent', key: 'offers_sent', prior: 'offers_sent_prior', delta: 'pct' },
|
|
||||||
{ label: 'Offers Accepted', key: 'offers_accepted', prior: 'offers_accepted_prior', delta: 'pct' },
|
|
||||||
{ label: 'Interviews Today', key: 'interviews_today' },
|
|
||||||
{ label: 'Interviews Upcoming', key: 'interviews_upcoming' },
|
|
||||||
{ label: 'Time to Hire (days)', key: 'time_to_hire', prior: 'time_to_hire_prior', delta: 'days', round: true },
|
|
||||||
{ label: 'Cost per Hire', key: 'cost_per_hire', prior: 'cost_per_hire_prior', delta: 'pct', round: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function fetchRangeSnapshot(rangeKey, department) {
|
async function fetchRangeSnapshot(rangeKey, department) {
|
||||||
const span = rangeWindow(rangeKey)
|
const span = rangeWindow(rangeKey)
|
||||||
const filters = { ...span, department: department || undefined }
|
const kpisRes = await analyticsApi.kpis({
|
||||||
const [kpisRes, jobsRes, funnelRes] = await Promise.all([
|
...span,
|
||||||
analyticsApi.kpis(filters),
|
department: department || undefined,
|
||||||
analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }),
|
})
|
||||||
analyticsApi.funnel(filters),
|
|
||||||
])
|
|
||||||
return {
|
return {
|
||||||
key: rangeKey,
|
key: rangeKey,
|
||||||
label: rangeLabel(rangeKey),
|
|
||||||
fromDate: span.fromDate,
|
fromDate: span.fromDate,
|
||||||
toDate: span.toDate,
|
toDate: span.toDate,
|
||||||
kpis: asObject(kpisRes?.data),
|
kpis: asObject(kpisRes?.data),
|
||||||
jobs: asList(jobsRes?.data),
|
|
||||||
funnel: asList(funnelRes?.data),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDashboardSheets({
|
function buildPeriodSheets({ department, snapshots, exportedAt }) {
|
||||||
department,
|
|
||||||
snapshots,
|
|
||||||
offers,
|
|
||||||
inboxCounts,
|
|
||||||
activity,
|
|
||||||
trend,
|
|
||||||
exportedAt,
|
|
||||||
}) {
|
|
||||||
const deptLabel = department || 'All Departments'
|
const deptLabel = department || 'All Departments'
|
||||||
const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10)
|
const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10)
|
||||||
const windowNote = `Week / Month / Quarter / Year · ${deptLabel} · exported ${stamp}`
|
const columns = [
|
||||||
const periodCols = RANGES.flatMap((r) => ([
|
{ header: 'Metric', key: 'metric', width: 22 },
|
||||||
{ header: r.label, key: r.key, width: 12 },
|
{ header: 'Value', key: 'value', width: 16 },
|
||||||
{ header: `${r.label} vs prior`, key: `${r.key}_delta`, width: 16 },
|
]
|
||||||
]))
|
return snapshots.map((s) => {
|
||||||
|
const name = PERIOD_SHEET_NAMES[s.key] || s.key
|
||||||
const overview = {
|
const from = fmtDate(s.fromDate) || s.fromDate
|
||||||
name: 'Overview',
|
const to = fmtDate(s.toDate) || s.toDate
|
||||||
title: 'Dashboard Export',
|
return {
|
||||||
subtitle: windowNote,
|
name,
|
||||||
columns: [
|
title: name,
|
||||||
{ header: 'Field', key: 'field', width: 28 },
|
subtitle: `${from} – ${to} · ${deptLabel} · exported ${stamp}`,
|
||||||
{ header: 'Value', key: 'value', width: 48 },
|
columns,
|
||||||
],
|
rows: PERIOD_METRICS.map((spec) => ({
|
||||||
rows: [
|
metric: spec.label,
|
||||||
{ field: 'Exported at', value: stamp },
|
value: kpiValue(s.kpis, spec.key, { round: spec.round }),
|
||||||
{ field: 'Department', value: deptLabel },
|
|
||||||
...snapshots.map((s) => ({
|
|
||||||
field: `${s.label} window`,
|
|
||||||
value: `${fmtDate(s.fromDate) || s.fromDate} – ${fmtDate(s.toDate) || s.toDate}`,
|
|
||||||
})),
|
})),
|
||||||
],
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const kpis = {
|
|
||||||
name: 'KPIs',
|
|
||||||
title: 'Dashboard KPIs',
|
|
||||||
subtitle: `Headline tiles across every window · ${windowNote}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Metric', key: 'metric', width: 28 },
|
|
||||||
...periodCols,
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
...KPI_EXPORT_ROWS.map((spec) => {
|
|
||||||
const row = { metric: spec.label }
|
|
||||||
for (const s of snapshots) {
|
|
||||||
row[s.key] = kpiValue(s.kpis, spec.key, { round: spec.round })
|
|
||||||
row[`${s.key}_delta`] = spec.prior
|
|
||||||
? kpiChange(s.kpis, spec.key, spec.prior, spec.delta)
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
return row
|
|
||||||
}),
|
|
||||||
(() => {
|
|
||||||
const row = { metric: 'Open jobs with no applications' }
|
|
||||||
for (const s of snapshots) {
|
|
||||||
row[s.key] = starvingCountOf(s.jobs)
|
|
||||||
row[`${s.key}_delta`] = ''
|
|
||||||
}
|
|
||||||
return row
|
|
||||||
})(),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobRows = snapshots.flatMap((s) => (
|
|
||||||
asList(s.jobs).map((j) => ({
|
|
||||||
period: s.label,
|
|
||||||
title: j.title || '',
|
|
||||||
department: j.department || '',
|
|
||||||
status: jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status || '',
|
|
||||||
vacancies: j.vacancies ?? '',
|
|
||||||
applications: j.count ?? 0,
|
|
||||||
}))
|
|
||||||
))
|
|
||||||
|
|
||||||
const jobs = {
|
|
||||||
name: 'Applications per Job',
|
|
||||||
title: 'Applications per Job',
|
|
||||||
subtitle: `Same source as the dashboard hero · ${windowNote}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Period', key: 'period', width: 12 },
|
|
||||||
{ header: 'Job Title', key: 'title', width: 36 },
|
|
||||||
{ header: 'Department', key: 'department', width: 22 },
|
|
||||||
{ header: 'Status', key: 'status', width: 14 },
|
|
||||||
{ header: 'Vacancies', key: 'vacancies', width: 12 },
|
|
||||||
{ header: 'Applications', key: 'applications', width: 14 },
|
|
||||||
],
|
|
||||||
rows: jobRows,
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipelineRows = snapshots.flatMap((s) => {
|
|
||||||
const rows = analyticsApi.toBoardStageRows(s.funnel, { includeRejected: true })
|
|
||||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
|
||||||
return rows.map((r) => ({
|
|
||||||
period: s.label,
|
|
||||||
stage: r.stage,
|
|
||||||
count: r.count,
|
|
||||||
share: total ? `${Math.round((r.count / total) * 100)}%` : '0%',
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const pipeline = {
|
|
||||||
name: 'Pipeline',
|
|
||||||
title: 'Candidate Pipeline',
|
|
||||||
subtitle: `Active by stage · ${windowNote}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Period', key: 'period', width: 12 },
|
|
||||||
{ header: 'Stage', key: 'stage', width: 16 },
|
|
||||||
{ header: 'Count', key: 'count', width: 12 },
|
|
||||||
{ header: 'Share', key: 'share', width: 12 },
|
|
||||||
],
|
|
||||||
rows: pipelineRows,
|
|
||||||
}
|
|
||||||
|
|
||||||
const offerCounts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0]))
|
|
||||||
for (const o of asList(offers)) {
|
|
||||||
if (o.status in offerCounts) offerCounts[o.status] += 1
|
|
||||||
}
|
|
||||||
const offerBook = {
|
|
||||||
name: 'Offer Book',
|
|
||||||
title: 'Offer Book',
|
|
||||||
subtitle: `All offers by status · point-in-time · exported ${stamp}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Status', key: 'status', width: 18 },
|
|
||||||
{ header: 'Count', key: 'count', width: 12 },
|
|
||||||
],
|
|
||||||
rows: offers == null
|
|
||||||
? [{ status: 'Offers not visible', count: '' }]
|
|
||||||
: offersApi.OFFER_STATUSES.map((s) => ({
|
|
||||||
status: offersApi.OFFER_STATUS_LABEL[s],
|
|
||||||
count: offerCounts[s],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts = inboxCounts ?? {}
|
|
||||||
const attention = {
|
|
||||||
name: 'Needs Attention',
|
|
||||||
title: 'Needs Attention',
|
|
||||||
subtitle: `Work queued right now · exported ${stamp}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Item', key: 'item', width: 36 },
|
|
||||||
{ header: 'Count', key: 'count', width: 12 },
|
|
||||||
{ header: 'Scope', key: 'scope', width: 22 },
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
{ item: 'Unread applications', count: counts.unread ?? '', scope: 'Now' },
|
|
||||||
{ item: 'Not assigned to a job', count: counts.unassigned ?? '', scope: 'Now' },
|
|
||||||
{ item: 'Flagged duplicates', count: counts.duplicates ?? '', scope: 'Now' },
|
|
||||||
...snapshots.map((s) => ({
|
|
||||||
item: 'Open jobs with no applications',
|
|
||||||
count: starvingCountOf(s.jobs),
|
|
||||||
scope: s.label,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const activitySheet = {
|
|
||||||
name: 'Recent Activity',
|
|
||||||
title: 'Recent Activity',
|
|
||||||
subtitle: `Latest across the org · exported ${stamp}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'When', key: 'when', width: 18 },
|
|
||||||
{ header: 'Type', key: 'type', width: 22 },
|
|
||||||
{ header: 'Actor', key: 'actor', width: 22 },
|
|
||||||
{ header: 'Status', key: 'status', width: 16 },
|
|
||||||
{ header: 'Description', key: 'description', width: 48 },
|
|
||||||
],
|
|
||||||
rows: asList(activity).map((a) => ({
|
|
||||||
when: fmtWhen(a.activity_date),
|
|
||||||
type: a.activity_type || 'Activity',
|
|
||||||
actor: a.actor_name || '',
|
|
||||||
status: a.activity_status || '',
|
|
||||||
description: a.description || '',
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = asList(trend?.labels)
|
|
||||||
const trendSheet = {
|
|
||||||
name: 'Hiring Trend',
|
|
||||||
title: 'Hiring Trend',
|
|
||||||
subtitle: `Last ${TREND_MONTHS} months · sparkline source · exported ${stamp}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Month', key: 'month', width: 16 },
|
|
||||||
{ header: 'Applications', key: 'applications', width: 14 },
|
|
||||||
{ header: 'Hires', key: 'hires', width: 12 },
|
|
||||||
],
|
|
||||||
rows: labels.map((month, i) => ({
|
|
||||||
month,
|
|
||||||
applications: asList(trend?.applications)[i] ?? 0,
|
|
||||||
hires: asList(trend?.hires)[i] ?? 0,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
return [overview, kpis, jobs, pipeline, offerBook, attention, activitySheet, trendSheet]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
||||||
|
|
@ -617,23 +423,15 @@ function DashboardHome() {
|
||||||
if (exporting) return
|
if (exporting) return
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
try {
|
try {
|
||||||
const [snapshots, offersRes, inboxCounts, activityRes, trendRes] = await Promise.all([
|
const snapshots = await Promise.all(
|
||||||
Promise.all(RANGES.map((r) => fetchRangeSnapshot(r.key, department))),
|
RANGES.map((r) => fetchRangeSnapshot(r.key, department)),
|
||||||
settle(offersApi.list({ top: 500 })),
|
)
|
||||||
settle(inboxApi.fetchCounts()),
|
|
||||||
settle(activityApi.feed({ top: 8 })),
|
|
||||||
settle(analyticsApi.hiringTrend({ months: TREND_MONTHS })),
|
|
||||||
])
|
|
||||||
const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all'
|
const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all'
|
||||||
await exportStyledWorkbook({
|
await exportStyledWorkbook({
|
||||||
filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`,
|
filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`,
|
||||||
sheets: buildDashboardSheets({
|
sheets: buildPeriodSheets({
|
||||||
department,
|
department,
|
||||||
snapshots,
|
snapshots,
|
||||||
offers: offersRes == null ? null : asList(offersRes?.data),
|
|
||||||
inboxCounts,
|
|
||||||
activity: asList(activityRes?.data),
|
|
||||||
trend: asObject(trendRes?.data) || { labels: [], applications: [], hires: [] },
|
|
||||||
exportedAt: new Date(),
|
exportedAt: new Date(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@ function mapFormRow(row) {
|
||||||
position: row.position_applied_for || '—',
|
position: row.position_applied_for || '—',
|
||||||
...FORM_LIST_SOURCE,
|
...FORM_LIST_SOURCE,
|
||||||
received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)),
|
received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)),
|
||||||
|
createdAt: parseGraphDate(row.created_at),
|
||||||
screenedBy: row.screened_by || '',
|
screenedBy: row.screened_by || '',
|
||||||
hrComments: row.hr_comments || '',
|
hrComments: row.hr_comments || '',
|
||||||
gender: row.gender || '',
|
gender: row.gender || '',
|
||||||
|
|
@ -464,6 +465,7 @@ async function fetchMessageDetail(recordId) {
|
||||||
position: row.subject || '(no subject)',
|
position: row.subject || '(no subject)',
|
||||||
...sourceFrom(row.message_to),
|
...sourceFrom(row.message_to),
|
||||||
received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time),
|
received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time),
|
||||||
|
createdAt: parseGraphDate(row.created_at),
|
||||||
unread: Boolean(row.unread),
|
unread: Boolean(row.unread),
|
||||||
processing: row.unread ? 'Unread' : 'Read',
|
processing: row.unread ? 'Unread' : 'Read',
|
||||||
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
||||||
|
|
@ -521,6 +523,7 @@ async function fetchApplications(params) {
|
||||||
position: row.position || '(no subject)',
|
position: row.position || '(no subject)',
|
||||||
...sourceFrom(row.source),
|
...sourceFrom(row.source),
|
||||||
received: parseGraphDate(row.received),
|
received: parseGraphDate(row.received),
|
||||||
|
createdAt: parseGraphDate(row.created_at),
|
||||||
unread: Boolean(row.unread),
|
unread: Boolean(row.unread),
|
||||||
processing: row.processing || 'Unread',
|
processing: row.processing || 'Unread',
|
||||||
processingState: row.processing_state || null,
|
processingState: row.processing_state || null,
|
||||||
|
|
@ -898,7 +901,12 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
|
||||||
* Both files are mid-edit elsewhere, so this is deliberately a local copy rather
|
* Both files are mid-edit elsewhere, so this is deliberately a local copy rather
|
||||||
* than a refactor of theirs.)
|
* than a refactor of theirs.)
|
||||||
*/
|
*/
|
||||||
|
/** Junk location tokens — hide from the dropdown, not from the list query. */
|
||||||
|
const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i
|
||||||
|
|
||||||
function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) {
|
function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) {
|
||||||
|
const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim()))
|
||||||
|
const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim())
|
||||||
return (
|
return (
|
||||||
<div className="inbox-filters">
|
<div className="inbox-filters">
|
||||||
<button
|
<button
|
||||||
|
|
@ -924,10 +932,10 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
|
||||||
onChange={(e) => onChange('location', e.target.value)}
|
onChange={(e) => onChange('location', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Any</option>
|
<option value="">Any</option>
|
||||||
{filters.location && !cities.includes(filters.location) && (
|
{locationOk && !locations.includes(filters.location) && (
|
||||||
<option value={filters.location}>{filters.location}</option>
|
<option value={filters.location}>{filters.location}</option>
|
||||||
)}
|
)}
|
||||||
{cities.map((name) => (
|
{locations.map((name) => (
|
||||||
<option key={name} value={name}>{name}</option>
|
<option key={name} value={name}>{name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -1069,9 +1077,9 @@ export default function Inbox() {
|
||||||
}, [deepOpen, deepKind, setSearchParams])
|
}, [deepOpen, deepKind, setSearchParams])
|
||||||
|
|
||||||
const isForms = channel === 'forms'
|
const isForms = channel === 'forms'
|
||||||
// Combined channel: each source is fetched with the UI page size, then
|
// Combined channel: email and form lists both arrive newest created_at
|
||||||
// merged newest-first. Per-source pages are not a perfect global timeline,
|
// first; the merge uses that same clock so a June form cannot sit above a
|
||||||
// but omitting limit dumped the whole form_data table on open.
|
// 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
|
||||||
|
|
@ -1211,14 +1219,17 @@ export default function Inbox() {
|
||||||
}
|
}
|
||||||
}, [isForms, formSheetsQuery.data, formSheet])
|
}, [isForms, formSheetsQuery.data, formSheet])
|
||||||
|
|
||||||
// All channel: one page from each source, merged newest-first.
|
// All channel: one page from each source, newest created_at first, then merged.
|
||||||
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 : []
|
||||||
return [...emails, ...forms].sort(
|
const when = (row) => {
|
||||||
(a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0),
|
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])
|
}, [isAllChannel, applicationsQuery.data, formQuery.data])
|
||||||
|
|
||||||
const activeQuery = isAllChannel
|
const activeQuery = isAllChannel
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue