pull/29/head
ahmed.mujtaba 2026-08-27 17:59:21 +05:00
parent ecd9b3597a
commit b10d02f325
5 changed files with 420 additions and 52 deletions

View File

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

29
frontend/src/api/sheet.js Normal file
View File

@ -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}`)
}

View File

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

View File

@ -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 &amp; 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,
}) {

View File

@ -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 ================= */