diff --git a/backend/README.md b/backend/README.md index 6f3f6cd..858277d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -110,6 +110,7 @@ backend/ ├── Dockerfile # image for the Taskiq worker / scheduler ├── alembic.ini # generated by alembic_setup.py, not hand-written ├── migrations/ # generated env.py + versions/ +│ └── manual/ # one-shot SQL (enum labels, RBAC seed, backfills) ├── LLM_CONTEXT_PROMPT.md # house-style prompt to paste into an LLM before editing │ ├── users/ # accounts, login, signup, RBAC enforcement @@ -117,10 +118,15 @@ backend/ ├── forget_password/ # reset-code request → verify → new password ├── notifications/ # email-confirmation tokens and mail ├── inbox/ # mailbox sync, attachments, applications +├── analytics/ # dashboard KPIs + charts (views only — no tables) +├── offer/ # offers + offer_status_history ├── job/ │ ├── app.py # routes for both sub-domains │ ├── job_post/ # job ads + Buffer publishing -│ └── candidate/ # CV reading, candidate profile +│ ├── candidate/ # CV reading, candidate profile, stage transitions model +│ ├── assignment/ # job_assignments + application_assignments +│ ├── cost/ # hiring_costs +│ └── pipeline/ # stage-change service (single writer) ├── agent/ # LangGraph CV → job-post matching agent └── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task ``` @@ -602,6 +608,22 @@ python alembic_setup.py current python alembic_setup.py head ``` +Alembic autogenerate does **not** detect new PostgreSQL enum labels. Permission-tag +rows and analytics role bundles are also seeded out-of-band. Those live in +`migrations/manual/` and must be run by hand in psql (autocommit for `ADD VALUE`): + +```bash +# After `python alembic_setup.py upgrade` has created the new tables/columns: +psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql +``` + +`001_dashboard_rbac_and_enum.sql` extends `candidate_application_status`, seeds all 104 +`permission_tags`, creates the `analytics_dashboard` bundle and attaches it to the +system roles that need the dashboard, seeds the eleven BRD `source_channels`, and +backfills `source_channel_id` / stage-transition / requisition-status rows. + +``` + Migrations run under a Postgres advisory lock, so several workers booting at once cannot migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is excluded from autogenerate, as is anything outside the configured schemas. diff --git a/backend/analytics/app.py b/backend/analytics/app.py new file mode 100644 index 0000000..bcfd118 --- /dev/null +++ b/backend/analytics/app.py @@ -0,0 +1,109 @@ +from datetime import datetime +from fastapi import APIRouter,Depends,Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from analytics.views import Analytics +from users.permissions import PermissionTag,require_permission +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +@router.get("/analytics/kpis/fetch") +async def fetch_kpis( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_kpis(from_date,to_date,department,recruiter_id) + 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.get("/analytics/funnel/fetch") +async def fetch_funnel( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_funnel(from_date,to_date,department,recruiter_id) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/analytics/hiring-trend/fetch") +async def fetch_hiring_trend( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + months: int = Query(7,ge=1), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_hiring_trend(months,from_date,to_date,department,recruiter_id) + 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.get("/analytics/source-performance/fetch") +async def fetch_source_performance( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_source_performance(from_date,to_date,department,recruiter_id) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/analytics/recruiter-performance/fetch") +async def fetch_recruiter_performance( + current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)), + top: int = Query(5,ge=1), + from_date: datetime | None = Query(None), + to_date: datetime | None = Query(None), + department: str | None = Query(None), + recruiter_id: str | None = Query(None), + session: AsyncSession = Depends(get_session), +): + try: + service=Analytics(session=session) + data=await service.get_recruiter_performance(top,from_date,to_date,department,recruiter_id) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/analytics/serializers.py b/backend/analytics/serializers.py new file mode 100644 index 0000000..2e4d2f6 --- /dev/null +++ b/backend/analytics/serializers.py @@ -0,0 +1,22 @@ +"""Analytics responses are mostly assembled as dicts in views. + +Keep helpers here only when reuse across methods would otherwise duplicate. +""" + + +def serialize_stage_count(stage,count) -> dict: + return {"stage": stage,"count": int(count or 0)} + + +def serialize_source_count(source,count) -> dict: + return {"source": source or "Unknown","count": int(count or 0)} + + +def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire) -> dict: + return { + "id": str(user_id) if user_id else None, + "name": name, + "hires": int(hires or 0), + "open_reqs": int(open_reqs or 0), + "avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None, + } diff --git a/backend/analytics/views.py b/backend/analytics/views.py new file mode 100644 index 0000000..d7c5521 --- /dev/null +++ b/backend/analytics/views.py @@ -0,0 +1,557 @@ +import uuid +from datetime import datetime,timedelta,timezone + +from sqlalchemy import and_,func,or_,select +from sqlalchemy.ext.asyncio import AsyncSession + +from analytics.serializers import ( + serialize_recruiter_row, + serialize_source_count, + serialize_stage_count, +) +from inbox.enums import Candidate_application_Status +from inbox.models import Inbox,Inbox_Messages,SourceChannels +from job.assignment.models import JobAssignments +from job.candidate.models import ApplicationStageTransitions,Interviews +from job.cost.models import HiringCosts +from job.job_post.models import JobPosts +from offer.models import Offers +from role.models import EnumRoles,Roles +from users.models import Users + + +def _as_uuid(value): + if value in (None,""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError,ValueError): + return None + + +def _month_start(dt: datetime) -> datetime: + return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) + + +def _next_month_start(dt: datetime) -> datetime: + if dt.month==12: + return datetime(dt.year+1,1,1,tzinfo=timezone.utc) + return datetime(dt.year,dt.month+1,1,tzinfo=timezone.utc) + + +def _resolve_windows(from_date,to_date): + """Return (from_date, to_date, prior_from, prior_to). Missing bounds → current calendar month.""" + now=datetime.now(timezone.utc) + if from_date is None and to_date is None: + from_date=_month_start(now) + to_date=_next_month_start(now) + elif from_date is None: + # open-ended lower bound: treat as same length as a calendar month ending at to_date + to_date=to_date if to_date.tzinfo else to_date.replace(tzinfo=timezone.utc) + from_date=_month_start(to_date) + elif to_date is None: + from_date=from_date if from_date.tzinfo else from_date.replace(tzinfo=timezone.utc) + to_date=_next_month_start(from_date) + else: + if from_date.tzinfo is None: + from_date=from_date.replace(tzinfo=timezone.utc) + if to_date.tzinfo is None: + to_date=to_date.replace(tzinfo=timezone.utc) + duration=to_date-from_date + prior_to=from_date + prior_from=from_date-duration + return from_date,to_date,prior_from,prior_to + + +def _month_key(dt): + """Normalize date_trunc / python month buckets for dict lookup.""" + if dt is None: + return None + if getattr(dt,"tzinfo",None) is None: + dt=dt.replace(tzinfo=timezone.utc) + else: + dt=dt.astimezone(timezone.utc) + return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) + + +def _days_expr(end_col,start_col): + return func.extract("epoch",end_col-start_col)/86400.0 + + +class Analytics: + def __init__(self,session:AsyncSession): + self.session=session + + async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False): + statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712 + if status: + statement=statement.where(JobPosts.requisition_status==status) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where(JobPosts.current_recruiter_id==rid) + if closed_in_window: + if from_date is not None: + statement=statement.where(JobPosts.closed_at>=from_date) + if to_date is not None: + statement=statement.where(JobPosts.closed_at=as_of), + JobPosts.requisition_status=="open", + ) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where(JobPosts.current_recruiter_id==rid) + result=await self.session.execute(statement) + return int(result.scalar_one() or 0) + + async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None): + statement=( + select(func.count()) + .select_from(Inbox) + .join(Users,Inbox.user_id==Users.id) + .join(Roles,Users.role_id==Roles.id) + .where(Roles.role_name==EnumRoles.CANDIDATE.value) + ) + if from_date is not None: + statement=statement.where(Inbox.created_at>=from_date) + if to_date is not None: + statement=statement.where(Inbox.created_at=from_date) + if to_date is not None: + statement=statement.where(stamp=from_date) + if to_date is not None: + statement=statement.where(hired.valid_from=from_date) + if to_date is not None: + msg=msg.where(Inbox.created_at=from_date) + if to_date is not None: + statement=statement.where(hire.c.valid_from=from_date) + if to_date is not None: + statement=statement.where(JobPosts.closed_at=from_date) + if to_date is not None: + statement=statement.where(HiringCosts.incurred_at=today_start, + Interviews.interview_date=now, + ) + interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0) + + next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where( + Interviews.interview_status.ilike("scheduled"), + Interviews.interview_date>=now, + ) + next_at=(await self.session.execute(next_q)).scalar_one() + next_interview_at=next_at.isoformat() if next_at else None + + offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id) + offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id) + offers_sent=await self._count_offers( + ["sent","negotiating","accepted","declined","expired"], + window_from,window_to,department,recruiter_id,exclude_draft=True, + ) + offers_sent_prior=await self._count_offers( + ["sent","negotiating","accepted","declined","expired"], + prior_from,prior_to,department,recruiter_id,exclude_draft=True, + ) + + hires=await self._count_hires(window_from,window_to,department,recruiter_id) + hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id) + + time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id) + time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id) + time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id) + time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id) + cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id) + cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id) + + return { + "open_jobs": open_jobs, + "open_jobs_prior": open_jobs_prior, + "total_candidates": total_candidates, + "total_candidates_prior": total_candidates_prior, + "interviews_today": interviews_today, + "interviews_upcoming": interviews_upcoming, + "next_interview_at": next_interview_at, + "offers_accepted": offers_accepted, + "offers_accepted_prior": offers_accepted_prior, + "offers_sent": offers_sent, + "offers_sent_prior": offers_sent_prior, + "time_to_hire": time_to_hire, + "time_to_hire_prior": time_to_hire_prior, + "time_to_fill": time_to_fill, + "time_to_fill_prior": time_to_fill_prior, + "cost_per_hire": cost_per_hire, + "cost_per_hire_prior": cost_per_hire_prior, + "closed_jobs": closed_jobs, + "closed_jobs_prior": closed_jobs_prior, + "hires": hires, + "hires_prior": hires_prior, + } + + async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): + statement=select( + Inbox_Messages.application_status, + func.count().label("count"), + ).select_from(Inbox_Messages) + if department or recruiter_id: + statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + if from_date is not None or to_date is not None: + statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) + if from_date is not None: + statement=statement.where(Inbox.created_at>=from_date) + if to_date is not None: + statement=statement.where(Inbox.created_at=start) + .group_by(month_bucket) + .order_by(month_bucket) + ) + if department or recruiter_id: + apps_q=( + apps_q + .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) + .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + ) + if department: + apps_q=apps_q.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + apps_q=apps_q.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + apps_rows=await self.session.execute(apps_q) + apps_map={} + for month,count in apps_rows.all(): + apps_map[_month_key(month)]=int(count or 0) + + hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from) + hires_q=( + select(hire_bucket.label("month"),func.count().label("count")) + .select_from(ApplicationStageTransitions) + .where( + ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value, + ApplicationStageTransitions.valid_from>=start, + ) + .group_by(hire_bucket) + .order_by(hire_bucket) + ) + if department or recruiter_id: + hires_q=( + hires_q + .outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id) + .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) + .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + ) + if department: + hires_q=hires_q.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + hires_q=hires_q.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + hire_rows=await self.session.execute(hires_q) + hire_map={} + for month,count in hire_rows.all(): + hire_map[_month_key(month)]=int(count or 0) + + labels=[] + applications=[] + hires=[] + cursor=start + for _ in range(months): + labels.append(cursor.strftime("%b %Y")) + applications.append(apps_map.get(cursor,0)) + hires.append(hire_map.get(cursor,0)) + cursor=_next_month_start(cursor) + return {"labels": labels,"applications": applications,"hires": hires} + + async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None): + statement=( + select( + func.coalesce(SourceChannels.label,"Unknown").label("source"), + func.count().label("count"), + ) + .select_from(Inbox_Messages) + .outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id) + ) + if department or recruiter_id: + statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) + if department: + statement=statement.where(JobPosts.department==department) + rid=_as_uuid(recruiter_id) + if rid is not None: + statement=statement.where( + or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) + ) + if from_date is not None or to_date is not None: + statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) + if from_date is not None: + statement=statement.where(Inbox.created_at>=from_date) + if to_date is not None: + statement=statement.where(Inbox.created_at=from_date) + if to_date is not None: + hires_q=hires_q.where(Inbox.created_at datetime: + return datetime.now(timezone.utc) + + class Inbox(SQLModel, table=True): __tablename__ = "inbox" @@ -42,8 +46,12 @@ class Inbox(SQLModel, table=True): message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id") messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox") - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + # tz-AWARE, matching every other timestamp the analytics layer filters on. + # A naive column here made asyncpg reject the aware UTC bounds that + # analytics/views.py builds, so /analytics/hiring-trend and /analytics/kpis + # both 500'd before the query ever reached Postgres. + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) favorite: Optional[bool] = Field(default=False) rating: Optional[float] = Field(default=0.0) @@ -132,6 +140,20 @@ class Inbox(SQLModel, table=True): result=await session.execute(select(cls).where(cls.id==iid)) return result.scalars().first() + @classmethod + async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None): + """Inbox row with `messages` selectin-loaded for stage / application writers.""" + if record_id is None: + return None + try: + iid=int(record_id) + except (TypeError,ValueError): + return None + result=await session.execute( + select(cls).options(selectinload(cls.messages)).where(cls.id==iid) + ) + return result.scalars().first() + @classmethod async def get_inbox_by_message_id(cls,session:AsyncSession,message_id): try: @@ -161,7 +183,7 @@ class Inbox(SQLModel, table=True): return None for key,value in fields.items(): setattr(row,key,value) - row.updated_at=datetime.now() + row.updated_at=_now() session.add(row) await session.commit() await session.refresh(row) @@ -175,7 +197,7 @@ class Inbox_Alerts(SQLModel, table=True): alert_sender_name: str alert_sender_email: str is_read: bool = Field(default=False) - recieve_time: datetime = Field(default_factory=datetime.now) + recieve_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) inbox: list[Inbox] = Relationship(back_populates="alerts") @@ -214,7 +236,15 @@ class Inbox_Messages(SQLModel, table=True): matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx") candidate_education: str | None = Field(default=None) - current_employment: str | None = Field(default=None) + current_employment: str | None = Field(default=None) + # Denormalised dashboard / list-screen fields. server_default is load-bearing + # for every NOT NULL column — these arrive as ALTERs on a populated table. + ats_score: float | None = Field(default=None) + ats_band: str = Field(default="", sa_column_kwargs={"server_default": ""}) + recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) + source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id") + processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"}) inbox: list[Inbox] = Relationship(back_populates="messages") @staticmethod @@ -533,3 +563,74 @@ class Inbox_Messages(SQLModel, table=True): await session.commit() await session.refresh(row) return row + + +class SourceChannels(SQLModel, table=True): + __tablename__ = "source_channels" + + id: int | None = Field(default=None, primary_key=True) + key: str = Field(max_length=40, unique=True, index=True) + label: str + is_active: bool = Field(default=True) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def get_by_id(cls, session: AsyncSession, record_id: int): + result = await session.execute(select(cls).where(cls.id == record_id)) + return result.scalars().first() + + @classmethod + async def get_by_key(cls, session: AsyncSession, key: str): + result = await session.execute(select(cls).where(cls.key == key)) + return result.scalars().first() + + @classmethod + async def list_active(cls, session: AsyncSession): + result = await session.execute( + select(cls).where(cls.is_active == True).order_by(cls.id.asc()) # noqa: E712 + ) + return list(result.scalars().all()) + + +class AtsResults(SQLModel, table=True): + __tablename__ = "ats_results" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") + overall_score: float = Field(default=0.0) + band: str = Field(default="") + is_current: bool = Field(default=True) + superseded_by_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id") + model_name: str | None = Field(default=None) + # default_factory was datetime.now: a naive LOCAL value bound to a timestamptz + # column, which asyncpg reads as UTC. That silently backdated every row by the + # host's offset (+5 h here) instead of raising, unlike the naive-column case. + computed_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_current_for_inbox(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls) + .where(cls.inbox_id == int(inbox_id), cls.is_current == True) # noqa: E712 + .order_by(cls.computed_at.desc()) + ) + return result.scalars().first() + + @classmethod + async def insert_result(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return row diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index b8cd123..8310bab 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -104,10 +104,13 @@ def serialize_application(message: Inbox_Messages) -> dict: "match_status": message.match_status, "match_error": message.match_error, "matched_at": message.matched_at.isoformat() if message.matched_at else None, - "ats_score": None, + "ats_score": message.ats_score, + "ats_band": message.ats_band or None, "phone": message.candidate_phone_number, "experience": message.experience or "", "current_employment": message.current_employment or "", - "recruiter": None, - "duplicate": None, + "recruiter": str(message.recruiter_id) if message.recruiter_id else None, + "duplicate": message.is_duplicate, + "processing_state": message.processing_state, + "source_channel_id": message.source_channel_id, } diff --git a/backend/job/activity/views.py b/backend/job/activity/views.py index 7339692..34acd64 100644 --- a/backend/job/activity/views.py +++ b/backend/job/activity/views.py @@ -28,16 +28,32 @@ class ActivityLog: return row return None - async def get_activity(self,activity_id=None,inbox_id=None): + async def get_activity(self,activity_id=None,inbox_id=None,top=None,skip=0): if activity_id: row=await Activity.get_activity_by_id(self.session,activity_id) if not row: raise HTTPException(status_code=404,detail="Activity not found") return serialize_activity(row) - if inbox_id is None: - raise HTTPException(status_code=400,detail="activity_id or inbox_id is required") - rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id)) - return [serialize_activity(r) for r in rows] + if inbox_id is not None: + rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id)) + return [serialize_activity(r) for r in rows] + if top is not None: + return await self.get_activity_feed(top=top,skip=skip) + raise HTTPException(status_code=400,detail="activity_id or inbox_id is required") + + async def get_activity_feed(self,top,skip=0): + rows,total=await Activity.get_activity_feed(self.session,top=top,skip=skip) + items=[] + for r in rows: + data=serialize_activity(r) + actor_name=None + if r.inbox_id is not None: + inbox=await Inbox.get_inbox_by_id(self.session,r.inbox_id) + if inbox and getattr(inbox,"user",None): + actor_name=inbox.user.name + data["actor_name"]=actor_name + items.append(data) + return items,total async def create_activity(self,payload): link=await self._resolve_inbox(payload) diff --git a/backend/job/app.py b/backend/job/app.py index 4568129..34ed192 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -7,6 +7,9 @@ from job.interviews.views import Interview from job.notes.views import Note from job.activity.views import ActivityLog from job.feedback.views import FeedbackView +from job.pipeline.views import Pipeline +from job.assignment.views import Assignment +from job.cost.views import HiringCost from sqlalchemy.ext.asyncio import AsyncSession from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost,JobPostCreate @@ -84,6 +87,32 @@ class FeedbackUpdate(BaseModel): reviewed_by: UUID | None = None +class StageChange(BaseModel): + inbox_id: int + to_stage: str + change_reason: str | None = None + + +class JobAssignmentCreate(BaseModel): + job_post_id: UUID + user_id: UUID + assignment_role: str | None = None + + +class ApplicationAssignmentCreate(BaseModel): + inbox_id: int + user_id: UUID + assignment_role: str | None = None + + +class HiringCostCreate(BaseModel): + cost_type: str + amount: float + job_post_id: UUID | None = None + currency: str | None = None + description: str | None = None + incurred_at: datetime | None = None + @router.get("/jobs/alias") async def get_job_alias(): @@ -294,13 +323,29 @@ async def update_candidate( async def fetch_interview( interview_id:str=Query(None), inbox_id:int=Query(None), + from_date:datetime=Query(None), + to_date:datetime=Query(None), + status:str=Query(None), + top:int=Query(None), + skip:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=Interview(session=session) - data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id) - total=1 if isinstance(data,dict) else len(data) + if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None): + data,total=await service.get_interviews_range( + from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + data=await service.get_interview( + interview_id=interview_id,inbox_id=inbox_id, + from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + ) + if isinstance(data,tuple): + data,total=data + else: + total=1 if isinstance(data,dict) else len(data) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise @@ -396,13 +441,21 @@ async def update_note( async def fetch_activity( activity_id:str=Query(None), inbox_id:int=Query(None), + top:int=Query(None), + skip:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=ActivityLog(session=session) - data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id) - total=1 if isinstance(data,dict) else len(data) + if not activity_id and inbox_id is None and top is not None: + data,total=await service.get_activity_feed(top=top,skip=skip) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id,top=top,skip=skip) + if isinstance(data,tuple): + data,total=data + else: + total=1 if isinstance(data,dict) else len(data) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise @@ -475,3 +528,141 @@ async def update_feedback( raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/candidate/stage") +async def change_candidate_stage( + payload:StageChange, + current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Pipeline(session=session) + data=await service.change_stage( + payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason, + ) + 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.get("/pipeline/transitions/fetch") +async def fetch_pipeline_transitions( + transition_id:str=Query(None), + inbox_id:int=Query(None), + current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Pipeline(session=session) + data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id) + total=1 if isinstance(data,dict) else len(data) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/job/assignments/fetch") +async def fetch_job_assignments( + job_post_id:str=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.list_job_assignments(job_post_id) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/job/assignments/create") +async def create_job_assignment( + payload:JobAssignmentCreate, + current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.create_job_assignment(payload.model_dump(exclude_unset=True),current_user) + 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.get("/candidate/assignments/fetch") +async def fetch_application_assignments( + inbox_id:int=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.list_application_assignments(inbox_id) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/candidate/assignments/create") +async def create_application_assignment( + payload:ApplicationAssignmentCreate, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Assignment(session=session) + data=await service.create_application_assignment(payload.model_dump(exclude_unset=True),current_user) + 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.get("/job/costs/fetch") +async def fetch_hiring_costs( + job_post_id:str=Query(None), + from_date:datetime=Query(None), + to_date:datetime=Query(None), + top:int=Query(None), + skip:int=Query(0,ge=0), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=HiringCost(session=session) + data,total=await service.list_costs( + job_post_id=job_post_id,from_date=from_date,to_date=to_date,top=top,skip=skip, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/job/costs/create") +async def create_hiring_cost( + payload:HiringCostCreate, + current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=HiringCost(session=session) + data=await service.create_cost(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py new file mode 100644 index 0000000..21fa7b3 --- /dev/null +++ b/backend/job/assignment/models.py @@ -0,0 +1,120 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class JobAssignments(SQLModel, table=True): + __tablename__ = "job_assignments" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_post_id: uuid.UUID = Field(index=True, foreign_key="job_posts.id") + user_id: uuid.UUID = Field(foreign_key="users.id") + assignment_role: str = Field(default="primary_recruiter") + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + assigned_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_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)) + return result.scalars().first() + + @classmethod + async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True): + uid = cls._as_uuid(job_post_id) + if uid is None: + return [] + statement = select(cls).where(cls.job_post_id == uid) + if current_only: + statement = statement.where(cls.valid_to.is_(None)) + statement = statement.order_by(cls.valid_from.desc()) + result = await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def insert_assignment(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_by_id(session, row.id) + + @classmethod + async def count_open_reqs_by_user(cls, session: AsyncSession, user_id): + uid = cls._as_uuid(user_id) + if uid is None: + return 0 + statement = ( + select(func.count()) + .select_from(cls) + .where(cls.user_id == uid, cls.valid_to.is_(None)) + ) + result = await session.execute(statement) + return result.scalar_one() + + +class ApplicationAssignments(SQLModel, table=True): + __tablename__ = "application_assignments" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + user_id: uuid.UUID = Field(foreign_key="users.id") + assignment_role: str = Field(default="primary_recruiter") + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + assigned_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_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)) + return result.scalars().first() + + @classmethod + async def fetch_by_inbox(cls, session: AsyncSession, inbox_id: int, *, current_only: bool = True): + statement = select(cls).where(cls.inbox_id == int(inbox_id)) + if current_only: + statement = statement.where(cls.valid_to.is_(None)) + statement = statement.order_by(cls.valid_from.desc()) + result = await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def insert_assignment(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_by_id(session, row.id) + +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/assignment/serializers.py b/backend/job/assignment/serializers.py new file mode 100644 index 0000000..c1f22fc --- /dev/null +++ b/backend/job/assignment/serializers.py @@ -0,0 +1,24 @@ +def serialize_job_assignment(row) -> dict: + return { + "id": str(row.id), + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "user_id": str(row.user_id) if row.user_id else None, + "assignment_role": row.assignment_role, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "assigned_by": str(row.assigned_by) if row.assigned_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +def serialize_application_assignment(row) -> dict: + return { + "id": str(row.id), + "inbox_id": row.inbox_id, + "user_id": str(row.user_id) if row.user_id else None, + "assignment_role": row.assignment_role, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "assigned_by": str(row.assigned_by) if row.assigned_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py new file mode 100644 index 0000000..b5be521 --- /dev/null +++ b/backend/job/assignment/views.py @@ -0,0 +1,69 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from job.assignment.models import ApplicationAssignments, JobAssignments +from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment +from role.models import EnumRoles, Roles +from users.models import Users + + +class Assignment: + def __init__(self,session:AsyncSession): + self.session=session + + async def _require_recruiter(self,user_id): + role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value) + user=await Users.get_user_by_id(self.session,user_id) + if not role or not user or user.role_id!=role.id: + raise HTTPException(status_code=422,detail="user_id must be a recruiter") + return user + + async def list_job_assignments(self,job_post_id): + if not job_post_id: + raise HTTPException(status_code=400,detail="job_post_id is required") + rows=await JobAssignments.fetch_by_job(self.session,job_post_id) + return [serialize_job_assignment(r) for r in rows] + + async def create_job_assignment(self,payload,current_user): + user_id=payload.get("user_id") + job_post_id=payload.get("job_post_id") + if not user_id or not job_post_id: + raise HTTPException(status_code=422,detail="user_id and job_post_id are required") + await self._require_recruiter(user_id) + fields={ + "job_post_id":JobAssignments._as_uuid(job_post_id), + "user_id":JobAssignments._as_uuid(user_id), + "assignment_role":payload.get("assignment_role") or "primary_recruiter", + "assigned_by":JobAssignments._as_uuid( + current_user.get("id") if isinstance(current_user,dict) else None + ), + } + if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]: + raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by") + row=await JobAssignments.insert_assignment(self.session,fields) + return serialize_job_assignment(row) + + async def list_application_assignments(self,inbox_id): + if inbox_id is None: + raise HTTPException(status_code=400,detail="inbox_id is required") + rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id)) + return [serialize_application_assignment(r) for r in rows] + + async def create_application_assignment(self,payload,current_user): + user_id=payload.get("user_id") + inbox_id=payload.get("inbox_id") + if not user_id or inbox_id is None: + raise HTTPException(status_code=422,detail="user_id and inbox_id are required") + await self._require_recruiter(user_id) + fields={ + "inbox_id":int(inbox_id), + "user_id":ApplicationAssignments._as_uuid(user_id), + "assignment_role":payload.get("assignment_role") or "primary_recruiter", + "assigned_by":ApplicationAssignments._as_uuid( + current_user.get("id") if isinstance(current_user,dict) else None + ), + } + if not fields["user_id"] or not fields["assigned_by"]: + raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by") + row=await ApplicationAssignments.insert_assignment(self.session,fields) + return serialize_application_assignment(row) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index c41a97e..4e57e81 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import DateTime +from sqlalchemy import DateTime, func from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel, select @@ -148,6 +148,34 @@ class Interviews(SQLModel, table=True): ) return result.scalars().all() + @classmethod + async def get_interviews_in_range( + cls, + session: AsyncSession, + *, + from_date=None, + to_date=None, + status: str | None = None, + top: int | None = None, + skip: int = 0, + ): + statement = select(cls) + if from_date is not None: + statement = statement.where(cls.interview_date >= from_date) + if to_date is not None: + statement = statement.where(cls.interview_date < to_date) + if status: + 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()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + @classmethod async def insert_interview(cls, session: AsyncSession, fields: dict): row = cls(**fields) @@ -269,6 +297,19 @@ class Activity(SQLModel, table=True): ) return result.scalars().all() + @classmethod + async def get_activity_feed(cls, session: AsyncSession, *, top: int | None = None, skip: int = 0): + statement = select(cls) + count_statement = select(func.count()).select_from(cls) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.activity_date.desc(), cls.activity_time.desc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + @classmethod async def insert_activity(cls, session: AsyncSession, fields: dict): row = cls(**fields) @@ -353,4 +394,85 @@ class Feedback(SQLModel, table=True): return row +class ApplicationStageTransitions(SQLModel, table=True): + """Temporal history of inbox_messages.application_status changes. + + valid_from / valid_to make time-in-stage a subtraction rather than a window + function. NULL valid_to means the stage is still current. + """ + + __tablename__ = "application_stage_transitions" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + from_stage: str | None = Field(default=None) + to_stage: str + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + actor_kind: str = Field(default="user") + change_reason: str | None = Field(default=None) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_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)) + return result.scalars().first() + + @classmethod + async def fetch_by_inbox(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls).where(cls.inbox_id == int(inbox_id)).order_by(cls.valid_from.desc()) + ) + return list(result.scalars().all()) + + @classmethod + async def get_open_transition(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls) + .where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None)) + .order_by(cls.valid_from.desc()) + ) + return result.scalars().first() + + @classmethod + async def insert_transition(cls, session: AsyncSession, fields: dict, *, commit: bool = True): + row = cls(**fields) + session.add(row) + if commit: + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False): + row = await cls.get_open_transition(session, inbox_id) + if not row: + return None + row.valid_to = at or _now() + session.add(row) + if commit: + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def count_by_inbox(cls, session: AsyncSession, inbox_id: int): + statement = select(func.count()).select_from(cls).where(cls.inbox_id == int(inbox_id)) + result = await session.execute(statement) + return result.scalar_one() + + import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/cost/models.py b/backend/job/cost/models.py new file mode 100644 index 0000000..7b97482 --- /dev/null +++ b/backend/job/cost/models.py @@ -0,0 +1,91 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class HiringCosts(SQLModel, table=True): + __tablename__ = "hiring_costs" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") + cost_type: str = Field(default="other") + amount: float = Field(default=0.0) + currency: str = Field(default="USD") + incurred_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + description: str | None = Field(default=None) + created_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_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)) + return result.scalars().first() + + @classmethod + async def fetch_costs( + cls, + session: AsyncSession, + *, + job_post_id=None, + from_date=None, + to_date=None, + top: int | None = None, + skip: int = 0, + ): + statement = select(cls) + if job_post_id is not None: + uid = cls._as_uuid(job_post_id) + if uid is not None: + statement = statement.where(cls.job_post_id == uid) + if from_date is not None: + statement = statement.where(cls.incurred_at >= from_date) + if to_date is not None: + statement = statement.where(cls.incurred_at < to_date) + count_statement = select(func.count()).select_from(statement.subquery()) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.incurred_at.desc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + + @classmethod + async def insert_cost(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_by_id(session, row.id) + + @classmethod + async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None): + statement = select(func.coalesce(func.sum(cls.amount), 0.0)) + if from_date is not None: + statement = statement.where(cls.incurred_at >= from_date) + if to_date is not None: + statement = statement.where(cls.incurred_at < to_date) + result = await session.execute(statement) + return float(result.scalar_one() or 0.0) + +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/cost/serializers.py b/backend/job/cost/serializers.py new file mode 100644 index 0000000..4975f16 --- /dev/null +++ b/backend/job/cost/serializers.py @@ -0,0 +1,13 @@ +def serialize_hiring_cost(row) -> dict: + return { + "id": str(row.id), + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "cost_type": row.cost_type, + "amount": row.amount, + "currency": row.currency, + "incurred_at": row.incurred_at.isoformat() if row.incurred_at else None, + "description": row.description, + "created_by": str(row.created_by) if row.created_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/job/cost/views.py b/backend/job/cost/views.py new file mode 100644 index 0000000..41b16e1 --- /dev/null +++ b/backend/job/cost/views.py @@ -0,0 +1,44 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from job.cost.models import HiringCosts +from job.cost.serializers import serialize_hiring_cost + + +class HiringCost: + def __init__(self,session:AsyncSession): + self.session=session + + async def list_costs(self,job_post_id=None,from_date=None,to_date=None,top=None,skip=0): + rows,total=await HiringCosts.fetch_costs( + self.session, + job_post_id=job_post_id, + from_date=from_date, + to_date=to_date, + top=top, + skip=skip, + ) + return [serialize_hiring_cost(r) for r in rows],total + + async def create_cost(self,payload,current_user): + cost_type=payload.get("cost_type") + amount=payload.get("amount") + if not cost_type or amount is None: + raise HTTPException(status_code=422,detail="cost_type and amount are required") + created_by=HiringCosts._as_uuid( + current_user.get("id") if isinstance(current_user,dict) else None + ) + if not created_by: + raise HTTPException(status_code=422,detail="created_by is required") + fields={ + "job_post_id":HiringCosts._as_uuid(payload.get("job_post_id")), + "cost_type":cost_type, + "amount":float(amount), + "currency":payload.get("currency") or "USD", + "description":payload.get("description"), + "created_by":created_by, + } + if payload.get("incurred_at") is not None: + fields["incurred_at"]=payload["incurred_at"] + row=await HiringCosts.insert_cost(self.session,fields) + return serialize_hiring_cost(row) diff --git a/backend/job/interviews/serializers.py b/backend/job/interviews/serializers.py index eb319a9..7cab1b9 100644 --- a/backend/job/interviews/serializers.py +++ b/backend/job/interviews/serializers.py @@ -1,4 +1,6 @@ def serialize_interview(row) -> dict: + inbox=getattr(row,"inbox",None) + user=getattr(inbox,"user",None) if inbox else None return { "id": str(row.id), "inbox_id": row.inbox_id, @@ -6,4 +8,5 @@ def serialize_interview(row) -> dict: "interview_time": row.interview_time.isoformat() if row.interview_time else None, "interview_type": row.interview_type, "interview_status": row.interview_status, + "candidate_name": user.name if user else None, } diff --git a/backend/job/interviews/views.py b/backend/job/interviews/views.py index 6956da4..bb2feec 100644 --- a/backend/job/interviews/views.py +++ b/backend/job/interviews/views.py @@ -9,16 +9,31 @@ class Interview: def __init__(self,session:AsyncSession): self.session=session - async def get_interview(self,interview_id=None,inbox_id=None): + 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) if not row: raise HTTPException(status_code=404,detail="Interview not found") return serialize_interview(row) - if inbox_id is None: - raise HTTPException(status_code=400,detail="interview_id or inbox_id is required") - rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id)) - return [serialize_interview(r) for r in rows] + if inbox_id is not None: + rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id)) + return [serialize_interview(r) for r in rows] + if from_date is not None or to_date is not None or status is not None or top is not None: + return await self.get_interviews_range( + from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + ) + raise HTTPException(status_code=400,detail="interview_id or inbox_id is required") + + async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0): + rows,total=await Interviews.get_interviews_in_range( + self.session, + from_date=from_date, + to_date=to_date, + status=status, + top=top, + skip=skip, + ) + return [serialize_interview(r) for r in rows],total async def create_interview(self,payload): fields={ diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 2a32b89..b688739 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -20,9 +20,15 @@ class JobPosts(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(index=True) + # foreign_keys is required, not decoration: current_recruiter_id below is a + # SECOND foreign key into users.id, so the join condition is ambiguous without + # it and every mapper fails to initialize. `user` is the AUTHOR of the post — + # current_recruiter_id is deliberately a bare column with no relationship of + # its own, because Users already carries five selectin relations that load on + # every authenticated request. Same pairing as Notes.user / Notes.author. user: Optional["Users"] = Relationship( back_populates="job_posts", - sa_relationship_kwargs={"lazy": "joined"}, + sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, ) platform: str = Field(default="linkedin") @@ -43,6 +49,14 @@ class JobPosts(SQLModel, table=True): buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) status: str = Field(default="draft") buffer_error: str | None = Field(default=None) + # requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from + # `status`, which tracks Buffer publishing (draft/scheduled/published/failed). + # server_default is load-bearing: this column arrives as an ALTER on a populated table. + requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) + department: str = Field(default="", sa_column_kwargs={"server_default": ""}) + vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) + closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") created_by: uuid.UUID = Field(foreign_key="users.id") created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) diff --git a/backend/job/pipeline/serializers.py b/backend/job/pipeline/serializers.py new file mode 100644 index 0000000..030bb84 --- /dev/null +++ b/backend/job/pipeline/serializers.py @@ -0,0 +1,13 @@ +def serialize_stage_transition(row) -> dict: + return { + "id": str(row.id), + "inbox_id": row.inbox_id, + "from_stage": row.from_stage, + "to_stage": row.to_stage, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "changed_by": str(row.changed_by) if row.changed_by else None, + "actor_kind": row.actor_kind, + "change_reason": row.change_reason, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/job/pipeline/views.py b/backend/job/pipeline/views.py new file mode 100644 index 0000000..28a9e3e --- /dev/null +++ b/backend/job/pipeline/views.py @@ -0,0 +1,64 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from inbox.enums import Candidate_application_Status +from inbox.models import Inbox +from job.candidate.models import ApplicationStageTransitions +from job.pipeline.serializers import serialize_stage_transition + + +class Pipeline: + def __init__(self,session:AsyncSession): + self.session=session + + async def get_transitions(self,inbox_id=None,transition_id=None): + if transition_id: + row=await ApplicationStageTransitions.get_by_id(self.session,transition_id) + if not row: + raise HTTPException(status_code=404,detail="Transition not found") + return serialize_stage_transition(row) + if inbox_id is None: + raise HTTPException(status_code=400,detail="transition_id or inbox_id is required") + rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id)) + return [serialize_stage_transition(r) for r in rows] + + async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None): + inbox=await Inbox.get_inbox_with_message(self.session,inbox_id) + if not inbox: + raise HTTPException(status_code=404,detail="Inbox not found") + message=inbox.messages + if not message: + raise HTTPException(status_code=404,detail="Inbox message not found") + try: + stage=Candidate_application_Status(to_stage) + except ValueError: + raise HTTPException(status_code=422,detail="Invalid to_stage") + current=message.application_status + from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current) + if from_stage==stage.value: + raise HTTPException(status_code=400,detail="already at stage") + changed_by=None + if isinstance(current_user,dict) and current_user.get("id"): + changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id")) + await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False) + transition_data={ + "inbox_id":inbox.id, + "from_stage":from_stage, + "to_stage":stage.value, + "changed_by":changed_by, + "actor_kind":"user", + "change_reason":change_reason, + } + transition=await ApplicationStageTransitions.insert_transition( + self.session, + transition_data, + commit=False, + ) + message.application_status=stage + self.session.add(message) + await self.session.commit() + return { + "inbox_id":inbox.id, + "application_status":stage.value, + "transition":serialize_stage_transition(transition), + } diff --git a/backend/main.py b/backend/main.py index af4b7b1..1755f98 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,8 @@ from role.app import router as role_router from forget_password.app import router as forget_password_router from job.app import router as candidate_router from notifications.app import router as confirmation_router +from analytics.app import router as analytics_router +from offer.app import router as offer_router logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s") logger=logging.getLogger("main") @@ -79,3 +81,5 @@ app.include_router(role_router) app.include_router(forget_password_router) app.include_router(confirmation_router) app.include_router(candidate_router) +app.include_router(analytics_router) +app.include_router(offer_router) diff --git a/backend/migrations/manual/001_dashboard_rbac_and_enum.sql b/backend/migrations/manual/001_dashboard_rbac_and_enum.sql new file mode 100644 index 0000000..05764fe --- /dev/null +++ b/backend/migrations/manual/001_dashboard_rbac_and_enum.sql @@ -0,0 +1,242 @@ +-- 001_dashboard_rbac_and_enum.sql +-- Manual one-shot: enum labels, permission tags, analytics_dashboard bundle, +-- source channels, and backfills. Run in psql against the app DB. +-- ADD VALUE cannot run inside a transaction that also uses the new labels — +-- run section 1a with autocommit (psql default outside BEGIN). +-- +-- Order: (1) alembic upgrade for new tables/columns, (2) this file. +-- Section 1a (enum) can run before or after alembic. + +-- ============================================================================= +-- 1a. Extend candidate_application_status (unqualified — matches original migration) +-- ============================================================================= +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'SCREENING'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'ASSESSMENT'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'INTERVIEW'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'OFFER'; +ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'HIRED'; + +-- ============================================================================= +-- 1b. Seed all 104 permission tags (module x action) idempotently +-- ============================================================================= +INSERT INTO app.permission_tags + (tag_name, module, action, description, created_at, updated_at, is_active, is_deleted) +VALUES +('dashboard.view', 'dashboard', 'view', NULL, NOW(), NOW(), true, false), + ('dashboard.create', 'dashboard', 'create', NULL, NOW(), NOW(), true, false), + ('dashboard.edit', 'dashboard', 'edit', NULL, NOW(), NOW(), true, false), + ('dashboard.delete', 'dashboard', 'delete', NULL, NOW(), NOW(), true, false), + ('dashboard.approve', 'dashboard', 'approve', NULL, NOW(), NOW(), true, false), + ('dashboard.export', 'dashboard', 'export', NULL, NOW(), NOW(), true, false), + ('dashboard.manage', 'dashboard', 'manage', NULL, NOW(), NOW(), true, false), + ('dashboard.configure', 'dashboard', 'configure', NULL, NOW(), NOW(), true, false), + ('inbox.view', 'inbox', 'view', NULL, NOW(), NOW(), true, false), + ('inbox.create', 'inbox', 'create', NULL, NOW(), NOW(), true, false), + ('inbox.edit', 'inbox', 'edit', NULL, NOW(), NOW(), true, false), + ('inbox.delete', 'inbox', 'delete', NULL, NOW(), NOW(), true, false), + ('inbox.approve', 'inbox', 'approve', NULL, NOW(), NOW(), true, false), + ('inbox.export', 'inbox', 'export', NULL, NOW(), NOW(), true, false), + ('inbox.manage', 'inbox', 'manage', NULL, NOW(), NOW(), true, false), + ('inbox.configure', 'inbox', 'configure', NULL, NOW(), NOW(), true, false), + ('jobs.view', 'jobs', 'view', NULL, NOW(), NOW(), true, false), + ('jobs.create', 'jobs', 'create', NULL, NOW(), NOW(), true, false), + ('jobs.edit', 'jobs', 'edit', NULL, NOW(), NOW(), true, false), + ('jobs.delete', 'jobs', 'delete', NULL, NOW(), NOW(), true, false), + ('jobs.approve', 'jobs', 'approve', NULL, NOW(), NOW(), true, false), + ('jobs.export', 'jobs', 'export', NULL, NOW(), NOW(), true, false), + ('jobs.manage', 'jobs', 'manage', NULL, NOW(), NOW(), true, false), + ('jobs.configure', 'jobs', 'configure', NULL, NOW(), NOW(), true, false), + ('candidates.view', 'candidates', 'view', NULL, NOW(), NOW(), true, false), + ('candidates.create', 'candidates', 'create', NULL, NOW(), NOW(), true, false), + ('candidates.edit', 'candidates', 'edit', NULL, NOW(), NOW(), true, false), + ('candidates.delete', 'candidates', 'delete', NULL, NOW(), NOW(), true, false), + ('candidates.approve', 'candidates', 'approve', NULL, NOW(), NOW(), true, false), + ('candidates.export', 'candidates', 'export', NULL, NOW(), NOW(), true, false), + ('candidates.manage', 'candidates', 'manage', NULL, NOW(), NOW(), true, false), + ('candidates.configure', 'candidates', 'configure', NULL, NOW(), NOW(), true, false), + ('pipeline.view', 'pipeline', 'view', NULL, NOW(), NOW(), true, false), + ('pipeline.create', 'pipeline', 'create', NULL, NOW(), NOW(), true, false), + ('pipeline.edit', 'pipeline', 'edit', NULL, NOW(), NOW(), true, false), + ('pipeline.delete', 'pipeline', 'delete', NULL, NOW(), NOW(), true, false), + ('pipeline.approve', 'pipeline', 'approve', NULL, NOW(), NOW(), true, false), + ('pipeline.export', 'pipeline', 'export', NULL, NOW(), NOW(), true, false), + ('pipeline.manage', 'pipeline', 'manage', NULL, NOW(), NOW(), true, false), + ('pipeline.configure', 'pipeline', 'configure', NULL, NOW(), NOW(), true, false), + ('interviews.view', 'interviews', 'view', NULL, NOW(), NOW(), true, false), + ('interviews.create', 'interviews', 'create', NULL, NOW(), NOW(), true, false), + ('interviews.edit', 'interviews', 'edit', NULL, NOW(), NOW(), true, false), + ('interviews.delete', 'interviews', 'delete', NULL, NOW(), NOW(), true, false), + ('interviews.approve', 'interviews', 'approve', NULL, NOW(), NOW(), true, false), + ('interviews.export', 'interviews', 'export', NULL, NOW(), NOW(), true, false), + ('interviews.manage', 'interviews', 'manage', NULL, NOW(), NOW(), true, false), + ('interviews.configure', 'interviews', 'configure', NULL, NOW(), NOW(), true, false), + ('assessments.view', 'assessments', 'view', NULL, NOW(), NOW(), true, false), + ('assessments.create', 'assessments', 'create', NULL, NOW(), NOW(), true, false), + ('assessments.edit', 'assessments', 'edit', NULL, NOW(), NOW(), true, false), + ('assessments.delete', 'assessments', 'delete', NULL, NOW(), NOW(), true, false), + ('assessments.approve', 'assessments', 'approve', NULL, NOW(), NOW(), true, false), + ('assessments.export', 'assessments', 'export', NULL, NOW(), NOW(), true, false), + ('assessments.manage', 'assessments', 'manage', NULL, NOW(), NOW(), true, false), + ('assessments.configure', 'assessments', 'configure', NULL, NOW(), NOW(), true, false), + ('offers.view', 'offers', 'view', NULL, NOW(), NOW(), true, false), + ('offers.create', 'offers', 'create', NULL, NOW(), NOW(), true, false), + ('offers.edit', 'offers', 'edit', NULL, NOW(), NOW(), true, false), + ('offers.delete', 'offers', 'delete', NULL, NOW(), NOW(), true, false), + ('offers.approve', 'offers', 'approve', NULL, NOW(), NOW(), true, false), + ('offers.export', 'offers', 'export', NULL, NOW(), NOW(), true, false), + ('offers.manage', 'offers', 'manage', NULL, NOW(), NOW(), true, false), + ('offers.configure', 'offers', 'configure', NULL, NOW(), NOW(), true, false), + ('reports.view', 'reports', 'view', NULL, NOW(), NOW(), true, false), + ('reports.create', 'reports', 'create', NULL, NOW(), NOW(), true, false), + ('reports.edit', 'reports', 'edit', NULL, NOW(), NOW(), true, false), + ('reports.delete', 'reports', 'delete', NULL, NOW(), NOW(), true, false), + ('reports.approve', 'reports', 'approve', NULL, NOW(), NOW(), true, false), + ('reports.export', 'reports', 'export', NULL, NOW(), NOW(), true, false), + ('reports.manage', 'reports', 'manage', NULL, NOW(), NOW(), true, false), + ('reports.configure', 'reports', 'configure', NULL, NOW(), NOW(), true, false), + ('analytics.view', 'analytics', 'view', NULL, NOW(), NOW(), true, false), + ('analytics.create', 'analytics', 'create', NULL, NOW(), NOW(), true, false), + ('analytics.edit', 'analytics', 'edit', NULL, NOW(), NOW(), true, false), + ('analytics.delete', 'analytics', 'delete', NULL, NOW(), NOW(), true, false), + ('analytics.approve', 'analytics', 'approve', NULL, NOW(), NOW(), true, false), + ('analytics.export', 'analytics', 'export', NULL, NOW(), NOW(), true, false), + ('analytics.manage', 'analytics', 'manage', NULL, NOW(), NOW(), true, false), + ('analytics.configure', 'analytics', 'configure', NULL, NOW(), NOW(), true, false), + ('job_board.view', 'job_board', 'view', NULL, NOW(), NOW(), true, false), + ('job_board.create', 'job_board', 'create', NULL, NOW(), NOW(), true, false), + ('job_board.edit', 'job_board', 'edit', NULL, NOW(), NOW(), true, false), + ('job_board.delete', 'job_board', 'delete', NULL, NOW(), NOW(), true, false), + ('job_board.approve', 'job_board', 'approve', NULL, NOW(), NOW(), true, false), + ('job_board.export', 'job_board', 'export', NULL, NOW(), NOW(), true, false), + ('job_board.manage', 'job_board', 'manage', NULL, NOW(), NOW(), true, false), + ('job_board.configure', 'job_board', 'configure', NULL, NOW(), NOW(), true, false), + ('settings.view', 'settings', 'view', NULL, NOW(), NOW(), true, false), + ('settings.create', 'settings', 'create', NULL, NOW(), NOW(), true, false), + ('settings.edit', 'settings', 'edit', NULL, NOW(), NOW(), true, false), + ('settings.delete', 'settings', 'delete', NULL, NOW(), NOW(), true, false), + ('settings.approve', 'settings', 'approve', NULL, NOW(), NOW(), true, false), + ('settings.export', 'settings', 'export', NULL, NOW(), NOW(), true, false), + ('settings.manage', 'settings', 'manage', NULL, NOW(), NOW(), true, false), + ('settings.configure', 'settings', 'configure', NULL, NOW(), NOW(), true, false), + ('rbac_users.view', 'rbac_users', 'view', NULL, NOW(), NOW(), true, false), + ('rbac_users.create', 'rbac_users', 'create', NULL, NOW(), NOW(), true, false), + ('rbac_users.edit', 'rbac_users', 'edit', NULL, NOW(), NOW(), true, false), + ('rbac_users.delete', 'rbac_users', 'delete', NULL, NOW(), NOW(), true, false), + ('rbac_users.approve', 'rbac_users', 'approve', NULL, NOW(), NOW(), true, false), + ('rbac_users.export', 'rbac_users', 'export', NULL, NOW(), NOW(), true, false), + ('rbac_users.manage', 'rbac_users', 'manage', NULL, NOW(), NOW(), true, false), + ('rbac_users.configure', 'rbac_users', 'configure', NULL, NOW(), NOW(), true, false) +ON CONFLICT (tag_name) DO NOTHING; + +-- Bundle holding dashboard / analytics / offers / interviews.view tags +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'analytics_dashboard', + 'Dashboard KPI tiles, analytics charts, offers, and interview list', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND ( + module IN ('dashboard', 'analytics', 'offers') + OR tag_name = 'interviews.view' + ) + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'analytics_dashboard' +); + +-- Append the bundle id to the named system roles (idempotent) +UPDATE app.roles r +SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id), + updated_at = NOW() +FROM app.permissions p +WHERE p.name = 'analytics_dashboard' + AND r.role_name IN ( + 'system_administrator', + 'hr_administrator', + 'recruiter', + 'hiring_manager', + 'department_head', + 'ceo' + ) + AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id)); + +-- ============================================================================= +-- 1c. Source channels (eleven BRD channels) +-- ============================================================================= +INSERT INTO app.source_channels (key, label, is_active, created_at, updated_at) +VALUES + ('microsoft_outlook', 'Microsoft Outlook', true, NOW(), NOW()), + ('career_portal', 'Career Portal', true, NOW(), NOW()), + ('manual_cv_upload', 'Manual CV Upload', true, NOW(), NOW()), + ('linkedin', 'LinkedIn', true, NOW(), NOW()), + ('indeed', 'Indeed', true, NOW(), NOW()), + ('rozee', 'Rozee', true, NOW(), NOW()), + ('mustakbil', 'Mustakbil', true, NOW(), NOW()), + ('employee_referral', 'Employee Referral', true, NOW(), NOW()), + ('recruitment_agency', 'Recruitment Agency', true, NOW(), NOW()), + ('campus_hiring', 'Campus Hiring', true, NOW(), NOW()), + ('walk_in', 'Walk-in', true, NOW(), NOW()) +ON CONFLICT (key) DO NOTHING; + +-- ============================================================================= +-- Backfills (require new columns/tables from alembic) +-- ============================================================================= + +-- Source channel from message_to board tags; default Microsoft Outlook +UPDATE app.inbox_messages m +SET source_channel_id = sc.id +FROM app.source_channels sc +WHERE m.source_channel_id IS NULL + AND ( + (LOWER(m.message_to) LIKE '%linkedin%' AND sc.key = 'linkedin') + OR (LOWER(m.message_to) LIKE '%indeed%' AND sc.key = 'indeed') + OR (LOWER(m.message_to) LIKE '%rozee%' AND sc.key = 'rozee') + OR (LOWER(m.message_to) LIKE '%mustakbil%' AND sc.key = 'mustakbil') + OR (LOWER(m.message_to) LIKE '%referral%' AND sc.key = 'employee_referral') + OR (LOWER(m.message_to) LIKE '%agency%' AND sc.key = 'recruitment_agency') + OR (LOWER(m.message_to) LIKE '%campus%' AND sc.key = 'campus_hiring') + OR (LOWER(m.message_to) LIKE '%portal%' AND sc.key = 'career_portal') + OR (LOWER(m.message_to) LIKE '%walk%' AND sc.key = 'walk_in') + OR (LOWER(m.message_to) LIKE '%manual%' AND sc.key = 'manual_cv_upload') + ); + +UPDATE app.inbox_messages m +SET source_channel_id = sc.id +FROM app.source_channels sc +WHERE m.source_channel_id IS NULL + AND sc.key = 'microsoft_outlook'; + +-- One open stage-transition row per application (cannot invent history) +INSERT INTO app.application_stage_transitions + (id, inbox_id, from_stage, to_stage, valid_from, valid_to, changed_by, actor_kind, change_reason, created_at) +SELECT + gen_random_uuid(), + i.id, + NULL, + m.application_status::text, + COALESCE(i.created_at, NOW()), + NULL, + NULL, + 'system', + 'backfill', + NOW() +FROM app.inbox i +JOIN app.inbox_messages m ON m.id = i.message_id +WHERE NOT EXISTS ( + SELECT 1 FROM app.application_stage_transitions t WHERE t.inbox_id = i.id +); + +-- Requisition status from is_active / is_deleted +UPDATE app.job_posts +SET requisition_status = CASE + WHEN is_active AND NOT is_deleted THEN 'open' + ELSE 'closed' +END +WHERE requisition_status IS NULL OR requisition_status = ''; diff --git a/backend/offer/app.py b/backend/offer/app.py new file mode 100644 index 0000000..6bc8fff --- /dev/null +++ b/backend/offer/app.py @@ -0,0 +1,125 @@ +from datetime import datetime +from fastapi import APIRouter,Depends,Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from offer.views import Offer +from users.permissions import PermissionTag,require_permission +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class OfferCreate(BaseModel): + inbox_id: int + job_post_id: str + candidate_user_id: str + status: str | None = "draft" + base_salary: float | None = None + currency: str | None = None + salary_period: str | None = None + signing_bonus: float | None = None + annual_bonus_pct: float | None = None + equity_units: int | None = None + equity_instrument: str | None = None + start_date: datetime | None = None + expiry_date: datetime | None = None + change_reason: str | None = None + + +class OfferUpdate(BaseModel): + status: str | None = None + base_salary: float | None = None + currency: str | None = None + salary_period: str | None = None + signing_bonus: float | None = None + annual_bonus_pct: float | None = None + equity_units: int | None = None + equity_instrument: str | None = None + start_date: datetime | None = None + expiry_date: datetime | None = None + sent_at: datetime | None = None + responded_at: datetime | None = None + closed_at: datetime | None = None + issued_by: str | None = None + inbox_id: int | None = None + job_post_id: str | None = None + candidate_user_id: str | None = None + change_reason: str | None = None + + +class OfferIssue(BaseModel): + change_reason: str | None = None + + +@router.get("/offers/fetch") +async def fetch_offers( + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)), + offer_id: str | None = Query(None), + status: str | None = Query(None), + inbox_id: int | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0,ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data,total=await service.get_offers(offer_id,status,inbox_id,top,skip) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/offers/create") +async def create_offer( + payload: OfferCreate, + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data=await service.create_offer(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/offers/update") +async def update_offer( + payload: OfferUpdate, + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_EDIT)), + offer_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data=await service.update_offer(offer_id,payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/offers/issue") +async def issue_offer( + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_APPROVE)), + offer_id: str = Query(...), + payload: OfferIssue | None = None, + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data=await service.issue_offer(offer_id,current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/offer/models.py b/backend/offer/models.py new file mode 100644 index 0000000..d65707e --- /dev/null +++ b/backend/offer/models.py @@ -0,0 +1,152 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Offers(SQLModel, table=True): + __tablename__ = "offers" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int = Field(index=True, foreign_key="inbox.id") + job_post_id: uuid.UUID = Field(foreign_key="job_posts.id") + candidate_user_id: uuid.UUID = Field(foreign_key="users.id") + status: str = Field(default="draft") + base_salary: float = Field(default=0.0) + currency: str = Field(default="USD") + salary_period: str = Field(default="annual") + signing_bonus: float | None = Field(default=None) + annual_bonus_pct: float | None = Field(default=None) + equity_units: int | None = Field(default=None) + equity_instrument: str | None = Field(default=None) + start_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + expiry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + responded_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + issued_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + created_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_offer_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)) + return result.scalars().first() + + @classmethod + async def fetch_offers( + cls, + session: AsyncSession, + *, + status: str | None = None, + inbox_id: int | None = None, + top: int | None = None, + skip: int = 0, + ): + statement = select(cls) + if status: + statement = statement.where(cls.status == status) + if inbox_id is not None: + statement = statement.where(cls.inbox_id == int(inbox_id)) + count_statement = select(func.count()).select_from(statement.subquery()) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.created_at.desc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + + @classmethod + async def insert_offer(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_offer_by_id(session, row.id) + + @classmethod + async def update_offer(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_offer_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def count_by_status(cls, session: AsyncSession, status: str, *, from_date=None, to_date=None): + statement = select(func.count()).select_from(cls).where(cls.status == status) + if from_date is not None: + statement = statement.where(cls.created_at >= from_date) + if to_date is not None: + statement = statement.where(cls.created_at < to_date) + result = await session.execute(statement) + return result.scalar_one() + + +class OfferStatusHistory(SQLModel, table=True): + __tablename__ = "offer_status_history" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + offer_id: uuid.UUID = Field(index=True, foreign_key="offers.id") + from_status: str | None = Field(default=None) + to_status: str + valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + actor_kind: str = Field(default="user") + change_reason: str | None = Field(default=None) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_by_offer(cls, session: AsyncSession, offer_id): + uid = cls._as_uuid(offer_id) + if uid is None: + return [] + result = await session.execute( + select(cls).where(cls.offer_id == uid).order_by(cls.valid_from.desc()) + ) + return list(result.scalars().all()) + + @classmethod + async def insert_history(cls, session: AsyncSession, fields: dict, *, commit: bool = True): + row = cls(**fields) + session.add(row) + if commit: + await session.commit() + await session.refresh(row) + return row + +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/offer/plugins.py b/backend/offer/plugins.py new file mode 100644 index 0000000..8f210ac --- /dev/null +++ b/backend/offer/plugins.py @@ -0,0 +1,4 @@ +def non_validation_values(): + fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", + "equity_units","equity_instrument","start_date","expiry_date") + return fields \ No newline at end of file diff --git a/backend/offer/serializers.py b/backend/offer/serializers.py new file mode 100644 index 0000000..b1b0660 --- /dev/null +++ b/backend/offer/serializers.py @@ -0,0 +1,38 @@ +def serialize_offer(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "inbox_id": row.inbox_id, + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None, + "status": row.status, + "base_salary": row.base_salary, + "currency": row.currency, + "salary_period": row.salary_period, + "signing_bonus": row.signing_bonus, + "annual_bonus_pct": row.annual_bonus_pct, + "equity_units": row.equity_units, + "equity_instrument": row.equity_instrument, + "start_date": row.start_date.isoformat() if row.start_date else None, + "expiry_date": row.expiry_date.isoformat() if row.expiry_date else None, + "sent_at": row.sent_at.isoformat() if row.sent_at else None, + "responded_at": row.responded_at.isoformat() if row.responded_at else None, + "closed_at": row.closed_at.isoformat() if row.closed_at else None, + "issued_by": str(row.issued_by) if row.issued_by else None, + "created_by": str(row.created_by) if row.created_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + +def serialize_offer_history(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "offer_id": str(row.offer_id) if row.offer_id else None, + "from_status": row.from_status, + "to_status": row.to_status, + "valid_from": row.valid_from.isoformat() if row.valid_from else None, + "valid_to": row.valid_to.isoformat() if row.valid_to else None, + "changed_by": str(row.changed_by) if row.changed_by else None, + "actor_kind": row.actor_kind, + "change_reason": row.change_reason, + } diff --git a/backend/offer/views.py b/backend/offer/views.py new file mode 100644 index 0000000..9595ae0 --- /dev/null +++ b/backend/offer/views.py @@ -0,0 +1,146 @@ +import uuid +from datetime import datetime,timezone + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from offer.models import Offers,OfferStatusHistory +from offer.serializers import serialize_offer +from offer.plugins import non_validation_values + +def _as_uuid(value): + if value in (None,""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError,ValueError): + return None + + +def _user_id(current_user): + if not current_user or not current_user.get("id"): + raise HTTPException(status_code=401,detail="Not authenticated") + uid=_as_uuid(current_user["id"]) + if uid is None: + raise HTTPException(status_code=401,detail="Invalid user id") + return uid + + +class Offer: + def __init__(self,session:AsyncSession): + self.session=session + + async def get_offers(self,offer_id=None,status=None,inbox_id=None,top=None,skip=0): + if offer_id is not None: + row=await Offers.get_offer_by_id(self.session,offer_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + return serialize_offer(row),1 + rows,total=await Offers.fetch_offers( + self.session, + status=status, + inbox_id=inbox_id, + top=top, + skip=skip or 0, + ) + return [serialize_offer(r) for r in rows],total + + async def create_offer(self,payload,current_user): + if not payload.get("inbox_id"): + raise HTTPException(status_code=422,detail="inbox_id is required") + job_post_id=_as_uuid(payload.get("job_post_id")) + if job_post_id is None: + raise HTTPException(status_code=422,detail="job_post_id is required") + candidate_user_id=_as_uuid(payload.get("candidate_user_id")) + if candidate_user_id is None: + raise HTTPException(status_code=422,detail="candidate_user_id is required") + created_by=_user_id(current_user) + status=payload.get("status") or "draft" + + fields={ + "inbox_id": int(payload["inbox_id"]), + "job_post_id": job_post_id, + "candidate_user_id": candidate_user_id, + "created_by": created_by, + "status": status, + } + for key in non_validation_values(): + if key in payload and payload[key] is not None: + fields[key]=payload[key] + + row=await Offers.insert_offer(self.session,fields) + history_data={ + "offer_id": row.id, + "from_status": None, + "to_status": status, + "changed_by": created_by, + "actor_kind": "user", + "change_reason": payload.get("change_reason"), + } + await OfferStatusHistory.insert_history(self.session,history_data) + return serialize_offer(row) + + async def update_offer(self,offer_id,payload,current_user): + row=await Offers.get_offer_by_id(self.session,offer_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + changed_by=_user_id(current_user) + fields={} + for key in ( + "status","base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", + "equity_units","equity_instrument","start_date","expiry_date","sent_at", + "responded_at","closed_at","issued_by","inbox_id","job_post_id","candidate_user_id", + ): + if key not in payload: + continue + value=payload[key] + if key in ("job_post_id","candidate_user_id","issued_by") and value is not None: + value=_as_uuid(value) + if value is None: + raise HTTPException(status_code=422,detail=f"Invalid {key}") + fields[key]=value + + if not fields: + raise HTTPException(status_code=400,detail="No fields to update") + + new_status=fields.get("status") + if new_status is not None and new_status!=row.status: + await OfferStatusHistory.insert_history(self.session,{ + "offer_id": row.id, + "from_status": row.status, + "to_status": new_status, + "changed_by": changed_by, + "actor_kind": "user", + "change_reason": payload.get("change_reason"), + },commit=False) + + updated=await Offers.update_offer(self.session,offer_id,fields) + if not updated: + raise HTTPException(status_code=404,detail="Offer not found") + return serialize_offer(updated) + + async def issue_offer(self,offer_id,current_user): + row=await Offers.get_offer_by_id(self.session,offer_id) + if not row: + raise HTTPException(status_code=404,detail="Offer not found") + issued_by=_user_id(current_user) + now=datetime.now(timezone.utc) + from_status=row.status + fields={"issued_by": issued_by,"sent_at": now} + to_status=from_status + if from_status=="draft": + fields["status"]="sent" + to_status="sent" + updated=await Offers.update_offer(self.session,offer_id,fields) + if not updated: + raise HTTPException(status_code=404,detail="Offer not found") + history_data={ + "offer_id": updated.id, + "from_status": from_status, + "to_status": to_status, + "changed_by": issued_by, + "actor_kind": "user", + "change_reason": "issued", + } + await OfferStatusHistory.insert_history(self.session,history_data) + return serialize_offer(updated) diff --git a/backend/role/models.py b/backend/role/models.py index e30298a..2d06382 100644 --- a/backend/role/models.py +++ b/backend/role/models.py @@ -1,11 +1,15 @@ -from datetime import datetime +from datetime import datetime, timezone from enum import Enum -from sqlalchemy import Column, UniqueConstraint, func, or_ +from sqlalchemy import Column, DateTime, UniqueConstraint, func, or_ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel, select +def _now() -> datetime: + return datetime.now(timezone.utc) + + class EnumRoles(str, Enum): """Canonical keys for the eight seeded system roles. `Roles.role_name` is a varchar.""" @@ -30,8 +34,8 @@ class PermissionTags(SQLModel, table=True): module: str = Field(max_length=32, nullable=False, index=True) action: str = Field(max_length=32, nullable=False) description: str | None = Field(default=None) - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) @@ -104,8 +108,8 @@ class Permissions(SQLModel, table=True): description: str | None = Field(default=None) permission_tags: list | None = Field(default=None, sa_column=Column(JSONB)) is_system: bool = Field(default=False) - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) @@ -182,7 +186,7 @@ class Permissions(SQLModel, table=True): return None for key, value in fields.items(): setattr(row, key, value) - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) @@ -195,7 +199,7 @@ class Permissions(SQLModel, table=True): return None row.is_deleted = True row.is_active = False - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) @@ -210,8 +214,8 @@ class Roles(SQLModel, table=True): description: str | None = Field(default=None) permissions: list | None = Field(default=None, sa_column=Column(JSONB)) is_system: bool = Field(default=False) - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) @@ -280,7 +284,7 @@ class Roles(SQLModel, table=True): return None for key, value in fields.items(): setattr(row, key, value) - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) @@ -293,7 +297,7 @@ class Roles(SQLModel, table=True): return None row.is_deleted = True row.is_active = False - row.updated_at = datetime.now() + row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) diff --git a/backend/users/models.py b/backend/users/models.py index d79f1f3..e6cb580 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,8 +1,8 @@ import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING,List,Optional -from sqlalchemy import func, or_ +from sqlalchemy import DateTime, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select @@ -14,6 +14,11 @@ if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this modul from inbox.models import Inbox from job.candidate.models import Feedback, Notes + +def _now() -> datetime: + return datetime.now(timezone.utc) + + class Users(SQLModel, table=True): __tablename__ = "users" @@ -27,9 +32,11 @@ class Users(SQLModel, table=True): # selectin, not joined: this is a one-to-many, so a joined load would repeat the # user row once per post. Without an explicit strategy the default is a lazy load, # which raises MissingGreenlet the moment anything touches it under asyncio. + # foreign_keys must match the other side: job_posts.current_recruiter_id is a + # second FK into this table, so this relation has to say it means created_by. job_posts: List[JobPosts] = Relationship( back_populates="user", - sa_relationship_kwargs={"lazy": "selectin"}, + sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"}, ) inbox: List["Inbox"] = Relationship( back_populates="user", @@ -50,8 +57,8 @@ class Users(SQLModel, table=True): password: str - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=False) is_deleted: bool = Field(default=False) @@ -140,7 +147,7 @@ class Users(SQLModel, table=True): return None for key, value in fields.items(): setattr(user, key, value) - user.updated_at = datetime.now() + user.updated_at = _now() session.add(user) await session.commit() await session.refresh(user) @@ -153,7 +160,7 @@ class Users(SQLModel, table=True): return None user.is_deleted = True user.is_active = False - user.updated_at = datetime.now() + user.updated_at = _now() session.add(user) await session.commit() await session.refresh(user) diff --git a/frontend/dist/assets/AiAssistant-B1ZQ2wQY.js b/frontend/dist/assets/AiAssistant-B1ZQ2wQY.js deleted file mode 100644 index 82824e8..0000000 --- a/frontend/dist/assets/AiAssistant-B1ZQ2wQY.js +++ /dev/null @@ -1,2 +0,0 @@ -import{r as n,j as s,I as c,a9 as i}from"./index-BTPmxtwM.js";function d(){const[e,a]=n.useState(0);return s.jsxs("div",{className:"page",children:[s.jsxs("div",{className:"page-head",children:[s.jsxs("div",{children:[s.jsx("h1",{className:"page-title",children:"AI Assistant"}),s.jsx("p",{className:"page-sub",children:"Your recruiting copilot — powered by AI (interface preview)"})]}),s.jsxs("div",{className:"page-head-actions",children:[s.jsxs("span",{className:"integration-status pending",children:[s.jsx("span",{className:"pulse"}),"Model endpoint · Not connected"]}),s.jsxs("button",{className:"btn btn-secondary",onClick:()=>a(t=>t+1),children:[s.jsx(c,{name:"plus"})," New Chat"]})]})]}),s.jsx("div",{className:"card",children:s.jsx("div",{className:"card-body",children:s.jsx(i,{resetKey:e})})})]})}export{d as default}; -//# sourceMappingURL=AiAssistant-B1ZQ2wQY.js.map diff --git a/frontend/dist/assets/AiAssistant-B1ZQ2wQY.js.map b/frontend/dist/assets/AiAssistant-B1ZQ2wQY.js.map deleted file mode 100644 index cecd558..0000000 --- a/frontend/dist/assets/AiAssistant-B1ZQ2wQY.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"AiAssistant-B1ZQ2wQY.js","sources":["../../src/screens/AiAssistant.jsx"],"sourcesContent":["import { useState } from 'react'\r\nimport Chat from '../app/ai/Chat'\r\nimport { Icon } from '../ui/primitives'\r\n\r\nexport default function AiAssistant() {\r\n // Bumping the key resets the transcript — the old AI.newChat().\r\n const [resetKey, setResetKey] = useState(0)\r\n\r\n return (\r\n
\r\n
\r\n
\r\n

