pull/29/head
ahmed.mujtaba 2026-08-27 18:54:40 +05:00
parent b10d02f325
commit d5a51a28b0
7 changed files with 363 additions and 60 deletions

View File

@ -158,6 +158,13 @@ async def fetch_sheet_import(
_FORM_DATA_READ = require_permission( _FORM_DATA_READ = require_permission(
PermissionTag.INBOX_VIEW, PermissionTag.SETTINGS_VIEW, require_all=False, PermissionTag.INBOX_VIEW, PermissionTag.SETTINGS_VIEW, require_all=False,
) )
_FORM_DATA_EDIT = require_permission(
PermissionTag.INBOX_EDIT, PermissionTag.SETTINGS_EDIT, require_all=False,
)
class AssignFormJobPostBody(BaseModel):
job_post_id: str | None = None
@router.get("/sheet/form-data/sheets") @router.get("/sheet/form-data/sheets")
@ -210,6 +217,23 @@ async def fetch_form_data_by_id(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.patch("/sheet/form-data/{record_id}/assign-job-post")
async def assign_form_job_post(
record_id: str,
payload: AssignFormJobPostBody,
current_user: dict = Depends(_FORM_DATA_EDIT),
session: AsyncSession = Depends(get_session),
):
try:
service=SheetFormData(session=session)
data=await service.assign_job_post(record_id,payload.job_post_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.delete("/sheet/form-data/{tab}/delete") @router.delete("/sheet/form-data/{tab}/delete")
async def delete_form_data_sheet( async def delete_form_data_sheet(
tab: str, tab: str,

View File

@ -119,6 +119,25 @@ class FormData(SQLModel, table=True):
result = await session.execute(select(cls).where(cls.id == rid)) result = await session.execute(select(cls).where(cls.id == rid))
return result.scalars().first() return result.scalars().first()
@classmethod
async def set_job_post(cls, session: AsyncSession, record_id, job_post_id):
"""Set or clear job_post_id; returns the row or None if missing."""
row = await cls.get_form_data_by_id(session, record_id)
if not row:
return None
if job_post_id is None:
row.job_post_id = None
else:
try:
row.job_post_id = uuid.UUID(str(job_post_id))
except (TypeError, ValueError):
return None
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod @classmethod
async def fetch_form_data(cls, session: AsyncSession, *, sheet=None, search=None, offset=0, limit=None): async def fetch_form_data(cls, session: AsyncSession, *, sheet=None, search=None, offset=0, limit=None):
statement = select(cls).order_by(cls.sheet, cls.row_number) statement = select(cls).order_by(cls.sheet, cls.row_number)

View File

@ -332,20 +332,71 @@ class SheetImport(SheetRead):
class SheetFormData(Sheet): class SheetFormData(Sheet):
"""FormData DB mirror — query / delete only (no Google client).""" """FormData DB mirror — query / delete only (no Google client)."""
async def _hydrate_job_posts(self,items):
"""Attach matching job_posts (title == position_applied_for) + assigned_job_post.
No AI suggestions form applicants already name the role. One query for
titles on the page, one for any assigned ids.
"""
if not items:
return items
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
session=self._require_session()
titles=[(item.get("position_applied_for") or "").strip() for item in items]
titles=[t for t in titles if t]
by_title={}
if titles:
for post in await JobPosts.get_by_titles(session,titles):
key=(post.title or "").strip().lower()
payload=serialize_job_post(post)
if post.is_deleted or not post.is_active:
payload={**payload,"unavailable":True}
by_title.setdefault(key,[]).append(payload)
assigned_ids=[item.get("job_post_id") for item in items if item.get("job_post_id")]
assigned_map={}
if assigned_ids:
for post in await JobPosts.get_by_ids(session,assigned_ids,active_only=False):
assigned_map[str(post.id)]=serialize_job_post(post)
for item in items:
key=(item.get("position_applied_for") or "").strip().lower()
item["job_posts"]=list(by_title.get(key) or [])
aid=item.get("job_post_id")
item["assigned_job_post"]=assigned_map.get(str(aid)) if aid else None
return items
async def get_form_data(self,sheet=None,search=None,offset=0,limit=None): async def get_form_data(self,sheet=None,search=None,offset=0,limit=None):
session=self._require_session() session=self._require_session()
rows=await FormData.fetch_form_data( rows=await FormData.fetch_form_data(
session,sheet=sheet,search=search,offset=offset,limit=limit, session,sheet=sheet,search=search,offset=offset,limit=limit,
) )
total=await FormData.count_form_data(session,sheet=sheet,search=search) total=await FormData.count_form_data(session,sheet=sheet,search=search)
return [serialize_form_data(row) for row in rows],total items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
return items,total
async def get_form_data_by_id(self,record_id): async def get_form_data_by_id(self,record_id):
session=self._require_session() session=self._require_session()
row=await FormData.get_form_data_by_id(session,record_id) row=await FormData.get_form_data_by_id(session,record_id)
if not row: if not row:
raise HTTPException(status_code=404,detail="Form data not found") raise HTTPException(status_code=404,detail="Form data not found")
return serialize_form_data(row) items=await self._hydrate_job_posts([serialize_form_data(row)])
return items[0]
async def assign_job_post(self,record_id,job_post_id):
"""Set or clear form_data.job_post_id (same contract as inbox assign)."""
session=self._require_session()
if job_post_id is not None:
from job.job_post.models import JobPosts
post=await JobPosts.get_job_post_by_id(session,job_post_id)
if not post or post.is_deleted or not post.is_active:
raise HTTPException(status_code=404,detail="Job post not found")
updated=await FormData.set_job_post(session,record_id,job_post_id)
if not updated:
raise HTTPException(status_code=404,detail="Form data not found")
return await self.get_form_data_by_id(record_id)
async def get_imported_sheets(self): async def get_imported_sheets(self):
session=self._require_session() session=self._require_session()

View File

@ -101,6 +101,27 @@ class JobPosts(SQLModel, table=True):
# Preserve request order so suggestion ranks stay stable. # Preserve request order so suggestion ranks stay stable.
return [by_id[str(u)] for u in uids if str(u) in by_id] return [by_id[str(u)] for u in uids if str(u) in by_id]
@classmethod
async def get_by_titles(cls, session: AsyncSession, titles: list[str], *, active_only: bool = False):
"""Match job posts whose title equals any of `titles` (trim + case-insensitive).
Used by sheet form-data: position_applied_for job_posts.title. Returns
non-deleted rows; inactive ones stay in the list so the UI can mark them
unavailable the same way inbox suggestions do.
"""
lowers = sorted({(t or "").strip().lower() for t in (titles or []) if (t or "").strip()})
if not lowers:
return []
statement = select(cls).where(
cls.is_deleted == False, # noqa: E712
func.lower(func.trim(cls.title)).in_(lowers),
)
if active_only:
statement = statement.where(cls.is_active == True) # noqa: E712
statement = statement.order_by(cls.created_at.desc())
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod @classmethod
async def fetch_job_posts( async def fetch_job_posts(
cls, cls,

View File

@ -27,3 +27,11 @@ export function listFormData({ sheet, search, offset = 0, limit } = {}) {
export function getFormData(recordId) { export function getFormData(recordId) {
return request(`/sheet/form-data/${recordId}`) return request(`/sheet/form-data/${recordId}`)
} }
/** Set or clear form_data.job_post_id (job_post_id: null clears). */
export function assignJobPost(recordId, jobPostId) {
return request(`/sheet/form-data/${recordId}/assign-job-post`, {
method: 'PATCH',
body: { job_post_id: jobPostId },
})
}

View File

@ -132,9 +132,11 @@ function formReceivedAt(entryDate, entryTime) {
/** /**
* GET /sheet/form-data/fetch row the same list/detail shape the email channel * 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. * uses for name / avatar / position / source / time, plus form-only profile fields.
* job_posts are title-matched (position_applied_for job_posts.title), not AI.
*/ */
function mapFormRow(row) { function mapFormRow(row) {
const name = (row.name || row.candidate_email || 'Unknown').trim() const name = (row.name || row.candidate_email || 'Unknown').trim()
const jobPosts = Array.isArray(row.job_posts) ? row.job_posts : []
return { return {
kind: 'form', kind: 'form',
id: String(row.id), id: String(row.id),
@ -168,6 +170,9 @@ function mapFormRow(row) {
rowNumber: row.row_number ?? null, rowNumber: row.row_number ?? null,
unread: false, unread: false,
processing: row.screened_by ? 'Screened' : 'New', processing: row.screened_by ? 'Screened' : 'New',
jobPosts,
assignedId: row.job_post_id ? String(row.job_post_id) : null,
assignedPost: row.assigned_job_post || null,
} }
} }
@ -1064,7 +1069,12 @@ export default function Inbox() {
</EmptyState> </EmptyState>
</div> </div>
) : isForms ? ( ) : isForms ? (
<FormApplicantDetail item={selected} loading={detailQuery.isPending} /> <FormApplicantDetail
item={selected}
loading={detailQuery.isPending}
canEdit={canEdit}
toast={toast}
/>
) : ( ) : (
<ApplicationDetail <ApplicationDetail
item={selected} item={selected}
@ -1158,15 +1168,57 @@ function externalHref(url) {
} }
/** /**
* Sheet form applicant detail profile grids + resume/LinkedIn links. * Sheet form applicant detail profile grids + resume/LinkedIn links +
* No email subject/body, no ATS match panel, no inbox write actions yet. * title-matched job selection (position_applied_for job_posts.title).
*/ */
function FormApplicantDetail({ item: i, loading }) { function FormApplicantDetail({ item: i, loading, canEdit, toast }) {
const qc = useQueryClient()
const resumeHref = externalHref(i.resumeLink) const resumeHref = externalHref(i.resumeLink)
const profileHref = externalHref(i.profileLink) const profileHref = externalHref(i.profileLink)
const location = [i.residingCity, i.residingCountry].filter(Boolean).join(', ') const location = [i.residingCity, i.residingCountry].filter(Boolean).join(', ')
const education = [i.degree, i.university].filter(Boolean).join(' · ') const education = [i.degree, i.university].filter(Boolean).join(' · ')
const [selection, setSelection] = useState(null)
const [manualPost, setManualPost] = useState(null)
const [showPicker, setShowPicker] = useState(false)
useEffect(() => {
setManualPost(null)
setSelection(i.assignedId || null)
}, [i.id, i.assignedId])
const matchCards = useMemo(() => {
return (i.jobPosts || []).map((post, idx) => ({
rank: idx + 1,
post,
}))
}, [i.jobPosts])
const selectedPost = useMemo(() => {
if (!selection) return null
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost
const hit = matchCards.find((c) => String(c.post.id) === String(selection))
return hit?.post || null
}, [selection, manualPost, i.assignedPost, matchCards])
const assignMutation = useMutation({
mutationFn: ({ recordId, jobPostId }) => sheetApi.assignJobPost(recordId, jobPostId),
onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'),
onSuccess: (_res, vars) => {
const title = selectedPost?.title || 'role'
if (vars.jobPostId) toast(`${i.name}${title}`, 'success')
else toast(`${i.name} unassigned`, 'success')
},
onSettled: (_res, _err, vars) => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) })
},
})
const assigned = i.assignedPost
const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}> <div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
@ -1207,63 +1259,190 @@ function FormApplicantDetail({ item: i, loading }) {
</div> </div>
)} )}
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Contact &amp; application</div> {assigned && (
<div <div
className="info-grid" className="card"
style={{ style={{
marginBottom: 20, boxShadow: 'none',
border: '1px solid var(--border)', background: 'var(--primary-soft)',
borderTop: 'none', border: '1px solid var(--primary-border)',
borderRadius: '0 0 10px 10px', marginBottom: 18,
padding: '14px 16px', }}
background: 'var(--bg-elev)', >
}} <div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
> <div className="flex items-center gap-8">
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div> <Icon name="check-circle" />
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div> <div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div> <div>Assigned to <b>{assigned.title}</b></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div> <div className="cell-sub">
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div> {[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div> </div>
</div> </div>
</div>
<div className="email-head" style={{ borderRadius: '10px 10px 0 0' }}>Profile</div> <div className="flex gap-8">
<div <button
className="info-grid" className="btn btn-secondary btn-sm"
style={{ disabled={!canEdit}
marginBottom: 20, title={!canEdit ? 'Requires inbox.edit' : undefined}
border: '1px solid var(--border)', onClick={() => setShowPicker(true)}
borderTop: 'none', >
borderRadius: '0 0 10px 10px', Change
padding: '14px 16px', </button>
background: 'var(--bg-elev)', <button
}} className="btn btn-ghost btn-sm"
> disabled={!canEdit || assignMutation.isPending}
<div className="info-item"><div className="il">Gender</div><div className="iv">{orDash(i.gender)}</div></div> title={!canEdit ? 'Requires inbox.edit' : undefined}
<div className="info-item"><div className="il">Date of birth</div><div className="iv">{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}</div></div> onClick={() => assignMutation.mutate({ recordId: i.id, jobPostId: null })}
<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> Unassign
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(location)}</div></div> </button>
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(education)}</div></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>
</div> </div>
)} )}
{i.sheet && ( <div
<div className="cell-sub" style={{ marginTop: 12 }}> style={{
Imported from {i.sheet} display: 'flex',
flexWrap: 'wrap',
gap: 18,
alignItems: 'start',
marginBottom: 20,
}}
>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
<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> </div>
<div role="radiogroup" aria-label="Matching roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>Matching roles</div>
<div className="cell-sub" style={{ marginBottom: 10 }}>
Matched by position applied for: {orDash(i.position)}
</div>
{matchCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No matching roles">
No job post title matches this position. Choose a role manually.
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a role
</button>
</div>
</EmptyState>
) : (
matchCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
badge={`Match #${rank}`}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
/>
)}
<button
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a different role
</button>
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
Assign
</button>
</div>
</div>
{showPicker && (
<PickRoleModal
onClose={() => setShowPicker(false)}
onPick={(post) => {
setManualPost(post)
setSelection(String(post.id))
}}
/>
)} )}
</div> </div>
) )

View File

@ -23,7 +23,7 @@ export function reqInResume(req, resumeText) {
return resumeText.toLowerCase().includes(needle) return resumeText.toLowerCase().includes(needle)
} }
export function JobCard({ post, rank, selected, onSelect, resumeText, manual }) { 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'
const meta = [ const meta = [
@ -33,6 +33,7 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual })
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs` ? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
: null, : null,
].filter(Boolean).join(' · ') ].filter(Boolean).join(' · ')
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
return ( return (
<div <div
@ -59,7 +60,7 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual })
> >
<div className="lr-main" style={{ minWidth: 0 }}> <div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}> <div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span> <span className="tag">{tag}</span>
<div className="lr-title">{title}</div> <div className="lr-title">{title}</div>
{unavailable ? ( {unavailable ? (
<Badge className="b-gray">Unavailable</Badge> <Badge className="b-gray">Unavailable</Badge>