From b10d02f325f10228684ecc38f9fb796603208fed Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 27 Aug 2026 17:59:21 +0500 Subject: [PATCH] . --- backend/g_sheet/app.py | 17 +- frontend/src/api/sheet.js | 29 +++ frontend/src/lib/queryKeys.js | 5 + frontend/src/screens/Inbox.jsx | 418 +++++++++++++++++++++++++++++---- frontend/src/styles/styles.css | 3 +- 5 files changed, 420 insertions(+), 52 deletions(-) create mode 100644 frontend/src/api/sheet.js 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() {

Recruitment 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'} +

- Microsoft Graph API · Connected - +
+ {CHANNELS.map((c) => ( + + ))} +
+ {!isForms && ( + Microsoft Graph API · Connected + )} + {isForms && ( + Google Sheets · Form data + )} + {!isForms && ( + + )} @@ -743,22 +913,24 @@ export default function Inbox() {
-
- { - setTab(t) - setPage(1) - setSelectedId(null) - selection.clear() - }} - tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))} - /> -
+ {!isForms && ( +
+ { + setTab(t) + setPage(1) + setSelectedId(null) + selection.clear() + }} + tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))} + /> +
+ )}
- {applicationsQuery.isSuccess && ( + {!isForms && applicationsQuery.isSuccess && ( )}
+ {isForms && ( +
+ + +
+ )}
{ setQ(e.target.value); setPage(1) }} - placeholder="Search applications…" + placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'} />
- {applicationsQuery.isPending && ( - Fetching applications from the server. - )} - {applicationsQuery.isError && ( - - {friendlyAuthError(applicationsQuery.error, 'Request failed')} + {activeQuery.isPending && ( + + {isForms ? 'Fetching form applicants from the sheet mirror.' : 'Fetching applications from the server.'} )} - {applicationsQuery.isSuccess && list.length === 0 ? ( - No applications in this view. + {activeQuery.isError && ( + + {friendlyAuthError(activeQuery.error, 'Request failed')} + + )} + {activeQuery.isSuccess && list.length === 0 ? ( + + {isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'} + ) : ( list.map((i) => (
select(i.id)} > - selection.toggle(i.id)} - label={`Select ${i.name}`} - /> + {!isForms && ( + selection.toggle(i.id)} + label={`Select ${i.name}`} + /> + )}
@@ -816,6 +1015,9 @@ export default function Inbox() { {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {i.applicationStatus} )} + {isForms && i.residingCity && ( + {i.residingCity} + )}
@@ -825,12 +1027,15 @@ export default function Inbox() { {i.atsScore != null && (
)} + {isForms && i.noticePeriod && ( +
{i.noticePeriod}
+ )}
)) )}
- {applicationsQuery.isSuccess && total > 0 && ( + {activeQuery.isSuccess && total > 0 && ( - Choose an item from the list to view details and take action. + {isForms + ? 'Pick a form applicant to see their profile, resume, and screening notes.' + : 'Choose an item from the list to view details and take action.'}
) : detailQuery.isError ? ( @@ -856,6 +1063,8 @@ export default function Inbox() { {friendlyAuthError(detailQuery.error, 'Request failed')}
+ ) : isForms ? ( + ) : ( +
+ +
+
{i.name}
+
{i.position}
+
+ {i.processing}{' '} + {i.hoAvailability && ( + + Relocate: {i.hoAvailability} + + )}{' '} + {loading && Loading details…} +
+
+ {i.rowNumber != null && ( +
+
Sheet row
+
{i.rowNumber}
+
+ )} +
+ + {(resumeHref || profileHref) && ( +
+ {resumeHref && ( + + Open resume + + )} + {profileHref && ( + + LinkedIn + + )} +
+ )} + +
Contact & application
+
+
Email
{orDash(i.email)}
+
Phone
{orDash(i.phone)}
+
Applied
{i.received ? fmtDate(i.received) : '—'}
+
Source
{orDash(i.source)}
+
Screened by
{orDash(i.screenedBy)}
+
Notice period
{orDash(i.noticePeriod)}
+
+ +
Profile
+
+
Gender
{orDash(i.gender)}
+
Date of birth
{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}
+
CNIC
{orDash(i.cnic)}
+
Marital status
{orDash(i.maritalStatus)}
+
Location
{orDash(location)}
+
Education
{orDash(education)}
+
Graduation
{orDash(i.graduationYear)}
+
Other university
{orDash(i.universityOther)}
+
Current salary
{orDash(i.currentSalary)}
+
Expected salary
{orDash(i.expectedSalary)}
+
+ + {i.hrComments && ( +
+
+
HR comment
+

{i.hrComments}

+
+
+ )} + + {i.sheet && ( +
+ Imported from {i.sheet} +
+ )} +
+ ) +} + function ApplicationDetail({ item: i, loading, busy, canEdit, toast, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate, }) { diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 76b59c2..df81e14 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -725,7 +725,8 @@ canvas { width: 100%; max-width: 100%; display: block; } .tab-pane { display: none; animation: fadeUp .25s; } .tab-pane.active { display: block; } .pill-tabs { display: inline-flex; gap: 4px; background: var(--bg-sunken); padding: 4px; border-radius: 11px; } -.pill-tab { padding: 7px 14px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--text-2); transition: .15s; } +.pill-tab { padding: 7px 14px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--text-2); transition: .15s; display: inline-flex; align-items: center; gap: 6px; border: none; background: transparent; cursor: pointer; } +.pill-tab svg { width: 14px; height: 14px; } .pill-tab.active { background: var(--bg-elev); color: var(--text); box-shadow: var(--shadow-sm); } /* ================= KANBAN ================= */