diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index b3d4451..01a407a 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -346,9 +346,10 @@ class FormData(SQLModel, table=True): async def fetch_form_data( cls, session: AsyncSession, *, sheet=None, search=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( sheet=sheet, search=search, processing_state=processing_state, is_duplicate=is_duplicate, @@ -360,7 +361,6 @@ class FormData(SQLModel, table=True): statement = statement.offset(offset) if limit is not None: statement = statement.limit(limit) - statement = statement.order_by(cls.row_number) result = await session.execute(statement) return result.scalars().all() diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 9ddf3e2..42d3d4f 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1078,9 +1078,8 @@ 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, city=None, light: bool=False ): - 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, no_suggestions, processing_state, city, ) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 1b46c42..e22b5c7 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -61,6 +61,7 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict: "body": message.message_body, "when": 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, "attachment": message.attachment, "attachment_name": attachment_name, @@ -120,6 +121,7 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light: "position": message.message_subject, "source": message.message_to, "received": message.message_received_time, + "created_at": message.created_at.isoformat() if message.created_at else None, "unread": not message.message_read, "processing": processing, "application_status": message.application_status, diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 8b0c1ff..8b74e19 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -105,10 +105,22 @@ function fmtWhen(iso) { return fmtShort(iso) || '—' } -function starvingCountOf(jobs) { - return asList(jobs).filter((r) => r.requisition_status === 'open' && !r.count).length +const PERIOD_SHEET_NAMES = { + 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 } = {}) { if (!kpis || kpis[key] == null || kpis[key] === '') return '' const n = Number(kpis[key]) @@ -116,248 +128,42 @@ function kpiValue(kpis, key, { round = false } = {}) { 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) { const span = rangeWindow(rangeKey) - const filters = { ...span, department: department || undefined } - const [kpisRes, jobsRes, funnelRes] = await Promise.all([ - analyticsApi.kpis(filters), - analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }), - analyticsApi.funnel(filters), - ]) + const kpisRes = await analyticsApi.kpis({ + ...span, + department: department || undefined, + }) return { key: rangeKey, - label: rangeLabel(rangeKey), fromDate: span.fromDate, toDate: span.toDate, kpis: asObject(kpisRes?.data), - jobs: asList(jobsRes?.data), - funnel: asList(funnelRes?.data), } } -function buildDashboardSheets({ - department, - snapshots, - offers, - inboxCounts, - activity, - trend, - exportedAt, -}) { +function buildPeriodSheets({ department, snapshots, exportedAt }) { const deptLabel = department || 'All Departments' const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10) - const windowNote = `Week / Month / Quarter / Year · ${deptLabel} · exported ${stamp}` - const periodCols = RANGES.flatMap((r) => ([ - { header: r.label, key: r.key, width: 12 }, - { header: `${r.label} vs prior`, key: `${r.key}_delta`, width: 16 }, - ])) - - const overview = { - name: 'Overview', - title: 'Dashboard Export', - subtitle: windowNote, - columns: [ - { header: 'Field', key: 'field', width: 28 }, - { header: 'Value', key: 'value', width: 48 }, - ], - rows: [ - { field: 'Exported at', value: stamp }, - { field: 'Department', value: deptLabel }, - ...snapshots.map((s) => ({ - field: `${s.label} window`, - value: `${fmtDate(s.fromDate) || s.fromDate} – ${fmtDate(s.toDate) || s.toDate}`, + const columns = [ + { header: 'Metric', key: 'metric', width: 22 }, + { header: 'Value', key: 'value', width: 16 }, + ] + return snapshots.map((s) => { + const name = PERIOD_SHEET_NAMES[s.key] || s.key + const from = fmtDate(s.fromDate) || s.fromDate + const to = fmtDate(s.toDate) || s.toDate + return { + name, + title: name, + subtitle: `${from} – ${to} · ${deptLabel} · exported ${stamp}`, + columns, + rows: PERIOD_METRICS.map((spec) => ({ + metric: spec.label, + value: kpiValue(s.kpis, spec.key, { round: spec.round }), })), - ], - } - - 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 }) { @@ -617,23 +423,15 @@ function DashboardHome() { if (exporting) return setExporting(true) try { - const [snapshots, offersRes, inboxCounts, activityRes, trendRes] = await Promise.all([ - Promise.all(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 snapshots = await Promise.all( + RANGES.map((r) => fetchRangeSnapshot(r.key, department)), + ) const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all' await exportStyledWorkbook({ filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`, - sheets: buildDashboardSheets({ + sheets: buildPeriodSheets({ department, snapshots, - offers: offersRes == null ? null : asList(offersRes?.data), - inboxCounts, - activity: asList(activityRes?.data), - trend: asObject(trendRes?.data) || { labels: [], applications: [], hires: [] }, exportedAt: new Date(), }), }) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 5e0e788..a6c91c6 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -315,6 +315,7 @@ function mapFormRow(row) { position: row.position_applied_for || '—', ...FORM_LIST_SOURCE, received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)), + createdAt: parseGraphDate(row.created_at), screenedBy: row.screened_by || '', hrComments: row.hr_comments || '', gender: row.gender || '', @@ -464,6 +465,7 @@ async function fetchMessageDetail(recordId) { position: row.subject || '(no subject)', ...sourceFrom(row.message_to), received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time), + createdAt: parseGraphDate(row.created_at), unread: Boolean(row.unread), processing: row.unread ? 'Unread' : 'Read', resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending', @@ -521,6 +523,7 @@ async function fetchApplications(params) { position: row.position || '(no subject)', ...sourceFrom(row.source), received: parseGraphDate(row.received), + createdAt: parseGraphDate(row.created_at), unread: Boolean(row.unread), processing: row.processing || 'Unread', 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 * 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 }) { + const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim())) + const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim()) return (