AI Assistant

\r\n

Your recruiting copilot — powered by AI (interface preview)

\r\n
\r\n
\r\n \r\n Model endpoint · Not connected\r\n \r\n \r\n
\r\n
\r\n
\r\n
\r\n \r\n
\r\n
\r\n
\r\n )\r\n}\r\n"],"names":["AiAssistant","resetKey","setResetKey","useState","jsxs","jsx","k","Icon","Chat"],"mappings":"8DAIA,SAAwBA,GAAc,CAEpC,KAAM,CAACC,EAAUC,CAAW,EAAIC,EAAAA,SAAS,CAAC,EAE1C,OACEC,EAAAA,KAAC,MAAA,CAAI,UAAU,OACb,SAAA,CAAAA,EAAAA,KAAC,MAAA,CAAI,UAAU,YACb,SAAA,CAAAA,OAAC,MAAA,CACC,SAAA,CAAAC,EAAAA,IAAC,KAAA,CAAG,UAAU,aAAa,SAAA,eAAY,EACvCA,EAAAA,IAAC,IAAA,CAAE,UAAU,WAAW,SAAA,6DAAA,CAA2D,CAAA,EACrF,EACAD,EAAAA,KAAC,MAAA,CAAI,UAAU,oBACb,SAAA,CAAAA,EAAAA,KAAC,OAAA,CAAK,UAAU,6BACd,SAAA,CAAAC,EAAAA,IAAC,OAAA,CAAK,UAAU,OAAA,CAAQ,EAAE,gCAAA,EAC5B,EACAD,EAAAA,KAAC,SAAA,CAAO,UAAU,oBAAoB,QAAS,IAAMF,EAAaI,GAAMA,EAAI,CAAC,EAC3E,SAAA,CAAAD,EAAAA,IAACE,EAAA,CAAK,KAAK,MAAA,CAAO,EAAE,WAAA,CAAA,CACtB,CAAA,CAAA,CACF,CAAA,EACF,EACAF,EAAAA,IAAC,MAAA,CAAI,UAAU,OACb,SAAAA,EAAAA,IAAC,MAAA,CAAI,UAAU,YACb,SAAAA,MAACG,EAAA,CAAK,SAAAP,CAAA,CAAoB,CAAA,CAC5B,CAAA,CACF,CAAA,EACF,CAEJ"} \ No newline at end of file diff --git a/frontend/dist/assets/AiStudio-CIA10Amn.js b/frontend/dist/assets/AiStudio-CIA10Amn.js deleted file mode 100644 index e8911f0..0000000 --- a/frontend/dist/assets/AiStudio-CIA10Amn.js +++ /dev/null @@ -1,12 +0,0 @@ -import{d,r as c,af as n,j as e,I as t,B as o}from"./index-BTPmxtwM.js";import{M as m}from"./Modal-B8aWFZt2.js";function p(){const{toast:l}=d(),[a,i]=c.useState(null),r=n.filter(s=>s.status==="Beta").length;return e.jsxs("div",{className:"page",children:[e.jsxs("div",{className:"page-head",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"page-title",children:"AI Studio"}),e.jsx("p",{className:"page-sub",children:"Next-generation AI modules — designed and API-ready for backend integration"})]}),e.jsx("div",{className:"page-head-actions",children:e.jsxs("span",{className:"integration-status pending",children:[e.jsx("span",{className:"pulse"}),r," in Beta"]})})]}),e.jsx("div",{className:"card brand-hero mb-18",children:e.jsxs("div",{className:"card-body",style:{display:"flex",alignItems:"center",gap:20,flexWrap:"wrap"},children:[e.jsx("div",{className:"ai-logo",style:{margin:0,width:56,height:56},children:e.jsx(t,{name:"sparkles"})}),e.jsxs("div",{style:{flex:1,minWidth:220},children:[e.jsx("h2",{style:{fontSize:19,marginBottom:4},children:"Everything is API-ready"}),e.jsx("p",{style:{opacity:.88},children:"Each module below ships with a complete, production-grade interface. Connect your model endpoint to activate them — no UI work required."})]}),e.jsxs("button",{className:"btn btn-on-brand",onClick:()=>l("Integration guide opened","info"),children:[e.jsx(t,{name:"external"})," Integration Guide"]})]})}),e.jsx("div",{className:"grid g-3",children:n.map(s=>e.jsx("div",{className:"card",style:{cursor:"pointer"},onClick:()=>i(s),children:e.jsxs("div",{className:"card-body",children:[e.jsxs("div",{className:"flex items-center",style:{justifyContent:"space-between",marginBottom:12},children:[e.jsx("span",{className:`kpi-icn ${s.cls}`,style:{width:46,height:46,borderRadius:13},children:e.jsx(t,{name:s.icon})}),e.jsx(o,{className:s.status==="Beta"?"b-indigo":"b-gray",children:s.status})]}),e.jsx("div",{className:"lr-title",style:{fontSize:15},children:s.name}),e.jsx("div",{className:"lr-sub",style:{marginTop:5,lineHeight:1.5},children:s.desc}),e.jsxs("div",{style:{marginTop:14,color:"var(--primary)",fontWeight:600,fontSize:13},children:[s.status==="Beta"?"Try it":"Join waitlist"," ",e.jsx(t,{name:"arrow-right"})]})]})},s.name))}),a&&e.jsxs(m,{title:a.name,subtitle:`${a.status} · AI Module`,size:"modal-lg",onClose:()=>i(null),footer:e.jsx("button",{className:"btn btn-secondary",onClick:()=>i(null),children:"Close"}),children:[e.jsxs("div",{className:"flex items-center gap-16",style:{marginBottom:18},children:[e.jsx("span",{className:`kpi-icn ${a.cls}`,style:{width:56,height:56,borderRadius:16},children:e.jsx(t,{name:a.icon})}),e.jsxs("div",{children:[e.jsx("div",{className:"fw-600",style:{fontSize:16},children:a.name}),e.jsx("div",{className:"text-muted",children:a.desc})]})]}),e.jsx("div",{className:"card",style:{boxShadow:"none",background:"var(--bg-sunken)"},children:e.jsxs("div",{className:"card-body",children:[e.jsx("div",{className:"form-section-title",style:{marginTop:0},children:"API Contract (preview)"}),e.jsx("pre",{className:"resume-thumb",style:{maxHeight:"none"},children:`POST /api/ai/${a.name.toLowerCase().replace(/ /g,"-")} -{ - "context": { "jobId": "JOB-1001", "candidateIds": [...] }, - "options": { "model": "claude-opus", "stream": true } -} - -→ 200 OK -{ - "result": { ... }, - "usage": { "tokens": 1240 } -}`})]})}),e.jsxs("p",{className:"text-muted text-sm",style:{marginTop:14},children:[e.jsx(t,{name:"lock"})," This feature’s UI is complete. Backend wiring is the only remaining step."]})]})]})}export{p as default}; -//# sourceMappingURL=AiStudio-CIA10Amn.js.map diff --git a/frontend/dist/assets/AiStudio-CIA10Amn.js.map b/frontend/dist/assets/AiStudio-CIA10Amn.js.map deleted file mode 100644 index 5d54897..0000000 --- a/frontend/dist/assets/AiStudio-CIA10Amn.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"AiStudio-CIA10Amn.js","sources":["../../src/screens/AiStudio.jsx"],"sourcesContent":["import { useState } from 'react'\r\nimport Modal from '../ui/Modal'\r\nimport { Badge, Icon } from '../ui/primitives'\r\nimport { useToast } from '../ui/Toast'\r\nimport { aiModules } from '../data/seed'\r\n\r\nexport default function AiStudio() {\r\n const { toast } = useToast()\r\n const [detail, setDetail] = useState(null)\r\n const betaCount = aiModules.filter((m) => m.status === 'Beta').length\r\n\r\n return (\r\n
\r\n
\r\n
\r\n

AI Studio

\r\n

\r\n Next-generation AI modules — designed and API-ready for backend integration\r\n

\r\n
\r\n
\r\n {betaCount} in Beta\r\n
\r\n
\r\n\r\n
\r\n
\r\n
\r\n
\r\n

Everything is API-ready

\r\n

\r\n Each module below ships with a complete, production-grade interface. Connect your model\r\n endpoint to activate them — no UI work required.\r\n

\r\n
\r\n \r\n
\r\n
\r\n\r\n
\r\n {aiModules.map((m) => (\r\n
setDetail(m)}>\r\n
\r\n
\r\n \r\n \r\n \r\n {m.status}\r\n
\r\n
{m.name}
\r\n
{m.desc}
\r\n
\r\n {m.status === 'Beta' ? 'Try it' : 'Join waitlist'} \r\n
\r\n
\r\n
\r\n ))}\r\n
\r\n\r\n {detail && (\r\n setDetail(null)}\r\n footer={}\r\n >\r\n
\r\n \r\n \r\n \r\n
\r\n
{detail.name}
\r\n
{detail.desc}
\r\n
\r\n
\r\n
\r\n
\r\n
API Contract (preview)
\r\n
\r\n{`POST /api/ai/${detail.name.toLowerCase().replace(/ /g, '-')}\r\n{\r\n  \"context\": { \"jobId\": \"JOB-1001\", \"candidateIds\": [...] },\r\n  \"options\": { \"model\": \"claude-opus\", \"stream\": true }\r\n}\r\n\r\n→ 200 OK\r\n{\r\n  \"result\": { ... },\r\n  \"usage\": { \"tokens\": 1240 }\r\n}`}\r\n              
\r\n
\r\n
\r\n

\r\n This feature’s UI is complete. Backend wiring is the only remaining step.\r\n

\r\n \r\n )}\r\n
\r\n )\r\n}\r\n"],"names":["AiStudio","toast","useToast","detail","setDetail","useState","betaCount","aiModules","m","jsxs","jsx","Icon","Badge","Modal"],"mappings":"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"} \ No newline at end of file diff --git a/frontend/dist/assets/Analytics-C14x4S-e.js b/frontend/dist/assets/Analytics-C14x4S-e.js deleted file mode 100644 index 3242529..0000000 --- a/frontend/dist/assets/Analytics-C14x4S-e.js +++ /dev/null @@ -1,2 +0,0 @@ -import{d as L,a as h,s as o,r as d,b as a,j as e,I as M}from"./index-BTPmxtwM.js";import{C as c,a as l,b as m}from"./Chart-BGHtrE8S.js";function P(){const{toast:p}=L(),{data:n=[]}=h(o("candidates")),{data:t=[]}=h(o("recruiters")),x=d.useMemo(()=>({labels:a.hiringTrend.labels,area:!0,datasets:[{label:"Applications",data:a.hiringTrend.applications,color:c.PALETTE[4]},{label:"Hires",data:a.hiringTrend.hires,color:c.PALETTE[0]}]}),[]),j=d.useMemo(()=>({labels:a.hiringTrend.labels,data:a.hiringTrend.applications}),[]),u=d.useMemo(()=>({labels:a.sources.map(s=>s.source),data:a.sources.map(s=>s.count),centerValue:n.length,centerLabel:"Total"}),[n.length]),v=d.useMemo(()=>{const{accepted:s,pending:r,declined:i}=a.offerAcceptance;return{labels:["Accepted","Pending","Declined"],data:[s,r,i],colors:[c.token("--success"),c.token("--warning"),c.token("--danger")],centerValue:`${Math.round(s/(s+i||1)*100)}%`,centerLabel:"Accept rate"}},[]),N=d.useMemo(()=>({labels:a.pipeline.map(s=>s.stage),data:a.pipeline.map(s=>s.count),colors:c.PALETTE}),[]),b=d.useMemo(()=>({labels:a.departments.map(s=>s.dept),data:a.departments.map(s=>s.apps)}),[]),g=d.useMemo(()=>{const s=[...t].sort((r,i)=>i.hires-r.hires).slice(0,8);return{labels:s.map(r=>r.name),data:s.map(r=>r.hires)}},[t]),y=d.useMemo(()=>({labels:a.hiringTrend.labels,area:!0,yFmt:s=>`${s}d`,datasets:[{label:"Time to Hire",data:a.timeToHire,color:c.PALETTE[0]}]}),[]),T=d.useMemo(()=>({labels:a.hiringTrend.labels,area:!0,yFmt:s=>`${s}d`,datasets:[{label:"Time to Fill",data:a.timeToFill,color:c.PALETTE[2]}]}),[]),A=d.useMemo(()=>[{label:"Applications",color:c.PALETTE[4]},{label:"Hires",color:c.PALETTE[0]}],[]),E=d.useMemo(()=>a.sources.map((s,r)=>({label:s.source,color:c.PALETTE[r%c.PALETTE.length]})),[]);return e.jsxs("div",{className:"page",children:[e.jsxs("div",{className:"page-head",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"page-title",children:"Analytics"}),e.jsx("p",{className:"page-sub",children:"Deep-dive metrics across your recruitment funnel"})]}),e.jsxs("div",{className:"page-head-actions",children:[e.jsxs("div",{className:"pill-tabs",children:[e.jsx("span",{className:"pill-tab",children:"Week"}),e.jsx("span",{className:"pill-tab active",children:"Month"}),e.jsx("span",{className:"pill-tab",children:"Quarter"})]}),e.jsxs("button",{className:"btn btn-secondary",onClick:()=>p("Analytics exported","success"),children:[e.jsx(M,{name:"download"})," Export"]})]})]}),e.jsxs("div",{className:"grid g-2 mb-18",children:[e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsxs("div",{children:[e.jsx("h3",{children:"Hiring Trend"}),e.jsx("span",{className:"ch-sub",children:"Hires vs applications"})]})}),e.jsxs("div",{className:"card-body",children:[e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"line",data:x,height:260})}),e.jsx(m,{items:A})]})]}),e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsxs("div",{children:[e.jsx("h3",{children:"Applications Received"}),e.jsx("span",{className:"ch-sub",children:"Monthly volume"})]})}),e.jsx("div",{className:"card-body",children:e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"bar",data:j,height:260})})})]})]}),e.jsxs("div",{className:"grid g-3 mb-18",children:[e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsx("div",{children:e.jsx("h3",{children:"Source Breakdown"})})}),e.jsxs("div",{className:"card-body",children:[e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"doughnut",data:u,height:220})}),e.jsx(m,{items:E})]})]}),e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsx("div",{children:e.jsx("h3",{children:"Offer Acceptance"})})}),e.jsxs("div",{className:"card-body",children:[e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"doughnut",data:v,height:220})}),e.jsxs("div",{className:"chart-legend",children:[e.jsxs("span",{className:"legend-item",children:[e.jsx("span",{className:"legend-dot",style:{background:"var(--success)"}}),"Accepted"]}),e.jsxs("span",{className:"legend-item",children:[e.jsx("span",{className:"legend-dot",style:{background:"var(--warning)"}}),"Pending"]}),e.jsxs("span",{className:"legend-item",children:[e.jsx("span",{className:"legend-dot",style:{background:"var(--danger)"}}),"Declined"]})]})]})]}),e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsx("div",{children:e.jsx("h3",{children:"Pipeline Distribution"})})}),e.jsx("div",{className:"card-body",children:e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"horizontalBar",data:N,height:260})})})]})]}),e.jsxs("div",{className:"grid g-2 mb-18",children:[e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsxs("div",{children:[e.jsx("h3",{children:"Applications by Department"}),e.jsx("span",{className:"ch-sub",children:"Volume per team"})]})}),e.jsx("div",{className:"card-body",children:e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"bar",data:b,height:300})})})]}),e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsxs("div",{children:[e.jsx("h3",{children:"Recruiter Performance"}),e.jsx("span",{className:"ch-sub",children:"Hires by recruiter (top 8)"})]})}),e.jsx("div",{className:"card-body",children:e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"horizontalBar",data:g,height:300})})})]})]}),e.jsxs("div",{className:"grid g-2",children:[e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsxs("div",{children:[e.jsx("h3",{children:"Time to Hire"}),e.jsx("span",{className:"ch-sub",children:"Days, monthly average"})]})}),e.jsx("div",{className:"card-body",children:e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"line",data:y,height:240})})})]}),e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsxs("div",{children:[e.jsx("h3",{children:"Time to Fill"}),e.jsx("span",{className:"ch-sub",children:"Days, monthly average"})]})}),e.jsx("div",{className:"card-body",children:e.jsx("div",{className:"chart-wrap",children:e.jsx(l,{type:"line",data:T,height:240})})})]})]})]})}export{P as default}; -//# sourceMappingURL=Analytics-C14x4S-e.js.map diff --git a/frontend/dist/assets/Analytics-C14x4S-e.js.map b/frontend/dist/assets/Analytics-C14x4S-e.js.map deleted file mode 100644 index 22cea1f..0000000 --- a/frontend/dist/assets/Analytics-C14x4S-e.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"Analytics-C14x4S-e.js","sources":["../../src/screens/Analytics.jsx"],"sourcesContent":["import { useMemo } from 'react'\r\nimport { useQuery } from '@tanstack/react-query'\r\n\r\nimport Chart, { ChartLegend } from '../ui/Chart'\r\nimport Charts from '../lib/charts'\r\nimport { Icon } from '../ui/primitives'\r\nimport { useToast } from '../ui/Toast'\r\nimport { seedQuery } from '../data/seedQueries'\r\nimport { analytics as a } from '../data/seed'\r\n\r\nexport default function Analytics() {\r\n const { toast } = useToast()\r\n const { data: candidates = [] } = useQuery(seedQuery('candidates'))\r\n const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))\r\n\r\n const trend = useMemo(\r\n () => ({\r\n labels: a.hiringTrend.labels,\r\n area: true,\r\n datasets: [\r\n { label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] },\r\n { label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] },\r\n ],\r\n }),\r\n [],\r\n )\r\n const apps = useMemo(() => ({ labels: a.hiringTrend.labels, data: a.hiringTrend.applications }), [])\r\n const source = useMemo(\r\n () => ({\r\n labels: a.sources.map((s) => s.source),\r\n data: a.sources.map((s) => s.count),\r\n centerValue: candidates.length,\r\n centerLabel: 'Total',\r\n }),\r\n [candidates.length],\r\n )\r\n const offer = useMemo(() => {\r\n const { accepted, pending, declined } = a.offerAcceptance\r\n return {\r\n labels: ['Accepted', 'Pending', 'Declined'],\r\n data: [accepted, pending, declined],\r\n colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')],\r\n centerValue: `${Math.round((accepted / (accepted + declined || 1)) * 100)}%`,\r\n centerLabel: 'Accept rate',\r\n }\r\n }, [])\r\n const pipeline = useMemo(\r\n () => ({\r\n labels: a.pipeline.map((p) => p.stage),\r\n data: a.pipeline.map((p) => p.count),\r\n colors: Charts.PALETTE,\r\n }),\r\n [],\r\n )\r\n const dept = useMemo(\r\n () => ({ labels: a.departments.map((d) => d.dept), data: a.departments.map((d) => d.apps) }),\r\n [],\r\n )\r\n const rec = useMemo(() => {\r\n const top = [...recruiters].sort((x, y) => y.hires - x.hires).slice(0, 8)\r\n return { labels: top.map((r) => r.name), data: top.map((r) => r.hires) }\r\n }, [recruiters])\r\n const tth = useMemo(\r\n () => ({\r\n labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,\r\n datasets: [{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] }],\r\n }),\r\n [],\r\n )\r\n const ttf = useMemo(\r\n () => ({\r\n labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,\r\n datasets: [{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] }],\r\n }),\r\n [],\r\n )\r\n\r\n const trendLegend = useMemo(\r\n () => [\r\n { label: 'Applications', color: Charts.PALETTE[4] },\r\n { label: 'Hires', color: Charts.PALETTE[0] },\r\n ],\r\n [],\r\n )\r\n const sourceLegend = useMemo(\r\n () => a.sources.map((s, i) => ({ label: s.source, color: Charts.PALETTE[i % Charts.PALETTE.length] })),\r\n [],\r\n )\r\n\r\n return (\r\n
\r\n
\r\n
\r\n

Analytics

\r\n

Deep-dive metrics across your recruitment funnel

\r\n
\r\n
\r\n
\r\n Week\r\n Month\r\n Quarter\r\n
\r\n \r\n
\r\n
\r\n\r\n
\r\n
\r\n

Hiring Trend

Hires vs applications
\r\n
\r\n
\r\n \r\n
\r\n
\r\n
\r\n

Applications Received

Monthly volume
\r\n
\r\n
\r\n
\r\n\r\n
\r\n
\r\n

Source Breakdown

\r\n
\r\n
\r\n \r\n
\r\n
\r\n
\r\n

Offer Acceptance

\r\n
\r\n
\r\n
\r\n Accepted\r\n Pending\r\n Declined\r\n
\r\n
\r\n
\r\n
\r\n

Pipeline Distribution

\r\n
\r\n
\r\n
\r\n\r\n
\r\n
\r\n

Applications by Department

Volume per team
\r\n
\r\n
\r\n
\r\n

Recruiter Performance

Hires by recruiter (top 8)
\r\n
\r\n
\r\n
\r\n\r\n
\r\n
\r\n

Time to Hire

Days, monthly average
\r\n
\r\n
\r\n
\r\n

Time to Fill

Days, monthly average
\r\n
\r\n
\r\n
\r\n
\r\n )\r\n}\r\n"],"names":["Analytics","toast","useToast","candidates","useQuery","seedQuery","recruiters","trend","useMemo","a","Charts","apps","source","offer","accepted","pending","declined","pipeline","p","dept","d","rec","top","x","y","tth","v","ttf","trendLegend","sourceLegend","i","jsxs","jsx","Icon","Chart","ChartLegend"],"mappings":"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"} \ No newline at end of file diff --git a/frontend/dist/assets/Assessments-IA_wRKvG.js b/frontend/dist/assets/Assessments-IA_wRKvG.js deleted file mode 100644 index 86c4b40..0000000 --- a/frontend/dist/assets/Assessments-IA_wRKvG.js +++ /dev/null @@ -1,2 +0,0 @@ -import{d as P,u as I,a as B,s as R,r as i,l as V,j as e,I as n,K as h,A as p,B as g,p as v,X as b,ad as z,f as N,S as E}from"./index-BTPmxtwM.js";import{D as F}from"./DataTable-D5imKbZq.js";import{M as f}from"./Modal-B8aWFZt2.js";const L=["Problem Solving","Code Quality","Communication","Time Management"];function $(){const{toast:j}=P(),y=I(),{data:l=[]}=B(R("assessments")),[c,C]=i.useState(""),[d,S]=i.useState(""),[r,A]=i.useState(""),[a,o]=i.useState(null),[k,m]=i.useState(!1),x=i.useMemo(()=>{const s=l.filter(t=>t.score);return{total:l.length,completed:l.filter(t=>t.status==="Completed").length,pending:l.filter(t=>["Pending","In Progress"].includes(t.status)).length,avg:Math.round(s.reduce((t,M)=>t+M.score,0)/(s.length||1))}},[l]),u=i.useMemo(()=>[...new Set(l.map(s=>s.type))],[l]),T=i.useMemo(()=>l.filter(s=>!(d&&s.status!==d||r&&s.type!==r||c&&!(s.candidate+s.jobTitle+s.type).toLowerCase().includes(c.toLowerCase()))),[l,c,d,r]),w=i.useMemo(()=>a?L.map(s=>({label:s,score:V(60,98)})):[],[a]),D=[{key:"candidate",label:"Candidate",sortable:!0,render:s=>e.jsxs("div",{className:"user-cell",children:[e.jsx(p,{name:s.candidate,initials:s.initials,color:s.color}),e.jsxs("div",{children:[e.jsx("div",{className:"cell-primary",children:s.candidate}),e.jsx("div",{className:"cell-sub",children:s.jobTitle})]})]})},{key:"type",label:"Assessment",sortable:!0,render:s=>e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"cell-primary text-sm",children:s.type}),e.jsx("div",{className:"cell-sub",children:s.duration})]})},{key:"assigned",label:"Assigned",sortable:!0,sortValue:s=>s.assigned.getTime(),render:s=>e.jsx("span",{className:"text-muted",children:N(s.assigned)})},{key:"due",label:"Due",sortable:!0,sortValue:s=>s.due.getTime(),render:s=>e.jsx("span",{className:"text-muted",children:N(s.due)})},{key:"score",label:"Score",sortable:!0,align:"center",render:s=>s.score!==null?e.jsx(E,{score:s.score}):e.jsx("span",{className:"text-muted",children:"—"})},{key:"status",label:"Status",sortable:!0,render:s=>e.jsx(g,{children:s.status})},{key:"_a",label:"Actions",align:"right",render:s=>e.jsxs("div",{className:"row-actions",children:[e.jsx("button",{className:"act-btn","data-tip":"View",onClick:()=>o(s),children:e.jsx(n,{name:"eye"})}),e.jsx("button",{className:"act-btn","data-tip":"Remind",onClick:()=>j(`Reminder sent to ${s.candidate}`,"info"),children:e.jsx(n,{name:"mail"})})]})}];return e.jsxs("div",{className:"page",children:[e.jsxs("div",{className:"page-head",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"page-title",children:"Assessments"}),e.jsx("p",{className:"page-sub",children:"Coding tests, take-homes, and evaluations"})]}),e.jsx("div",{className:"page-head-actions",children:e.jsxs("button",{className:"btn btn-primary",onClick:()=>m(!0),children:[e.jsx(n,{name:"plus"})," Assign Assessment"]})})]}),e.jsxs("div",{className:"grid g-kpi mb-18",children:[e.jsx(h,{label:"Total Assigned",value:x.total,icon:"file",tone:"i-indigo"}),e.jsx(h,{label:"Completed",value:x.completed,icon:"check-circle",tone:"i-green"}),e.jsx(h,{label:"In Progress / Pending",value:x.pending,icon:"clock",tone:"i-amber"}),e.jsx(h,{label:"Average Score",value:`${x.avg}%`,icon:"target",tone:"i-teal"})]}),e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-body",style:{paddingBottom:0},children:e.jsxs("div",{className:"toolbar",children:[e.jsxs("div",{className:"toolbar-search",children:[e.jsx(n,{name:"search"}),e.jsx("input",{value:c,onChange:s=>C(s.target.value),placeholder:"Search candidate or assessment…"})]}),e.jsxs("select",{className:"select",value:d,onChange:s=>S(s.target.value),children:[e.jsx("option",{value:"",children:"All Status"}),["Completed","In Progress","Pending","Expired"].map(s=>e.jsx("option",{children:s},s))]}),e.jsxs("select",{className:"select",value:r,onChange:s=>A(s.target.value),children:[e.jsx("option",{value:"",children:"All Types"}),u.map(s=>e.jsx("option",{children:s},s))]})]})}),e.jsx(F,{columns:D,rows:T,pageSize:8})]}),a&&e.jsxs(f,{title:"Assessment Result",subtitle:a.id,onClose:()=>o(null),footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{className:"btn btn-secondary",onClick:()=>o(null),children:"Close"}),e.jsx("button",{className:"btn btn-primary",onClick:()=>{const s=a.candidateId;o(null),y("/candidates",{state:{openCandidate:s}})},children:"View Candidate"})]}),children:[e.jsxs("div",{className:"flex items-center gap-12 mb-18",children:[e.jsx(p,{name:a.candidate,initials:a.initials,color:a.color,className:"avatar-lg"}),e.jsxs("div",{children:[e.jsx("div",{className:"ph-name",style:{fontSize:17},children:a.candidate}),e.jsxs("div",{className:"ph-role",children:[a.type," · ",a.jobTitle]})]}),e.jsx("div",{style:{marginLeft:"auto"},children:e.jsx(g,{children:a.status})})]}),e.jsxs("div",{className:"info-grid mb-18",children:[e.jsxs("div",{className:"info-item",children:[e.jsx("div",{className:"il",children:"Type"}),e.jsx("div",{className:"iv",children:a.type})]}),e.jsxs("div",{className:"info-item",children:[e.jsx("div",{className:"il",children:"Duration"}),e.jsx("div",{className:"iv",children:a.duration})]}),e.jsxs("div",{className:"info-item",children:[e.jsx("div",{className:"il",children:"Assigned"}),e.jsx("div",{className:"iv",children:v(a.assigned)})]}),e.jsxs("div",{className:"info-item",children:[e.jsx("div",{className:"il",children:"Due"}),e.jsx("div",{className:"iv",children:v(a.due)})]})]}),a.score!==null?e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"divider"}),e.jsxs("div",{style:{textAlign:"center",padding:"10px 0"},children:[e.jsxs("div",{style:{fontSize:44,fontWeight:800,letterSpacing:-1,color:a.score>=70?"var(--success)":"var(--warning)"},children:[a.score,"%"]}),e.jsx("div",{className:"text-muted",children:"Overall Score"})]}),e.jsx("div",{className:"mb-18",children:e.jsx(b,{pct:a.score})}),e.jsx("div",{className:"form-section-title",style:{marginTop:0},children:"Section Breakdown"}),w.map(s=>e.jsxs("div",{className:"flex items-center gap-12",style:{marginBottom:10},children:[e.jsx("span",{style:{width:130,fontSize:13},children:s.label}),e.jsx("div",{style:{flex:1},children:e.jsx(b,{pct:s.score})}),e.jsxs("b",{style:{width:40,textAlign:"right"},children:[s.score,"%"]})]},s.label))]}):e.jsxs("div",{className:"empty-state",children:[e.jsx(n,{name:"clock"}),e.jsx("h3",{children:"Assessment not completed"}),e.jsx("p",{children:"Results will appear once the candidate submits."})]})]}),k&&e.jsx(f,{title:"Assign Assessment",subtitle:"Send an evaluation to a candidate",onClose:()=>m(!1),footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{className:"btn btn-secondary",onClick:()=>m(!1),children:"Cancel"}),e.jsxs("button",{className:"btn btn-primary",onClick:()=>{m(!1),j("Assessment assigned & invite sent","success")},children:[e.jsx(n,{name:"send"})," Assign"]})]}),children:e.jsx("form",{children:e.jsxs("div",{className:"form-grid",children:[e.jsxs("div",{className:"form-field col-span-2",children:[e.jsx("label",{children:"Candidate"}),e.jsx("select",{children:z.slice(0,40).map(s=>e.jsx("option",{children:s.name},s.id))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Assessment Type"}),e.jsx("select",{children:u.map(s=>e.jsx("option",{children:s},s))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Time Limit"}),e.jsxs("select",{children:[e.jsx("option",{children:"45 min"}),e.jsx("option",{children:"60 min"}),e.jsx("option",{children:"90 min"}),e.jsx("option",{children:"3 days"})]})]}),e.jsxs("div",{className:"form-field col-span-2",children:[e.jsx("label",{children:"Due Date"}),e.jsx("input",{type:"date"})]})]})})})]})}export{$ as default}; -//# sourceMappingURL=Assessments-IA_wRKvG.js.map diff --git a/frontend/dist/assets/Assessments-IA_wRKvG.js.map b/frontend/dist/assets/Assessments-IA_wRKvG.js.map deleted file mode 100644 index 1effb06..0000000 --- a/frontend/dist/assets/Assessments-IA_wRKvG.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"Assessments-IA_wRKvG.js","sources":["../../src/screens/Assessments.jsx"],"sourcesContent":["import { useMemo, useState } from 'react'\r\nimport { useNavigate } from 'react-router-dom'\r\nimport { useQuery } from '@tanstack/react-query'\r\n\r\nimport DataTable from '../ui/DataTable'\r\nimport Modal from '../ui/Modal'\r\nimport { Avatar, Badge, Icon, KpiCard, ProgressBar, ScoreChip } from '../ui/primitives'\r\nimport { useToast } from '../ui/Toast'\r\nimport { seedQuery } from '../data/seedQueries'\r\nimport { candidates as allCandidates, fmtDate, fmtShort, int } from '../data/seed'\r\n\r\nconst SECTIONS = ['Problem Solving', 'Code Quality', 'Communication', 'Time Management']\r\n\r\nexport default function Assessments() {\r\n const { toast } = useToast()\r\n const navigate = useNavigate()\r\n const { data: assessments = [] } = useQuery(seedQuery('assessments'))\r\n\r\n const [q, setQ] = useState('')\r\n const [status, setStatus] = useState('')\r\n const [type, setType] = useState('')\r\n const [viewing, setViewing] = useState(null)\r\n const [assigning, setAssigning] = useState(false)\r\n\r\n const stats = useMemo(() => {\r\n const scored = assessments.filter((a) => a.score)\r\n return {\r\n total: assessments.length,\r\n completed: assessments.filter((a) => a.status === 'Completed').length,\r\n pending: assessments.filter((a) => ['Pending', 'In Progress'].includes(a.status)).length,\r\n avg: Math.round(scored.reduce((s, a) => s + a.score, 0) / (scored.length || 1)),\r\n }\r\n }, [assessments])\r\n\r\n const types = useMemo(() => [...new Set(assessments.map((a) => a.type))], [assessments])\r\n\r\n const rows = useMemo(\r\n () =>\r\n assessments.filter((a) => {\r\n if (status && a.status !== status) return false\r\n if (type && a.type !== type) return false\r\n if (q && !(a.candidate + a.jobTitle + a.type).toLowerCase().includes(q.toLowerCase())) return false\r\n return true\r\n }),\r\n [assessments, q, status, type],\r\n )\r\n\r\n // Section scores were generated inline at render in the prototype, so they\r\n // reshuffled on every repaint. Derived per assessment id and memoised here.\r\n const sectionScores = useMemo(\r\n () => (viewing ? SECTIONS.map((s) => ({ label: s, score: int(60, 98) })) : []),\r\n [viewing],\r\n )\r\n\r\n const columns = [\r\n {\r\n key: 'candidate', label: 'Candidate', sortable: true,\r\n render: (a) => (\r\n
\r\n \r\n
\r\n
{a.candidate}
\r\n
{a.jobTitle}
\r\n
\r\n
\r\n ),\r\n },\r\n {\r\n key: 'type', label: 'Assessment', sortable: true,\r\n render: (a) => (\r\n <>\r\n
{a.type}
\r\n
{a.duration}
\r\n \r\n ),\r\n },\r\n { key: 'assigned', label: 'Assigned', sortable: true, sortValue: (a) => a.assigned.getTime(), render: (a) => {fmtShort(a.assigned)} },\r\n { key: 'due', label: 'Due', sortable: true, sortValue: (a) => a.due.getTime(), render: (a) => {fmtShort(a.due)} },\r\n { key: 'score', label: 'Score', sortable: true, align: 'center', render: (a) => (a.score !== null ? : ) },\r\n { key: 'status', label: 'Status', sortable: true, render: (a) => {a.status} },\r\n {\r\n key: '_a', label: 'Actions', align: 'right',\r\n render: (a) => (\r\n
\r\n \r\n \r\n
\r\n ),\r\n },\r\n ]\r\n\r\n return (\r\n
\r\n
\r\n
\r\n

Assessments

\r\n

Coding tests, take-homes, and evaluations

\r\n
\r\n
\r\n \r\n
\r\n
\r\n\r\n
\r\n \r\n \r\n \r\n \r\n
\r\n\r\n
\r\n
\r\n
\r\n
\r\n \r\n setQ(e.target.value)} placeholder=\"Search candidate or assessment…\" />\r\n
\r\n \r\n \r\n
\r\n
\r\n \r\n
\r\n\r\n {viewing && (\r\n setViewing(null)}\r\n footer={\r\n <>\r\n \r\n {\r\n const id = viewing.candidateId\r\n setViewing(null)\r\n navigate('/candidates', { state: { openCandidate: id } })\r\n }}\r\n >\r\n View Candidate\r\n \r\n \r\n }\r\n >\r\n
\r\n \r\n
\r\n
{viewing.candidate}
\r\n
{viewing.type} · {viewing.jobTitle}
\r\n
\r\n
{viewing.status}
\r\n
\r\n\r\n
\r\n
Type
{viewing.type}
\r\n
Duration
{viewing.duration}
\r\n
Assigned
{fmtDate(viewing.assigned)}
\r\n
Due
{fmtDate(viewing.due)}
\r\n
\r\n\r\n {viewing.score !== null ? (\r\n <>\r\n
\r\n
\r\n = 70 ? 'var(--success)' : 'var(--warning)',\r\n }}\r\n >\r\n {viewing.score}%\r\n
\r\n
Overall Score
\r\n
\r\n
\r\n
Section Breakdown
\r\n {sectionScores.map((s) => (\r\n
\r\n {s.label}\r\n
\r\n {s.score}%\r\n
\r\n ))}\r\n \r\n ) : (\r\n
\r\n \r\n

Assessment not completed

\r\n

Results will appear once the candidate submits.

\r\n
\r\n )}\r\n \r\n )}\r\n\r\n {assigning && (\r\n setAssigning(false)}\r\n footer={\r\n <>\r\n \r\n {\r\n setAssigning(false)\r\n toast('Assessment assigned & invite sent', 'success')\r\n }}\r\n >\r\n Assign\r\n \r\n \r\n }\r\n >\r\n
\r\n
\r\n
\r\n \r\n \r\n
\r\n
\r\n \r\n \r\n
\r\n
\r\n \r\n \r\n
\r\n
\r\n \r\n \r\n
\r\n
\r\n
\r\n \r\n )}\r\n
\r\n )\r\n}\r\n"],"names":["SECTIONS","Assessments","toast","useToast","navigate","useNavigate","assessments","useQuery","seedQuery","q","setQ","useState","status","setStatus","type","setType","viewing","setViewing","assigning","setAssigning","stats","useMemo","scored","a","s","types","rows","sectionScores","int","columns","jsxs","jsx","Avatar","Fragment","fmtShort","ScoreChip","Badge","Icon","KpiCard","e","t","DataTable","Modal","id","fmtDate","ProgressBar","allCandidates","c"],"mappings":"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"} \ No newline at end of file diff --git a/frontend/dist/assets/Calendar-C28nSZlh.js b/frontend/dist/assets/Calendar-C28nSZlh.js deleted file mode 100644 index 2813877..0000000 --- a/frontend/dist/assets/Calendar-C28nSZlh.js +++ /dev/null @@ -1,2 +0,0 @@ -import{u as b,a as v,s as S,r as y,T as m,j as e,I as x,A as w}from"./index-BTPmxtwM.js";const D={"Phone Screen":"b-blue",Technical:"b-indigo","System Design":"b-purple","Onsite Loop":"b-teal","Hiring Manager":"b-amber","Culture Fit":"b-green","Final Round":"b-red"},f=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];function C(l,i){const r=new Date(l,i,1).getDay(),c=new Date(l,i+1,0).getDate(),u=new Date(l,i,0).getDate(),s=[];for(let t=r-1;t>=0;t--)s.push({day:u-t,other:!0});for(let t=1;t<=c;t++)s.push({day:t,other:!1,date:new Date(l,i,t)});for(;s.length%7!==0||s.length<42;)s.push({day:s.length-c-r+1,other:!0});return s.slice(0,42)}function I(){const l=b(),{data:i=[]}=v(S("interviews")),[{year:r,month:c},u]=y.useState({year:m.getFullYear(),month:m.getMonth()}),s=y.useMemo(()=>C(r,c),[r,c]),t=new Date(r,c).toLocaleDateString("en-US",{month:"long",year:"numeric"}),g=m.toDateString(),j=i.filter(a=>a.when.toDateString()===g),p=a=>u(({year:d,month:o})=>{const h=o+a;return h<0?{year:d-1,month:11}:h>11?{year:d+1,month:0}:{year:d,month:h}}),N=a=>l("/candidates",{state:{openCandidate:a}});return e.jsxs("div",{className:"page",children:[e.jsxs("div",{className:"page-head",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"page-title",children:"Calendar"}),e.jsx("p",{className:"page-sub",children:"Interview schedule at a glance"})]}),e.jsxs("div",{className:"page-head-actions",children:[e.jsxs("div",{className:"flex items-center gap-8",children:[e.jsx("button",{className:"btn btn-icon btn-secondary",onClick:()=>p(-1),"aria-label":"Previous month",children:e.jsx(x,{name:"chevron-left"})}),e.jsx("span",{className:"fw-600",style:{minWidth:140,textAlign:"center"},children:t}),e.jsx("button",{className:"btn btn-icon btn-secondary",onClick:()=>p(1),"aria-label":"Next month",children:e.jsx(x,{name:"chevron-right"})})]}),e.jsxs("button",{className:"btn btn-primary",onClick:()=>l("/interviews",{state:{openSchedule:!0}}),children:[e.jsx(x,{name:"plus"})," Schedule"]})]})]}),e.jsxs("div",{className:"grid g-2-1",children:[e.jsx("div",{className:"card",children:e.jsx("div",{className:"card-body",children:e.jsxs("div",{className:"cal-grid",children:[f.map(a=>e.jsx("div",{className:"cal-dow",children:a},a)),s.map((a,d)=>{const o=!a.other&&a.date?i.filter(n=>n.when.toDateString()===a.date.toDateString()):[],h=!a.other&&a.date&&a.date.toDateString()===g;return e.jsxs("div",{className:`cal-cell ${a.other?"other":""} ${h?"today":""}`,children:[e.jsx("div",{className:"cal-date",children:a.day}),o.slice(0,3).map(n=>e.jsxs("div",{className:`cal-event ${D[n.type]||"b-blue"}`,title:`${n.candidate} · ${n.type}`,onClick:()=>N(n.candidateId),children:[n.when.toLocaleTimeString("en-US",{hour:"numeric"})," ",n.candidate.split(" ")[0]]},n.id)),o.length>3&&e.jsxs("div",{className:"cal-event b-gray",children:["+",o.length-3," more"]})]},d)})]})})}),e.jsxs("div",{className:"card",style:{alignSelf:"start"},children:[e.jsx("div",{className:"card-head",children:e.jsxs("div",{children:[e.jsx("h3",{children:"Today"}),e.jsx("span",{className:"ch-sub",children:m.toLocaleDateString("en-US",{month:"long",day:"numeric",year:"numeric"})})]})}),e.jsx("div",{className:"card-body",children:e.jsx("div",{className:"list-tight",children:j.length===0?e.jsx("p",{className:"text-muted",children:"No interviews today"}):j.map(a=>e.jsxs("div",{className:"list-row",style:{cursor:"pointer"},onClick:()=>N(a.candidateId),children:[e.jsx(w,{name:a.candidate,initials:a.candInitials,color:a.color}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:a.candidate}),e.jsx("div",{className:"lr-sub",children:a.type})]}),e.jsx("div",{className:"lr-right",children:e.jsx("div",{className:"fw-600 text-sm",children:a.when.toLocaleTimeString("en-US",{hour:"numeric",minute:"2-digit"})})})]},a.id))})})]})]})]})}export{I as default}; -//# sourceMappingURL=Calendar-C28nSZlh.js.map diff --git a/frontend/dist/assets/Calendar-C28nSZlh.js.map b/frontend/dist/assets/Calendar-C28nSZlh.js.map deleted file mode 100644 index 54eaee2..0000000 --- a/frontend/dist/assets/Calendar-C28nSZlh.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"Calendar-C28nSZlh.js","sources":["../../src/screens/Calendar.jsx"],"sourcesContent":["import { useMemo, useState } from 'react'\r\nimport { useNavigate } from 'react-router-dom'\r\nimport { useQuery } from '@tanstack/react-query'\r\n\r\nimport { Avatar, Icon } from '../ui/primitives'\r\nimport { seedQuery } from '../data/seedQueries'\r\nimport { TODAY } from '../data/seed'\r\n\r\nconst EVENT_COLORS = {\r\n 'Phone Screen': 'b-blue', Technical: 'b-indigo', 'System Design': 'b-purple',\r\n 'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green',\r\n 'Final Round': 'b-red',\r\n}\r\nconst DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']\r\n\r\n/** The prototype's hand-built month grid, unchanged in behaviour. */\r\nfunction buildCells(year, month) {\r\n const startDow = new Date(year, month, 1).getDay()\r\n const daysInMonth = new Date(year, month + 1, 0).getDate()\r\n const prevDays = new Date(year, month, 0).getDate()\r\n const cells = []\r\n for (let i = startDow - 1; i >= 0; i--) cells.push({ day: prevDays - i, other: true })\r\n for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, other: false, date: new Date(year, month, d) })\r\n while (cells.length % 7 !== 0 || cells.length < 42) {\r\n cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true })\r\n }\r\n return cells.slice(0, 42)\r\n}\r\n\r\nexport default function Calendar() {\r\n const navigate = useNavigate()\r\n const { data: interviews = [] } = useQuery(seedQuery('interviews'))\r\n const [{ year, month }, setView] = useState({ year: TODAY.getFullYear(), month: TODAY.getMonth() })\r\n\r\n const cells = useMemo(() => buildCells(year, month), [year, month])\r\n const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })\r\n const todayKey = TODAY.toDateString()\r\n const todayIvs = interviews.filter((iv) => iv.when.toDateString() === todayKey)\r\n\r\n const step = (delta) =>\r\n setView(({ year: y, month: m }) => {\r\n const next = m + delta\r\n if (next < 0) return { year: y - 1, month: 11 }\r\n if (next > 11) return { year: y + 1, month: 0 }\r\n return { year: y, month: next }\r\n })\r\n\r\n const openCandidate = (id) => navigate('/candidates', { state: { openCandidate: id } })\r\n\r\n return (\r\n
\r\n
\r\n
\r\n

Calendar

\r\n

Interview schedule at a glance

\r\n
\r\n
\r\n
\r\n \r\n {monthName}\r\n \r\n
\r\n navigate('/interviews', { state: { openSchedule: true } })}\r\n >\r\n Schedule\r\n \r\n
\r\n
\r\n\r\n
\r\n
\r\n
\r\n
\r\n {DOW.map((d) =>
{d}
)}\r\n {cells.map((c, i) => {\r\n const dayEvents = !c.other && c.date\r\n ? interviews.filter((iv) => iv.when.toDateString() === c.date.toDateString())\r\n : []\r\n const isToday = !c.other && c.date && c.date.toDateString() === todayKey\r\n return (\r\n
\r\n
{c.day}
\r\n {dayEvents.slice(0, 3).map((iv) => (\r\n openCandidate(iv.candidateId)}\r\n >\r\n {iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}\r\n
\r\n ))}\r\n {dayEvents.length > 3 && (\r\n
+{dayEvents.length - 3} more
\r\n )}\r\n
\r\n )\r\n })}\r\n
\r\n
\r\n
\r\n\r\n
\r\n
\r\n
\r\n

