Compare commits

...

2 Commits

Author SHA1 Message Date
ahmed.mujtaba ded1b9053d dashboiard sheet export
CI / checks (push) Failing after 1m55s Details
CI / checks (pull_request) Failing after 1m47s Details
2026-09-07 17:53:49 +05:00
ahmed.mujtaba 41f46c2c39 dashboard sheet added 2026-09-07 17:36:36 +05:00
6 changed files with 184 additions and 42 deletions

View File

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

View File

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

View File

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

View File

@ -24,30 +24,15 @@ const BRAND = {
const thin = { style: 'thin', color: { argb: BRAND.border } }
/**
* Build and download a styled .xlsx.
*
* @param {object} opts
* @param {string} opts.filename without extension
* @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox"
* @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026"
* @param {Array<{header: string, key: string, width?: number}>} opts.columns
* @param {Array<object>} opts.rows keyed by columns[].key; null/undefined print blank
*/
export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) {
const ExcelJS = (await import('exceljs')).default
const wb = new ExcelJS.Workbook()
wb.creator = 'TalentFlow ATS'
wb.created = new Date()
const ws = wb.addWorksheet(title.slice(0, 31) || 'Export', {
views: [{ state: 'frozen', ySplit: 3 }],
})
function sanitizeSheetName(name) {
const cleaned = String(name || 'Export').replace(/[:\\/?*[\]]/g, ' ').trim()
return cleaned.slice(0, 31) || 'Export'
}
function paintSheet(ws, { title, subtitle, columns, rows }) {
ws.columns = columns.map((c) => ({ key: c.key, width: c.width ?? 18 }))
const span = columns.length
const span = Math.max(1, columns.length)
// Row 1 — brand title band
const titleRow = ws.addRow([title])
ws.mergeCells(1, 1, 1, span)
titleRow.height = 30
@ -56,7 +41,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.green } }
titleCell.alignment = { vertical: 'middle', indent: 1 }
// Row 2 — meta line
const metaRow = ws.addRow([subtitle])
ws.mergeCells(2, 1, 2, span)
metaRow.height = 20
@ -65,7 +49,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
metaCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.mint } }
metaCell.alignment = { vertical: 'middle', indent: 1 }
// Row 3 — column headers
const headRow = ws.addRow(columns.map((c) => c.header))
headRow.height = 22
headRow.eachCell((cell) => {
@ -75,7 +58,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
cell.border = { bottom: { style: 'medium', color: { argb: BRAND.lime } } }
})
// Data — zebra rows with hairline borders
for (const [i, r] of rows.entries()) {
const row = ws.addRow(columns.map((c) => r[c.key] ?? ''))
row.eachCell({ includeEmpty: true }, (cell) => {
@ -87,7 +69,9 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
}
ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: span } }
}
async function downloadWorkbook(wb, filename) {
const buf = await wb.xlsx.writeBuffer()
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const a = document.createElement('a')
@ -98,3 +82,54 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
a.remove()
URL.revokeObjectURL(a.href)
}
/**
* Build and download a styled multi-sheet .xlsx.
*
* @param {object} opts
* @param {string} opts.filename without extension
* @param {Array<{name?: string, title: string, subtitle: string, columns: Array<{header: string, key: string, width?: number}>, rows: Array<object>}>} opts.sheets
*/
export async function exportStyledWorkbook({ filename, sheets }) {
const ExcelJS = (await import('exceljs')).default
const wb = new ExcelJS.Workbook()
wb.creator = 'TalentFlow ATS'
wb.created = new Date()
const used = new Set()
for (const sheet of sheets) {
if (!sheet?.columns?.length) continue
let name = sanitizeSheetName(sheet.name || sheet.title)
if (used.has(name)) {
const base = name.slice(0, 28)
let n = 2
while (used.has(`${base} ${n}`)) n += 1
name = `${base} ${n}`
}
used.add(name)
const ws = wb.addWorksheet(name, {
views: [{ state: 'frozen', ySplit: 3 }],
})
paintSheet(ws, sheet)
}
if (!used.size) throw new Error('Nothing to export')
await downloadWorkbook(wb, filename)
}
/**
* Build and download a styled .xlsx.
*
* @param {object} opts
* @param {string} opts.filename without extension
* @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox"
* @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026"
* @param {Array<{header: string, key: string, width?: number}>} opts.columns
* @param {Array<object>} opts.rows keyed by columns[].key; null/undefined print blank
*/
export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) {
return exportStyledWorkbook({
filename,
sheets: [{ name: title, title, subtitle, columns, rows }],
})
}

View File

@ -31,11 +31,14 @@ import Charts from '../lib/charts'
import ChartCard, { widgetError } from '../ui/ChartCard'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys'
import { exportStyledWorkbook } from '../lib/exportXlsx'
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
import { fmtWeekdayDate, fmtShort } from '../lib/format'
import { fmtWeekdayDate, fmtShort, fmtDate } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import { money } from '../data/seed'
import * as activityApi from '../api/activity'
import * as analyticsApi from '../api/analytics'
@ -102,6 +105,67 @@ function fmtWhen(iso) {
return fmtShort(iso) || '—'
}
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])
if (!Number.isFinite(n)) return kpis[key]
return round ? Math.round(n) : n
}
async function fetchRangeSnapshot(rangeKey, department) {
const span = rangeWindow(rangeKey)
const kpisRes = await analyticsApi.kpis({
...span,
department: department || undefined,
})
return {
key: rangeKey,
fromDate: span.fromDate,
toDate: span.toDate,
kpis: asObject(kpisRes?.data),
}
}
function buildPeriodSheets({ department, snapshots, exportedAt }) {
const deptLabel = department || 'All Departments'
const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10)
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 }),
})),
}
})
}
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
if (query.isPending) {
return (
@ -136,12 +200,14 @@ export default function Dashboard() {
function DashboardHome() {
const navigate = useNavigate()
const { toast } = useToast()
const { user } = useAuth()
const firstName = (user?.name || 'there').split(' ')[0]
const todayLabel = formatDashDate()
const [rangeKey, setRangeKey] = useState('month')
const [department, setDepartment] = useState('')
const [exporting, setExporting] = useState(false)
// rangeWindow returns fresh ISO strings on every call recompute only when
// the key changes, or every render would churn the query keys below.
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
@ -353,6 +419,30 @@ function DashboardHome() {
},
]
async function exportDashboard() {
if (exporting) return
setExporting(true)
try {
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: buildPeriodSheets({
department,
snapshots,
exportedAt: new Date(),
}),
})
toast('Dashboard exported to Excel', 'success')
} catch (err) {
toast(friendlyAuthError(err, 'Could not export the dashboard'), 'error')
} finally {
setExporting(false)
}
}
return (
<div className="page">
<PageHeader
@ -377,9 +467,14 @@ function DashboardHome() {
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
<Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export
</Link>
<button
type="button"
className="btn btn-secondary"
onClick={exportDashboard}
disabled={exporting}
>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button>
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job
</Link>

View File

@ -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 (
<div className="inbox-filters">
<button
@ -924,10 +932,10 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
onChange={(e) => onChange('location', e.target.value)}
>
<option value="">Any</option>
{filters.location && !cities.includes(filters.location) && (
{locationOk && !locations.includes(filters.location) && (
<option value={filters.location}>{filters.location}</option>
)}
{cities.map((name) => (
{locations.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
@ -1069,9 +1077,9 @@ export default function Inbox() {
}, [deepOpen, deepKind, setSearchParams])
const isForms = channel === 'forms'
// Combined channel: each source is fetched with the UI page size, then
// merged newest-first. Per-source pages are not a perfect global timeline,
// but omitting limit dumped the whole form_data table on open.
// 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
@ -1211,14 +1219,17 @@ export default function Inbox() {
}
}, [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(() => {
if (!isAllChannel) return null
const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : []
const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : []
return [...emails, ...forms].sort(
(a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0),
)
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 activeQuery = isAllChannel