From cd35af8161d407f5b1800484de8ca2fa6fa15870 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 20 Aug 2026 16:41:21 +0500 Subject: [PATCH] Enhance interview and job functionalities - Updated the interview model to include `graph_event_id` and `web_link` for calendar integration. - Added methods for setting calendar events and retrieving job titles associated with interviews. - Modified serializers to include new fields in the interview response. - Enhanced the job post model to include applicant counts. - Updated frontend components to handle new fields and improve user experience with calendar event management. - Introduced image upload functionality in job forms with validation for file types and sizes. - Improved dashboard and calendar UI for better data presentation. This commit streamlines interview scheduling and job management processes, enhancing overall application functionality. --- .gitignore | 4 +- backend/.env.example | 3 + backend/interview/app.py | 85 +++++ backend/interview/plugins.py | 92 +++++ backend/interview/serializers.py | 32 ++ backend/interview/views.py | 175 +++++++++ backend/job/candidate/models.py | 64 +++- backend/job/interviews/serializers.py | 5 +- backend/job/interviews/views.py | 20 +- backend/job/job_post/models.py | 20 + backend/job/job_post/serializers.py | 3 +- backend/job/job_post/views.py | 7 +- backend/main.py | 2 + frontend/src/api/interviews.js | 42 ++- frontend/src/api/jobs.js | 1 + frontend/src/screens/Calendar.jsx | 17 +- frontend/src/screens/Dashboard.jsx | 512 +++++++++++++------------- frontend/src/screens/Interviews.jsx | 60 ++- frontend/src/screens/Jobs.jsx | 117 +++++- frontend/src/styles/styles.css | 44 +++ frontend/src/ui/primitives.jsx | 23 ++ 21 files changed, 1049 insertions(+), 279 deletions(-) create mode 100644 backend/interview/app.py create mode 100644 backend/interview/plugins.py create mode 100644 backend/interview/serializers.py create mode 100644 backend/interview/views.py diff --git a/.gitignore b/.gitignore index 9d60d3e..c30fcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ node_modules/ frontend/dist/ **.pdf -**_**_**.py \ No newline at end of file +**_**_**.py +Utopia-ai-hr-ats-portal 1.pem +db_setup.py \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 20041c7..6320fd0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,6 +5,9 @@ DB_PORT= DB_NAME= EMAIL_URL= EMAIL_API_TOKEN= +# Optional overrides; blank falls back to EMAIL_URL / EMAIL_API_TOKEN. +CALENDAR_URL= +CALENDAR_API_TOKEN= EMAIL_SYNC_FOLDER=inbox EMAIL_SYNC_SINCE= EMAIL_SYNC_CRON=* * * * * diff --git a/backend/interview/app.py b/backend/interview/app.py new file mode 100644 index 0000000..82dbbed --- /dev/null +++ b/backend/interview/app.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter,Depends +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from pydantic import BaseModel +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from interview.views import Calendar +from users.permissions import PermissionTag,require_permission +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class CalendarCreateBody(BaseModel): + duration_minutes: int | None = 30 + + +class CalendarRescheduleBody(BaseModel): + instant: str + duration_minutes: int | None = 30 + + +class CalendarCancelBody(BaseModel): + comment: str | None = None + + +@router.post("/interview/{interview_id}/calendar-event") +async def create_calendar_event( + interview_id: str, + payload: CalendarCreateBody | None = None, + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + body=payload or CalendarCreateBody() + service=Calendar(session=session) + data=await service.create_for_interview( + interview_id, + duration_minutes=body.duration_minutes, + ) + 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.patch("/interview/{interview_id}/calendar-event/reschedule") +async def reschedule_calendar_event( + interview_id: str, + payload: CalendarRescheduleBody, + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Calendar(session=session) + data=await service.reschedule_for_interview( + interview_id, + instant=payload.instant, + duration_minutes=payload.duration_minutes, + ) + 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.post("/interview/{interview_id}/calendar-event/cancel") +async def cancel_calendar_event( + interview_id: str, + payload: CalendarCancelBody | None = None, + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + body=payload or CalendarCancelBody() + service=Calendar(session=session) + data=await service.cancel_for_interview(interview_id,comment=body.comment) + 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)) diff --git a/backend/interview/plugins.py b/backend/interview/plugins.py new file mode 100644 index 0000000..73f770b --- /dev/null +++ b/backend/interview/plugins.py @@ -0,0 +1,92 @@ +"""Calendar upstream helpers — HTTP to EMAIL_URL/CALENDAR_URL. + +Write paths (create / reschedule / cancel) follow the documented OpenAPI contract +but were not exercised live when this package was written; treat a non-2xx as an +upstream-contract finding before changing the request shape. +""" + +from __future__ import annotations + +import os +from urllib.parse import quote + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +CALENDAR_URL=os.getenv("CALENDAR_URL") or os.getenv("EMAIL_URL") +CALENDAR_API_TOKEN=os.getenv("CALENDAR_API_TOKEN") or os.getenv("EMAIL_API_TOKEN") + + +def _base_url(): + if not CALENDAR_URL: + raise RuntimeError("CALENDAR_URL or EMAIL_URL must be set") + return CALENDAR_URL.rstrip("/") + + +def _auth_headers(token=None): + auth_token=token or CALENDAR_API_TOKEN + if not auth_token: + raise RuntimeError("CALENDAR_API_TOKEN or EMAIL_API_TOKEN must be set") + return {"Authorization":f"Bearer {auth_token}"} + + +async def create_event(payload, token=None): + """POST {base}/calendar/events -> created event dict.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response=await client.post( + f"{_base_url()}/calendar/events", + json=payload, + headers=_auth_headers(token), + ) + 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 reschedule_event(event_id, payload, token=None): + """PATCH {base}/calendar/events/{id}/reschedule -> updated event dict.""" + encoded_id=quote(str(event_id),safe="") + async with httpx.AsyncClient(timeout=30.0) as client: + response=await client.patch( + f"{_base_url()}/calendar/events/{encoded_id}/reschedule", + json=payload, + headers=_auth_headers(token), + ) + 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 cancel_event(event_id, comment=None, token=None): + """POST {base}/calendar/events/{id}/cancel -> response body or empty dict.""" + encoded_id=quote(str(event_id),safe="") + body={"comment":comment} if comment is not None else {} + async with httpx.AsyncClient(timeout=30.0) as client: + response=await client.post( + f"{_base_url()}/calendar/events/{encoded_id}/cancel", + json=body, + headers=_auth_headers(token), + ) + if response.status_code>=400: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) + if not response.content: + return {} + return response.json() diff --git a/backend/interview/serializers.py b/backend/interview/serializers.py new file mode 100644 index 0000000..340b457 --- /dev/null +++ b/backend/interview/serializers.py @@ -0,0 +1,32 @@ +def serialize_event(payload) -> dict: + """Upstream calendar event -> the fields the interview row stores.""" + if not isinstance(payload,dict): + payload={} + online=payload.get("onlineMeeting") or payload.get("online_meeting") or {} + if not isinstance(online,dict): + online={} + start=payload.get("start") + end=payload.get("end") + if isinstance(start,dict): + start=start.get("dateTime") or start.get("date_time") + if isinstance(end,dict): + end=end.get("dateTime") or end.get("date_time") + if hasattr(start,"isoformat"): + start=start.isoformat() + if hasattr(end,"isoformat"): + end=end.isoformat() + event_id=payload.get("id") + return { + "id": str(event_id) if event_id is not None else None, + "web_link": payload.get("webLink") or payload.get("web_link") or None, + "online_meeting_url": ( + payload.get("onlineMeetingUrl") + or payload.get("online_meeting_url") + or online.get("joinUrl") + or online.get("join_url") + or None + ), + "subject": payload.get("subject"), + "start": start, + "end": end, + } diff --git a/backend/interview/views.py b/backend/interview/views.py new file mode 100644 index 0000000..5965920 --- /dev/null +++ b/backend/interview/views.py @@ -0,0 +1,175 @@ +"""Calendar service — create / reschedule / cancel Outlook events for interviews. + +Upstream write paths follow the documented contract but were not proven live +when this landed; a non-2xx here is an upstream-contract finding first. +""" + +from __future__ import annotations + +from datetime import datetime,timedelta,timezone + +import httpx +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from interview.plugins import CALENDAR_API_TOKEN,cancel_event,create_event,reschedule_event +from interview.serializers import serialize_event +from job.candidate.models import Interviews +from job.interviews.serializers import serialize_interview +from job.job_post.models import JobPosts + +DEFAULT_DURATION_MINUTES=30 + + +def _naive_utc(dt): + """ISO8601 without offset — CreateEventRequest / RescheduleRequest form.""" + if dt is None: + return None + if isinstance(dt,str): + dt=datetime.fromisoformat(dt.replace("Z","+00:00")) + if dt.tzinfo is not None: + dt=dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt.strftime("%Y-%m-%dT%H:%M:%S") + + +def _as_datetime(value): + if value is None: + return None + if isinstance(value,datetime): + return value + if isinstance(value,str): + return datetime.fromisoformat(value.replace("Z","+00:00")) + return value + + +class Calendar: + def __init__(self,session:AsyncSession,token=None): + self.session=session + self.token=token or CALENDAR_API_TOKEN + + async def _load_interview(self,interview_id): + row=await Interviews.get_interview_by_id(self.session,interview_id) + if not row: + raise HTTPException(status_code=404,detail="Interview not found") + return row + + def _attendee(self,row): + inbox=getattr(row,"inbox",None) #getattr(object,method/key,default) + user=getattr(inbox,"user",None) if inbox else None + email=(getattr(user,"email",None) or "").strip() if user else "" + name=(getattr(user,"name",None) or "").strip() if user else "" + return email or None,name or None + + async def _job_title(self,row): + inbox=getattr(row,"inbox",None) + messages=getattr(inbox,"messages",None) if inbox else None + job_post_id=getattr(messages,"assigned_job_post_id",None) if messages else None + if not job_post_id: + return None + job=await JobPosts.get_job_post_by_id(self.session,str(job_post_id)) + return job.title if job else None + + async def _serialize(self,row): + return serialize_interview(row,job_title=await self._job_title(row)) + + async def create_for_interview(self,interview_id,duration_minutes=None): + row=await self._load_interview(interview_id) + if row.graph_event_id: + return await self._serialize(row) + + minutes=int(duration_minutes or DEFAULT_DURATION_MINUTES) + if minutes<=0: + raise HTTPException(status_code=422,detail="duration_minutes must be positive") + + start_dt=row.interview_date or row.interview_time + if start_dt is None: + raise HTTPException(status_code=422,detail="Interview has no start time") + end_dt=start_dt+timedelta(minutes=minutes) + + email,name=self._attendee(row) + if not email: + raise HTTPException(status_code=422,detail="Interview candidate has no email") + + subject_bits=[row.interview_type or "Interview"] + if name: + subject_bits.append(name) + job_title=await self._job_title(row) + if job_title: + subject_bits.append(job_title) + + payload={ + "subject":" — ".join(subject_bits), + "start":_naive_utc(start_dt), + "end":_naive_utc(end_dt), + "time_zone":"UTC", + "attendees":[{"email":email,"name":name,"type":"required"}], + "is_online_meeting":True, + "allow_new_time_proposals":False, + } + + try: + raw=await create_event(payload,token=self.token) + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code,detail=e.response.text) + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + event=serialize_event(raw) + if not event.get("id"): + raise HTTPException(status_code=502,detail="Calendar create returned no event id") + + row=await Interviews.set_calendar_event( + self.session,interview_id,event["id"],event.get("web_link"), + ) + return await self._serialize(row) + + async def reschedule_for_interview(self,interview_id,instant,duration_minutes=None): + row=await self._load_interview(interview_id) + if not row.graph_event_id: + raise HTTPException(status_code=404,detail="No calendar event for this interview") + + start_dt=_as_datetime(instant) + if start_dt is None: + raise HTTPException(status_code=422,detail="instant is required") + minutes=int(duration_minutes or DEFAULT_DURATION_MINUTES) + if minutes<=0: + raise HTTPException(status_code=422,detail="duration_minutes must be positive") + end_dt=start_dt+timedelta(minutes=minutes) + + payload={ + "start":_naive_utc(start_dt), + "end":_naive_utc(end_dt), + "time_zone":"UTC", + } + + try: + raw=await reschedule_event(row.graph_event_id,payload,token=self.token) + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code,detail=e.response.text) + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + event=serialize_event(raw) + fields={ + "interview_date":start_dt, + "interview_time":start_dt, + } + if event.get("web_link"): + fields["web_link"]=event["web_link"] + row=await Interviews.update_interview(self.session,interview_id,fields) + return await self._serialize(row) + + async def cancel_for_interview(self,interview_id,comment=None): + row=await self._load_interview(interview_id) + if not row.graph_event_id: + return await self._serialize(row) + + try: + await cancel_event(row.graph_event_id,comment=comment,token=self.token) + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code,detail=e.response.text) + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + row=await Interviews.set_calendar_event(self.session,interview_id,None,None) + return await self._serialize(row) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index b3e4e89..9df020d 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -6,6 +6,7 @@ from fastapi import HTTPException from sqlalchemy import JSON, DateTime, func, UniqueConstraint from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select if TYPE_CHECKING: @@ -363,6 +364,8 @@ class Interviews(SQLModel, table=True): interview_type: str = Field(default="") interview_status: str = Field(default="") inbox_id: int | None = Field(default=None, foreign_key="inbox.id") + graph_event_id: str | None = Field(default=None) + web_link: str | None = Field(default=None) inbox: Optional["Inbox"] = Relationship( back_populates="interviews", sa_relationship_kwargs={"lazy": "selectin"}, @@ -375,18 +378,29 @@ class Interviews(SQLModel, table=True): except ValueError: return None + @classmethod + def _with_inbox_message(cls): + from inbox.models import Inbox + + return selectinload(cls.inbox).selectinload(Inbox.messages) + @classmethod async def get_interview_by_id(cls, session: AsyncSession, record_id): uid = cls._as_uuid(record_id) if uid is None: return None - result = await session.execute(select(cls).where(cls.id == uid)) + result = await session.execute( + select(cls).options(cls._with_inbox_message()).where(cls.id == uid) + ) return result.scalars().first() @classmethod async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int): result = await session.execute( - select(cls).where(cls.inbox_id == inbox_id).order_by(cls.interview_date.desc()) + select(cls) + .options(cls._with_inbox_message()) + .where(cls.inbox_id == inbox_id) + .order_by(cls.interview_date.desc()) ) return result.scalars().all() @@ -410,7 +424,9 @@ class Interviews(SQLModel, table=True): statement = statement.where(cls.interview_status == status) count_statement = select(func.count()).select_from(statement.subquery()) total = (await session.execute(count_statement)).scalar_one() - statement = statement.order_by(cls.interview_date.asc()) + statement = ( + statement.options(cls._with_inbox_message()).order_by(cls.interview_date.asc()) + ) if skip: statement = statement.offset(skip) if top is not None: @@ -418,6 +434,36 @@ class Interviews(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()), total + @classmethod + async def job_titles_by_inbox(cls, session: AsyncSession, inbox_ids) -> dict[int, str]: + """Resolve {inbox_id: job_title} for a page of interview rows. + + Two constraints keep this off get_interviews_in_range: + 1. Interviews.inbox is selectin, but Inbox.messages defaults to lazy="select" + and raises MissingGreenlet under AsyncSession. Switching that relation to + selectin would extra-query the candidate list, pipeline, activity and inbox. + 2. Widening the range statement with a join would INNER-join the count + subquery and drop interviews whose application has no assigned requisition, + changing `total` on Interviews and Calendar. + + INNER joins are correct here — an unassigned inbox simply produces no dict + entry and .get() yields None. No response rows are lost because the rows + still come from the untouched range statement. + """ + from inbox.models import Inbox, Inbox_Messages + from job.job_post.models import JobPosts + + ids = {int(i) for i in (inbox_ids or []) if i is not None} + if not ids: + return {} + result = await session.execute( + select(Inbox.id, JobPosts.title) + .join(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) + .join(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id) + .where(Inbox.id.in_(ids)) + ) + return {int(inbox_id): title for inbox_id, title in result.all()} + @classmethod async def insert_interview(cls, session: AsyncSession, fields: dict): row = cls(**fields) @@ -437,6 +483,18 @@ class Interviews(SQLModel, table=True): await session.refresh(row) return row + @classmethod + async def set_calendar_event(cls, session: AsyncSession, record_id, event_id, web_link): + row = await cls.get_interview_by_id(session, record_id) + if not row: + return None + row.graph_event_id = event_id + row.web_link = web_link + session.add(row) + await session.commit() + await session.refresh(row) + return row + class Notes(SQLModel, table=True): __tablename__ = "notes" diff --git a/backend/job/interviews/serializers.py b/backend/job/interviews/serializers.py index 7cab1b9..7a2bebd 100644 --- a/backend/job/interviews/serializers.py +++ b/backend/job/interviews/serializers.py @@ -1,4 +1,4 @@ -def serialize_interview(row) -> dict: +def serialize_interview(row, *, job_title=None) -> dict: inbox=getattr(row,"inbox",None) user=getattr(inbox,"user",None) if inbox else None return { @@ -9,4 +9,7 @@ def serialize_interview(row) -> dict: "interview_type": row.interview_type, "interview_status": row.interview_status, "candidate_name": user.name if user else None, + "job_title": job_title or None, + "graph_event_id": row.graph_event_id or None, + "web_link": row.web_link or None, } diff --git a/backend/job/interviews/views.py b/backend/job/interviews/views.py index bb2feec..5c75538 100644 --- a/backend/job/interviews/views.py +++ b/backend/job/interviews/views.py @@ -3,12 +3,25 @@ from sqlalchemy.ext.asyncio import AsyncSession from job.candidate.models import Interviews from job.interviews.serializers import serialize_interview +from job.job_post.models import JobPosts class Interview: def __init__(self,session:AsyncSession): self.session=session + async def _job_title_for(self,row): + inbox=getattr(row,"inbox",None) + messages=getattr(inbox,"messages",None) if inbox else None + job_post_id=getattr(messages,"assigned_job_post_id",None) if messages else None + if not job_post_id: + return None + job=await JobPosts.get_job_post_by_id(self.session,str(job_post_id)) + return job.title if job else None + + async def _serialize(self,row): + return serialize_interview(row,job_title=await self._job_title_for(row)) + async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0): if interview_id: row=await Interviews.get_interview_by_id(self.session,interview_id) @@ -33,7 +46,8 @@ class Interview: top=top, skip=skip, ) - return [serialize_interview(r) for r in rows],total + titles=await Interviews.job_titles_by_inbox(self.session,[r.inbox_id for r in rows]) + return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total async def create_interview(self,payload): fields={ @@ -45,7 +59,7 @@ class Interview: } fields={k:v for k,v in fields.items() if v is not None} row=await Interviews.insert_interview(self.session,fields) - return serialize_interview(row) + return await self._serialize(row) async def update_interview(self,interview_id,payload): fields={k:v for k,v in payload.items() if v is not None} @@ -54,4 +68,4 @@ class Interview: row=await Interviews.update_interview(self.session,interview_id,fields) if not row: raise HTTPException(status_code=404,detail="Interview not found") - return serialize_interview(row) + return await self._serialize(row) diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index a91ca55..1f6277b 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -163,6 +163,26 @@ class JobPosts(SQLModel, table=True): ) return {str(uid): name for uid, name in result.all()} + @classmethod + async def applicant_counts(cls, session: AsyncSession, job_post_ids) -> dict[str, int]: + """Resolve {job_post_id: applicant_count} for a page of rows in a single query. + + Counts Inbox_Messages rows, not Inbox rows: one message fans out to several + Inbox rows (one per recipient), so counting Inbox would over-count. + Local import matches recruiter_names — job_post.models ↔ inbox.models is a cycle. + """ + from inbox.models import Inbox_Messages + + uids = {u for u in (job_post_ids or []) if u} + if not uids: + return {} + result = await session.execute( + select(Inbox_Messages.assigned_job_post_id, func.count().label("applicants")) + .where(Inbox_Messages.assigned_job_post_id.in_(uids)) + .group_by(Inbox_Messages.assigned_job_post_id) + ) + return {str(job_id): int(n) for job_id, n in result.all()} + @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 65220c2..52b223f 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -27,7 +27,7 @@ def serialize_job_post(row) -> dict: } -def serialize_job_row(row, *, recruiter_name=None) -> dict: +def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict: """Requisition view of a job post, for the Jobs screen. Deliberately separate from serialize_job_post: that payload is shared by the @@ -56,6 +56,7 @@ def serialize_job_row(row, *, recruiter_name=None) -> dict: "closed_at": row.closed_at.isoformat() if row.closed_at else None, "current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None, "recruiter_name": recruiter_name, + "applicant_count": applicant_count, "created_by": str(row.created_by) if row.created_by else None, "created_by_name": row.user.name if getattr(row, "user", None) else None, "created_at": row.created_at.isoformat() if row.created_at else None, diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 22e385d..b983477 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -176,8 +176,13 @@ class JobPost: names=await JobPosts.recruiter_names( self.session,[r.current_recruiter_id for r in rows], ) + counts=await JobPosts.applicant_counts(self.session,[r.id for r in rows]) return [ - serialize_job_row(r,recruiter_name=names.get(str(r.current_recruiter_id))) + serialize_job_row( + r, + recruiter_name=names.get(str(r.current_recruiter_id)), + applicant_count=counts.get(str(r.id),0), + ) for r in rows ],total diff --git a/backend/main.py b/backend/main.py index 2c8bf64..b171a05 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,7 @@ from assessments.app import router as assessments_router from org_settings.app import router as org_settings_router from saved_search.app import router as saved_search_router from search.app import router as search_router +from interview.app import router as interview_router logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s") logger=logging.getLogger("main") @@ -100,3 +101,4 @@ app.include_router(assessments_router) app.include_router(org_settings_router) app.include_router(saved_search_router) app.include_router(search_router) +app.include_router(interview_router) diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js index ca97f30..73a3238 100644 --- a/frontend/src/api/interviews.js +++ b/frontend/src/api/interviews.js @@ -93,13 +93,11 @@ export function update(interviewId, { instant, type, status } = {}) { * API row -> what the Interviews table, the Calendar grid and the Up Next rail * render. * - * serialize_interview returns seven fields and the `interviews` table has no - * more columns than that, so five things the prototype showed have no source: - * meeting mode (video / on-site / phone), duration, interviewer list, the - * feedback verdict and a numeric score. They are absent here rather than - * defaulted, and the screens drop those columns — the same rule Jobs and - * Candidates already follow. `job_title` is likewise absent; the caller - * hydrates it from the application row when it has one. + * serialize_interview returns the interview columns plus optional calendar sync + * fields (`graph_event_id`, `web_link`) and `job_title`. Meeting mode, duration, + * interviewer list and feedback verdict still have no source — they stay absent + * rather than defaulted. Screens that already hydrate `jobTitle` from the + * application row keep doing so as a fallback. */ export function toInterviewView(row) { const whenRaw = row.interview_date || row.interview_time @@ -111,5 +109,35 @@ export function toInterviewView(row) { type: row.interview_type || 'Interview', status: row.interview_status || 'Scheduled', when: when && !Number.isNaN(when.getTime()) ? when : null, + jobTitle: row.job_title || null, + graphEventId: row.graph_event_id || null, + webLink: row.web_link || null, } } + +/** Create an Outlook event for an existing interview row (30 min default). */ +export function createCalendarEvent(interviewId, { durationMinutes = 30 } = {}) { + return request(`/interview/${interviewId}/calendar-event`, { + method: 'POST', + body: { duration_minutes: durationMinutes }, + }) +} + +/** Move the Outlook event; also updates the interview row times server-side. */ +export function rescheduleCalendarEvent(interviewId, { instant, durationMinutes = 30 }) { + return request(`/interview/${interviewId}/calendar-event/reschedule`, { + method: 'PATCH', + body: { + instant, + duration_minutes: durationMinutes, + }, + }) +} + +/** Cancel the Outlook event and clear graph_event_id / web_link on the row. */ +export function cancelCalendarEvent(interviewId, { comment } = {}) { + return request(`/interview/${interviewId}/calendar-event/cancel`, { + method: 'POST', + body: comment != null ? { comment } : {}, + }) +} diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index fae4530..f673b65 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -49,6 +49,7 @@ export function toJobView(row) { recruiter: row.recruiter_name, recruiterId: row.current_recruiter_id, createdByName: row.created_by_name, + applicantCount: row.applicant_count ?? 0, // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. created: row.created_at ? new Date(row.created_at) : null, closedAt: row.closed_at ? new Date(row.closed_at) : null, diff --git a/frontend/src/screens/Calendar.jsx b/frontend/src/screens/Calendar.jsx index 9c5660e..b4516e4 100644 --- a/frontend/src/screens/Calendar.jsx +++ b/frontend/src/screens/Calendar.jsx @@ -80,7 +80,11 @@ export default function Calendar() { .filter((iv) => iv.when) .map((iv) => { const app = appByInbox.get(iv.inboxId) - return { ...iv, jobTitle: app?.jobTitle ?? null, userId: app?.userId ?? null } + return { + ...iv, + jobTitle: iv.jobTitle || app?.jobTitle || null, + userId: app?.userId ?? null, + } }), [monthQuery.data, appByInbox], ) @@ -228,6 +232,17 @@ export default function Calendar() {
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+ {iv.webLink && ( + e.stopPropagation()} + > + Open in Outlook + + )} )) diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 57fa3f6..226b73f 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -1,20 +1,23 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Chart, { ChartLegend } from '../ui/Chart' import Charts from '../lib/charts' -import { Avatar, EmptyState, Icon, KpiCard, ScoreChip } from '../ui/primitives' +import Dropdown from '../ui/Dropdown' +import { Avatar, Badge, EmptyState, Icon, KpiTile, ProgressBar } from '../ui/primitives' +import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { ApiError, friendlyAuthError } from '../lib/errors' -import { fmtShort, money, relTime, initials as initialsOf, avatarColor } from '../data/seed' +import { fmtShort, money, initials as initialsOf, avatarColor } from '../data/seed' import * as analyticsApi from '../api/analytics' import * as interviewsApi from '../api/interviews' -import * as activityApi from '../api/activity' -import * as candidatesApi from '../api/candidates' +import * as jobsApi from '../api/jobs' +import * as tasksApi from '../api/tasks' const POLL_MS = 60_000 +const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' } function asObject(data) { return data && typeof data === 'object' && !Array.isArray(data) ? data : null @@ -79,48 +82,6 @@ function mapInterviewRow(iv) { } } -function mapCandidateRow(c) { - const name = c.name || 'Candidate' - const appliedRaw = c.created_at || c.applied - return { - id: c.user_id || c.inbox_id || name, - userId: c.user_id, - name, - initials: initialsFrom(name), - color: avatarColor(name), - jobTitle: c.current_employment || c.experience || '—', - aiScore: c.ats_score ?? c.ai_score ?? null, - applied: appliedRaw ? new Date(appliedRaw) : new Date(0), - } -} - -function mapActivityRow(a) { - const desc = a.description || a.activity_type || 'Activity' - const actor = a.actor_name - const parts = actor - ? [{ b: actor }, ` ${desc}`] - : [desc] - const whenRaw = a.activity_date || a.activity_time - const when = whenRaw ? new Date(whenRaw) : null - const mins = when && !Number.isNaN(when.getTime()) - ? Math.max(0, Math.round((Date.now() - when.getTime()) / 60000)) - : 0 - const type = (a.activity_type || '').toLowerCase() - let icon = 'file' - let color = 'i-indigo' - if (type.includes('interview')) { icon = 'calendar'; color = 'i-blue' } - else if (type.includes('offer')) { icon = 'check'; color = 'i-teal' } - else if (type.includes('hire') || type.includes('stage')) { icon = 'user-plus'; color = 'i-green' } - return { - id: a.id, - icon, - color, - parts, - time: mins, - candidateId: a.inbox_id, - } -} - /** * What actually went wrong, rather than one guess applied to everything. * @@ -170,11 +131,33 @@ function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) return children(rows) } +function clock(d) { + return d + ? d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + : '—' +} + export default function Dashboard() { const navigate = useNavigate() - const { user } = useAuth() + const { user, can } = useAuth() + const { toast } = useToast() + const qc = useQueryClient() const firstName = (user?.name || 'there').split(' ')[0] const todayLabel = formatDashDate() + const [trendMonths, setTrendMonths] = useState(7) + + // Frozen at mount so the interviews query key does not churn every render + // (or every 60s poll) and refetch the widget. + const dayStart = useMemo(() => { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d + }, []) + const dayEnd = useMemo(() => { + const d = new Date(dayStart) + d.setDate(d.getDate() + 1) + return d + }, [dayStart]) const kpisQuery = useQuery({ queryKey: qk.analytics.kpis(), @@ -182,8 +165,8 @@ export default function Dashboard() { refetchInterval: POLL_MS, }) const trendQuery = useQuery({ - queryKey: qk.analytics.trend({ months: 7 }), - queryFn: async () => asObject((await analyticsApi.hiringTrend({ months: 7 })).data) || { + queryKey: qk.analytics.trend({ months: trendMonths }), + queryFn: async () => asObject((await analyticsApi.hiringTrend({ months: trendMonths })).data) || { labels: [], applications: [], hires: [], @@ -195,37 +178,61 @@ export default function Dashboard() { queryFn: async () => asList((await analyticsApi.funnel()).data), refetchInterval: POLL_MS, }) - const sourcesQuery = useQuery({ - queryKey: qk.analytics.sources(), - queryFn: async () => asList((await analyticsApi.sourcePerformance()).data), - refetchInterval: POLL_MS, - }) - const recruitersQuery = useQuery({ - queryKey: qk.analytics.recruiters({ top: 5 }), - queryFn: async () => asList((await analyticsApi.recruiterPerformance({ top: 5 })).data), - refetchInterval: POLL_MS, - }) const interviewsQuery = useQuery({ - queryKey: qk.interviews.range({ status: 'Scheduled', top: 5 }), - queryFn: async () => asList((await interviewsApi.listRange({ status: 'Scheduled', top: 5 })).data) - .map(mapInterviewRow), + queryKey: qk.interviews.range({ + fromDate: dayStart.toISOString(), + toDate: dayEnd.toISOString(), + top: 8, + }), + queryFn: async () => asList((await interviewsApi.listRange({ + fromDate: dayStart.toISOString(), + toDate: dayEnd.toISOString(), + top: 8, + })).data).map(mapInterviewRow), }) - const candidatesQuery = useQuery({ - queryKey: qk.candidates.list({ limit: 5 }), - queryFn: async () => { - const rows = candidatesApi.toRows(await candidatesApi.list({ limit: 5, offset: 0 })) - return asList(rows).map(mapCandidateRow).sort((a, b) => b.applied - a.applied).slice(0, 5) - }, + const jobsQuery = useQuery({ + queryKey: qk.jobs.list({ top: 5 }), + queryFn: async () => asList((await jobsApi.list({ top: 5 })).data).map(jobsApi.toJobView), }) - const activityQuery = useQuery({ - queryKey: qk.activity.feed({ top: 8 }), - queryFn: async () => asList((await activityApi.feed({ top: 8 })).data).map(mapActivityRow), + const tasksQuery = useQuery({ + queryKey: qk.tasks.list({ top: 8 }), + queryFn: async () => asList((await tasksApi.list({ top: 8 })).data).map(tasksApi.toTaskView), }) const k = kpisQuery.data + const canEditTasks = can('tasks.edit') + + const flip = useMutation({ + mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }), + onMutate: async ({ id, done }) => { + await qc.cancelQueries({ queryKey: qk.tasks.all() }) + const key = qk.tasks.list({ top: 8 }) + const previous = qc.getQueryData(key) + qc.setQueryData(key, (old = []) => + old.map((t) => (t.id === id ? { ...t, done } : t)), + ) + return { previous, key } + }, + onError: (err, _vars, ctx) => { + if (ctx?.previous) qc.setQueryData(ctx.key, ctx.previous) + toast(friendlyAuthError(err, 'Could not update the task.'), 'error') + }, + onSuccess: (_res, { done }) => { + toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info') + }, + onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }), + }) + + function toggleTask(task) { + if (!canEditTasks) { + toast('Requires tasks.edit', 'info') + return + } + flip.mutate({ id: task.id, done: !task.done }) + } const trendData = useMemo(() => { const t = trendQuery.data || { labels: [], applications: [], hires: [] } @@ -239,23 +246,38 @@ export default function Dashboard() { } }, [trendQuery.data]) - const pipelineData = useMemo(() => { + const candidateSpark = useMemo( + () => asList(trendQuery.data?.applications), + [trendQuery.data], + ) + const hireSpark = useMemo( + () => asList(trendQuery.data?.hires), + [trendQuery.data], + ) + + const pipeRows = useMemo(() => { + const rows = asList(funnelQuery.data) + const base = rows[0]?.count || 0 + const pal = Charts.PALETTE + return rows.map((r, i) => ({ + stage: r.stage, + count: r.count, + pct: base ? Math.round((r.count / base) * 100) : 0, + color: pal[i % pal.length], + })) + }, [funnelQuery.data]) + + const pipelineDoughnut = useMemo(() => { const rows = asList(funnelQuery.data) return { labels: rows.map((p) => p.stage), data: rows.map((p) => p.count), colors: Charts.PALETTE, + centerValue: rows.reduce((sum, s) => sum + (s.count || 0), 0), + centerLabel: 'In pipeline', } }, [funnelQuery.data]) - const sourceData = useMemo(() => { - const rows = asList(sourcesQuery.data) - return { - labels: rows.map((s) => s.source), - data: rows.map((s) => s.count), - } - }, [sourcesQuery.data]) - const legend = useMemo( () => [ { label: 'Applications', color: Charts.PALETTE[4] }, @@ -267,91 +289,67 @@ export default function Dashboard() { const pending = kpisQuery.isPending const dash = (v) => (pending || v == null || v === '' ? '—' : v) - const row1 = [ + const tiles = [ { label: 'Open Jobs', value: dash(k?.open_jobs), - icon: 'briefcase', - tone: 'i-indigo', trend: pctDelta(k?.open_jobs, k?.open_jobs_prior) || '—', dir: Number(k?.open_jobs) >= Number(k?.open_jobs_prior) ? 'up' : 'down', - foot: 'vs prior window', + spark: null, }, { label: 'Total Candidates', value: dash(k?.total_candidates), - icon: 'users', - tone: 'i-blue', trend: pctDelta(k?.total_candidates, k?.total_candidates_prior) || '—', dir: Number(k?.total_candidates) >= Number(k?.total_candidates_prior) ? 'up' : 'down', - foot: 'active in pipeline', + spark: candidateSpark, + sparkColor: Charts.PALETTE[4], }, { label: 'Interviews Today', value: dash(k?.interviews_today), - icon: 'calendar', - tone: 'i-purple', trend: k?.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : '—', dir: 'flat', - foot: k?.next_interview_at - ? `next at ${new Date(k.next_interview_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}` - : 'no upcoming', + spark: null, }, { label: 'Offers Accepted', value: dash(k?.offers_accepted), - icon: 'check-circle', - tone: 'i-green', trend: pctDelta(k?.offers_accepted, k?.offers_accepted_prior) || '—', dir: Number(k?.offers_accepted) >= Number(k?.offers_accepted_prior) ? 'up' : 'down', - foot: k?.offers_sent != null ? `of ${k.offers_sent} sent` : '—', + spark: hireSpark, + sparkColor: Charts.PALETTE[0], }, - ] - - const row2 = [ { label: 'Time to Hire', value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—', - icon: 'clock', - tone: 'i-teal', trend: dayDelta(k?.time_to_hire, k?.time_to_hire_prior) || '—', dir: Number(k?.time_to_hire) <= Number(k?.time_to_hire_prior) ? 'up' : 'down', - foot: k?.time_to_hire == null ? 'no hires in window' : 'vs prior window', - }, - { - label: 'Time to Fill', - value: k?.time_to_fill != null && !pending ? `${Math.round(k.time_to_fill)} days` : '—', - icon: 'target', - tone: 'i-amber', - trend: dayDelta(k?.time_to_fill, k?.time_to_fill_prior) || '—', - dir: Number(k?.time_to_fill) <= Number(k?.time_to_fill_prior) ? 'up' : 'down', - foot: k?.time_to_fill == null ? 'no closes in window' : 'vs prior window', + spark: null, }, { label: 'Cost per Hire', value: k?.cost_per_hire != null && !pending ? money(Math.round(k.cost_per_hire)) : '—', - icon: 'dollar', - tone: 'i-red', trend: pctDelta(k?.cost_per_hire, k?.cost_per_hire_prior) || '—', dir: Number(k?.cost_per_hire) <= Number(k?.cost_per_hire_prior) ? 'up' : 'down', - foot: k?.cost_per_hire == null ? 'no cost data yet' : 'vs prior window', + spark: null, }, { label: 'Closed Jobs', value: dash( k == null ? null : Number(k.closed_jobs || 0) + Number(k.hires || 0), ), - icon: 'award', - tone: 'i-purple', trend: pctDelta( Number(k?.closed_jobs || 0) + Number(k?.hires || 0), Number(k?.closed_jobs_prior || 0) + Number(k?.hires_prior || 0), ) || '—', dir: 'up', - foot: 'this window', + spark: null, }, ] + const now = new Date() + return (
@@ -365,17 +363,31 @@ export default function Dashboard() { Export + ( + + )} + > + + Schedule Interview + + + New Task + + + Add Candidate + + Create Job
-
- {row1.map((c) => )} -
-
- {row2.map((c) => )} +
+ {tiles.map((c) => )}
@@ -384,13 +396,25 @@ export default function Dashboard() {

Hiring Trend

- {trendQuery.isPending ? 'Loading…' : 'Hires vs applications over the last 7 months'} + {trendQuery.isPending + ? 'Loading…' + : `Hires vs applications over the last ${trendMonths === 7 ? '7 months' : 'year'}`}
-
- 7M - 1Y -
+ ( + + )} + > + + +
{trendQuery.isError ? ( @@ -419,84 +443,29 @@ export default function Dashboard() { {widgetError(funnelQuery.error, 'analytics.view', 'The server did not return the funnel.')} - ) : asList(funnelQuery.data).length === 0 && funnelQuery.isSuccess ? ( + ) : pipeRows.length === 0 && funnelQuery.isSuccess ? ( Stage counts appear once applications are in the system. ) : ( -
- -
- )} -
-
- - -
-
-
-
-

Upcoming Interviews

- Next scheduled sessions -
- View all -
-
-
- - {(upcoming) => upcoming.map((iv) => ( -
navigate('/interviews')} - > - -
-
{iv.candidate}
-
{iv.type} · {iv.jobTitle || '—'}
-
-
-
{iv.when ? fmtShort(iv.when) : '—'}
-
- {iv.when - ? iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) - : '—'} +
+
+ {pipeRows.map((r) => ( +
+ {r.stage} +
+
+ {r.pct}%
-
- ))} - -
-
-
-
-
-
-

Source Analytics

- - {sourcesQuery.isPending ? 'Loading…' : 'Where candidates come from'} - -
-
-
- {sourcesQuery.isError ? ( - - {widgetError(sourcesQuery.error, 'analytics.view', 'The server did not return source analytics.')} - - ) : asList(sourcesQuery.data).length === 0 && sourcesQuery.isSuccess ? ( - - Source channels appear after applications are tagged. - - ) : ( -
- + ))} +
+
+ +
)}
@@ -506,32 +475,33 @@ export default function Dashboard() {
-

Recent Applications

- View all +

Recent Job Openings

+ View all
- {(recentApps) => recentApps.map((c) => ( + {(jobs) => jobs.map((job) => (
navigate('/candidates', { state: { openCandidate: c.userId || c.id } })} + onClick={() => navigate('/jobs', { state: { openJob: job.id } })} > -
-
{c.name}
-
{c.jobTitle}
-
-
- {c.aiScore != null ? : '—'} +
{job.title}
+
+ {job.applicantCount} Applicant{job.applicantCount === 1 ? '' : 's'} + {job.department ? ` · ${job.department}` : ''} +
+ {job.status}
))}
@@ -540,60 +510,102 @@ export default function Dashboard() {
-

Recruiter Performance

+
+

My Tasks

+ View all +
- {(topRecruiters) => topRecruiters.map((r) => { - const name = r.name || 'Recruiter' + {(tasks) => { + const done = tasks.filter((t) => t.done).length + const pct = tasks.length ? Math.round((done / tasks.length) * 100) : 0 return ( -
- -
-
{name}
-
- {r.open_reqs ?? 0} open reqs · {r.avg_time_to_hire != null ? `${Math.round(r.avg_time_to_hire)}d` : '—'} avg -
+ <> + {tasks.map((t) => { + const overdue = !t.done && t.due && t.due < now + return ( +
+ toggleTask(t)} + role="checkbox" + aria-checked={t.done} + tabIndex={canEditTasks ? 0 : -1} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleTask(t) + } + }} + > + + +
+
+ {t.title} +
+
+ {t.due ? fmtShort(t.due) : 'No due date'} + {overdue ? ' · Overdue' : ''} +
+
+ {t.priority} +
+ ) + })} +
+ + {done} of {tasks.length}
-
-
{r.hires ?? 0}
-
hires
-
-
+ ) - })} + }}
-

Recent Activity

-
+
+
+

Today’s Schedule

+ Interviews on the calendar today +
+ View all +
+
- {(activity) => activity.map((a, i) => ( -
- - - + {(upcoming) => upcoming.map((iv) => ( +
navigate('/interviews')} + > + {clock(iv.when)} +
-
- {a.parts.map((p, j) => (typeof p === 'string' ? p : {p.b}))} -
-
{relTime(a.time)}
+
{iv.candidate}
+
{iv.jobTitle || '—'}
+ {iv.type}
))} diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index 5f1d1d1..4df4188 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -106,7 +106,11 @@ export default function Interviews() { const hydrate = useMemo( () => (iv) => { const app = appByInbox.get(iv.inboxId) - return { ...iv, jobTitle: app?.jobTitle ?? null, userId: app?.userId ?? null } + return { + ...iv, + jobTitle: iv.jobTitle || app?.jobTitle || null, + userId: app?.userId ?? null, + } }, [appByInbox], ) @@ -154,7 +158,20 @@ export default function Interviews() { } const setStatusMutation = useMutation({ - mutationFn: ({ id, next }) => interviewsApi.update(id, { status: next }), + mutationFn: async ({ id, next }) => { + const res = await interviewsApi.update(id, { status: next }) + if (next === 'Cancelled') { + try { + await interviewsApi.cancelCalendarEvent(id) + } catch (err) { + toast( + friendlyAuthError(err, 'Interview cancelled, but the Outlook invite could not be cancelled.'), + 'warning', + ) + } + } + return res + }, onSuccess: (_res, { next }) => { invalidate() toast(`Interview marked ${next.toLowerCase()}`, 'success') @@ -163,7 +180,21 @@ export default function Interviews() { }) const create = useMutation({ - mutationFn: (body) => interviewsApi.create(body), + mutationFn: async (body) => { + const res = await interviewsApi.create(body) + const interviewId = res?.data?.id + if (interviewId) { + try { + await interviewsApi.createCalendarEvent(interviewId) + } catch (err) { + toast( + friendlyAuthError(err, 'Interview saved, but the Outlook invite could not be created.'), + 'warning', + ) + } + } + return res + }, onSuccess: () => { invalidate() setScheduling(false) @@ -212,13 +243,22 @@ export default function Interviews() { {iv.status === 'Scheduled' && ( - + <> + + + )}