diff --git a/backend/interview/plugins.py b/backend/interview/plugins.py index 73f770b..7b1feea 100644 --- a/backend/interview/plugins.py +++ b/backend/interview/plugins.py @@ -32,6 +32,27 @@ def _auth_headers(token=None): return {"Authorization":f"Bearer {auth_token}"} +async def get_event(event_id, token=None): + """GET {base}/calendar/events/{id} -> event dict, or None on 404.""" + encoded_id=quote(str(event_id),safe="") + async with httpx.AsyncClient(timeout=30.0) as client: + response=await client.get( + f"{_base_url()}/calendar/events/{encoded_id}", + headers={**_auth_headers(token),"accept":"application/json"}, + ) + if response.status_code==404: + return None + if response.status_code>=400: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) + if not response.content: + return {} + return response.json() + + async def create_event(payload, token=None): """POST {base}/calendar/events -> created event dict.""" async with httpx.AsyncClient(timeout=30.0) as client: diff --git a/backend/interview/serializers.py b/backend/interview/serializers.py index 340b457..adb4a5a 100644 --- a/backend/interview/serializers.py +++ b/backend/interview/serializers.py @@ -1,3 +1,34 @@ +def _email_address(block): + if not isinstance(block,dict): + return None,None + inner=block.get("emailAddress") or block.get("email_address") or block + if not isinstance(inner,dict): + return None,None + email=(inner.get("address") or inner.get("email") or "").strip() or None + name=(inner.get("name") or "").strip() or None + return email,name + + +def participants_from_event(payload): + """Graph event dict -> (organizer, attendees) as {name, email} snapshots.""" + if not isinstance(payload,dict): + payload={} + org_email,org_name=_email_address(payload.get("organizer") or {}) + organizer=None + if org_email or org_name: + organizer={"name":org_name,"email":org_email} + attendees=[] + seen=set() + for item in payload.get("attendees") or []: + email,name=_email_address(item) + key=(email or "").lower() + if not email or key in seen: + continue + seen.add(key) + attendees.append({"name":name,"email":email}) + return organizer,attendees + + def serialize_event(payload) -> dict: """Upstream calendar event -> the fields the interview row stores.""" if not isinstance(payload,dict): diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 05053f2..629893d 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -913,6 +913,18 @@ class Interviews(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_interviews_by_ids(cls, session: AsyncSession, ids): + keys=[] + for raw in ids or []: + uid=cls._as_uuid(raw) + if uid is not None: + keys.append(uid) + if not keys: + return [] + result=await session.execute(select(cls).where(cls.id.in_(keys))) + return list(result.scalars().all()) + @classmethod async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int): result = await session.execute( diff --git a/backend/job/history/serializers.py b/backend/job/history/serializers.py index 0f91e87..6b27625 100644 --- a/backend/job/history/serializers.py +++ b/backend/job/history/serializers.py @@ -1,4 +1,4 @@ -def serialize_history(row, actor_name=None) -> dict: +def serialize_history(row, actor_name=None, organizer_email=None, attendee_emails=None) -> dict: return { "id": str(row.id), "user_id": str(row.user_id) if row.user_id else None, @@ -16,5 +16,7 @@ def serialize_history(row, actor_name=None) -> dict: "actor_name": actor_name, "actor_kind": row.actor_kind, "meta": row.meta, + "organizer_email": organizer_email, + "attendee_emails": attendee_emails or None, "created_at": row.created_at.isoformat() if row.created_at else None, } diff --git a/backend/job/history/views.py b/backend/job/history/views.py index 385c971..bab10e5 100644 --- a/backend/job/history/views.py +++ b/backend/job/history/views.py @@ -1,15 +1,25 @@ +import asyncio import logging from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from inbox.models import Inbox -from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE +from job.candidate.models import CandidateHistory, Interviews, Manual_UPLOAD_CANDIDATE +from job.history.enums import HistoryEvent from job.history.serializers import serialize_history from users.models import Users logger = logging.getLogger(__name__) +INTERVIEW_HISTORY_EVENTS = { + HistoryEvent.INTERVIEW_CREATED.value, + HistoryEvent.INTERVIEW_UPDATED.value, + HistoryEvent.CALENDAR_CREATED.value, + HistoryEvent.CALENDAR_RESCHEDULED.value, + HistoryEvent.CALENDAR_CANCELLED.value, +} + def _text(value): if value is None: @@ -111,10 +121,70 @@ class HistoryRecorder: logger.exception("candidate history rollback failed") return None + async def _event_emails(self, event_id): + from interview.plugins import get_event + from interview.serializers import participants_from_event + + try: + raw = await get_event(event_id) + except Exception: + logger.exception("calendar event fetch failed for history") + return None, [] + if not raw: + return None, [] + organizer, attendees = participants_from_event(raw) + org_email = (organizer or {}).get("email") + attendee_emails = [a.get("email") for a in (attendees or []) if a.get("email")] + return org_email, attendee_emails + + async def _attach_outlook_emails(self, rows, items): + pairs = [ + (row, item) + for row, item in zip(rows, items) + if row.event_type in INTERVIEW_HISTORY_EVENTS and row.entity_id + ] + if not pairs: + return + interviews = await Interviews.get_interviews_by_ids( + self.session, {row.entity_id for row, _item in pairs} + ) + event_by_interview = { + str(r.id): r.graph_event_id for r in interviews if r.graph_event_id + } + pending = [] + event_ids = [] + for row, item in pairs: + eid = event_by_interview.get(str(row.entity_id)) + if not eid: + continue + event_ids.append(eid) + pending.append((item, eid)) + ids = list(dict.fromkeys(event_ids)) + if not ids: + return + sem = asyncio.Semaphore(5) + + async def one(eid): + async with sem: + return eid, *(await self._event_emails(eid)) + + fetched = {eid: (org, atts) for eid, org, atts in await asyncio.gather(*[one(eid) for eid in ids])} + for item, eid in pending: + org_email, attendee_emails = fetched.get(eid, (None, [])) + if org_email: + item["organizer_email"] = org_email + if attendee_emails: + item["attendee_emails"] = attendee_emails + async def list_for_user(self, user_id, *, limit=200, offset=0): rows, total = await CandidateHistory.fetch_by_user( self.session, user_id, limit=limit, offset=offset ) actor_ids = {r.actor_id for r in rows if r.actor_id} names = await Users.names_by_ids(self.session, actor_ids) - return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total + items = [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows] + try: + await self._attach_outlook_emails(rows, items) + except Exception: + logger.exception("calendar participant hydrate failed for history") + return items, total diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 55fdb54..3f2ce3b 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -345,6 +345,7 @@ class JobPosts(SQLModel, table=True): cls.location, cls.requisition_status, cls.current_recruiter_id, + cls.created_at, Recruiter.name.label("recruiter_name"), func.coalesce(stats.c.total_applicants, 0).label("total_applicants"), func.coalesce(stats.c.shortlisting, 0).label("shortlisting"), diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index a490191..cfd756d 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -86,6 +86,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app def serialize_job_stats(row) -> dict: """One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats.""" recruiter_id=row.get("current_recruiter_id") + created_at = row.get("created_at") return { "job_post_id": str(row["job_post_id"]), "title": row["title"], @@ -94,6 +95,8 @@ def serialize_job_stats(row) -> dict: "requisition_status": row["requisition_status"], "current_recruiter_id": str(recruiter_id) if recruiter_id else None, "recruiter_name": row.get("recruiter_name") or None, + # Frontend computes days-open vs client clock; no server days_open field. + "created_at": created_at.isoformat() if created_at else None, "total_applicants": int(row["total_applicants"] or 0), "shortlisting": int(row["shortlisting"] or 0), "screened": int(row["screened"] or 0), diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 3e9cc7b..9fdb59b 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -24,8 +24,8 @@ - - + +
diff --git a/frontend/smoke.test.mjs b/frontend/smoke.test.mjs index 7902e73..4309b34 100644 --- a/frontend/smoke.test.mjs +++ b/frontend/smoke.test.mjs @@ -50,7 +50,8 @@ dom.window.HTMLCanvasElement.prototype.getContext = () => // A signed-in session holding every permission tag, so no route is gated away. const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', - 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent'] + 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent', + 'requisitions'] const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'] const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`)) diff --git a/frontend/src/api/jobStats.js b/frontend/src/api/jobStats.js index 525e845..5bbd82e 100644 --- a/frontend/src/api/jobStats.js +++ b/frontend/src/api/jobStats.js @@ -27,8 +27,17 @@ export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) { }) } +/** Whole days from created_at to the browser clock. Null if timestamp missing. */ +export function daysOpen(createdAt, now = Date.now()) { + if (!createdAt) return null + const start = new Date(createdAt).getTime() + if (!Number.isFinite(start)) return null + return Math.max(0, Math.floor((now - start) / 86_400_000)) +} + /** API row -> what Progress cards and the table render. */ export function toJobStatsView(row) { + const createdAt = row.created_at || null return { id: row.job_post_id, title: row.title || 'Untitled role', @@ -38,6 +47,8 @@ export function toJobStatsView(row) { requisitionStatus: row.requisition_status, recruiterId: row.current_recruiter_id || null, recruiterName: row.recruiter_name || null, + createdAt, + daysOpen: daysOpen(createdAt), total: Number(row.total_applicants) || 0, shortlist: Number(row.shortlisting) || 0, screened: Number(row.screened) || 0, diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index ddb8bca..a4d4bf3 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -1,26 +1,4 @@ -/* The candidate profile modal, split out of Candidates.jsx — it was the - single largest block in js/candidates.js and deserves its own file. - - TWO DATA MODES, selected by whether the caller passes a `userId`: - - SEED (Candidates.jsx) — every tab renders from the seed record, exactly as - the prototype did. - LIVE (TalentPool.jsx) — GET /candidate/fetch?user_id= switches the endpoint - into detail mode and returns the real record: résumé text, the agent's - match verdict, documents, and the child collections (interviews, - notes, activity). The write tabs POST to their own endpoints - and invalidate this one query, so the whole modal repaints from a single - refetch. History is fetched separately (GET /candidate/history/fetch) - when that tab opens — it is append-only and not part of the detail payload. - - Live collections are NEVER padded with the seed's demo rows. An empty tab gets - an empty state, because inventing three scorecards for a real applicant is - worse than showing none. - - Scoping differs between the child tables and is not interchangeable: notes - hang off the candidate (users.id), while interviews and activity - hang off one application (inbox.id). */ - + import { useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -836,14 +814,22 @@ function HistoryTab({ userId }) { ) } +const INTERVIEW_HISTORY_TYPES = new Set([ + 'interview.created', 'interview.updated', + 'calendar.created', 'calendar.rescheduled', 'calendar.cancelled', +]) + function HistoryRow({ row: r }) { const look = HISTORY_ICON[r.event_type] || { icon: 'clock', tone: 'i-blue' } const title = HISTORY_TITLE[r.event_type] || r.event_type const change = [r.from_value, r.to_value].some((v) => v != null && v !== '') ? `${r.from_value ?? '—'} → ${r.to_value ?? '—'}` : null + const isInterview = INTERVIEW_HISTORY_TYPES.has(r.event_type) const actor = r.actor_name || (r.actor_id ? 'Unknown' : 'System') const when = [fmtWhen(r.created_at), fmtClock(r.created_at)].filter(Boolean).join(' · ') + const organizerEmail = r.organizer_email || null + const attendeeEmails = Array.isArray(r.attendee_emails) ? r.attendee_emails.filter(Boolean) : [] return (
@@ -853,12 +839,20 @@ function HistoryRow({ row: r }) {
{title}
{change &&
{change}
} {r.description &&
{r.description}
} -
{actor} · {when}
+ {organizerEmail &&
Organizer: {organizerEmail}
} + {attendeeEmails.length > 0 && ( +
+ Attendees: {attendeeEmails.join(', ')} +
+ )} +
{isInterview ? when : `${actor} · ${when}`}
- {r.actor_name || r.actor_id - ? - : System} + {isInterview + ? null + : (r.actor_name || r.actor_id + ? + : System)}
) diff --git a/frontend/src/screens/Progress.jsx b/frontend/src/screens/Progress.jsx index 4714e4c..fc56c36 100644 --- a/frontend/src/screens/Progress.jsx +++ b/frontend/src/screens/Progress.jsx @@ -1,9 +1,8 @@ /* ============================================================ - Progress — per-job pipeline stage overview from GET /job/stats/fetch. + Progress — single master/detail view of GET /job/stats/fetch. - Two tabs: Overview (job picker + stage tiles) and All job posts (table). - Counts are unique applicants by email; recruiter comes from - job_posts.current_recruiter_id. + Left: searchable/filterable job index (paged). Right: selected job + pipeline breakdown. Days-open is computed client-side from created_at. ============================================================ */ import { useEffect, useMemo, useState } from 'react' @@ -11,65 +10,193 @@ import { useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import PageHeader from '../ui/PageHeader' -import DataTable from '../ui/DataTable' -import { Badge, EmptyState, Icon, KpiCard, SkeletonRows } from '../ui/primitives' +import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as jobStatsApi from '../api/jobStats' +const PAGE_SIZE = 10 + const STAGES = [ - { key: 'shortlist', label: 'Shortlisted', icon: 'star', tone: 'blue' }, - { key: 'screened', label: 'Screened', icon: 'eye', tone: 'purple' }, - { key: 'assessment', label: 'Assessment', icon: 'check-square', tone: 'amber' }, - { key: 'interviewed', label: 'Interviewed', icon: 'calendar', tone: 'indigo' }, - { key: 'offered', label: 'Offered', icon: 'send', tone: 'teal' }, - { key: 'onHold', label: 'On Hold', icon: 'clock', tone: 'amber' }, - { key: 'rejected', label: 'Rejected', icon: 'x-circle', tone: 'red' }, + { key: 'shortlist', label: 'Shortlisted', tone: 'blue' }, + { key: 'screened', label: 'Screened', tone: 'purple' }, + { key: 'assessment', label: 'Assessment', tone: 'amber' }, + { key: 'interviewed', label: 'Interviewed', tone: 'indigo' }, + { key: 'offered', label: 'Offered', tone: 'teal' }, + { key: 'onHold', label: 'On hold', tone: 'amber' }, + { key: 'rejected', label: 'Rejected', tone: 'red' }, + { key: 'hired', label: 'Hired', tone: 'teal' }, ] function sumField(jobs, key) { return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0) } -function StageTile({ stage, value, total }) { - const pct = total ? Math.round((value / total) * 100) : 0 +function statusTone(status) { + const value = String(status || '').toLowerCase() + if (value === 'open') return 'success' + if (value === 'on_hold' || value === 'on hold') return 'warning' + return 'muted' +} + +function Metric({ value, label, accent = false }) { return ( -
-
- - {stage.label} - {pct}% -
- {value} - +
+ {value} + {label}
) } -function StageBar({ job }) { - const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0) - const base = Math.max(used, job.total, 1) +function JobRow({ job, active, onSelect }) { return ( -
-
- Current stage distribution - {job.total} unique applicants +