Today

\r\n \r\n {TODAY.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}\r\n \r\n
\r\n
\r\n
\r\n
\r\n {todayIvs.length === 0 ? (\r\n

No interviews today

\r\n ) : (\r\n todayIvs.map((iv) => (\r\n openCandidate(iv.candidateId)}\r\n >\r\n \r\n
\r\n
{iv.candidate}
\r\n
{iv.type}
\r\n
\r\n
\r\n
\r\n {iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}\r\n
\r\n
\r\n
\r\n ))\r\n )}\r\n
\r\n
\r\n
\r\n \r\n \r\n )\r\n}\r\n"],"names":["EVENT_COLORS","DOW","buildCells","year","month","startDow","daysInMonth","prevDays","cells","i","d","Calendar","navigate","useNavigate","interviews","useQuery","seedQuery","setView","useState","TODAY","useMemo","monthName","todayKey","todayIvs","iv","step","delta","y","m","next","openCandidate","id","jsxs","jsx","Icon","c","dayEvents","isToday","Avatar"],"mappings":"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"} \ No newline at end of file diff --git a/frontend/dist/assets/Candidates-DDm6mZmg.js b/frontend/dist/assets/Candidates-DDm6mZmg.js deleted file mode 100644 index 7d50533..0000000 --- a/frontend/dist/assets/Candidates-DDm6mZmg.js +++ /dev/null @@ -1,2 +0,0 @@ -import{a1 as U,d as J,r as N,a as V,s as G,v as Fe,x as Pe,j as e,E,h as je,A as K,B as H,S as ke,W as Ie,I as x,p as ie,e as pe,q as Z,P as Re,g as Ee,i as Ne,T as Ce,a2 as $e,a3 as Me,Q as Be,w as De,Z as Le,a4 as ue,a5 as he,t as Oe,Y as qe,l as ze,y as Ve,z as We,a0 as ee,X as Ke}from"./index-BTPmxtwM.js";import{u as Ae}from"./useMutation-CMO99S-s.js";import{M as ne}from"./Modal-B8aWFZt2.js";import{u as He,P as Qe}from"./DataTable-D5imKbZq.js";import{T as Ue}from"./Tabs-DVZeUemd.js";import{l as Je}from"./jobPosts-CNShlpwX.js";function Ps({search:s,limit:l,offset:o}={}){return U("/candidate/fetch",{params:{search:s,limit:l,offset:o}})}function Ye(s){return U("/candidate/fetch",{params:{user_id:s}})}function Xe(s){return Array.isArray(s==null?void 0:s.data)?s.data:s!=null&&s.data?[s.data]:[]}function Ge(s,l){return U("/candidate/update",{method:"PATCH",params:{user_id:s},body:l})}function Ze({file:s,name:l,email:o,phone:c,jobPostId:m,company:p,source:v,experience:g,stage:f,referralBy:a}){const S=new FormData;S.append("file",s);const j=(n,_)=>{const y=_==null?"":String(_).trim();y&&S.append(n,y)};return j("candidate_email",o),j("candidate_name",l),j("candidate_phone",c),j("job_post_id",m),j("current_company",p),j("platform",v),j("experience",g),j("status",f),j("referral_by",a),U("/candidate/create/candidate",{method:"POST",body:S})}function es({userId:s,note:l}){return U("/notes/create",{method:"POST",body:{user_id:s,note:l}})}function ss({inboxId:s,date:l,time:o,type:c,status:m}){return U("/interview/create",{method:"POST",body:{inbox_id:s,interview_date:l,interview_time:o,interview_type:c,interview_status:m}})}function ts({inboxId:s,review:l,score:o,note:c}){return U("/feedback/create",{method:"POST",body:{inbox_id:s,review:l,score:o,note:c}})}function as({inboxId:s,type:l,status:o,description:c}){return U("/activity/create",{method:"POST",body:{inbox_id:s,activity_type:l,activity_status:o,description:c}})}const is=["Overview","Resume","Timeline","Interview","Notes","Activity","Documents","Feedback"],ae={fontSize:12,color:"var(--text-3)",fontWeight:600,textTransform:"uppercase",marginBottom:8},le=["Strong Hire","Hire","Lean Hire","No Hire"],re=["Phone Screen","Technical","System Design","Culture Fit","Final Round"],ce=["Scheduled","Completed","Cancelled","No Show"],oe=["Call","Email","Meeting","Screening","Assessment","Note"];function Q(s,l="—"){if(!s)return l;const o=s instanceof Date?s:new Date(s);return Number.isNaN(o.getTime())?String(s):ie(o)}function Te(s){if(!s)return null;const l=new Date(s);return Number.isNaN(l.getTime())?null:l.toLocaleTimeString("en-US",{hour:"numeric",minute:"2-digit"})}function we(s){const l=s instanceof Date?s:new Date(s??NaN);return Number.isNaN(l.getTime())?0:l.getTime()}function ns(s,l){if(!s)return null;const o=new Date(`${s}T${l||"00:00"}`);return Number.isNaN(o.getTime())?null:o.toISOString()}function w({label:s,val:l}){return e.jsxs("div",{className:"info-item",children:[e.jsx("div",{className:"il",children:s}),e.jsx("div",{className:"iv",children:l===0||l?l:"—"})]})}function ls(s){return V({queryKey:Z.candidates.detail(s),queryFn:async()=>Xe(await Ye(s))[0]??null,enabled:!!s})}function se({userId:s,mutationFn:l,success:o,onDone:c}){const m=pe(),{toast:p}=J();return Ae({mutationFn:l,onSuccess:async(v,g)=>{await m.invalidateQueries({queryKey:Z.candidates.detail(s)}),p(typeof o=="function"?o(g):o,"success"),c==null||c()},onError:v=>p(je(v,"Could not save. Please try again."),"error")})}function rs({candidate:s,onClose:l,onAdvance:o,onToggleFav:c,onAtsMatch:m}){var h,A,M,B,L,T,D;const{toast:p}=J(),[v,g]=N.useState("Overview"),{data:f=[]}=V(G("interviews")),{data:a=[]}=V(G("recruiters")),S=!!s.userId,j=ls(s.userId),n=j.data??null,_=N.useMemo(()=>Fe(Pe),[]),y=f.filter(u=>u.candidateId===s.id),C=(n==null?void 0:n.inbox_id)??null,F=n?!!n.favorite:s.favorite,P=se({userId:s.userId,mutationFn:u=>Ge(s.userId,{favorite:u}),success:u=>u?`${s.name} added to favorites`:"Removed from favorites"}),$=(n==null?void 0:n.job_title)||s.currentTitle,O=(n==null?void 0:n.currentCompany)||s.currentCompany,k=n&&{Interview:((h=n.interviews)==null?void 0:h.length)??0,Notes:((A=n.notes)==null?void 0:A.length)??0,Activity:((M=n.activity)==null?void 0:M.length)??0,Documents:((B=n.documents)==null?void 0:B.length)??0,Feedback:((L=n.feedback)==null?void 0:L.length)??0},i=S?j.isPending?e.jsx(E,{icon:"refresh",title:"Loading candidate…",children:"Fetching the full record."}):j.isError?e.jsx(E,{icon:"alert",title:"Could not load this candidate",children:je(j.error,"Please try again.")}):n?null:e.jsx(E,{icon:"user",title:"No record found",children:"This candidate is no longer in the pipeline."}):null;return e.jsxs(ne,{title:"Candidate Profile",subtitle:s.id,size:"modal-lg",onClose:l,footer:e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:`btn btn-ghost star-btn${F?" on":""}`,style:{marginRight:"auto"},disabled:S&&(P.isPending||!n),onClick:()=>S?P.mutate(!F):c(s),children:[e.jsx(x,{name:"star"})," ",F?"Favorited":"Favorite"]}),e.jsxs("button",{className:"btn btn-secondary",onClick:()=>m(s),children:[e.jsx(x,{name:"target"})," ATS Match"]}),e.jsxs("button",{className:"btn btn-secondary",onClick:()=>p("Email drafted","info"),children:[e.jsx(x,{name:"mail"})," Message"]}),e.jsxs("button",{className:"btn btn-primary",onClick:()=>{o(s),l()},children:[e.jsx(x,{name:"check"})," Advance Stage"]})]}),children:[e.jsxs("div",{className:"profile-hero",children:[e.jsx(K,{name:s.name,initials:s.initials,color:s.color,className:"avatar-lg"}),e.jsxs("div",{style:{flex:1},children:[e.jsx("div",{className:"ph-name",children:(n==null?void 0:n.name)||s.name}),e.jsx("div",{className:"ph-role",children:O?`${$} at ${O}`:$}),e.jsxs("div",{className:"ph-tags",children:[e.jsx(H,{children:s.stage})," ",e.jsx(H,{className:"b-gray",children:(n==null?void 0:n.source)||s.source}),e.jsxs("span",{className:"badge b-plain b-indigo badge-plain",children:[s.experience," yrs exp"]})]})]}),e.jsxs("div",{style:{textAlign:"center"},children:[e.jsx(ke,{score:s.aiScore}),e.jsx("div",{className:"cell-sub",style:{marginTop:4},children:"AI Match"})]})]}),e.jsx("div",{style:{marginTop:22},children:e.jsx(Ue,{value:v,onChange:g,tabs:is.map(u=>({key:u,label:u,count:k?k[u]:void 0}))})}),e.jsxs("div",{className:"tab-pane active",children:[v==="Overview"&&(i||(n?e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"info-grid",style:{marginBottom:20},children:[e.jsx(w,{label:"Email",val:n.email}),e.jsx(w,{label:"Phone",val:n.phone}),e.jsx(w,{label:"Applied For",val:n.job_title}),e.jsx(w,{label:"Current Company",val:n.currentCompany}),e.jsx(w,{label:"Experience",val:n.experience}),e.jsx(w,{label:"Education",val:n.education}),e.jsx(w,{label:"Source",val:n.source}),e.jsx(w,{label:"Recruiter",val:n.recruiter}),e.jsx(w,{label:"Applied On",val:Q(n.applied)}),e.jsx(w,{label:"Screened On",val:Q(n.matched_at)}),e.jsx(w,{label:"Rating",val:`⭐ ${(n.rating??0).toFixed(1)} / 5.0`}),e.jsx(w,{label:"Applications",val:((T=n.job_posts)==null?void 0:T.length)||0})]}),(n.match_summary||n.match_reasoning)&&e.jsxs(e.Fragment,{children:[e.jsx("div",{style:ae,children:"AI Screening"}),e.jsx("div",{className:"card",style:{boxShadow:"none",background:"var(--bg-sunken)",marginBottom:18},children:e.jsxs("div",{className:"card-body",children:[n.match_summary&&e.jsx("p",{style:{marginBottom:n.match_reasoning?10:0},children:n.match_summary}),n.match_reasoning&&e.jsx("p",{className:"text-muted text-sm",children:n.match_reasoning})]})})]}),n.assigned_job_post&&e.jsxs(e.Fragment,{children:[e.jsx("div",{style:ae,children:"Assigned Role"}),e.jsx("div",{className:"k-tags",style:{marginBottom:14},children:e.jsx("span",{className:"tag",style:{background:"var(--primary-soft)",color:"var(--primary-fg)"},children:n.assigned_job_post.title})})]}),((D=n.job_posts)==null?void 0:D.length)>0&&e.jsxs(e.Fragment,{children:[e.jsx("div",{style:ae,children:"Suggested Roles"}),e.jsx("div",{className:"k-tags",children:n.job_posts.map(u=>e.jsx("span",{className:"tag",children:u.title},u.id))})]})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"info-grid",style:{marginBottom:20},children:[e.jsx(w,{label:"Email",val:s.email}),e.jsx(w,{label:"Phone",val:s.phone}),e.jsx(w,{label:"Location",val:s.location}),e.jsx(w,{label:"Applied For",val:s.jobTitle}),e.jsx(w,{label:"Current Company",val:s.currentCompany}),e.jsx(w,{label:"Experience",val:`${s.experience} years`}),e.jsx(w,{label:"Education",val:s.education}),e.jsx(w,{label:"Source",val:s.source}),e.jsx(w,{label:"Recruiter",val:s.recruiter}),e.jsx(w,{label:"Applied On",val:Q(s.applied)}),e.jsx(w,{label:"Expected Salary",val:Ie(s.salary)}),e.jsx(w,{label:"Rating",val:`⭐ ${s.rating} / 5.0`})]}),e.jsx("div",{style:ae,children:"Skills"}),e.jsx("div",{className:"k-tags",children:s.skills.map(u=>e.jsx("span",{className:"tag",children:u},u))})]}))),v==="Resume"&&(i||(n?e.jsx(cs,{live:n}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"card",style:{boxShadow:"none",background:"var(--bg-sunken)"},children:e.jsxs("div",{className:"card-body",children:[e.jsx("h3",{style:{marginBottom:4},children:s.name}),e.jsxs("p",{className:"text-muted",children:[s.currentTitle," · ",s.location]}),e.jsx("div",{className:"divider"}),e.jsx("div",{className:"form-section-title",style:{marginTop:0},children:"Summary"}),e.jsxs("p",{className:"text-muted",children:["Results-driven ",s.currentTitle.toLowerCase()," with ",s.experience," years of experience across ",s.department.toLowerCase(),". Passionate about building high-quality products and collaborating with cross-functional teams."]}),e.jsx("div",{className:"form-section-title",children:"Experience"}),e.jsxs("div",{className:"info-item",children:[e.jsxs("div",{className:"iv",children:[s.currentTitle," — ",s.currentCompany]}),e.jsx("div",{className:"il",style:{textTransform:"none"},children:"2021 – Present"})]}),e.jsxs("div",{className:"info-item",style:{marginTop:10},children:[e.jsxs("div",{className:"iv",children:["Associate — ",_]}),e.jsx("div",{className:"il",style:{textTransform:"none"},children:"2018 – 2021"})]}),e.jsx("div",{className:"form-section-title",children:"Education"}),e.jsx("div",{className:"iv",children:s.education})]})}),e.jsxs("button",{className:"btn btn-secondary",style:{marginTop:14},onClick:()=>p("Downloading resume.pdf","info"),children:[e.jsx(x,{name:"download"})," Download PDF"]})]}))),v==="Timeline"&&(i||(n?e.jsx(os,{live:n}):e.jsx("div",{className:"timeline",children:[{icon:"user-plus",title:"Application received",meta:ie(s.applied),desc:`Applied via ${s.source}`},{icon:"star",title:"AI screening completed",meta:"1 day later",desc:`Match score: ${s.aiScore}%`},{icon:"phone",title:"Recruiter screen",meta:"3 days later",desc:`Call with ${s.recruiter}`},{icon:"calendar",title:"Technical interview",meta:"1 week later",desc:"Panel of 3 interviewers"},{icon:"check",title:`Moved to ${s.stage}`,meta:"Recently",desc:"Current stage in pipeline"}].map(u=>e.jsxs("div",{className:"tl-item",children:[e.jsx("div",{className:"tl-dot",children:e.jsx(x,{name:u.icon})}),e.jsx("div",{className:"tl-title",children:u.title}),e.jsx("div",{className:"tl-meta",children:u.meta}),e.jsx("div",{className:"tl-desc",children:u.desc})]},u.title))}))),v==="Interview"&&(i||(n?e.jsx(ds,{userId:s.userId,inboxId:C,rows:n.interviews??[]}):y.length?e.jsx("div",{className:"list-tight",children:y.map(u=>e.jsxs("div",{className:"list-row",children:[e.jsx("span",{className:"kpi-icn i-blue",style:{width:38,height:38,borderRadius:10},children:e.jsx(x,{name:"calendar"})}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:u.type}),e.jsxs("div",{className:"lr-sub",children:[ie(u.when)," · ",u.meeting]})]}),e.jsx("div",{className:"lr-right",children:e.jsx(H,{children:u.status})})]},u.id))}):e.jsx(E,{icon:"calendar",title:"No interviews scheduled",children:"Schedule an interview to get started."}))),v==="Notes"&&(i||(n?e.jsx(ms,{userId:s.userId,rows:n.notes??[]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Add a note"}),e.jsx("textarea",{placeholder:"Write a private note about this candidate…"})]}),e.jsxs("button",{className:"btn btn-primary btn-sm",style:{margin:"10px 0 18px"},onClick:()=>p("Note saved","success"),children:[e.jsx(x,{name:"plus"})," Add Note"]}),e.jsxs("div",{className:"list-tight",children:[e.jsxs("div",{className:"list-row",children:[e.jsx(K,{name:s.recruiter}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:s.recruiter}),e.jsx("div",{className:"lr-sub",style:{color:"var(--text-2)"},children:"Strong communication skills, great culture fit. Recommend advancing."}),e.jsx("div",{className:"lr-sub",children:"2 days ago"})]})]}),e.jsxs("div",{className:"list-row",children:[e.jsx(K,{name:"Asfand Ahmed",initials:"AA"}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:"Asfand Ahmed"}),e.jsx("div",{className:"lr-sub",style:{color:"var(--text-2)"},children:"Reviewed portfolio — impressive work. Schedule technical round."}),e.jsx("div",{className:"lr-sub",children:"4 days ago"})]})]})]})]}))),v==="Activity"&&(i||(n?e.jsx(us,{userId:s.userId,inboxId:C,rows:n.activity??[]}):e.jsx("div",{className:"list-tight",children:[{icon:"eye",tone:"i-green",text:`Profile viewed by ${s.recruiter}`,when:"1h ago"},{icon:"mail",tone:"i-blue",text:"Email sent: Interview invitation",when:"1 day ago"},{icon:"star",tone:"i-amber",text:`Assessment score updated to ${s.aiScore}%`,when:"2 days ago"},{icon:"user-plus",tone:"i-purple",text:`Applied for ${s.jobTitle}`,when:ie(s.applied)}].map(u=>e.jsxs("div",{className:"list-row",children:[e.jsx("span",{className:`kpi-icn ${u.tone}`,style:{width:34,height:34,borderRadius:9},children:e.jsx(x,{name:u.icon})}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-sub",style:{color:"var(--text-2)",fontSize:13},children:u.text}),e.jsx("div",{className:"lr-sub",children:u.when})]})]},u.text))}))),v==="Documents"&&(i||(n?e.jsx(hs,{rows:n.documents??[]}):e.jsx("div",{className:"list-tight",children:[{n:"Resume.pdf",s:"284 KB"},{n:"Cover_Letter.pdf",s:"112 KB"},{n:"Portfolio.pdf",s:"4.2 MB"},{n:"References.docx",s:"48 KB"}].map(u=>e.jsxs("div",{className:"list-row",children:[e.jsx("span",{className:"kpi-icn i-red",style:{width:38,height:38,borderRadius:10},children:e.jsx(x,{name:"file"})}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:u.n}),e.jsx("div",{className:"lr-sub",children:u.s})]}),e.jsx("button",{className:"act-btn",onClick:()=>p(`Downloading ${u.n}`,"info"),children:e.jsx(x,{name:"download"})})]},u.n))}))),v==="Feedback"&&(i||(n?e.jsx(xs,{userId:s.userId,inboxId:C,rows:n.feedback??[]}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"list-tight",children:["Strong Hire","Hire","Lean Hire"].map((u,Y)=>{const q=a[Y];if(!q)return null;const z=["Excellent technical depth and clear communication.","Good problem solving, would benefit from more system design exposure.","Solid candidate, positive team energy."];return e.jsxs("div",{className:"list-row",children:[e.jsx(K,{name:q.name,initials:q.initials,color:q.color}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:q.name}),e.jsx("div",{className:"lr-sub",style:{color:"var(--text-2)"},children:z[Y]})]}),e.jsx("div",{className:"lr-right",children:e.jsx(H,{children:u})})]},u)})}),e.jsxs("button",{className:"btn btn-primary btn-sm",style:{marginTop:14},onClick:()=>p("Scorecard form opened","info"),children:[e.jsx(x,{name:"plus"})," Submit Scorecard"]})]})))]})]})}function cs({live:s}){var o,c;const l=(c=(o=s.documents)==null?void 0:o[0])==null?void 0:c.name;return s.resume_text?e.jsx("div",{className:"card",style:{boxShadow:"none",background:"var(--bg-sunken)"},children:e.jsxs("div",{className:"card-body",children:[e.jsx("h3",{style:{marginBottom:4},children:s.name}),e.jsx("p",{className:"text-muted",children:l?`Extracted from ${l}`:"Extracted from the application email"}),e.jsx("div",{className:"divider"}),e.jsx("div",{className:"text-sm",style:{whiteSpace:"pre-wrap",lineHeight:1.6},children:s.resume_text})]})}):e.jsx(E,{icon:"file",title:"No résumé text",children:l?`${l} is attached but has not been parsed yet — run the match to extract it.`:"This candidate applied without an attachment we could read."})}function os({live:s}){const l=N.useMemo(()=>{const o=[];s.applied&&o.push({icon:"user-plus",title:"Application received",at:s.applied,desc:s.source?`Applied via ${s.source}`:null}),s.matched_at&&o.push({icon:"sparkles",title:"AI screening completed",at:s.matched_at,desc:s.match_error||s.match_summary||s.match_status});for(const c of s.interviews??[])o.push({icon:"calendar",title:c.interview_type||"Interview",at:c.interview_date,desc:c.interview_status});for(const c of s.activity??[])o.push({icon:"zap",title:c.activity_type||"Activity",at:c.activity_date,desc:c.description||c.activity_status});for(const c of s.feedback??[])o.push({icon:"star",title:c.review?`Feedback: ${c.review}`:"Feedback submitted",at:c.created_at,desc:c.reviewed_by_name?`by ${c.reviewed_by_name}`:c.note});return o.sort((c,m)=>we(c.at)-we(m.at))},[s]);return l.length?e.jsx("div",{className:"timeline",children:l.map((o,c)=>e.jsxs("div",{className:"tl-item",children:[e.jsx("div",{className:"tl-dot",children:e.jsx(x,{name:o.icon})}),e.jsx("div",{className:"tl-title",children:o.title}),e.jsx("div",{className:"tl-meta",children:Q(o.at)}),o.desc&&e.jsx("div",{className:"tl-desc",children:o.desc})]},`${o.title}-${c}`))}):e.jsx(E,{icon:"clock",title:"Nothing recorded yet",children:"Activity appears here as the candidate moves."})}function ds({userId:s,inboxId:l,rows:o}){const{toast:c}=J(),[m,p]=N.useState({type:re[0],date:"",time:"",status:ce[0]}),v=(a,S)=>p(j=>({...j,[a]:S})),g=se({userId:s,mutationFn:a=>ss({inboxId:l,date:a,time:a,type:m.type,status:m.status}),success:"Interview scheduled",onDone:()=>p({type:re[0],date:"",time:"",status:ce[0]})});function f(){const a=ns(m.date,m.time);if(!a){c("Pick a date for the interview","warning");return}g.mutate(a)}return e.jsxs(e.Fragment,{children:[o.length?e.jsx("div",{className:"list-tight",children:o.map(a=>{const S=Te(a.interview_time);return e.jsxs("div",{className:"list-row",children:[e.jsx("span",{className:"kpi-icn i-blue",style:{width:38,height:38,borderRadius:10},children:e.jsx(x,{name:"calendar"})}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:a.interview_type||"Interview"}),e.jsxs("div",{className:"lr-sub",children:[Q(a.interview_date),S?` · ${S}`:""]})]}),e.jsx("div",{className:"lr-right",children:a.interview_status?e.jsx(H,{children:a.interview_status}):null})]},a.id)})}):e.jsx(E,{icon:"calendar",title:"No interviews scheduled",children:"Schedule the first round below."}),e.jsx("div",{className:"divider"}),e.jsx("div",{className:"form-section-title",style:{marginTop:0},children:"Schedule an interview"}),e.jsxs("div",{className:"form-grid",children:[e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Type"}),e.jsx("select",{value:m.type,onChange:a=>v("type",a.target.value),children:re.map(a=>e.jsx("option",{children:a},a))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Status"}),e.jsx("select",{value:m.status,onChange:a=>v("status",a.target.value),children:ce.map(a=>e.jsx("option",{children:a},a))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Date"}),e.jsx("input",{type:"date",value:m.date,onChange:a=>v("date",a.target.value)})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Time"}),e.jsx("input",{type:"time",value:m.time,onChange:a=>v("time",a.target.value)})]})]}),e.jsxs("button",{className:"btn btn-primary btn-sm",style:{marginTop:10},disabled:!l||g.isPending,onClick:f,children:[e.jsx(x,{name:"plus"})," ",g.isPending?"Scheduling…":"Schedule Interview"]})]})}function ms({userId:s,rows:l}){const[o,c]=N.useState(""),m=se({userId:s,mutationFn:()=>es({userId:s,note:o.trim()}),success:"Note saved",onDone:()=>c("")});return e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Add a note"}),e.jsx("textarea",{value:o,onChange:p=>c(p.target.value),placeholder:"Write a private note about this candidate…"})]}),e.jsxs("button",{className:"btn btn-primary btn-sm",style:{margin:"10px 0 18px"},disabled:!o.trim()||m.isPending,onClick:()=>m.mutate(),children:[e.jsx(x,{name:"plus"})," ",m.isPending?"Saving…":"Add Note"]}),l.length?e.jsx("div",{className:"list-tight",children:l.map(p=>e.jsxs("div",{className:"list-row",children:[e.jsx(K,{name:p.created_by_name||"Unknown"}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:p.created_by_name||"Unknown author"}),e.jsx("div",{className:"lr-sub",style:{color:"var(--text-2)"},children:p.note}),e.jsx("div",{className:"lr-sub",children:Q(p.created_at)})]})]},p.id))}):e.jsx(E,{icon:"edit",title:"No notes yet",children:"The first note on this candidate goes above."})]})}function us({userId:s,inboxId:l,rows:o}){const{toast:c}=J(),[m,p]=N.useState({type:oe[0],description:""}),v=(a,S)=>p(j=>({...j,[a]:S})),g=se({userId:s,mutationFn:()=>as({inboxId:l,type:m.type,status:"Logged",description:m.description.trim()}),success:"Activity logged",onDone:()=>p({type:oe[0],description:""})});function f(){if(!m.description.trim()){c("Describe what happened","warning");return}g.mutate()}return e.jsxs(e.Fragment,{children:[o.length?e.jsx("div",{className:"list-tight",children:o.map(a=>{const S=Te(a.activity_time);return e.jsxs("div",{className:"list-row",children:[e.jsx("span",{className:"kpi-icn i-purple",style:{width:34,height:34,borderRadius:9},children:e.jsx(x,{name:"zap"})}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:a.activity_type||"Activity"}),a.description&&e.jsx("div",{className:"lr-sub",style:{color:"var(--text-2)",fontSize:13},children:a.description}),e.jsxs("div",{className:"lr-sub",children:[Q(a.activity_date),S?` · ${S}`:""]})]}),e.jsx("div",{className:"lr-right",children:a.activity_status?e.jsx(H,{children:a.activity_status}):null})]},a.id)})}):e.jsx(E,{icon:"zap",title:"No activity recorded",children:"Log the first touchpoint below."}),e.jsx("div",{className:"divider"}),e.jsx("div",{className:"form-section-title",style:{marginTop:0},children:"Log activity"}),e.jsxs("div",{className:"form-grid",children:[e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Type"}),e.jsx("select",{value:m.type,onChange:a=>v("type",a.target.value),children:oe.map(a=>e.jsx("option",{children:a},a))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"What happened"}),e.jsx("input",{value:m.description,onChange:a=>v("description",a.target.value),placeholder:"Called to confirm availability"})]})]}),e.jsxs("button",{className:"btn btn-secondary btn-sm",style:{marginTop:10},disabled:!l||g.isPending,onClick:f,children:[e.jsx(x,{name:"plus"})," ",g.isPending?"Logging…":"Log Activity"]})]})}function hs({rows:s}){return s.length?e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"list-tight",children:s.map((l,o)=>e.jsxs("div",{className:"list-row",children:[e.jsx("span",{className:"kpi-icn i-red",style:{width:38,height:38,borderRadius:10},children:e.jsx(x,{name:"file"})}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:l.name}),e.jsx("div",{className:"lr-sub",children:l.path||"Stored with the application"})]})]},`${l.name}-${o}`))}),e.jsxs("p",{className:"text-muted text-sm",style:{marginTop:12},children:[e.jsx(x,{name:"info"})," Attachments are stored server-side; download is not exposed yet."]})]}):e.jsx(E,{icon:"file",title:"No documents",children:"This application arrived without attachments."})}function xs({userId:s,inboxId:l,rows:o}){const{toast:c}=J(),[m,p]=N.useState({review:le[0],score:"",note:""}),v=(a,S)=>p(j=>({...j,[a]:S})),g=se({userId:s,mutationFn:()=>ts({inboxId:l,review:m.review,score:m.score===""?0:Number(m.score),note:m.note.trim()}),success:"Scorecard submitted",onDone:()=>p({review:le[0],score:"",note:""})});function f(){const a=m.score===""?0:Number(m.score);if(!Number.isFinite(a)||a<0||a>100){c("Score must be between 0 and 100","warning");return}g.mutate()}return e.jsxs(e.Fragment,{children:[o.length?e.jsx("div",{className:"list-tight",children:o.map(a=>e.jsxs("div",{className:"list-row",children:[e.jsx(K,{name:a.reviewed_by_name||"Unknown"}),e.jsxs("div",{className:"lr-main",children:[e.jsx("div",{className:"lr-title",children:a.reviewed_by_name||"Unknown reviewer"}),a.note&&e.jsx("div",{className:"lr-sub",style:{color:"var(--text-2)"},children:a.note}),e.jsxs("div",{className:"lr-sub",children:[Q(a.created_at),a.score?` · ${a.score}/100`:""]})]}),e.jsx("div",{className:"lr-right",children:a.review?e.jsx(H,{children:a.review}):null})]},a.id))}):e.jsx(E,{icon:"award",title:"No scorecards yet",children:"Be the first to review this candidate."}),e.jsx("div",{className:"divider"}),e.jsx("div",{className:"form-section-title",style:{marginTop:0},children:"Submit a scorecard"}),e.jsxs("div",{className:"form-grid",children:[e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Recommendation"}),e.jsx("select",{value:m.review,onChange:a=>v("review",a.target.value),children:le.map(a=>e.jsx("option",{children:a},a))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Score (0–100)"}),e.jsx("input",{type:"number",min:"0",max:"100",value:m.score,onChange:a=>v("score",a.target.value),placeholder:"80"})]})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Notes"}),e.jsx("textarea",{value:m.note,onChange:a=>v("note",a.target.value),placeholder:"What stood out, and what would you probe next round?"})]}),e.jsxs("button",{className:"btn btn-primary btn-sm",style:{marginTop:10},disabled:!l||g.isPending,onClick:f,children:[e.jsx(x,{name:"plus"})," ",g.isPending?"Submitting…":"Submit Scorecard"]})]})}const X=["Applied","Screening","Assessment","Interview","Offer","Hired"],js=["0-2","3-5","6-9","10+"],ps=["85+","70-84","<70"],vs=["Not Scheduled","Scheduled","Completed"],gs=["Immediate","2 weeks","1 month","2 months","3 months"],bs=["Immediate","2 weeks","1 month","Passive"],de=10,Se=100,xe="utopiabrands.com",fs=new RegExp(`^[a-z0-9][a-z0-9._%+-]*@${xe.replace(/\./g,"\\.")}$`,"i"),me=s=>(s||"").trim().toLowerCase(),ys={job:"",skill:"",dept:"",location:"",exp:"",edu:"",recruiter:"",manager:"",source:"",ats:"",stage:"",interview:"",notice:"",availability:""};function Ns(){const{toast:s}=J(),l=pe(),o=Re(),{data:c=[]}=V(G("candidates")),{data:m=[]}=V(G("recruiters")),{data:p=[]}=V(G("managers")),{data:v=[]}=V(G("jobs")),{data:g=[]}=V({queryKey:Z.seed.recentlyViewed(),queryFn:async()=>[],staleTime:1/0,gcTime:1/0}),f=Ee("candidates"),[a,S]=N.useState(""),[j,n]=N.useState(ys),[_,y]=N.useState(!1),[C,F]=N.useState("relevance"),[P,$]=N.useState(()=>new Set),[O,k]=N.useState(null),[i,h]=N.useState(null),[A,M]=N.useState(!1),[B,L]=N.useState(!1),T=N.useCallback(t=>{const d=(Ne(t.jobId)||{}).skills||[],r=d.length?t.matchedSkills.length/d.length:.5,b=1-Math.min(1,(Ce-t.applied)/(90*864e5));return Math.round(t.aiScore*.7+r*20+b*10)},[]),D=N.useCallback(t=>{k(t),l.setQueryData(Z.seed.recentlyViewed(),(d=[])=>{const r=[t.id,...d.filter(b=>b!==t.id)].slice(0,12);return $e("tf-recent",r),r})},[l]);N.useEffect(()=>{const t=o.state;if(t&&(t.openAdd&&M(!0),t.openCandidate)){const d=c.find(r=>r.id===t.openCandidate);d&&D(d)}},[o.state,c,D]);const u=N.useMemo(()=>[...new Set(c.map(t=>t.jobTitle))],[c]),Y=N.useMemo(()=>{const t=j;let d=c.filter(r=>{if(t.job&&r.jobTitle!==t.job||t.skill&&!r.skills.includes(t.skill)||t.dept&&r.department!==t.dept||t.location&&r.location!==t.location||t.exp==="0-2"&&r.experience>2||t.exp==="3-5"&&(r.experience<3||r.experience>5)||t.exp==="6-9"&&(r.experience<6||r.experience>9)||t.exp==="10+"&&r.experience<10||t.edu&&r.education!==t.edu||t.recruiter&&r.recruiter!==t.recruiter)return!1;if(t.manager){const b=Ne(r.jobId);if(!b||b.manager!==t.manager)return!1}if(t.source&&r.source!==t.source||t.ats==="85+"&&r.aiScore<85||t.ats==="70-84"&&(r.aiScore<70||r.aiScore>84)||t.ats==="<70"&&r.aiScore>=70||t.stage&&r.stage!==t.stage||t.interview&&r.interviewStatus!==t.interview||t.notice&&r.noticePeriod!==t.notice||t.availability&&r.availability!==t.availability)return!1;if(a){const b=a.toLowerCase();if(!(r.name+r.email+r.jobTitle+r.currentCompany+r.recruiter+r.skills.join(" ")).toLowerCase().includes(b))return!1}return!0});return C==="relevance"?d=[...d].sort((r,b)=>T(b)-T(r)):C==="ats"?d=[...d].sort((r,b)=>b.aiScore-r.aiScore):C==="recent"?d=[...d].sort((r,b)=>b.applied-r.applied):C==="name"&&(d=[...d].sort((r,b)=>r.name.localeCompare(b.name))),d},[c,j,a,C,T]),q=N.useMemo(()=>[{key:"_sel",label:""},{key:"name",label:"Candidate",sortable:!0},{key:"jobTitle",label:"Applied Job",sortable:!0},{key:"experience",label:"Exp",sortable:!0,align:"center"},{key:"_rel",label:"Relevance",sortable:!0,align:"center",sortValue:T},{key:"stage",label:"Stage",sortable:!0},{key:"aiScore",label:"ATS",sortable:!0,align:"center"},{key:"availability",label:"Availability"},{key:"_a",label:"Actions",align:"right"}],[T]),z=He({columns:q,rows:Y,pageSize:10});function ve(t){$(d=>{const r=new Set(d);return r.has(t)?r.delete(t):r.add(t),r})}function ge(t){f(d=>d.map(r=>r.id===t.id?{...r,favorite:!r.favorite}:r)),k(d=>d&&d.id===t.id?{...d,favorite:!d.favorite}:d),s(t.favorite?"Removed from favorites":`${t.name} added to favorites`,"success")}function be(t){const d=X.indexOf(t.stage);if(d===-1||d>=X.length-1){s(`${t.name} cannot be advanced further`,"warning");return}const r=X[d+1];f(b=>b.map(W=>W.id===t.id?{...W,stage:r,status:r}:W)),s(`${t.name} moved to ${r}`,"success")}function te(t){const d=[...P];if(d.length){if(t==="email"){s(`Bulk email drafted to ${d.length} candidates`,"success"),$(new Set);return}if(t==="assign"){L(!0);return}t==="advance"&&(f(r=>r.map(b=>{if(!P.has(b.id))return b;const W=X.indexOf(b.stage);if(W===-1||W>=X.length-1)return b;const ye=X[W+1];return{...b,stage:ye,status:ye}})),s(`${d.length} candidates advanced`,"success")),t==="reject"&&(f(r=>r.map(b=>P.has(b.id)?{...b,stage:"Rejected",status:"Rejected"}:b)),s(`${d.length} candidates rejected`,"warning")),$(new Set)}}const fe=g.slice(0,6).map(t=>c.find(d=>d.id===t)).filter(Boolean),I=(t,d)=>n(r=>({...r,[t]:d}));return e.jsxs("div",{className:"page",children:[e.jsxs("div",{className:"page-head",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"page-title",children:"Candidates"}),e.jsxs("p",{className:"page-sub",children:[Y.length," candidate",Y.length===1?"":"s"," · ranked by AI relevance"]})]}),e.jsxs("div",{className:"page-head-actions",children:[e.jsxs("button",{className:"btn btn-secondary",onClick:()=>s("Search saved","success"),children:[e.jsx(x,{name:"bookmark"})," Save Search"]}),e.jsxs("button",{className:"btn btn-secondary",onClick:()=>s("Candidates exported","success"),children:[e.jsx(x,{name:"download"})," Export"]}),e.jsxs("button",{className:"btn btn-primary",onClick:()=>M(!0),children:[e.jsx(x,{name:"plus"})," Add Candidate"]})]})]}),fe.length>0&&e.jsxs("div",{className:"flex items-center gap-8",style:{marginBottom:14,flexWrap:"wrap"},children:[e.jsx("span",{className:"text-muted text-sm fw-600",children:"Recently viewed:"}),fe.map(t=>e.jsxs("button",{className:"prompt-chip",style:{padding:"5px 10px"},onClick:()=>D(t),children:[e.jsx(K,{name:t.name,initials:t.initials,color:t.color})," ",t.name.split(" ")[0]]},t.id))]}),P.size>0&&e.jsxs("div",{className:"bulk-bar",style:{display:"flex"},children:[e.jsx("span",{className:"checkbox on",children:e.jsx(x,{name:"check"})}),e.jsxs("span",{className:"fw-600",children:[P.size," selected"]}),e.jsx("div",{style:{flex:1}}),e.jsxs("button",{className:"btn btn-sm",onClick:()=>te("email"),children:[e.jsx(x,{name:"mail"})," Bulk Email"]}),e.jsxs("button",{className:"btn btn-sm",onClick:()=>te("assign"),children:[e.jsx(x,{name:"users"})," Assign"]}),e.jsxs("button",{className:"btn btn-sm",onClick:()=>te("advance"),children:[e.jsx(x,{name:"check"})," Advance"]}),e.jsxs("button",{className:"btn btn-sm",onClick:()=>te("reject"),children:[e.jsx(x,{name:"x"})," Reject"]}),e.jsxs("button",{className:"btn btn-sm",onClick:()=>$(new Set),children:[e.jsx(x,{name:"x"})," Clear"]})]}),e.jsxs("div",{className:"card",children:[e.jsxs("div",{className:"card-body",style:{paddingBottom:0},children:[e.jsxs("div",{className:"toolbar",children:[e.jsxs("div",{className:"toolbar-search",children:[e.jsx(x,{name:"search"}),e.jsx("input",{value:a,onChange:t=>S(t.target.value),placeholder:"Search name, skill, company…"})]}),e.jsxs("button",{className:"btn btn-secondary",onClick:()=>y(t=>!t),children:[e.jsx(x,{name:"filter"})," Filters"]}),e.jsx("div",{className:"spacer"}),e.jsx("label",{className:"text-muted text-sm",children:"Sort:"}),e.jsxs("select",{className:"select",value:C,onChange:t=>F(t.target.value),children:[e.jsx("option",{value:"relevance",children:"AI Relevance"}),e.jsx("option",{value:"ats",children:"ATS Score"}),e.jsx("option",{value:"recent",children:"Most Recent"}),e.jsx("option",{value:"name",children:"Name A–Z"})]})]}),_&&e.jsxs("div",{className:"filter-panel",style:{display:"grid",padding:"16px 0",borderTop:"1px solid var(--border)",marginTop:12},children:[e.jsx(R,{label:"Job",value:j.job,onChange:t=>I("job",t),any:"Any Job",options:u}),e.jsx(R,{label:"Skill",value:j.skill,onChange:t=>I("skill",t),any:"Any Skill",options:Me}),e.jsx(R,{label:"Department",value:j.dept,onChange:t=>I("dept",t),any:"Any Dept",options:Be}),e.jsx(R,{label:"Location",value:j.location,onChange:t=>I("location",t),any:"Any Location",options:De}),e.jsx(R,{label:"Experience",value:j.exp,onChange:t=>I("exp",t),any:"Any Exp",options:js}),e.jsx(R,{label:"Education",value:j.edu,onChange:t=>I("edu",t),any:"Any",options:Le}),e.jsx(R,{label:"Recruiter",value:j.recruiter,onChange:t=>I("recruiter",t),any:"Any Recruiter",options:m.map(t=>t.name)}),e.jsx(R,{label:"Hiring Manager",value:j.manager,onChange:t=>I("manager",t),any:"Any Manager",options:p.map(t=>t.name)}),e.jsx(R,{label:"Source",value:j.source,onChange:t=>I("source",t),any:"Any Source",options:ue}),e.jsx(R,{label:"ATS Score",value:j.ats,onChange:t=>I("ats",t),any:"Any Score",options:ps}),e.jsx(R,{label:"Pipeline Stage",value:j.stage,onChange:t=>I("stage",t),any:"Any Stage",options:he}),e.jsx(R,{label:"Interview Status",value:j.interview,onChange:t=>I("interview",t),any:"Any",options:vs}),e.jsx(R,{label:"Notice Period",value:j.notice,onChange:t=>I("notice",t),any:"Any",options:gs}),e.jsx(R,{label:"Availability",value:j.availability,onChange:t=>I("availability",t),any:"Any",options:bs})]})]}),e.jsxs("div",{className:"dt",children:[e.jsx("div",{className:"table-wrap",children:e.jsxs("table",{className:"data",children:[e.jsx("thead",{children:e.jsx("tr",{children:q.map(t=>{const d=z.sort.key===t.key,r=[t.sortable?"sortable":"",d?z.sort.dir===1?"sorted-asc":"sorted-desc":""].filter(Boolean).join(" ");return e.jsxs("th",{className:r,style:{textAlign:t.align||"left"},onClick:t.sortable?()=>z.toggleSort(t.key):void 0,children:[t.label,t.sortable&&e.jsx("span",{className:"sort-ind",children:d?z.sort.dir===1?"▲":"▼":"⇅"})]},t.key)})})}),e.jsx("tbody",{children:z.pageRows.length===0?e.jsx("tr",{children:e.jsx("td",{colSpan:q.length,children:e.jsx(E,{})})}):z.pageRows.map(t=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("span",{className:`checkbox ${P.has(t.id)?"on":""}`,onClick:()=>ve(t.id),role:"checkbox","aria-checked":P.has(t.id),tabIndex:0,onKeyDown:d=>{(d.key==="Enter"||d.key===" ")&&(d.preventDefault(),ve(t.id))},children:e.jsx(x,{name:"check"})})}),e.jsx("td",{children:e.jsxs("div",{className:"user-cell",children:[e.jsx(K,{name:t.name,initials:t.initials,color:t.color}),e.jsxs("div",{children:[e.jsxs("div",{className:"cell-primary",children:[t.name," ",t.favorite&&e.jsx("span",{className:"star-btn on",style:{display:"inline"},children:e.jsx(x,{name:"star"})})]}),e.jsxs("div",{className:"cell-sub",children:[t.currentTitle," · ",t.location]})]})]})}),e.jsxs("td",{children:[e.jsx("div",{className:"text-sm",children:t.jobTitle}),e.jsx("div",{className:"cell-sub",children:t.department})]}),e.jsxs("td",{style:{textAlign:"center"},children:[e.jsx("b",{children:t.experience}),"y"]}),e.jsx("td",{style:{textAlign:"center"},children:e.jsxs("span",{className:`badge ${Oe(t.recommendation)} badge-plain`,children:[T(t),"%"]})}),e.jsx("td",{children:e.jsx(H,{children:t.stage})}),e.jsx("td",{style:{textAlign:"center"},children:e.jsx("span",{style:{cursor:"pointer"},onClick:()=>h(t),children:e.jsx(ke,{score:t.aiScore})})}),e.jsxs("td",{children:[e.jsx("span",{className:"text-sm",children:t.availability}),e.jsxs("div",{className:"cell-sub",children:[t.noticePeriod," notice"]})]}),e.jsx("td",{style:{textAlign:"right"},children:e.jsxs("div",{className:"row-actions",children:[e.jsx("button",{className:`act-btn star-btn ${t.favorite?"on":""}`,"data-tip":"Favorite",onClick:()=>ge(t),children:e.jsx(x,{name:"star"})}),e.jsx("button",{className:"act-btn","data-tip":"ATS Match",onClick:()=>h(t),children:e.jsx(x,{name:"target"})}),e.jsx("button",{className:"act-btn","data-tip":"Profile",onClick:()=>D(t),children:e.jsx(x,{name:"eye"})}),e.jsx("button",{className:"act-btn","data-tip":"Advance",onClick:()=>be(t),children:e.jsx(x,{name:"check"})})]})})]},t.id))})]})}),e.jsx(Qe,{...z})]})]}),i&&e.jsx(_e,{candidate:i,onClose:()=>h(null),onProfile:t=>{h(null),D(t)}}),O&&e.jsx(rs,{candidate:c.find(t=>t.id===O.id)??O,onClose:()=>k(null),onAdvance:be,onToggleFav:ge,onAtsMatch:t=>{k(null),h(t)}}),B&&e.jsx(ws,{count:P.size,recruiters:m,onClose:()=>L(!1),onSave:t=>{f(d=>d.map(r=>P.has(r.id)?{...r,recruiter:t}:r)),L(!1),$(new Set),s("Recruiter assigned to selected candidates","success")}}),A&&e.jsx(Ss,{jobs:v,count:c.length,onClose:()=>M(!1),onSave:t=>{f(d=>[t,...d]),M(!1),s("Candidate added to pipeline","success")},onInvalid:()=>s("Please fix the highlighted fields","error")})]})}function R({label:s,value:l,onChange:o,any:c,options:m}){return e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:s}),e.jsxs("select",{value:l,onChange:p=>o(p.target.value),children:[e.jsx("option",{value:"",children:c}),m.map(p=>e.jsx("option",{children:p},p))]})]})}function _e({candidate:s,onClose:l,onProfile:o}){const c=s.subScores,m=s.recommendation==="Strong Match"?"recc-strong":s.recommendation==="Potential Match"?"recc-potential":"recc-weak",p=s.aiScore>=82?"var(--success)":s.aiScore>=65?"var(--warning)":"var(--danger)",v=({label:g,val:f})=>e.jsxs("div",{className:"flex items-center gap-12",style:{marginBottom:12},children:[e.jsx("span",{style:{width:110,fontSize:13},children:g}),e.jsx("div",{style:{flex:1},children:e.jsx(Ke,{pct:f})}),e.jsxs("b",{style:{width:42,textAlign:"right"},children:[f,"%"]})]});return e.jsxs(ne,{title:"ATS Match Analysis",subtitle:`${s.id} · ${s.jobTitle}`,size:"modal-lg",onClose:l,footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{className:"btn btn-secondary",onClick:l,children:"Close"}),e.jsx("button",{className:"btn btn-primary",onClick:()=>o(s),children:"View Full Profile"})]}),children:[e.jsxs("div",{className:`recc-banner ${m}`,children:[e.jsx("span",{className:"recc-icn",children:e.jsx(x,{name:s.recommendation==="Weak Match"?"x-circle":"check-circle"})}),e.jsxs("div",{style:{flex:1},children:[e.jsx("div",{className:"fw-600",style:{fontSize:15},children:s.recommendation}),e.jsxs("div",{style:{opacity:.85,fontSize:13},children:[s.name," for ",s.jobTitle]})]})]}),e.jsxs("div",{className:"grid g-2",style:{alignItems:"center",marginBottom:20},children:[e.jsx("div",{style:{textAlign:"center"},children:e.jsx("div",{className:"ats-ring",style:{"--pct":s.aiScore,"--c":p},children:e.jsxs("div",{className:"ats-val",children:[e.jsx("div",{className:"ats-num",children:s.aiScore}),e.jsx("div",{className:"ats-lbl",children:"ATS MATCH"})]})})}),e.jsxs("div",{children:[e.jsx(v,{label:"Skills",val:c.skills}),e.jsx(v,{label:"Experience",val:c.experience}),e.jsx(v,{label:"Education",val:c.education}),e.jsx(v,{label:"Keywords",val:c.keywords}),e.jsx(v,{label:"Location",val:c.location}),e.jsx(v,{label:"Salary",val:c.salary})]})]}),e.jsxs("div",{className:"form-section-title",style:{marginTop:0},children:["Matched Skills (",s.matchedSkills.length,")"]}),e.jsx("div",{className:"k-tags",style:{marginBottom:16},children:s.matchedSkills.length?s.matchedSkills.map(g=>e.jsxs("span",{className:"skill-pill skill-matched",children:[e.jsx(x,{name:"check"})," ",g]},g)):e.jsx("span",{className:"text-muted",children:"—"})}),e.jsxs("div",{className:"form-section-title",style:{marginTop:0},children:["Missing Skills (",s.missingSkills.length,")"]}),e.jsx("div",{className:"k-tags",children:s.missingSkills.length?s.missingSkills.map(g=>e.jsxs("span",{className:"skill-pill skill-missing",children:[e.jsx(x,{name:"x"})," ",g]},g)):e.jsx("span",{className:"text-muted",children:"None — full match"})}),e.jsx("div",{className:"divider"}),e.jsxs("p",{className:"text-muted text-sm",children:[e.jsx(x,{name:"sparkles"})," Score computed from JD keywords, resume parsing, experience, education, location and salary alignment. Connect an AI model to refine with semantic matching."]})]})}function ws({count:s,recruiters:l,onClose:o,onSave:c}){var v;const[m,p]=N.useState(((v=l[0])==null?void 0:v.name)??"");return e.jsx(ne,{title:"Bulk Assign Recruiter",subtitle:`${s} candidates`,onClose:o,footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{className:"btn btn-secondary",onClick:o,children:"Cancel"}),e.jsx("button",{className:"btn btn-primary",onClick:()=>c(m),children:"Assign"})]}),children:e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Assign to"}),e.jsx("select",{value:m,onChange:g=>p(g.target.value),children:l.map(g=>e.jsx("option",{children:g.name},g.id))})]})})}function Ss({jobs:s,count:l,onClose:o,onSave:c,onInvalid:m}){const{toast:p}=J(),v=pe(),g=N.useRef(null),[f,a]=N.useState(null),[S,j]=N.useState(!1),n=V({queryKey:Z.jobPosts.list({top:Se}),queryFn:async()=>{const i=await Je({top:Se});return Array.isArray(i==null?void 0:i.data)?i.data:[]}}),_=n.data??[],y=qe({name:"",email:"",phone:"",job:"",experience:"3",company:"",source:ue[0],stage:he[0],referral:""}),C=y.values.job||(_[0]?String(_[0].id):""),F=Ae({mutationFn:i=>Ze(i),onError:i=>p(je(i,"Could not add the candidate."),"error"),onSuccess:i=>{v.invalidateQueries({queryKey:Z.candidates.all()}),c(P(i==null?void 0:i.data))}});function P(i){var D;const h=y.values,A=_.find(u=>String(u.id)===C),M=(A==null?void 0:A.title)||h.job||((D=s[0])==null?void 0:D.title)||"Unassigned",B=s.find(u=>u.title===M)||s[0]||{},L=B.skills??[],T=ze(55,95);return{id:`CAN-${5001+l}`,userId:(i==null?void 0:i.user_id)??null,manualUploadId:(i==null?void 0:i.id)??null,name:h.name,initials:We(h.name),color:Ve(h.name),email:h.email,phone:h.phone||"+1 (555) 000-0000",jobId:B.id,jobTitle:M,department:B.department,experience:Number(h.experience)||1,currentCompany:h.company||"—",currentTitle:M,location:B.location,stage:h.stage,status:h.stage,aiScore:T,source:h.source,referredBy:me(h.referral)||null,recruiter:B.recruiter,recruiterId:B.recruiterId,applied:new Date(Ce),education:"Bachelor's Degree",skills:L.slice(0,4),rating:"4.0",salary:12e4,matchedSkills:L.slice(0,3),missingSkills:L.slice(3),recommendation:T>=82?"Strong Match":T>=65?"Potential Match":"Weak Match",subScores:{skills:T,experience:80,education:80,keywords:T,location:100,salary:90},noticePeriod:"1 month",availability:"2 weeks",certifications:[],favorite:!1,interviewStatus:"Not Scheduled"}}function $(i){i&&(a(i),y.setErrors(h=>{if(!h.cv)return h;const A={...h};return delete A.cv,A}))}function O(){if(F.isPending)return;const i=y.values,h={};i.name.trim()||(h.name="Required"),/^\S+@\S+\.\S+$/.test(i.email)||(h.email="Valid email required"),_.length&&!C&&(h.job="Required"),f?/\.pdf$/i.test(f.name)?f.size>de*1024*1024&&(h.cv=`Keep the file under ${de} MB`):h.cv="Only PDF resumes can be parsed":h.cv="Attach the candidate’s CV";const A=me(i.referral);if(A&&!fs.test(A)&&(h.referral=`Must be a @${xe} address`),y.setErrors(h),Object.keys(h).length){m();return}F.mutate({file:f,name:i.name,email:i.email,phone:i.phone,jobPostId:C,company:i.company,source:i.source,experience:i.experience,stage:i.stage,referralBy:A})}const k=i=>({value:y.values[i],onChange:h=>y.setField(i,h.target.value)});return e.jsx(ne,{title:"Add Candidate",subtitle:"Manually add a candidate to the pipeline",onClose:o,footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{className:"btn btn-secondary",onClick:o,disabled:F.isPending,children:"Cancel"}),e.jsxs("button",{className:"btn btn-primary",onClick:O,disabled:F.isPending,children:[e.jsx(x,{name:"check"})," ",F.isPending?"Adding…":"Add Candidate"]})]}),children:e.jsxs("form",{noValidate:!0,onSubmit:i=>{i.preventDefault(),O()},children:[e.jsxs("div",{className:"form-grid",children:[e.jsxs("div",{className:"form-field",children:[e.jsxs("label",{children:["Full Name ",e.jsx("span",{className:"req",children:"*"})]}),e.jsx("input",{...k("name"),className:y.errors.name?"err":"",placeholder:"Jane Doe"}),e.jsx(ee,{children:y.errors.name})]}),e.jsxs("div",{className:"form-field",children:[e.jsxs("label",{children:["Email ",e.jsx("span",{className:"req",children:"*"})]}),e.jsx("input",{type:"email",...k("email"),className:y.errors.email?"err":"",placeholder:"jane@email.com"}),e.jsx(ee,{children:y.errors.email})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Phone"}),e.jsx("input",{...k("phone"),placeholder:"+1 (555) 000-0000"})]}),e.jsxs("div",{className:"form-field",children:[e.jsxs("label",{children:["Applied Job ",e.jsx("span",{className:"req",children:"*"})]}),e.jsxs("select",{value:C,onChange:i=>y.setField("job",i.target.value),className:y.errors.job?"err":"",disabled:n.isPending||!_.length,children:[n.isPending&&e.jsx("option",{value:"",children:"Loading job posts…"}),!n.isPending&&!_.length&&e.jsx("option",{value:"",children:n.isError?"Could not load job posts":"No active job posts"}),_.map(i=>e.jsx("option",{value:String(i.id),children:i.title},i.id))]}),e.jsx(ee,{children:y.errors.job})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Experience (years)"}),e.jsx("input",{type:"number",...k("experience")})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Current Company"}),e.jsx("input",{...k("company"),placeholder:"Acme Inc."})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Source"}),e.jsx("select",{...k("source"),children:ue.map(i=>e.jsx("option",{children:i},i))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Stage"}),e.jsx("select",{...k("stage"),children:he.map(i=>e.jsx("option",{children:i},i))})]}),e.jsxs("div",{className:"form-field",children:[e.jsx("label",{children:"Referral By"}),e.jsx("input",{type:"email",...k("referral"),onBlur:i=>y.setField("referral",me(i.target.value)),className:y.errors.referral?"err":"",placeholder:`name@${xe}`}),e.jsx(ee,{children:y.errors.referral})]})]}),e.jsxs("div",{className:"form-section-title",children:["CV / Resume ",e.jsx("span",{className:"req",style:{color:"var(--danger)"},children:"*"})]}),e.jsx("input",{ref:g,type:"file",accept:"application/pdf,.pdf",hidden:!0,onChange:i=>{var h;$((h=i.target.files)==null?void 0:h[0]),i.target.value=""}}),e.jsxs("div",{className:`dropzone${S?" drag":""}`,style:{padding:"22px 18px",cursor:F.isPending?"default":"pointer"},role:"button",tabIndex:0,onClick:()=>{var i;F.isPending||(i=g.current)==null||i.click()},onKeyDown:i=>{var h;(i.key==="Enter"||i.key===" ")&&(i.preventDefault(),(h=g.current)==null||h.click())},onDragOver:i=>{i.preventDefault(),j(!0)},onDragLeave:()=>j(!1),onDrop:i=>{var h;i.preventDefault(),j(!1),$((h=i.dataTransfer.files)==null?void 0:h[0])},children:[e.jsx("div",{className:"dz-icn",style:{width:44,height:44,borderRadius:13,marginBottom:10},children:e.jsx(x,{name:"upload"})}),e.jsx("h3",{style:{fontSize:15},children:"Drop the CV here or click to browse"}),e.jsxs("p",{className:"text-muted text-sm",children:["PDF only · text-based resumes · up to ",de," MB"]})]}),f&&e.jsxs("div",{className:"upload-row",children:[e.jsx("span",{className:"attach-icn",style:{width:34,height:34},children:e.jsx(x,{name:"file"})}),e.jsxs("div",{style:{flex:1,minWidth:0},children:[e.jsx("div",{className:"fw-600 text-sm",children:f.name}),e.jsxs("div",{className:"cell-sub",children:[Math.max(1,Math.round(f.size/1024))," KB"]})]}),e.jsx("button",{type:"button",className:"act-btn","aria-label":"Remove file",disabled:F.isPending,onClick:()=>a(null),children:e.jsx(x,{name:"trash"})})]}),e.jsx(ee,{children:y.errors.cv})]})})}const Is=Object.freeze(Object.defineProperty({__proto__:null,AtsMatch:_e,default:Ns},Symbol.toStringTag,{value:"Module"}));export{_e as A,rs as C,Is as a,Ps as l,Xe as t}; -//# sourceMappingURL=Candidates-DDm6mZmg.js.map diff --git a/frontend/dist/assets/Candidates-DDm6mZmg.js.map b/frontend/dist/assets/Candidates-DDm6mZmg.js.map deleted file mode 100644 index 22a21cd..0000000 --- a/frontend/dist/assets/Candidates-DDm6mZmg.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"Candidates-DDm6mZmg.js","sources":["../../src/api/candidates.js","../../src/screens/CandidateProfile.jsx","../../src/screens/Candidates.jsx"],"sourcesContent":["import { request } from '../lib/apiClient'\n\n/**\n * Candidate profiles — the `inbox -> users -> roles` join, restricted server-side\n * to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).\n *\n * Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the\n * tag gets a 403.\n *\n * `search` is an ilike over users.name / users.email only — it does NOT reach\n * the résumé text or the suggested job titles.\n */\nexport function list({ search, limit, offset } = {}) {\n return request('/candidate/fetch', { params: { search, limit, offset } })\n}\n\n/**\n * One candidate by users.id.\n *\n * Passing user_id switches the endpoint into DETAIL mode\n * (backend/job/candidate/views.py:get_candidate), which is a different and much\n * larger payload than the list rows: résumé text, the AI match verdict, phone,\n * education, source, documents, favorite/rating, and the four child collections\n * — interviews, activity, feedback, notes — flattened across every inbox row the\n * candidate owns.\n *\n * NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT\n * rather than a one-element list when user_id matches exactly one row\n * (backend/inbox/models.py:68-70). Callers must normalise — see toRows().\n */\nexport function getByUserId(userId) {\n return request('/candidate/fetch', { params: { user_id: userId } })\n}\n\n/** `data` is a list on the list path and a bare object on the by-id path. */\nexport function toRows(res) {\n if (Array.isArray(res?.data)) return res.data\n return res?.data ? [res.data] : []\n}\n\n/**\n * favorite/rating live on the `inbox` row, not on the user, so the server applies\n * the change to EVERY application belonging to the candidate and hands back the\n * refreshed detail payload. Pipeline stage is not writable here — no endpoint\n * updates inbox_messages.application_status yet.\n */\nexport function update(userId, payload) {\n return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload })\n}\n\n/**\n * Manual candidate creation — POST /candidate/create/candidate (backend/job/app.py:98).\n *\n * Multipart, and the CV is REQUIRED, not an extra: the route declares\n * `file: UploadFile = File(...)`, so a request without one is a 422, and\n * injest_manual_upload then rejects the upload with 400 when pypdf extracts no\n * text. The extracted text IS the record — it is what later scoring reads — so\n * a scanned or image-only PDF fails here rather than storing an empty row.\n * PDF only: read_file goes straight to PdfReader, so DOC/DOCX 400s.\n *\n * Every other field is an optional Form value, with one exception —\n * candidate_email, which create_candidate rejects when blank (422). It is also\n * the identity key: an unknown address creates the `users` row (role CANDIDATE,\n * default password from DEFAULT_CANDIDATE_PASSWORD), a known one reuses it.\n * That user write is why the route sits behind candidates.create.\n *\n * job_post_id must be a real job_posts UUID. Anything unparseable is coerced to\n * NULL rather than raising (Manual_UPLOAD_CANDIDATE._as_uuid), so a seed id like\n * \"JOB-101\" would silently drop the link — the picker must offer live posts from\n * /job/fetch, never the seed catalogue.\n *\n * `platform`, `status` and `referral_by` are free-text columns, not enums; the\n * UI's Source and Stage vocabularies go in verbatim, and a referrer is whatever\n * the recruiter typed — often someone with no account here.\n */\nexport function createManual({\n file, name, email, phone, jobPostId, company, source, experience, stage, referralBy,\n}) {\n const form = new FormData()\n form.append('file', file)\n // Blank optional fields are omitted rather than sent as \"\": Form(None) then\n // leaves them None, and the model's own defaults apply.\n const put = (key, value) => {\n const text = value == null ? '' : String(value).trim()\n if (text) form.append(key, text)\n }\n put('candidate_email', email)\n put('candidate_name', name)\n put('candidate_phone', phone)\n put('job_post_id', jobPostId)\n put('current_company', company)\n put('platform', source)\n put('experience', experience)\n put('status', stage)\n put('referral_by', referralBy)\n return request('/candidate/create/candidate', { method: 'POST', body: form })\n}\n\n/* ------------------------------------------------------------------\n Child records of a profile.\n\n Reads are deliberately absent: the detail payload above already bundles all\n four collections, so a separate GET per tab would be a second round trip for\n data the modal is holding. Writers invalidate qk.candidates.detail(userId) and\n the whole modal repaints from one refetch.\n\n Scoping differs by table and is not interchangeable — notes hang off the\n candidate (users.id), while interviews, activity and feedback hang off one\n application (inbox.id).\n ------------------------------------------------------------------ */\n\nexport function createNote({ userId, note }) {\n return request('/notes/create', { method: 'POST', body: { user_id: userId, note } })\n}\n\nexport function createInterview({ inboxId, date, time, type, status }) {\n return request('/interview/create', {\n method: 'POST',\n body: {\n inbox_id: inboxId,\n interview_date: date,\n interview_time: time,\n interview_type: type,\n interview_status: status,\n },\n })\n}\n\n/** `reviewed_by` is omitted on purpose: the server stamps the caller. */\nexport function createFeedback({ inboxId, review, score, note }) {\n return request('/feedback/create', {\n method: 'POST',\n body: { inbox_id: inboxId, review, score, note },\n })\n}\n\nexport function createActivity({ inboxId, type, status, description }) {\n return request('/activity/create', {\n method: 'POST',\n body: { inbox_id: inboxId, activity_type: type, activity_status: status, description },\n })\n}\n","/* The 8-tab candidate profile modal, split out of Candidates.jsx — it was the\n single largest block in js/candidates.js and deserves its own file.\n\n TWO DATA MODES, selected by whether the caller passes a `userId`:\n\n SEED (Candidates.jsx) — every tab renders from the seed record, exactly as\n the prototype did.\n LIVE (TalentPool.jsx) — GET /candidate/fetch?user_id= switches the endpoint\n into detail mode and returns the real record: résumé text, the agent's\n match verdict, documents, and the four child collections (interviews,\n notes, activity, feedback). The write tabs POST to their own endpoints\n and invalidate this one query, so the whole modal repaints from a single\n refetch.\n\n Live collections are NEVER padded with the seed's demo rows. An empty tab gets\n an empty state, because inventing three scorecards for a real applicant is\n worse than showing none.\n\n Scoping differs between the child tables and is not interchangeable: notes\n hang off the candidate (users.id), while interviews, activity and feedback\n hang off one application (inbox.id). */\n\nimport { useMemo, useState } from 'react'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\n\nimport Modal from '../ui/Modal'\nimport { Tabs } from '../ui/Tabs'\nimport { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'\nimport { useToast } from '../ui/Toast'\nimport { seedQuery } from '../data/seedQueries'\nimport { qk } from '../lib/queryKeys'\nimport { friendlyAuthError } from '../lib/errors'\nimport * as candidatesApi from '../api/candidates'\nimport { companies, fmtDate, moneyK, pick } from '../data/seed'\n\nconst TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']\nconst LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }\nconst REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']\nconst INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']\nconst INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']\nconst ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']\n\n/** Seed timestamps are Date objects; the API sends ISO strings. */\nfunction fmtWhen(value, fallback = '—') {\n if (!value) return fallback\n const d = value instanceof Date ? value : new Date(value)\n return Number.isNaN(d.getTime()) ? String(value) : fmtDate(d)\n}\n\nfunction fmtClock(value) {\n if (!value) return null\n const d = new Date(value)\n return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })\n}\n\nfunction stamp(value) {\n const d = value instanceof Date ? value : new Date(value ?? NaN)\n return Number.isNaN(d.getTime()) ? 0 : d.getTime()\n}\n\n/** + -> one ISO instant, or null. */\nfunction toInstant(date, time) {\n if (!date) return null\n const d = new Date(`${date}T${time || '00:00'}`)\n return Number.isNaN(d.getTime()) ? null : d.toISOString()\n}\n\nfunction Info({ label, val }) {\n return (\n
\n
{label}
\n
{val === 0 || val ? val : '—'}
\n
\n )\n}\n\nfunction useCandidateDetail(userId) {\n return useQuery({\n queryKey: qk.candidates.detail(userId),\n queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,\n enabled: Boolean(userId),\n })\n}\n\n/**\n * A write against one of the child endpoints. Every one of them invalidates the\n * single detail query the modal renders from, so a saved note and a submitted\n * scorecard both land through the same refetch rather than through hand-patched\n * cache entries that could drift from the server's view.\n */\nfunction useProfileWrite({ userId, mutationFn, success, onDone }) {\n const qc = useQueryClient()\n const { toast } = useToast()\n return useMutation({\n mutationFn,\n onSuccess: async (_data, vars) => {\n await qc.invalidateQueries({ queryKey: qk.candidates.detail(userId) })\n toast(typeof success === 'function' ? success(vars) : success, 'success')\n onDone?.()\n },\n onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'),\n })\n}\n\nexport default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {\n const { toast } = useToast()\n const [tab, setTab] = useState('Overview')\n const { data: interviews = [] } = useQuery(seedQuery('interviews'))\n const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))\n\n const isLive = Boolean(c.userId)\n const detail = useCandidateDetail(c.userId)\n const live = detail.data ?? null\n\n // The prototype called DB.pick() inline while rendering, so the \"previous\n // employer\" changed every repaint. Fixed per candidate.\n const priorCompany = useMemo(() => pick(companies), [])\n\n const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)\n\n // The application row interviews/activity/feedback attach to. Detail mode\n // flattens every application the candidate owns; writes land on the first,\n // which is the one the header is describing.\n const inboxId = live?.inbox_id ?? null\n\n const favorite = live ? Boolean(live.favorite) : c.favorite\n const setFavorite = useProfileWrite({\n userId: c.userId,\n mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }),\n success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),\n })\n\n const title = live?.job_title || c.currentTitle\n const company = live?.currentCompany || c.currentCompany\n\n const counts = live && {\n Interview: live.interviews?.length ?? 0,\n Notes: live.notes?.length ?? 0,\n Activity: live.activity?.length ?? 0,\n Documents: live.documents?.length ?? 0,\n Feedback: live.feedback?.length ?? 0,\n }\n\n // In live mode nothing below the hero can be trusted until the detail payload\n // lands, so one guard replaces every tab body rather than each tab inventing\n // its own half-loaded state.\n const guard = !isLive ? null\n : detail.isPending ? (\n Fetching the full record.\n ) : detail.isError ? (\n \n {friendlyAuthError(detail.error, 'Please try again.')}\n \n ) : !live ? (\n This candidate is no longer in the pipeline.\n ) : null\n\n return (\n \n (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}\n >\n {favorite ? 'Favorited' : 'Favorite'}\n \n \n \n \n \n }\n >\n
\n \n
\n
{live?.name || c.name}
\n
{company ? `${title} at ${company}` : title}
\n
\n {c.stage} {live?.source || c.source}\n {c.experience} yrs exp\n
\n
\n
\n \n
AI Match
\n
\n
\n\n
\n ({ key: t, label: t, count: counts ? counts[t] : undefined }))}\n />\n
\n\n
\n {tab === 'Overview' && (guard || (live ? (\n <>\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n\n {(live.match_summary || live.match_reasoning) && (\n <>\n
AI Screening
\n
\n
\n {live.match_summary &&

{live.match_summary}

}\n {live.match_reasoning &&

{live.match_reasoning}

}\n
\n
\n \n )}\n\n {live.assigned_job_post && (\n <>\n
Assigned Role
\n
\n \n {live.assigned_job_post.title}\n \n
\n \n )}\n\n {live.job_posts?.length > 0 && (\n <>\n
Suggested Roles
\n
\n {live.job_posts.map((j) => {j.title})}\n
\n \n )}\n \n ) : (\n <>\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n
Skills
\n
{c.skills.map((s) => {s})}
\n \n )))}\n\n {tab === 'Resume' && (guard || (live ? (\n \n ) : (\n <>\n
\n
\n

{c.name}

\n

{c.currentTitle} · {c.location}

\n
\n
Summary
\n

\n Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience\n across {c.department.toLowerCase()}. Passionate about building high-quality products\n and collaborating with cross-functional teams.\n

\n
Experience
\n
\n
{c.currentTitle} — {c.currentCompany}
\n
2021 – Present
\n
\n
\n
Associate — {priorCompany}
\n
2018 – 2021
\n
\n
Education
\n
{c.education}
\n
\n
\n \n \n )))}\n\n {tab === 'Timeline' && (guard || (live ? (\n \n ) : (\n
\n {[\n { icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` },\n { icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` },\n { icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` },\n { icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' },\n { icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' },\n ].map((e) => (\n
\n
\n
{e.title}
\n
{e.meta}
\n
{e.desc}
\n
\n ))}\n
\n )))}\n\n {tab === 'Interview' && (guard || (live ? (\n \n ) : (\n candidateInterviews.length ? (\n
\n {candidateInterviews.map((iv) => (\n
\n \n \n \n
\n
{iv.type}
\n
{fmtDate(iv.when)} · {iv.meeting}
\n
\n
{iv.status}
\n
\n ))}\n
\n ) : (\n \n Schedule an interview to get started.\n \n )\n )))}\n\n {tab === 'Notes' && (guard || (live ? (\n \n ) : (\n <>\n
\n \n
Data
Query Explorer
`); -var _tmpl$37 = template(`