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.pull/19/head
parent
6c506cd8f9
commit
cd35af8161
|
|
@ -55,4 +55,6 @@ node_modules/
|
|||
frontend/dist/
|
||||
|
||||
**.pdf
|
||||
**_**_**.py
|
||||
**_**_**.py
|
||||
Utopia-ai-hr-ats-portal 1.pem
|
||||
db_setup.py
|
||||
|
|
@ -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=* * * * *
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 } : {},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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="Couldn’t 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>Today’s 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="Today’s 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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue