Merge pull request 'is approved and file uplaod done' (#33) from Is_Approved into main
Deploy to S3 / deploy (push) Successful in 42s Details

Reviewed-on: #33
pull/34/head^2
ahmed.mujtaba 2026-08-28 15:04:45 +00:00
commit 07f849a699
16 changed files with 146 additions and 36 deletions

View File

@ -337,6 +337,7 @@ async def cv_bank_upload(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.exception("cv-bank upload failed")
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@ -660,6 +661,31 @@ async def fetch_job_posts(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/departments/fetch")
async def fetch_job_departments(
active_only: bool = Query(False),
current_user: dict = Depends(
require_permission(
PermissionTag.JOB_BOARD_VIEW,
PermissionTag.CANDIDATES_VIEW,
PermissionTag.TALENT_VIEW,
PermissionTag.JOBS_VIEW,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
"""Distinct job_posts.department values for filter dropdowns."""
try:
service=JobPost(session=session)
data=await service.fetch_departments(active_only=active_only)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/fetch") @router.get("/jobs/fetch")
async def fetch_jobs( async def fetch_jobs(
search: str | None = Query(None), search: str | None = Query(None),

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel import Field, Relationship, SQLModel, select
from linkedin_utils import NO_SLUG, slug_from_url from linkedin_utils import NO_SLUG, primary_slug_from_text, slug_from_url
if TYPE_CHECKING: if TYPE_CHECKING:
from inbox.models import Inbox from inbox.models import Inbox

View File

@ -167,6 +167,24 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return list(result.scalars().all()), total return list(result.scalars().all()), total
@classmethod
async def list_departments(cls, session: AsyncSession, *, active_only: bool = False):
"""Distinct non-empty departments on non-deleted job posts.
The column default is "" those rows are omitted so a dropdown never
offers a blank option. Closed requisitions still contribute unless
`active_only` is set: a past hiring department is a legitimate filter.
"""
statement = select(cls.department).where(
cls.is_deleted == False, # noqa: E712
cls.department != "",
)
if active_only:
statement = statement.where(cls.is_active == True) # noqa: E712
statement = statement.distinct().order_by(cls.department)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod @classmethod
async def insert_job_post(cls, session: AsyncSession, fields: dict): async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields) row = cls(**fields)

View File

@ -183,6 +183,9 @@ class JobPost:
) )
return [serialize_job_post(r) for r in rows],total return [serialize_job_post(r) for r in rows],total
async def fetch_departments(self,active_only=False):
return await JobPosts.list_departments(self.session,active_only=active_only)
async def fetch_jobs(self,search=None,department=None,requisition_status=None, async def fetch_jobs(self,search=None,department=None,requisition_status=None,
employment_type=None,top=None,skip=0,active_only=True): employment_type=None,top=None,skip=0,active_only=True):
rows,total=await JobPosts.fetch_job_posts( rows,total=await JobPosts.fetch_job_posts(

View File

@ -211,7 +211,12 @@ class Users(SQLModel, table=True):
user = cls(**fields) user = cls(**fields)
session.add(user) session.add(user)
await session.commit() await session.commit()
return await cls.get_user_by_id(session, user.id) # get_user_by_id is staff-only (role_id != 8). Candidate inserts must
# still return the row — CV bank / Add Candidate call user.id next.
loaded = await cls.get_user_by_id(session, user.id)
if loaded is not None:
return loaded
return await cls.get_user_id(session, user.id)
@classmethod @classmethod
async def update_user(cls, session: AsyncSession, record_id: str, fields: dict): async def update_user(cls, session: AsyncSession, record_id: str, fields: dict):

View File

@ -1,5 +1,18 @@
import { request } from '../lib/apiClient' import { request } from '../lib/apiClient'
/**
* Distinct departments on job_posts GET /job/departments/fetch.
*
* Vocabulary for candidate / talent department dropdowns. Empty strings (the
* column default) are omitted server-side. `activeOnly` defaults false so a
* closed requisition's department still appears.
*/
export function listDepartments({ activeOnly = false } = {}) {
return request('/job/departments/fetch', {
params: { active_only: activeOnly },
})
}
/** /**
* Active job posts Job Matching hydrates suggestions and the manual picker. * Active job posts Job Matching hydrates suggestions and the manual picker.
* *

View File

@ -63,7 +63,12 @@ export const TODAY = new Date('2026-07-09T09:00:00');
function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); } function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); }
function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); } function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); }
function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; } function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; }
function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } function fmtDate(d) {
if (d == null || d === '') return ''
const date = d instanceof Date ? d : new Date(d)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
const avatarColors = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']; const avatarColors = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)'];

View File

@ -73,6 +73,7 @@ export const qk = {
jobPosts: { jobPosts: {
all: () => ['jobPosts'], all: () => ['jobPosts'],
list: (p = {}) => ['jobPosts', 'list', p], list: (p = {}) => ['jobPosts', 'list', p],
departments: (p = {}) => ['jobPosts', 'departments', p],
}, },
jobs: { jobs: {
all: () => ['jobs'], all: () => ['jobs'],

View File

@ -29,7 +29,7 @@ import { useFormState } from '../components/AuthLayout'
import { persist, useSeedMutation } from '../data/seedQueries' import { persist, useSeedMutation } from '../data/seedQueries'
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed' import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
const EMPTY_FILTERS = { account: '' } const EMPTY_FILTERS = { account: '', department: '' }
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */ /** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
@ -137,6 +137,13 @@ export default function Candidates() {
queryFn: () => fetchCandidates({ top: pageSize, skip }), queryFn: () => fetchCandidates({ top: pageSize, skip }),
}) })
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const deptsQuery = useQuery({
queryKey: qk.jobPosts.departments(),
queryFn: async () => {
const res = await jobPostsApi.listDepartments()
return Array.isArray(res?.data) ? res.data : []
},
})
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data]) const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo( const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])), () => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
@ -352,6 +359,7 @@ export default function Candidates() {
with the scoring columns: on a users row every one of them would with the scoring columns: on a users row every one of them would
match nothing and silently empty the table. */} match nothing and silently empty the table. */}
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} /> <Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
<Facet label="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} />
</div> </div>
)} )}
</div> </div>

