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() {
+ PNG, JPG, WEBP or GIF · up to {MAX_IMAGE_MB} MB +
+diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index e5d2d24..ea9807f 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1362,3 +1362,47 @@ canvas { width: 100%; max-width: 100%; display: block; } .ai-fab { width: 46px; height: 46px; } .ai-fab svg { width: 22px; height: 22px; } } + +/* ============================================================ + DASHBOARD v2 — scoped to the restyled dashboard. Brand tokens only. + KpiCard / .g-kpi on Interviews, JobBoard, RecruiterHub stay untouched. + ============================================================ */ +.kpi-tile { padding: 16px 18px; } +.kpi-tile:hover { transform: none; } +.kpi-tile .kpi-label { display: block; margin-bottom: 8px; } +.kpi-tile .kpi-value { font-size: 24px; margin-bottom: 8px; } +.kpi-tile .trend { margin-bottom: 4px; } +.kpi-spark { height: 36px; margin-top: 8px; } +.kpi-spark canvas { width: 100%; display: block; } + +.g-kpi-7 { grid-template-columns: repeat(7, 1fr); } + +.pipe-split { display: grid; grid-template-columns: 1fr 150px; gap: 16px; align-items: center; } +.pipe-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.pipe-row:last-child { margin-bottom: 0; } +.pipe-label { + flex: 0 0 92px; font-size: 12.5px; color: var(--text-2); font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.pipe-track { flex: 1; height: 8px; background: var(--bg-sunken); border-radius: 99px; overflow: hidden; } +.pipe-fill { height: 100%; border-radius: 99px; } +.pipe-pct { flex: 0 0 40px; text-align: right; font-size: 12.5px; font-weight: 600; color: var(--text-2); } + +.task-foot { + display: flex; align-items: center; gap: 10px; margin-top: 14px; padding-top: 12px; + border-top: 1px solid var(--border); font-size: 12.5px; color: var(--text-2); +} +.task-foot .pbar { flex: 1; } + +.sched-time { flex: 0 0 64px; font-size: 12.5px; font-weight: 700; color: var(--primary); } + +@media (max-width: 1400px) { + .g-kpi-7 { grid-template-columns: repeat(4, 1fr); } +} +@media (max-width: 1200px) { + .g-kpi-7 { grid-template-columns: repeat(2, 1fr); } + .pipe-split { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .g-kpi-7 { grid-template-columns: 1fr; } +} diff --git a/frontend/src/ui/primitives.jsx b/frontend/src/ui/primitives.jsx index d29d629..d45adc1 100644 --- a/frontend/src/ui/primitives.jsx +++ b/frontend/src/ui/primitives.jsx @@ -6,6 +6,7 @@ ============================================================ */ import Icon from './icons' +import Chart from './Chart' import { avatarColor, initials as initialsOf } from '../data/seed' export function Avatar({ name = '', initials, color, className = '' }) { @@ -117,6 +118,28 @@ export function KpiCard({ icon, tone = 'i-indigo', label, value, foot, trend, di ) } +/** + * Compact dashboard tile. Sibling of KpiCard, not a variant — Interviews, + * JobBoard and RecruiterHub keep the 40px icon card and the 4-up `.g-kpi` row. + * Sparkline args are positional (charts.js sparkline(canvas, data, color)), so + * `spark` is a number[] and `sparkColor` is a color string. A 1-element array + * divides by zero in the engine; the length guard is load-bearing. + */ +export function KpiTile({ label, value, trend, dir = 'flat', spark, sparkColor }) { + return ( +