pull/29/head
parent
ecd9b3597a
commit
b10d02f325
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Recruitment Inbox</h1>
|
||||
<p className="page-sub">Every candidate, every source — one unified queue</p>
|
||||
<p className="page-sub">
|
||||
{isForms
|
||||
? 'Google Form applicants — same queue energy, profile-first cards'
|
||||
: 'Every candidate, every source — one unified queue'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={sync.isPending}
|
||||
onClick={() => {
|
||||
toast('Fetching from Outlook…', 'info')
|
||||
sync.mutate()
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync'}
|
||||
</button>
|
||||
<div className="pill-tabs" role="tablist" aria-label="Inbox channel">
|
||||
{CHANNELS.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={channel === c.key}
|
||||
className={`pill-tab${channel === c.key ? ' active' : ''}`}
|
||||
onClick={() => switchChannel(c.key)}
|
||||
>
|
||||
<Icon name={c.icon} /> {c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!isForms && (
|
||||
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
|
||||
)}
|
||||
{isForms && (
|
||||
<span className="integration-status"><span className="pulse" />Google Sheets · Form data</span>
|
||||
)}
|
||||
{!isForms && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={sync.isPending}
|
||||
onClick={() => {
|
||||
toast('Fetching from Outlook…', 'info')
|
||||
sync.mutate()
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||
<Icon name="upload" /> Upload CVs
|
||||
</button>
|
||||
|
|
@ -743,22 +913,24 @@ export default function Inbox() {
|
|||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ margin: '0 16px', paddingTop: 8 }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(t) => {
|
||||
setTab(t)
|
||||
setPage(1)
|
||||
setSelectedId(null)
|
||||
selection.clear()
|
||||
}}
|
||||
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
|
||||
/>
|
||||
</div>
|
||||
{!isForms && (
|
||||
<div style={{ margin: '0 16px', paddingTop: 8 }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(t) => {
|
||||
setTab(t)
|
||||
setPage(1)
|
||||
setSelectedId(null)
|
||||
selection.clear()
|
||||
}}
|
||||
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="split inbox-split">
|
||||
<div className="split-list inbox-queue">
|
||||
{applicationsQuery.isSuccess && (
|
||||
{!isForms && applicationsQuery.isSuccess && (
|
||||
<BulkReadBar
|
||||
rows={list}
|
||||
selection={selection}
|
||||
|
|
@ -770,26 +942,51 @@ export default function Inbox() {
|
|||
/>
|
||||
)}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
{isForms && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<label className="cell-sub" htmlFor="inbox-form-sheet" style={{ display: 'block', marginBottom: 4 }}>
|
||||
Sheet tab
|
||||
</label>
|
||||
<select
|
||||
id="inbox-form-sheet"
|
||||
value={formSheet}
|
||||
onChange={(e) => {
|
||||
setFormSheet(e.target.value)
|
||||
setPage(1)
|
||||
setSelectedId(null)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{formSheetOptions.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
|
||||
<Icon name="search" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setPage(1) }}
|
||||
placeholder="Search applications…"
|
||||
placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{applicationsQuery.isPending && (
|
||||
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
|
||||
)}
|
||||
{applicationsQuery.isError && (
|
||||
<EmptyState icon="inbox" title="Couldn't load applications">
|
||||
{friendlyAuthError(applicationsQuery.error, 'Request failed')}
|
||||
{activeQuery.isPending && (
|
||||
<EmptyState icon="inbox" title="Loading…">
|
||||
{isForms ? 'Fetching form applicants from the sheet mirror.' : 'Fetching applications from the server.'}
|
||||
</EmptyState>
|
||||
)}
|
||||
{applicationsQuery.isSuccess && list.length === 0 ? (
|
||||
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
|
||||
{activeQuery.isError && (
|
||||
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
|
||||
{friendlyAuthError(activeQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{activeQuery.isSuccess && list.length === 0 ? (
|
||||
<EmptyState icon="inbox" title="Nothing here">
|
||||
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
|
||||
</EmptyState>
|
||||
) : (
|
||||
list.map((i) => (
|
||||
<div
|
||||
|
|
@ -797,11 +994,13 @@ export default function Inbox() {
|
|||
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
||||
onClick={() => select(i.id)}
|
||||
>
|
||||
<RowCheck
|
||||
checked={selection.selectedIds.has(i.id)}
|
||||
onToggle={() => selection.toggle(i.id)}
|
||||
label={`Select ${i.name}`}
|
||||
/>
|
||||
{!isForms && (
|
||||
<RowCheck
|
||||
checked={selection.selectedIds.has(i.id)}
|
||||
onToggle={() => selection.toggle(i.id)}
|
||||
label={`Select ${i.name}`}
|
||||
/>
|
||||
)}
|
||||
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
||||
<div className="ii-main">
|
||||
<div className="ii-name">
|
||||
|
|
@ -816,6 +1015,9 @@ export default function Inbox() {
|
|||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||
<Badge>{i.applicationStatus}</Badge>
|
||||
)}
|
||||
{isForms && i.residingCity && (
|
||||
<span className="cell-sub">{i.residingCity}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
|
|
@ -825,12 +1027,15 @@ export default function Inbox() {
|
|||
{i.atsScore != null && (
|
||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||
)}
|
||||
{isForms && i.noticePeriod && (
|
||||
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{applicationsQuery.isSuccess && total > 0 && (
|
||||
{activeQuery.isSuccess && total > 0 && (
|
||||
<Pagination
|
||||
from={total ? (currentPage - 1) * PAGE_SIZE + 1 : 0}
|
||||
to={Math.min(currentPage * PAGE_SIZE, total)}
|
||||
|
|
@ -847,7 +1052,9 @@ export default function Inbox() {
|
|||
{!selected ? (
|
||||
<div style={{ padding: '100px 20px' }}>
|
||||
<EmptyState icon="inbox" title="Select an application">
|
||||
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.'}
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : detailQuery.isError ? (
|
||||
|
|
@ -856,6 +1063,8 @@ export default function Inbox() {
|
|||
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : isForms ? (
|
||||
<FormApplicantDetail item={selected} loading={detailQuery.isPending} />
|
||||
) : (
|
||||
<ApplicationDetail
|
||||
item={selected}
|
||||
|
|
@ -941,6 +1150,125 @@ function orDash(value, suffix = '') {
|
|||
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||||
}
|
||||
|
||||
function externalHref(url) {
|
||||
const raw = (url || '').trim()
|
||||
if (!raw) return null
|
||||
if (/^https?:\/\//i.test(raw)) return raw
|
||||
return `https://${raw}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet form applicant detail — profile grids + resume/LinkedIn links.
|
||||
* No email subject/body, no ATS match panel, no inbox write actions yet.
|
||||
*/
|
||||
function FormApplicantDetail({ item: i, loading }) {
|
||||
const resumeHref = externalHref(i.resumeLink)
|
||||
const profileHref = externalHref(i.profileLink)
|
||||
const location = [i.residingCity, i.residingCountry].filter(Boolean).join(', ')
|
||||
const education = [i.degree, i.university].filter(Boolean).join(' · ')
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||||
<div className="ph-role">{i.position}</div>
|
||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
{i.hoAvailability && (
|
||||
<Badge className={String(i.hoAvailability).toLowerCase() === 'yes' ? 'b-green' : 'b-amber'}>
|
||||
Relocate: {i.hoAvailability}
|
||||
</Badge>
|
||||
)}{' '}
|
||||
{loading && <span className="cell-sub">Loading details…</span>}
|
||||
</div>
|
||||
</div>
|
||||
{i.rowNumber != null && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="cell-sub">Sheet row</div>
|
||||
<div className="fw-600">{i.rowNumber}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(resumeHref || profileHref) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{resumeHref && (
|
||||
<a className="btn btn-primary btn-sm" href={resumeHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="paperclip" /> Open resume
|
||||
</a>
|
||||
)}
|
||||
{profileHref && (
|
||||
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="linkedin" /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Contact & application</div>
|
||||
<div
|
||||
className="info-grid"
|
||||
style={{
|
||||
marginBottom: 20,
|
||||
border: '1px solid var(--border)',
|
||||
borderTop: 'none',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
padding: '14px 16px',
|
||||
background: 'var(--bg-elev)',
|
||||
}}
|
||||
>
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||||
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
|
||||
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
|
||||
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Profile</div>
|
||||
<div
|
||||
className="info-grid"
|
||||
style={{
|
||||
marginBottom: 20,
|
||||
border: '1px solid var(--border)',
|
||||
borderTop: 'none',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
padding: '14px 16px',
|
||||
background: 'var(--bg-elev)',
|
||||
}}
|
||||
>
|
||||
<div className="info-item"><div className="il">Gender</div><div className="iv">{orDash(i.gender)}</div></div>
|
||||
<div className="info-item"><div className="il">Date of birth</div><div className="iv">{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">CNIC</div><div className="iv">{orDash(i.cnic)}</div></div>
|
||||
<div className="info-item"><div className="il">Marital status</div><div className="iv">{orDash(i.maritalStatus)}</div></div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(location)}</div></div>
|
||||
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(education)}</div></div>
|
||||
<div className="info-item"><div className="il">Graduation</div><div className="iv">{orDash(i.graduationYear)}</div></div>
|
||||
<div className="info-item"><div className="il">Other university</div><div className="iv">{orDash(i.universityOther)}</div></div>
|
||||
<div className="info-item"><div className="il">Current salary</div><div className="iv">{orDash(i.currentSalary)}</div></div>
|
||||
<div className="info-item"><div className="il">Expected salary</div><div className="iv">{orDash(i.expectedSalary)}</div></div>
|
||||
</div>
|
||||
|
||||
{i.hrComments && (
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 8 }}>
|
||||
<div className="card-body">
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>HR comment</div>
|
||||
<p style={{ margin: 0 }}>{i.hrComments}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{i.sheet && (
|
||||
<div className="cell-sub" style={{ marginTop: 12 }}>
|
||||
Imported from {i.sheet}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApplicationDetail({
|
||||
item: i, loading, busy, canEdit, toast, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate,
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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 ================= */
|
||||
|
|
|
|||
Loading…
Reference in New Issue