View File

@ -117,8 +117,8 @@ export default function CvImport() {
try { try {
const res = await candidatesApi.uploadToCvBank(files[k]) const res = await candidatesApi.uploadToCvBank(files[k])
results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null }) results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null })
} catch { } catch (err) {
results.push({ rowId: rowIds[k], ok: false, error: 'FAILED' }) results.push({ rowId: rowIds[k], ok: false, error: friendlyAuthError(err, 'FAILED') })
} }
} }
return results return results
@ -294,7 +294,7 @@ export default function CvImport() {
</div> </div>
)} )}
{i.status === 'Failed' && ( {i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be processed</div> <div className="cell-sub" style={{ marginTop: 4 }}>{i.error || 'Could not be processed'}</div>
)} )}
</div> </div>
<div style={{ textAlign: 'right', flexShrink: 0 }}> <div style={{ textAlign: 'right', flexShrink: 0 }}>

View File

@ -123,8 +123,8 @@ function outlookListTime(value) {
* Nothing matches -> show the first recipient verbatim rather than guess. * Nothing matches -> show the first recipient verbatim rather than guess.
*/ */
function sourceFrom(messageTo) { function sourceFrom(messageTo) {
const raw = (messageTo || '').trim() const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
if (!raw) return { source: 'Unknown', sourceMeta: null } if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
const flat = raw.toLowerCase().replace(/[^a-z]/g, '') const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.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] } if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
@ -304,7 +304,9 @@ async function fetchMessageDetail(recordId) {
matchError: row.match_error || '', matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at), matchedAt: parseDate(row.matched_at),
resumeText: row.resume_text || '', resumeText: row.resume_text || '',
suggestedIds: (row.suggested_job_post_ids || []).map(String), suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null, assignedPost: row.assigned_job_post || null,
@ -350,7 +352,9 @@ async function fetchApplications(params) {
experience: row.experience, experience: row.experience,
recruiter: row.recruiter, recruiter: row.recruiter,
duplicate: Boolean(row.duplicate), duplicate: Boolean(row.duplicate),
suggestedIds: (row.suggested_job_post_ids || []).map(String), suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
} }
}), }),
@ -497,7 +501,10 @@ function useSetReadAll(toast) {
*/ */
function useRowSelection(rows) { function useRowSelection(rows) {
const [selectedIds, setSelectedIds] = useState(() => new Set()) const [selectedIds, setSelectedIds] = useState(() => new Set())
const visibleIds = useMemo(() => rows.map((r) => r.id), [rows]) const visibleIds = useMemo(
() => (Array.isArray(rows) ? rows.map((r) => r.id) : []),
[rows],
)
useEffect(() => { useEffect(() => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
@ -783,17 +790,20 @@ export default function Inbox() {
}, [isForms, formSheetsQuery.data, formSheet]) }, [isForms, formSheetsQuery.data, formSheet])
const activeQuery = isForms ? formQuery : applicationsQuery const activeQuery = isForms ? formQuery : applicationsQuery
const inbox = activeQuery.data?.rows ?? [] const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : []
const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {}) const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
const counts = useMemo( const counts = useMemo(
() => ({ () => {
'All Applications': serverCounts.all ?? 0, const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
Unread: serverCounts.unread ?? 0, return {
Processed: serverCounts.processed ?? 0, 'All Applications': n(serverCounts.all),
Rejected: serverCounts.rejected ?? 0, Unread: n(serverCounts.unread),
Duplicates: serverCounts.duplicates ?? 0, Processed: n(serverCounts.processed),
}), Rejected: n(serverCounts.rejected),
Duplicates: n(serverCounts.duplicates),
}
},
[serverCounts], [serverCounts],
) )

View File

@ -59,8 +59,8 @@ function parseDate(value) {
} }
function sourceFrom(messageTo) { function sourceFrom(messageTo) {
const raw = (messageTo || '').trim() const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
if (!raw) return { source: 'Unknown', sourceMeta: null } if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
const flat = raw.toLowerCase().replace(/[^a-z]/g, '') const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.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] } if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
@ -90,7 +90,6 @@ function htmlToText(value) {
function mapApplication(row) { function mapApplication(row) {
const name = row.name || row.email || 'Unknown' const name = row.name || row.email || 'Unknown'
const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : []
return { return {
id: String(row.id), id: String(row.id),
name, name,
@ -105,7 +104,9 @@ function mapApplication(row) {
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending', resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
resumeText: row.resume_text || '', resumeText: row.resume_text || '',
filePath: row.file_path || '', filePath: row.file_path || '',
suggestedIds: suggested.map(String), suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
matchStatus: row.match_status || null, matchStatus: row.match_status || null,
matchSummary: row.match_summary || '', matchSummary: row.match_summary || '',
@ -203,19 +204,19 @@ export default function Matching() {
}) })
const needsCount = useQuery({ const needsCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false }), queryKey: qk.mailbox.assignments({ assigned: false, count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0, queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
}) })
const assignedCount = useQuery({ const assignedCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: true }), queryKey: qk.mailbox.assignments({ assigned: true, count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0, queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
}) })
const allCount = useQuery({ const allCount = useQuery({
queryKey: qk.mailbox.assignments({}), queryKey: qk.mailbox.assignments({ count: true }),
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0, queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
}) })
const noneCountQuery = useQuery({ const noneCountQuery = useQuery({
queryKey: qk.mailbox.assignments({ kind: 'none' }), queryKey: qk.mailbox.assignments({ kind: 'none', count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0, queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0,
}) })

View File

@ -44,8 +44,9 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline' import * as pipelineApi from '../api/pipeline'
import { avatarColor, departments, initials as initialsOf } from '../data/seed' import { avatarColor, initials as initialsOf } from '../data/seed'
/** Backend GET /candidate/fetch caps `limit` at 100. */ /** Backend GET /candidate/fetch caps `limit` at 100. */
const PAGE_SIZE_MAX = 100 const PAGE_SIZE_MAX = 100
@ -145,6 +146,14 @@ export default function TalentPool() {
queryKey: qk.candidates.list({ limit: pageSize }), queryKey: qk.candidates.list({ limit: pageSize }),
queryFn: () => candidatesApi.list({ limit: pageSize }), queryFn: () => candidatesApi.list({ limit: pageSize }),
}) })
const deptsQuery = useQuery({
queryKey: qk.jobPosts.departments(),
queryFn: async () => {
const res = await jobPostsApi.listDepartments()
return Array.isArray(res?.data) ? res.data : []
},
})
const departments = deptsQuery.data ?? []
const pool = useMemo( const pool = useMemo(
() => buildPool(candidatesApi.toRows(query.data), templates), () => buildPool(candidatesApi.toRows(query.data), templates),

View File

@ -166,7 +166,7 @@ export function Pagination({
<Icon name="chevron-left" /> <Icon name="chevron-left" />
</button> </button>
<div className="page-nums"> <div className="page-nums">
{pageButtons.map((p, i) => {(pageButtons ?? []).map((p, i) =>
p === '…' ? ( p === '…' ? (
<span key={`gap-${i}`} className="page-btn" style={{ cursor: 'default' }}></span> <span key={`gap-${i}`} className="page-btn" style={{ cursor: 'default' }}></span>
) : ( ) : (

View File

@ -18,11 +18,18 @@ import * as jobPostsApi from '../api/jobPosts'
/** Requirement chip lights green when the resume text contains it (client-side). */ /** Requirement chip lights green when the resume text contains it (client-side). */
export function reqInResume(req, resumeText) { export function reqInResume(req, resumeText) {
if (!req || !resumeText) return false if (!req || !resumeText) return false
const needle = String(req).trim().toLowerCase() const needle = (typeof req === 'string' ? req : (req?.name || req?.label || '')).trim().toLowerCase()
if (!needle) return false if (!needle) return false
return resumeText.toLowerCase().includes(needle) return resumeText.toLowerCase().includes(needle)
} }
function reqLabel(req) {
if (req == null) return ''
if (typeof req === 'string' || typeof req === 'number') return String(req)
if (typeof req === 'object') return String(req.name || req.label || req.skill || '')
return String(req)
}
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) { export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) {
const unavailable = Boolean(post?.unavailable) || !post?.title const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable' const title = post?.title || 'Unavailable'
@ -70,20 +77,22 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
{selected && <Icon name="check-circle" />} {selected && <Icon name="check-circle" />}
</div> </div>
{meta && <div className="cell-sub">{meta}</div>} {meta && <div className="cell-sub">{meta}</div>}
{!unavailable && (post.requirements || []).length > 0 && ( {!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}> <div className="k-tags" style={{ marginTop: 8 }}>
{(post.requirements || []).slice(0, 8).map((req) => { {post.requirements.slice(0, 8).map((req, i) => {
const label = reqLabel(req)
if (!label) return null
const hit = reqInResume(req, resumeText) const hit = reqInResume(req, resumeText)
return ( return (
<span <span
key={req} key={`${label}-${i}`}
className="tag" className="tag"
style={hit ? { style={hit ? {
background: 'var(--success-soft)', background: 'var(--success-soft)',
color: 'var(--success-fg)', color: 'var(--success-fg)',
} : undefined} } : undefined}
> >
{req} {label}
</span> </span>
) )
})} })}

View File

@ -51,7 +51,9 @@ export function Tabs({ tabs, value, onChange, className = 'tabs', idBase }) {
className={`tab${active ? ' active' : ''}`} className={`tab${active ? ' active' : ''}`}
onClick={() => onChange(key)} onClick={() => onChange(key)}
> >
{label}{t.count != null && <span className="tab-count">{t.count}</span>} {label}{t.count != null && typeof t.count !== 'object' && (
<span className="tab-count">{t.count}</span>
)}
</button> </button>
) )
})} })}