diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index d367d1d..155e376 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -154,9 +154,15 @@ async def fetch_sheet_import( raise HTTPException(status_code=500,detail=str(e)) +# Form-data reads are shared by Settings (import UI) and Inbox (form applicants). +_FORM_DATA_READ = require_permission( + PermissionTag.INBOX_VIEW, PermissionTag.SETTINGS_VIEW, require_all=False, +) + + @router.get("/sheet/form-data/sheets") async def fetch_form_data_sheets( - current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)), + current_user: dict = Depends(_FORM_DATA_READ), session: AsyncSession = Depends(get_session), ): try: @@ -175,7 +181,7 @@ async def fetch_form_data( search: str | None = Query(None), offset: int = Query(0,ge=0), limit: int | None = Query(None,ge=1), - current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)), + current_user: dict = Depends(_FORM_DATA_READ), session: AsyncSession = Depends(get_session), ): try: @@ -186,13 +192,12 @@ async def fetch_form_data( raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) - -# @router.get + @router.get("/sheet/form-data/{record_id}") async def fetch_form_data_by_id( - record_id: int, - current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)), + record_id: str, + current_user: dict = Depends(_FORM_DATA_READ), session: AsyncSession = Depends(get_session), ): try: diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js new file mode 100644 index 0000000..44b7ea7 --- /dev/null +++ b/frontend/src/api/sheet.js @@ -0,0 +1,29 @@ +import { request } from '../lib/apiClient' + +/** + * Google Sheet form-data mirror (backend/g_sheet/). + * + * Read endpoints accept inbox.view OR settings.view. Import / write / delete stay + * under settings.view on the server — this module only covers what Inbox needs. + */ + +/** Distinct sheet tab names already imported into form_data. */ +export function listFormDataSheets() { + return request('/sheet/form-data/sheets') +} + +/** + * Paginated form_data rows. + * + * `offset` / `limit` map 1:1 to the backend Query params (not skip/top). + */ +export function listFormData({ sheet, search, offset = 0, limit } = {}) { + return request('/sheet/form-data/fetch', { + params: { sheet, search, offset, limit }, + }) +} + +/** One form_data row by UUID. */ +export function getFormData(recordId) { + return request(`/sheet/form-data/${recordId}`) +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 1c750f2..003fbe0 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -28,6 +28,11 @@ export const qk = { // override or a sync needs no extra invalidation. triage: (p = {}) => ['mailbox', 'triage', p], sync: (id) => ['mailbox', 'sync', id], + // Sheet form applicants live under the same mailbox prefix so the Inbox + // channel toggle can invalidate both email and form caches together. + formSheets: () => ['mailbox', 'form-sheets'], + formData: (p = {}) => ['mailbox', 'form-data', p], + formRow: (id) => ['mailbox', 'form-row', id], }, assessments: { all: () => ['assessments'], diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index f634776..9f9dea3 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -19,6 +19,7 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' +import * as sheetApi from '../api/sheet' import { atsRecommendationClass, avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta, @@ -27,6 +28,15 @@ import { const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates'] const PAGE_SIZE = 10 +/** Inbox channel: Outlook email queue vs imported Google Form rows. */ +const CHANNELS = [ + { key: 'email', label: 'Email', icon: 'mail' }, + { key: 'forms', label: 'Sheet Forms', icon: 'layers' }, +] + +const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026' +const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' } + /** * Server-side filters for each tab. Processed / Rejected use * Candidate_application_Status (PROCESS / REJECTED), not processing_state. @@ -96,6 +106,86 @@ function sourceFrom(messageTo) { return { source: raw.split(',')[0].trim(), sourceMeta: null } } +/** + * Form `source_of_application` is a free-text label (LinkedIn, Indeed, …), not a + * To-address. Reuse the email source palette when the spelling matches; otherwise + * tag the row as a Sheet Forms entry so the chip still paints. + */ +function formSourceFrom(raw) { + const label = (raw || '').trim() + if (!label) return { source: 'Google Forms', sourceMeta: SHEET_SOURCE_META } + const flat = label.toLowerCase().replace(/[^a-z]/g, '') + const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) + if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } + return { source: label, sourceMeta: SHEET_SOURCE_META } +} + +/** entry_date is midnight UTC; entry_time is a separate "HH:MM" string from the sheet. */ +function formReceivedAt(entryDate, entryTime) { + const d = parseDate(entryDate) + if (!d) return null + const m = String(entryTime || '').match(/(\d{1,2}):(\d{2})/) + if (m) d.setHours(Number(m[1]), Number(m[2]), 0, 0) + return d +} + +/** + * GET /sheet/form-data/fetch row → the same list/detail shape the email channel + * uses for name / avatar / position / source / time, plus form-only profile fields. + */ +function mapFormRow(row) { + const name = (row.name || row.candidate_email || 'Unknown').trim() + return { + kind: 'form', + id: String(row.id), + name, + initials: initialsOf(name), + color: avatarColor(name), + email: row.candidate_email || '', + phone: row.candidate_number || '', + position: row.position_applied_for || '—', + ...formSourceFrom(row.source_of_application), + received: formReceivedAt(row.entry_date, row.entry_time), + screenedBy: row.screened_by || '', + hrComments: row.hr_comments || '', + gender: row.gender || '', + dateOfBirth: parseDate(row.date_of_birth), + cnic: row.cnic || '', + degree: row.degree || '', + university: row.university || '', + universityOther: row.university_other || '', + graduationYear: row.entry_year || '', + residingCity: row.residing_city || '', + residingCountry: row.residing_country || '', + maritalStatus: row.marital_status || '', + hoAvailability: row.ho_availability || '', + noticePeriod: row.notice_period || '', + currentSalary: row.current_salary || '', + expectedSalary: row.expected_salary || '', + profileLink: row.profile_link || '', + resumeLink: row.resume_link || '', + sheet: row.sheet || '', + rowNumber: row.row_number ?? null, + unread: false, + processing: row.screened_by ? 'Screened' : 'New', + } +} + +async function fetchFormApplications(params) { + const res = await sheetApi.listFormData(params) + const rows = Array.isArray(res?.data) ? res.data : [] + return { + rows: rows.map(mapFormRow), + total: Number(res?.total ?? rows.length) || 0, + } +} + +async function fetchFormDetail(recordId) { + const res = await sheetApi.getFormData(recordId) + const row = res?.data + return row ? mapFormRow(row) : null +} + function SourceChip({ item }) { // The dot carries the partner's brand colour; the label uses theme text — // 11px labels in the partner colour failed AA in both themes. @@ -550,6 +640,8 @@ export default function Inbox() { const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const updateInbox = useSeedMutation('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 [selectedId, setSelectedId] = useState(null) @@ -558,6 +650,8 @@ export default function Inbox() { const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) + const isForms = channel === 'forms' + const tabFilter = TAB_FILTERS[tab] ?? {} const listParams = useMemo(() => ({ ...tabFilter, @@ -566,18 +660,59 @@ export default function Inbox() { ...(q.trim() ? { search: q.trim() } : {}), }), [tabFilter, page, q]) + const formParams = useMemo(() => ({ + sheet: formSheet || undefined, + offset: (page - 1) * PAGE_SIZE, + limit: PAGE_SIZE, + ...(q.trim() ? { search: q.trim() } : {}), + }), [formSheet, page, q]) + const applicationsQuery = useQuery({ queryKey: qk.mailbox.applications(listParams), queryFn: () => fetchApplications(listParams), + enabled: !isForms, + }) + + const formSheetsQuery = useQuery({ + queryKey: qk.mailbox.formSheets(), + queryFn: async () => { + const res = await sheetApi.listFormDataSheets() + const sheets = res?.data?.sheets + return Array.isArray(sheets) ? sheets : [] + }, + enabled: isForms, + }) + + const formQuery = useQuery({ + queryKey: qk.mailbox.formData(formParams), + queryFn: () => fetchFormApplications(formParams), + enabled: isForms, }) const countsQuery = useQuery({ queryKey: qk.mailbox.counts(), queryFn: fetchInboxCounts, + enabled: !isForms, }) - const inbox = applicationsQuery.data?.rows ?? [] - const total = applicationsQuery.data?.total ?? 0 + // Prefer the imported sheet list; keep the known 2026 tab even when the + // sheets endpoint is still loading so the first paint is not blank. + const formSheetOptions = useMemo(() => { + const fromApi = formSheetsQuery.data ?? [] + if (fromApi.length) return fromApi + return formSheet ? [formSheet] : [DEFAULT_FORM_SHEET] + }, [formSheetsQuery.data, formSheet]) + + useEffect(() => { + if (!isForms || !formSheetsQuery.data?.length) return + if (!formSheetsQuery.data.includes(formSheet)) { + setFormSheet(formSheetsQuery.data[0]) + } + }, [isForms, formSheetsQuery.data, formSheet]) + + const activeQuery = isForms ? formQuery : applicationsQuery + const inbox = activeQuery.data?.rows ?? [] + const total = activeQuery.data?.total ?? 0 const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const currentPage = Math.min(page, pages) const serverCounts = countsQuery.data ?? {} @@ -596,8 +731,8 @@ export default function Inbox() { const list = inbox const detailQuery = useQuery({ - queryKey: qk.mailbox.message(selectedId), - queryFn: () => fetchMessageDetail(selectedId), + queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), + queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), enabled: Boolean(selectedId), }) @@ -622,6 +757,15 @@ export default function Inbox() { ? 'every application' : `the ${tab} tab` + function switchChannel(next) { + if (next === channel) return + setChannel(next) + setPage(1) + setSelectedId(null) + setQ('') + selection.clear() + } + function setReadSelected(read) { const ids = [...selection.selectedIds] if (!ids.length) return @@ -679,6 +823,7 @@ export default function Inbox() { function select(id) { setSelectedId(id) + if (isForms) return const item = inbox.find((i) => i.id === id) if (item?.unread) setRead.mutate({ ids: [id], read: true }) } @@ -722,20 +867,45 @@ export default function Inbox() {
Every candidate, every source — one unified queue
++ {isForms + ? 'Google Form applicants — same queue energy, profile-first cards' + : 'Every candidate, every source — one unified queue'} +
{i.hrComments}
+