Merge pull request 'Add_History' (#19) from Add_History into main
Deploy to S3 / deploy (push) Successful in 40s Details

Reviewed-on: #19
pull/20/head^2
ahmed.mujtaba 2026-08-20 11:44:28 +00:00
commit 868f14b10d
21 changed files with 1049 additions and 279 deletions

4
.gitignore vendored
View File

@ -55,4 +55,6 @@ node_modules/
frontend/dist/
**.pdf
**_**_**.py
**_**_**.py
Utopia-ai-hr-ats-portal 1.pem
db_setup.py

View File

@ -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=* * * * *

85
backend/interview/app.py Normal file
View File

@ -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))

View File

@ -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()

View File

@ -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,
}

175
backend/interview/views.py Normal file
View File

@ -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)

View File

@ -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"

View File

@ -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,
}

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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 } : {},
})
}

View File

@ -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,

View File

@ -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() {
<div className="fw-600 text-sm">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
{iv.webLink && (
<a
className="lr-sub"
href={iv.webLink}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
>
Open in Outlook
</a>
)}
</div>
</div>
))

View File

@ -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 (
<div className="page">
<div className="page-head">
@ -365,17 +363,31 @@ export default function Dashboard() {
<Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export
</Link>
<Dropdown
trigger={({ toggle }) => (
<button className="btn btn-secondary" onClick={toggle}>
Quick Actions <Icon name="chevron-down" />
</button>
)}
>
<Link className="dropdown-link" to="/interviews" state={{ openSchedule: true }}>
<Icon name="calendar" /> Schedule Interview
</Link>
<Link className="dropdown-link" to="/tasks">
<Icon name="check-square" /> New Task
</Link>
<Link className="dropdown-link" to="/candidates">
<Icon name="user-plus" /> Add Candidate
</Link>
</Dropdown>
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job
</Link>
</div>
</div>
<div className="grid g-kpi">
{row1.map((c) => <KpiCard key={c.label} {...c} />)}
</div>
<div className="grid g-kpi mt-18">
{row2.map((c) => <KpiCard key={c.label} {...c} />)}
<div className="grid g-kpi-7">
{tiles.map((c) => <KpiTile key={c.label} {...c} />)}
</div>
<div className="grid g-2-1 mt-18">
@ -384,13 +396,25 @@ export default function Dashboard() {
<div>
<h3>Hiring Trend</h3>
<span className="ch-sub">
{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'}`}
</span>
</div>
<div className="pill-tabs">
<span className="pill-tab active">7M</span>
<span className="pill-tab">1Y</span>
</div>
<Dropdown
trigger={({ toggle }) => (
<button className="btn btn-ghost btn-sm" onClick={toggle}>
{trendMonths === 7 ? '7M' : '1Y'} <Icon name="chevron-down" />
</button>
)}
>
<button type="button" className="dropdown-link" onClick={() => setTrendMonths(7)}>
7 months
</button>
<button type="button" className="dropdown-link" onClick={() => setTrendMonths(12)}>
1 year
</button>
</Dropdown>
</div>
<div className="card-body">
{trendQuery.isError ? (
@ -419,84 +443,29 @@ export default function Dashboard() {
<EmptyState icon="alert" title="Couldnt load pipeline">
{widgetError(funnelQuery.error, 'analytics.view', 'The server did not return the funnel.')}
</EmptyState>
) : asList(funnelQuery.data).length === 0 && funnelQuery.isSuccess ? (
) : pipeRows.length === 0 && funnelQuery.isSuccess ? (
<EmptyState icon="inbox" title="No pipeline data yet">
Stage counts appear once applications are in the system.
</EmptyState>
) : (
<div className="chart-wrap">
<Chart type="horizontalBar" data={pipelineData} height={280} />
</div>
)}
</div>
</div>
</div>
<div className="grid g-2-1 mt-18">
<div className="card">
<div className="card-head">
<div>
<h3>Upcoming Interviews</h3>
<span className="ch-sub">Next scheduled sessions</span>
</div>
<Link className="btn btn-ghost btn-sm" to="/interviews">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
<ListGate
query={interviewsQuery}
title="interviews"
permission="candidates.view"
emptyTitle="No upcoming interviews"
emptyHint="Scheduled interviews will show up here."
>
{(upcoming) => upcoming.map((iv) => (
<div
key={iv.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/interviews')}
>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type} · {iv.jobTitle || '—'}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">{iv.when ? fmtShort(iv.when) : '—'}</div>
<div className="lr-sub">
{iv.when
? iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
: '—'}
<div className="pipe-split">
<div>
{pipeRows.map((r) => (
<div className="pipe-row" key={r.stage}>
<span className="pipe-label" title={r.stage}>{r.stage}</span>
<div className="pipe-track">
<div
className="pipe-fill"
style={{ width: `${r.pct}%`, background: r.color }}
/>
</div>
<span className="pipe-pct">{r.pct}%</span>
</div>
</div>
))}
</ListGate>
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Source Analytics</h3>
<span className="ch-sub">
{sourcesQuery.isPending ? 'Loading…' : 'Where candidates come from'}
</span>
</div>
</div>
<div className="card-body">
{sourcesQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load sources">
{widgetError(sourcesQuery.error, 'analytics.view', 'The server did not return source analytics.')}
</EmptyState>
) : asList(sourcesQuery.data).length === 0 && sourcesQuery.isSuccess ? (
<EmptyState icon="inbox" title="No source data yet">
Source channels appear after applications are tagged.
</EmptyState>
) : (
<div className="chart-wrap">
<Chart type="bar" data={sourceData} height={240} />
))}
</div>
<div className="chart-wrap">
<Chart type="doughnut" data={pipelineDoughnut} height={160} />
</div>
</div>
)}
</div>
@ -506,32 +475,33 @@ export default function Dashboard() {
<div className="grid g-3 mt-18">
<div className="card">
<div className="card-head">
<div><h3>Recent Applications</h3></div>
<Link className="btn btn-ghost btn-sm" to="/candidates">View all</Link>
<div><h3>Recent Job Openings</h3></div>
<Link className="btn btn-ghost btn-sm" to="/jobs">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
<ListGate
query={candidatesQuery}
title="applications"
permission="candidates.view"
emptyTitle="No applications yet"
query={jobsQuery}
title="jobs"
permission="jobs.view"
emptyTitle="No job openings"
emptyHint="Create a requisition to see it here."
>
{(recentApps) => recentApps.map((c) => (
{(jobs) => jobs.map((job) => (
<div
key={c.id}
key={job.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/candidates', { state: { openCandidate: c.userId || c.id } })}
onClick={() => navigate('/jobs', { state: { openJob: job.id } })}
>
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div className="lr-main">
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.jobTitle}</div>
</div>
<div className="lr-right">
{c.aiScore != null ? <ScoreChip score={c.aiScore} /> : '—'}
<div className="lr-title">{job.title}</div>
<div className="lr-sub">
{job.applicantCount} Applicant{job.applicantCount === 1 ? '' : 's'}
{job.department ? ` · ${job.department}` : ''}
</div>
</div>
<Badge>{job.status}</Badge>
</div>
))}
</ListGate>
@ -540,60 +510,102 @@ export default function Dashboard() {
</div>
<div className="card">
<div className="card-head"><div><h3>Recruiter Performance</h3></div></div>
<div className="card-head">
<div><h3>My Tasks</h3></div>
<Link className="btn btn-ghost btn-sm" to="/tasks">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
<ListGate
query={recruitersQuery}
title="recruiters"
permission="analytics.view"
emptyTitle="No recruiter stats yet"
emptyHint="Assign recruiters to jobs to populate this list."
query={tasksQuery}
title="tasks"
permission="tasks.view"
emptyTitle="No tasks yet"
emptyHint="Tasks assigned to you will show up here."
>
{(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 (
<div key={r.id || name} className="list-row">
<Avatar name={name} initials={initialsFrom(name)} color={avatarColor(name)} />
<div className="lr-main">
<div className="lr-title">{name}</div>
<div className="lr-sub">
{r.open_reqs ?? 0} open reqs · {r.avg_time_to_hire != null ? `${Math.round(r.avg_time_to_hire)}d` : '—'} avg
</div>
<>
{tasks.map((t) => {
const overdue = !t.done && t.due && t.due < now
return (
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
<span
className={`checkbox ${t.done ? 'on' : ''}`}
onClick={() => toggleTask(t)}
role="checkbox"
aria-checked={t.done}
tabIndex={canEditTasks ? 0 : -1}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleTask(t)
}
}}
>
<Icon name="check" />
</span>
<div className="lr-main">
<div
className="lr-title"
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
>
{t.title}
</div>
<div className="lr-sub">
{t.due ? fmtShort(t.due) : 'No due date'}
{overdue ? ' · Overdue' : ''}
</div>
</div>
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
</div>
)
})}
<div className="task-foot">
<ProgressBar pct={pct} />
<span>{done} of {tasks.length}</span>
</div>
<div className="lr-right">
<div className="fw-600">{r.hires ?? 0}</div>
<div className="lr-sub">hires</div>
</div>
</div>
</>
)
})}
}}
</ListGate>
</div>
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Recent Activity</h3></div></div>
<div className="card-body" style={{ maxHeight: 360, overflowY: 'auto' }}>
<div className="card-head">
<div>
<h3>Todays Schedule</h3>
<span className="ch-sub">Interviews on the calendar today</span>
</div>
<Link className="btn btn-ghost btn-sm" to="/interviews">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
<ListGate
query={activityQuery}
title="activity"
query={interviewsQuery}
title="interviews"
permission="candidates.view"
emptyTitle="No recent activity"
emptyTitle="Nothing scheduled today"
emptyHint="Todays interviews will show up here."
>
{(activity) => activity.map((a, i) => (
<div className="list-row" key={a.id || `${a.candidateId}-${i}`}>
<span className={`kpi-icn ${a.color}`} style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
{(upcoming) => upcoming.map((iv) => (
<div
key={iv.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/interviews')}
>
<span className="sched-time">{clock(iv.when)}</span>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>
{a.parts.map((p, j) => (typeof p === 'string' ? p : <b key={j}>{p.b}</b>))}
</div>
<div className="lr-sub">{relTime(a.time)}</div>
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.jobTitle || '—'}</div>
</div>
<Badge className="badge-plain">{iv.type}</Badge>
</div>
))}
</ListGate>

View File

@ -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() {
<Icon name="eye" />
</button>
{iv.status === 'Scheduled' && (
<button
className="act-btn" data-tip="Mark completed"
disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })}
>
<Icon name="check" />
</button>
<>
<button
className="act-btn" data-tip="Mark completed"
disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })}
>
<Icon name="check" />
</button>
<button
className="act-btn" data-tip="Cancel interview"
disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Cancelled' })}
>
<Icon name="x-circle" />
</button>
</>
)}
<button className="act-btn" data-tip="Scorecard" onClick={() => setFeedbackFor(iv)}>
<Icon name="star" />

View File

@ -7,7 +7,7 @@
DELETE /jobs/delete, PATCH /jobs/status.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -324,6 +324,9 @@ const SECTION_LABEL = {
textTransform: 'uppercase', marginBottom: 6,
}
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif'
const MAX_IMAGE_MB = 5
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const form = useFormState({
title: '',
@ -338,6 +341,44 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
optional_skills: '',
description: '',
})
const imageInput = useRef(null)
const [imageFile, setImageFile] = useState(null)
const [imagePreview, setImagePreview] = useState(null)
const [draggingImage, setDraggingImage] = useState(false)
useEffect(() => {
if (!imageFile) {
setImagePreview(null)
return undefined
}
const url = URL.createObjectURL(imageFile)
setImagePreview(url)
return () => URL.revokeObjectURL(url)
}, [imageFile])
function pickImage(file) {
if (!file || busy) return
if (!String(file.type || '').startsWith('image/')) {
form.setErrors({ ...form.errors, image: 'Please upload an image file' })
return
}
if (file.size > MAX_IMAGE_MB * 1024 * 1024) {
form.setErrors({ ...form.errors, image: `Image must be under ${MAX_IMAGE_MB} MB` })
return
}
form.setErrors((prev) => {
if (!prev.image) return prev
const next = { ...prev }
delete next.image
return next
})
setImageFile(file)
}
function clearImage() {
setImageFile(null)
if (imageInput.current) imageInput.current.value = ''
}
function submit() {
if (busy) return
@ -362,6 +403,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
// No channel_id / platform: the backend saves an internal-only requisition
// and skips Buffer entirely. Publishing happens later from the Job Board.
// Image is UI-only for now not sent to the API.
onSubmit({
title: v.title.trim(),
department: v.department.trim() || null,
@ -508,6 +550,79 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
<textarea {...field('description')} placeholder="Describe the role…" rows={4} />
</div>
<div className="form-field col-span-2">
<label>Cover image</label>
<input
ref={imageInput}
type="file"
accept={IMAGE_ACCEPT}
hidden
onChange={(e) => { pickImage(e.target.files?.[0]); e.target.value = '' }}
/>
<div
className={`dropzone${draggingImage ? ' drag' : ''}`}
style={{ padding: '22px 18px', cursor: busy ? 'default' : 'pointer' }}
role="button"
tabIndex={0}
onClick={() => { if (!busy) imageInput.current?.click() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (!busy) imageInput.current?.click()
}
}}
onDragOver={(e) => { e.preventDefault(); setDraggingImage(true) }}
onDragLeave={() => setDraggingImage(false)}
onDrop={(e) => {
e.preventDefault()
setDraggingImage(false)
pickImage(e.dataTransfer.files?.[0])
}}
>
<div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}>
<Icon name="upload" />
</div>
<h3 style={{ fontSize: 15 }}>Drop an image here or click to browse</h3>
<p className="text-muted text-sm">
PNG, JPG, WEBP or GIF · up to {MAX_IMAGE_MB} MB
</p>
</div>
{imageFile && (
<div className="upload-row">
{imagePreview ? (
<img
src={imagePreview}
alt=""
style={{
width: 44,
height: 44,
borderRadius: 10,
objectFit: 'cover',
flexShrink: 0,
background: 'var(--bg-sunken)',
}}
/>
) : (
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{imageFile.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(imageFile.size / 1024))} KB</div>
</div>
<button
type="button"
className="act-btn"
aria-label="Remove image"
disabled={busy}
onClick={(e) => { e.stopPropagation(); clearImage() }}
>
<Icon name="trash" />
</button>
</div>
)}
<FieldError>{form.errors.image}</FieldError>
</div>
</div>
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>

View File

@ -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; }
}

View File

@ -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 (
<div className="kpi kpi-tile">
<span className="kpi-label">{label}</span>
<div className="kpi-value">{value}</div>
{trend && <Trend dir={dir}>{trend}</Trend>}
{spark?.length > 1 && (
<div className="kpi-spark">
<Chart type="sparkline" data={spark} options={sparkColor} height={36} />
</div>
)}
</div>
)
}
export function FieldError({ children }) {
return <span className={`field-error${children ? ' show' : ''}`}>{children || ''}</span>
}