is approved and file uplaod done

pull/33/head
ahmed.mujtaba 2026-08-28 20:02:38 +05:00
parent 76e4552679
commit db176cf672
16 changed files with 146 additions and 36 deletions

View File

@ -337,6 +337,7 @@ async def cv_bank_upload(
except HTTPException:
raise
except Exception as e:
logger.exception("cv-bank upload failed")
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))
@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")
async def fetch_jobs(
search: str | None = Query(None),

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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:
from inbox.models import Inbox

View File

@ -167,6 +167,24 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement)
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
async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields)

View File

@ -183,6 +183,9 @@ class JobPost:
)
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,
employment_type=None,top=None,skip=0,active_only=True):
rows,total=await JobPosts.fetch_job_posts(

View File

@ -211,7 +211,12 @@ class Users(SQLModel, table=True):
user = cls(**fields)
session.add(user)
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
async def update_user(cls, session: AsyncSession, record_id: str, fields: dict):

View File

@ -1,5 +1,18 @@
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.
*

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 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 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' }); }
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: {
all: () => ['jobPosts'],
list: (p = {}) => ['jobPosts', 'list', p],
departments: (p = {}) => ['jobPosts', 'departments', p],
},
jobs: {
all: () => ['jobs'],

View File

@ -29,7 +29,7 @@ import { useFormState } from '../components/AuthLayout'
import { persist, useSeedMutation } from '../data/seedQueries'
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. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
@ -137,6 +137,13 @@ export default function Candidates() {
queryFn: () => fetchCandidates({ top: pageSize, skip }),
})
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 jobsById = useMemo(
() => 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
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="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} />
</div>
)}
</div>

View File

@ -117,8 +117,8 @@ export default function CvImport() {
try {
const res = await candidatesApi.uploadToCvBank(files[k])
results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null })
} catch {
results.push({ rowId: rowIds[k], ok: false, error: 'FAILED' })
} catch (err) {
results.push({ rowId: rowIds[k], ok: false, error: friendlyAuthError(err, 'FAILED') })
}
}
return results
@ -294,7 +294,7 @@ export default function CvImport() {
</div>
)}
{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 style={{ textAlign: 'right', flexShrink: 0 }}>

View File

@ -123,8 +123,8 @@ function outlookListTime(value) {
* Nothing matches -> show the first recipient verbatim rather than guess.
*/
function sourceFrom(messageTo) {
const raw = (messageTo || '').trim()
if (!raw) return { source: 'Unknown', sourceMeta: null }
const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
const flat = raw.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] }
@ -304,7 +304,9 @@ async function fetchMessageDetail(recordId) {
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
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 : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
@ -350,7 +352,9 @@ async function fetchApplications(params) {
experience: row.experience,
recruiter: row.recruiter,
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,
}
}),
@ -497,7 +501,10 @@ function useSetReadAll(toast) {
*/
function useRowSelection(rows) {
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(() => {
setSelectedIds((prev) => {
@ -783,17 +790,20 @@ export default function Inbox() {
}, [isForms, formSheetsQuery.data, formSheet])
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 counts = useMemo(
() => ({
'All Applications': serverCounts.all ?? 0,
Unread: serverCounts.unread ?? 0,
Processed: serverCounts.processed ?? 0,
Rejected: serverCounts.rejected ?? 0,
Duplicates: serverCounts.duplicates ?? 0,
}),
() => {
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
return {
'All Applications': n(serverCounts.all),
Unread: n(serverCounts.unread),
Processed: n(serverCounts.processed),
Rejected: n(serverCounts.rejected),
Duplicates: n(serverCounts.duplicates),
}
},
[serverCounts],
)

View File

@ -59,8 +59,8 @@ function parseDate(value) {
}
function sourceFrom(messageTo) {
const raw = (messageTo || '').trim()
if (!raw) return { source: 'Unknown', sourceMeta: null }
const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
const flat = raw.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] }
@ -90,7 +90,6 @@ function htmlToText(value) {
function mapApplication(row) {
const name = row.name || row.email || 'Unknown'
const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : []
return {
id: String(row.id),
name,
@ -105,7 +104,9 @@ function mapApplication(row) {
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
resumeText: row.resume_text || '',
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,
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
@ -203,19 +204,19 @@ export default function Matching() {
})
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,
})
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,
})
const allCount = useQuery({
queryKey: qk.mailbox.assignments({}),
queryKey: qk.mailbox.assignments({ count: true }),
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
})
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,
})

View File

@ -44,8 +44,9 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
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. */
const PAGE_SIZE_MAX = 100
@ -145,6 +146,14 @@ export default function TalentPool() {
queryKey: qk.candidates.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(
() => buildPool(candidatesApi.toRows(query.data), templates),

View File

@ -166,7 +166,7 @@ export function Pagination({
<Icon name="chevron-left" />
</button>
<div className="page-nums">
{pageButtons.map((p, i) =>
{(pageButtons ?? []).map((p, i) =>
p === '…' ? (
<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). */
export function reqInResume(req, resumeText) {
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
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 }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
@ -70,20 +77,22 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
{selected && <Icon name="check-circle" />}
</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 }}>
{(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)
return (
<span
key={req}
key={`${label}-${i}`}
className="tag"
style={hit ? {
background: 'var(--success-soft)',
color: 'var(--success-fg)',
} : undefined}
>
{req}
{label}
</span>
)
})}

View File

@ -51,7 +51,9 @@ export function Tabs({ tabs, value, onChange, className = 'tabs', idBase }) {
className={`tab${active ? ' active' : ''}`}
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>
)
})}