From d5a51a28b0a2f0709e76ee2e9fa51cc28d7223c0 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 27 Aug 2026 18:54:40 +0500 Subject: [PATCH] / --- backend/g_sheet/app.py | 24 +++ backend/g_sheet/models.py | 19 ++ backend/g_sheet/views.py | 55 +++++- backend/job/job_post/models.py | 21 +++ frontend/src/api/sheet.js | 8 + frontend/src/screens/Inbox.jsx | 291 +++++++++++++++++++++++------ frontend/src/ui/SuggestedRoles.jsx | 5 +- 7 files changed, 363 insertions(+), 60 deletions(-) diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index 155e376..2eee20f 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -158,6 +158,13 @@ async def fetch_sheet_import( _FORM_DATA_READ = require_permission( 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") @@ -210,6 +217,23 @@ async def fetch_form_data_by_id( 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") async def delete_form_data_sheet( tab: str, diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 62be928..f3a6350 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -119,6 +119,25 @@ class FormData(SQLModel, table=True): result = await session.execute(select(cls).where(cls.id == rid)) 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 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) diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 7a5ef15..dd31024 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -332,20 +332,71 @@ class SheetImport(SheetRead): class SheetFormData(Sheet): """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): session=self._require_session() rows=await FormData.fetch_form_data( session,sheet=sheet,search=search,offset=offset,limit=limit, ) 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): session=self._require_session() row=await FormData.get_form_data_by_id(session,record_id) if not row: 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): session=self._require_session() diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 1f6277b..b0f2c5c 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -101,6 +101,27 @@ class JobPosts(SQLModel, table=True): # Preserve request order so suggestion ranks stay stable. 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 async def fetch_job_posts( cls, diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index 44b7ea7..fb8708f 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -27,3 +27,11 @@ export function listFormData({ sheet, search, offset = 0, limit } = {}) { export function getFormData(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 }, + }) +} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 9f9dea3..d68953b 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -132,9 +132,11 @@ function formReceivedAt(entryDate, entryTime) { /** * 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. + * job_posts are title-matched (position_applied_for ↔ job_posts.title), not AI. */ function mapFormRow(row) { const name = (row.name || row.candidate_email || 'Unknown').trim() + const jobPosts = Array.isArray(row.job_posts) ? row.job_posts : [] return { kind: 'form', id: String(row.id), @@ -168,6 +170,9 @@ function mapFormRow(row) { rowNumber: row.row_number ?? null, unread: false, 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() { ) : isForms ? ( - + ) : ( { + 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 (
@@ -1207,63 +1259,190 @@ function FormApplicantDetail({ item: i, loading }) {
)} -
Contact & application
-
-
Email
{orDash(i.email)}
-
Phone
{orDash(i.phone)}
-
Applied
{i.received ? fmtDate(i.received) : '—'}
-
Source
{orDash(i.source)}
-
Screened by
{orDash(i.screenedBy)}
-
Notice period
{orDash(i.noticePeriod)}
-
- -
Profile
-
-
Gender
{orDash(i.gender)}
-
Date of birth
{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}
-
CNIC
{orDash(i.cnic)}
-
Marital status
{orDash(i.maritalStatus)}
-
Location
{orDash(location)}
-
Education
{orDash(education)}
-
Graduation
{orDash(i.graduationYear)}
-
Other university
{orDash(i.universityOther)}
-
Current salary
{orDash(i.currentSalary)}
-
Expected salary
{orDash(i.expectedSalary)}
-
- - {i.hrComments && ( -
-
-
HR comment
-

{i.hrComments}

+ {assigned && ( +
+
+
+ +
+
Assigned to {assigned.title}
+
+ {[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'} +
+
+
+
+ + +
)} - {i.sheet && ( -
- Imported from {i.sheet} +
+
+
Contact & application
+
+
Email
{orDash(i.email)}
+
Phone
{orDash(i.phone)}
+
Applied
{i.received ? fmtDate(i.received) : '—'}
+
Source
{orDash(i.source)}
+
Screened by
{orDash(i.screenedBy)}
+
Notice period
{orDash(i.noticePeriod)}
+
+ +
Profile
+
+
Gender
{orDash(i.gender)}
+
Date of birth
{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}
+
CNIC
{orDash(i.cnic)}
+
Marital status
{orDash(i.maritalStatus)}
+
Location
{orDash(location)}
+
Education
{orDash(education)}
+
Graduation
{orDash(i.graduationYear)}
+
Other university
{orDash(i.universityOther)}
+
Current salary
{orDash(i.currentSalary)}
+
Expected salary
{orDash(i.expectedSalary)}
+
+ + {i.hrComments && ( +
+
+
HR comment
+

{i.hrComments}

+
+
+ )} + + {i.sheet && ( +
+ Imported from {i.sheet} +
+ )}
+ +
+
Matching roles
+
+ Matched by position applied for: {orDash(i.position)} +
+ {matchCards.length === 0 && !manualPost ? ( + + No job post title matches this position. Choose a role manually. +
+ +
+
+ ) : ( + matchCards.map(({ rank, post }) => ( + setSelection(String(id))} + /> + )) + )} + {manualPost && ( + setSelection(String(id))} + /> + )} + + +
+
+ + {showPicker && ( + setShowPicker(false)} + onPick={(post) => { + setManualPost(post) + setSelection(String(post.id)) + }} + /> )}
) diff --git a/frontend/src/ui/SuggestedRoles.jsx b/frontend/src/ui/SuggestedRoles.jsx index e78da6a..4285bea 100644 --- a/frontend/src/ui/SuggestedRoles.jsx +++ b/frontend/src/ui/SuggestedRoles.jsx @@ -23,7 +23,7 @@ export function reqInResume(req, resumeText) { 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 title = post?.title || 'Unavailable' const meta = [ @@ -33,6 +33,7 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual }) ? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs` : null, ].filter(Boolean).join(' · ') + const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`) return (
- {manual ? 'Manual' : `AI #${rank}`} + {tag}
{title}
{unavailable ? ( Unavailable