is approved and file uplaod done #33
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)'];
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export const qk = {
|
|||
jobPosts: {
|
||||
all: () => ['jobPosts'],
|
||||
list: (p = {}) => ['jobPosts', 'list', p],
|
||||
departments: (p = {}) => ['jobPosts', 'departments', p],
|
||||
},
|
||||
jobs: {
|
||||
all: () => ['jobs'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
Loading…
Reference in New Issue