pull/29/head
parent
b10d02f325
commit
d5a51a28b0
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</EmptyState>
|
||||
</div>
|
||||
) : isForms ? (
|
||||
<FormApplicantDetail item={selected} loading={detailQuery.isPending} />
|
||||
<FormApplicantDetail
|
||||
item={selected}
|
||||
loading={detailQuery.isPending}
|
||||
canEdit={canEdit}
|
||||
toast={toast}
|
||||
/>
|
||||
) : (
|
||||
<ApplicationDetail
|
||||
item={selected}
|
||||
|
|
@ -1158,15 +1168,57 @@ function externalHref(url) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sheet form applicant detail — profile grids + resume/LinkedIn links.
|
||||
* No email subject/body, no ATS match panel, no inbox write actions yet.
|
||||
* Sheet form applicant detail — profile grids + resume/LinkedIn links +
|
||||
* 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 profileHref = externalHref(i.profileLink)
|
||||
const location = [i.residingCity, i.residingCountry].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 (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||||
|
|
@ -1207,6 +1259,58 @@ function FormApplicantDetail({ item: i, loading }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{assigned && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
boxShadow: 'none',
|
||||
background: 'var(--primary-soft)',
|
||||
border: '1px solid var(--primary-border)',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<div className="flex items-center gap-8">
|
||||
<Icon name="check-circle" />
|
||||
<div>
|
||||
<div>Assigned to <b>{assigned.title}</b></div>
|
||||
<div className="cell-sub">
|
||||
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!canEdit}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={() => setShowPicker(true)}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={!canEdit || assignMutation.isPending}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={() => assignMutation.mutate({ recordId: i.id, jobPostId: null })}
|
||||
>
|
||||
Unassign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
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 & application</div>
|
||||
<div
|
||||
className="info-grid"
|
||||
|
|
@ -1266,6 +1370,81 @@ function FormApplicantDetail({ item: i, loading }) {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
@ -59,7 +60,7 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual })
|
|||
>
|
||||
<div className="lr-main" style={{ minWidth: 0 }}>
|
||||
<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>
|
||||
{unavailable ? (
|
||||
<Badge className="b-gray">Unavailable</Badge>
|
||||
|
|
|
|||
Loading…
Reference in New Issue