Dashboard seems to work

Dashboard_Wiring
ahmed.mujtaba 2026-08-12 16:11:36 +05:00
parent 6e1ecb6d72
commit 29ee228eaa
129 changed files with 3014 additions and 21781 deletions

View File

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

109
backend/analytics/app.py Normal file
View File

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

View File

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

557
backend/analytics/views.py Normal file
View File

@ -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<to_date)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_open_snapshot(self,as_of,department=None,recruiter_id=None):
"""Jobs that existed and were still open at `as_of` (best-effort)."""
statement=select(func.count()).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.created_at<as_of,
or_(JobPosts.closed_at.is_(None),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<to_date)
# Best-effort department/recruiter via linked message → job post
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.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)
)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
statement=select(func.count()).select_from(Offers)
if exclude_draft:
statement=statement.where(Offers.status!="draft")
elif statuses:
statement=statement.where(Offers.status.in_(statuses))
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.created_at)
if from_date is not None:
statement=statement.where(stamp>=from_date)
if to_date is not None:
statement=statement.where(stamp<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Offers.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(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
# Prefer HIRED transitions in window; fall back path uses inbox_messages status.
hired=ApplicationStageTransitions
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
if from_date is not None:
statement=statement.where(hired.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hired.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hired.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:
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)
)
result=await self.session.execute(statement)
count=int(result.scalar_one() or 0)
if count:
return count
# Fallback: messages currently HIRED, windowed via inbox.created_at
msg=(
select(func.count())
.select_from(Inbox_Messages)
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
)
if from_date is not None:
msg=msg.where(Inbox.created_at>=from_date)
if to_date is not None:
msg=msg.where(Inbox.created_at<to_date)
if department or recruiter_id:
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
msg=msg.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
result=await self.session.execute(msg)
return int(result.scalar_one() or 0)
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
entry=ApplicationStageTransitions.__table__.alias("entry")
hire=ApplicationStageTransitions.__table__.alias("hire")
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
statement=(
select(func.avg(days))
.select_from(
hire.join(
entry,
and_(
hire.c.inbox_id==entry.c.inbox_id,
entry.c.from_stage.is_(None),
),
)
)
.where(hire.c.to_stage==Candidate_application_Status.HIRED.value)
)
if from_date is not None:
statement=statement.where(hire.c.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hire.c.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hire.c.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:
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)
)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
statement=select(func.avg(days)).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.requisition_status=="closed",
JobPosts.closed_at.is_not(None),
)
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<to_date)
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)
value=result.scalar_one()
return float(value) if value is not None else None
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
if not hires:
return None
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
if from_date is not None:
statement=statement.where(HiringCosts.incurred_at>=from_date)
if to_date is not None:
statement=statement.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,HiringCosts.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(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
total=float(result.scalar_one() or 0.0)
return total/hires
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
window_from,window_to,prior_from,prior_to=_resolve_windows(from_date,to_date)
now=datetime.now(timezone.utc)
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
tomorrow=today_start+timedelta(days=1)
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id)
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
closed_jobs=await self._count_jobs(
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
)
closed_jobs_prior=await self._count_jobs(
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
)
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
interviews_today_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_date>=today_start,
Interviews.interview_date<tomorrow,
)
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
upcoming_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_status.ilike("scheduled"),
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<to_date)
statement=statement.group_by(Inbox_Messages.application_status)
result=await self.session.execute(statement)
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
return [
serialize_stage_count(stage.value,counts.get(stage.value,0))
for stage in Candidate_application_Status
]
async def get_hiring_trend(self,months=7,from_date=None,to_date=None,department=None,recruiter_id=None):
months=max(1,int(months or 7))
now=datetime.now(timezone.utc)
start=_month_start(now)
# Walk back (months-1) months
for _ in range(months-1):
start=_month_start(start-timedelta(days=1))
month_bucket=func.date_trunc("month",Inbox.created_at)
apps_q=(
select(month_bucket.label("month"),func.count().label("count"))
.select_from(Inbox)
.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<to_date)
statement=statement.group_by(SourceChannels.label).order_by(func.count().desc())
result=await self.session.execute(statement)
return [serialize_source_count(source,count) for source,count in result.all()]
async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None):
top=max(1,int(top or 5))
recruiters_q=(
select(Users)
.join(Roles,Users.role_id==Roles.id)
.where(
Roles.role_name==EnumRoles.RECRUITER.value,
Users.is_deleted==False, # noqa: E712
)
)
rid=_as_uuid(recruiter_id)
if rid is not None:
recruiters_q=recruiters_q.where(Users.id==rid)
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
rows=[]
for user in recruiters:
hires_q=select(func.count()).select_from(Inbox_Messages).where(
Inbox_Messages.recruiter_id==user.id,
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
)
if department:
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
JobPosts.department==department
)
if from_date is not None or to_date is not None:
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
hires_q=hires_q.where(Inbox.created_at>=from_date)
if to_date is not None:
hires_q=hires_q.where(Inbox.created_at<to_date)
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
open_posts_q=select(func.count()).select_from(JobPosts).where(
JobPosts.current_recruiter_id==user.id,
JobPosts.requisition_status=="open",
JobPosts.is_deleted==False, # noqa: E712
)
if department:
open_posts_q=open_posts_q.where(JobPosts.department==department)
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
open_reqs=max(open_assign,open_posts)
avg_tth=await self._avg_time_to_hire(
from_date,to_date,department,recruiter_id=str(user.id)
)
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth))
rows.sort(key=lambda r: r["hires"],reverse=True)
return rows[:top]

View File

@ -30,6 +30,10 @@ SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
"mailer-daemon", "postmaster", "bounce")
def _now() -> 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

44
backend/job/cost/views.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = '';

125
backend/offer/app.py Normal file
View File

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

152
backend/offer/models.py Normal file
View File

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

4
backend/offer/plugins.py Normal file
View File

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

View File

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

146
backend/offer/views.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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 <div className=\"page\">\r\n <div className=\"page-head\">\r\n <div>\r\n <h1 className=\"page-title\">AI Assistant</h1>\r\n <p className=\"page-sub\">Your recruiting copilot — powered by AI (interface preview)</p>\r\n </div>\r\n <div className=\"page-head-actions\">\r\n <span className=\"integration-status pending\">\r\n <span className=\"pulse\" />Model endpoint · Not connected\r\n </span>\r\n <button className=\"btn btn-secondary\" onClick={() => setResetKey((k) => k + 1)}>\r\n <Icon name=\"plus\" /> New Chat\r\n </button>\r\n </div>\r\n </div>\r\n <div className=\"card\">\r\n <div className=\"card-body\">\r\n <Chat resetKey={resetKey} />\r\n </div>\r\n </div>\r\n </div>\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"}

View File

@ -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 features UI is complete. Backend wiring is the only remaining step."]})]})]})}export{p as default};
//# sourceMappingURL=AiStudio-CIA10Amn.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{j as e,E as k,r as f,I as p}from"./index-BTPmxtwM.js";function N({columns:l,rows:i,pageSize:r=10}){const[t,o]=f.useState({key:null,dir:1}),[s,d]=f.useState(1);f.useEffect(()=>d(1),[i]);const a=f.useMemo(()=>{if(!t.key)return i;const n=l.find(c=>c.key===t.key);return[...i].sort((c,m)=>{let u=n!=null&&n.sortValue?n.sortValue(c):c[t.key],j=n!=null&&n.sortValue?n.sortValue(m):m[t.key];return typeof u=="string"&&(u=u.toLowerCase(),j=(j||"").toLowerCase()),u<j?-1*t.dir:u>j?1*t.dir:0})},[i,l,t]),h=a.length,g=Math.max(1,Math.ceil(h/r)),b=Math.min(s,g),x=(b-1)*r;function y(n){o(c=>c.key===n?{key:n,dir:c.dir*-1}:{key:n,dir:1})}return{pageRows:a.slice(x,x+r),sort:t,toggleSort:y,page:b,pages:g,setPage:d,from:h?x+1:0,to:Math.min(x+r,h),total:h,pageButtons:v(b,g)}}function v(l,i){const r=[];for(let t=1;t<=i;t++)t===1||t===i||Math.abs(t-l)<=1?r.push(t):r[r.length-1]!=="…"&&r.push("…");return r}function C({from:l,to:i,total:r,page:t,pages:o,setPage:s,pageButtons:d}){return e.jsxs("div",{className:"pagination",children:[e.jsxs("span",{className:"page-info",children:["Showing ",e.jsxs("b",{children:[l,"",i]})," of ",e.jsx("b",{children:r})]}),e.jsxs("div",{className:"page-controls",children:[e.jsx("button",{className:"page-btn",disabled:t===1,onClick:()=>s(t-1),"aria-label":"Previous page",children:e.jsx(p,{name:"chevron-left"})}),d.map((a,h)=>a==="…"?e.jsx("span",{className:"page-btn",style:{cursor:"default"},children:"…"},`gap-${h}`):e.jsx("button",{className:`page-btn ${a===t?"active":""}`,onClick:()=>s(a),"aria-current":a===t?"page":void 0,children:a},a)),e.jsx("button",{className:"page-btn",disabled:t===o,onClick:()=>s(t+1),"aria-label":"Next page",children:e.jsx(p,{name:"chevron-right"})})]})]})}function S({columns:l,rows:i,pageSize:r=10,empty:t}){const o=N({columns:l,rows:i,pageSize:r});return 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:l.map(s=>{const d=o.sort.key===s.key,a=[s.sortable?"sortable":"",d?o.sort.dir===1?"sorted-asc":"sorted-desc":""].filter(Boolean).join(" ");return e.jsxs("th",{className:a,style:{textAlign:s.align||"left"},onClick:s.sortable?()=>o.toggleSort(s.key):void 0,children:[s.label,s.sortable&&e.jsx("span",{className:"sort-ind",children:d?o.sort.dir===1?"▲":"▼":"⇅"})]},s.key)})})}),e.jsx("tbody",{children:o.pageRows.length===0?e.jsx("tr",{children:e.jsx("td",{colSpan:l.length,children:e.jsx(k,{children:t})})}):o.pageRows.map((s,d)=>e.jsx("tr",{children:l.map(a=>e.jsx("td",{style:{textAlign:a.align||"left"},children:a.render?a.render(s):s[a.key]??""},a.key))},s.id??d))})]})}),e.jsx(C,{...o})]})}export{S as D,C as P,N as u};
//# sourceMappingURL=DataTable-D5imKbZq.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{d as c,r as d,j as e,I as i}from"./index-BTPmxtwM.js";const l=[{q:"How do I create a new job requisition?",a:'Navigate to Jobs and click "Create Job". Fill in the required fields marked with an asterisk and click Save. The job will immediately appear in your listings.'},{q:"How does the AI candidate score work?",a:"The AI score (0100) evaluates how well a candidate matches the job requirements based on skills, experience, and education. Higher scores indicate stronger matches."},{q:"Can I move candidates between pipeline stages?",a:"Yes. Open the Pipeline view and simply drag any candidate card between stage columns. The candidates status updates automatically."},{q:"How do I schedule an interview?",a:'Go to Interviews or Calendar and click "Schedule Interview". Select the candidate, round, date, time, and interviewers.'},{q:"How do I export reports?",a:'On the Reports page, use the "Export Report" button for a full PDF, or the CSV buttons on individual tables.'}],o=[{icn:"file",t:"Documentation",d:"Complete product guides",cls:"i-indigo"},{icn:"video",t:"Video Tutorials",d:"Watch step-by-step walkthroughs",cls:"i-red"},{icn:"message",t:"Live Chat",d:"Chat with our support team",cls:"i-green"},{icn:"users",t:"Community",d:"Connect with other recruiters",cls:"i-purple"}];function p(){const{toast:n}=c(),[t,r]=d.useState(null);return e.jsxs("div",{className:"page",children:[e.jsx("div",{className:"page-head",children:e.jsxs("div",{children:[e.jsx("h1",{className:"page-title",children:"Help Center"}),e.jsx("p",{className:"page-sub",children:"Find answers and get support"})]})}),e.jsx("div",{className:"card brand-hero mb-18",children:e.jsxs("div",{className:"card-body",style:{padding:32,textAlign:"center"},children:[e.jsx("h2",{style:{fontSize:22,marginBottom:8},children:"How can we help you?"}),e.jsx("p",{style:{opacity:.85,marginBottom:18},children:"Search our knowledge base or browse the topics below"}),e.jsxs("div",{className:"topbar-search",style:{maxWidth:480,margin:"0 auto"},children:[e.jsx(i,{name:"search"}),e.jsx("input",{placeholder:"Search help articles…"})]})]})}),e.jsx("div",{className:"grid g-kpi mb-18",children:o.map(s=>e.jsx("div",{className:"card",style:{cursor:"pointer"},onClick:()=>n(`Opening ${s.t}`,"info"),children:e.jsxs("div",{className:"card-body",style:{textAlign:"center"},children:[e.jsx("span",{className:`kpi-icn ${s.cls}`,style:{margin:"0 auto 12px",width:48,height:48,borderRadius:14},children:e.jsx(i,{name:s.icn})}),e.jsx("div",{className:"fw-600",children:s.t}),e.jsx("div",{className:"lr-sub",style:{marginTop:4},children:s.d})]})},s.t))}),e.jsxs("div",{className:"card",children:[e.jsx("div",{className:"card-head",children:e.jsx("div",{children:e.jsx("h3",{children:"Frequently Asked Questions"})})}),e.jsx("div",{className:"card-body",children:l.map((s,a)=>e.jsxs("div",{className:"setting-row",style:{cursor:"pointer",flexDirection:"column",alignItems:"stretch"},onClick:()=>r(t===a?null:a),children:[e.jsxs("div",{className:"flex items-center",style:{justifyContent:"space-between"},children:[e.jsx("h4",{children:s.q}),e.jsx("span",{style:{color:"var(--text-3)",transition:".2s",transform:t===a?"rotate(90deg)":"rotate(0deg)"},children:e.jsx(i,{name:"chevron-right"})})]}),t===a&&e.jsx("p",{style:{marginTop:10},children:s.a})]},s.q))})]})]})}export{p as default};
//# sourceMappingURL=Help-CrblBaFq.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{r as l,av as N,j as t,I as g}from"./index-BTPmxtwM.js";let c=0;function w(){c+=1,document.body.style.overflow="hidden"}function R(){c=Math.max(0,c-1),c===0&&(document.body.style.overflow="")}const j='a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';function L({open:d=!0,title:i,subtitle:u,size:p,footer:f,onClose:s,children:E}){const m=l.useRef(null),x=l.useRef(null),n=l.useCallback(()=>s==null?void 0:s(),[s]);return l.useEffect(()=>{var a,b;if(!d)return;x.current=document.activeElement,w();const r=m.current;(b=(a=(r==null?void 0:r.querySelector(j))??r)==null?void 0:a.focus)==null||b.call(a);const v=e=>{if(e.key==="Escape"){e.stopPropagation(),n();return}if(e.key!=="Tab"||!r)return;const o=Array.from(r.querySelectorAll(j)).filter(k=>k.offsetParent!==null);if(!o.length)return;const h=o[0],y=o[o.length-1];e.shiftKey&&document.activeElement===h?(e.preventDefault(),y.focus()):!e.shiftKey&&document.activeElement===y&&(e.preventDefault(),h.focus())};return document.addEventListener("keydown",v,!0),()=>{var e,o;document.removeEventListener("keydown",v,!0),R(),(o=(e=x.current)==null?void 0:e.focus)==null||o.call(e)}},[d,n]),d?N.createPortal(t.jsxs("div",{className:"modal-root open",children:[t.jsx("div",{className:"modal-backdrop",onClick:n}),t.jsxs("div",{className:`modal ${p||""}`,role:"dialog","aria-modal":"true","aria-label":typeof i=="string"?i:void 0,tabIndex:-1,ref:m,children:[t.jsxs("div",{className:"modal-head",children:[t.jsxs("div",{children:[t.jsx("h2",{children:i}),u&&t.jsx("p",{children:u})]}),t.jsx("button",{className:"modal-close",onClick:n,"aria-label":"Close",children:t.jsx(g,{name:"x"})})]}),t.jsx("div",{className:"modal-body",children:E}),f&&t.jsx("div",{className:"modal-foot",children:f})]})]}),document.body):null}export{L as M};
//# sourceMappingURL=Modal-B8aWFZt2.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{d as m,a as x,s as h,g as j,j as s,I as i}from"./index-BTPmxtwM.js";function u(){const{toast:t}=m(),{data:o=[]}=x(h("notifications")),n=j("notifications"),l=a=>n(e=>e.map((c,r)=>r===a?{...c,unread:!1}:c)),d=()=>{n(a=>a.map(e=>({...e,unread:!1}))),t("All notifications marked as read","success")};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:"Notifications"}),s.jsx("p",{className:"page-sub",children:"Stay on top of hiring activity"})]}),s.jsxs("div",{className:"page-head-actions",children:[s.jsxs("button",{className:"btn btn-secondary",onClick:d,children:[s.jsx(i,{name:"check"})," Mark all read"]}),s.jsx("button",{className:"btn btn-ghost",onClick:()=>t("Notification settings","info"),children:s.jsx(i,{name:"more"})})]})]}),s.jsx("div",{className:"card",children:s.jsx("div",{className:"list-tight",style:{padding:0},children:o.map((a,e)=>s.jsxs("div",{className:`notif-row${a.unread?" unread":""}`,onClick:()=>l(e),children:[s.jsx("span",{className:`notif-icn ${a.color}`,children:s.jsx(i,{name:a.icon})}),s.jsxs("div",{className:"notif-body",children:[s.jsx("div",{className:"notif-title",children:a.title}),s.jsx("div",{className:"notif-text",children:a.text}),s.jsx("div",{className:"notif-time",children:a.time})]}),a.unread&&s.jsx("span",{className:"dot dot-blue",style:{position:"static",border:"none",alignSelf:"center"}})]},a.id??`${a.title}-${e}`))})})]})}export{u as default};
//# sourceMappingURL=Notifications-C-babC9D.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"Notifications-C-babC9D.js","sources":["../../src/screens/Notifications.jsx"],"sourcesContent":["import { useQuery } from '@tanstack/react-query'\r\nimport { Icon } from '../ui/primitives'\r\nimport { useToast } from '../ui/Toast'\r\nimport { seedQuery, useSeedMutation } from '../data/seedQueries'\r\n\r\nexport default function Notifications() {\r\n const { toast } = useToast()\r\n const { data: notifications = [] } = useQuery(seedQuery('notifications'))\r\n const update = useSeedMutation('notifications')\r\n\r\n // Marking one read used to be `this.classList.remove('unread')` — a DOM edit\r\n // the badge count never saw. Writing to the cache keeps the sidebar in sync.\r\n const markOne = (i) => update((ns) => ns.map((n, j) => (j === i ? { ...n, unread: false } : n)))\r\n const markAll = () => {\r\n update((ns) => ns.map((n) => ({ ...n, unread: false })))\r\n toast('All notifications marked as read', 'success')\r\n }\r\n\r\n return (\r\n <div className=\"page\">\r\n <div className=\"page-head\">\r\n <div>\r\n <h1 className=\"page-title\">Notifications</h1>\r\n <p className=\"page-sub\">Stay on top of hiring activity</p>\r\n </div>\r\n <div className=\"page-head-actions\">\r\n <button className=\"btn btn-secondary\" onClick={markAll}><Icon name=\"check\" /> Mark all read</button>\r\n <button className=\"btn btn-ghost\" onClick={() => toast('Notification settings', 'info')}>\r\n <Icon name=\"more\" />\r\n </button>\r\n </div>\r\n </div>\r\n\r\n <div className=\"card\">\r\n <div className=\"list-tight\" style={{ padding: 0 }}>\r\n {notifications.map((n, i) => (\r\n <div\r\n key={n.id ?? `${n.title}-${i}`}\r\n className={`notif-row${n.unread ? ' unread' : ''}`}\r\n onClick={() => markOne(i)}\r\n >\r\n <span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>\r\n <div className=\"notif-body\">\r\n <div className=\"notif-title\">{n.title}</div>\r\n <div className=\"notif-text\">{n.text}</div>\r\n <div className=\"notif-time\">{n.time}</div>\r\n </div>\r\n {n.unread && (\r\n <span className=\"dot dot-blue\" style={{ position: 'static', border: 'none', alignSelf: 'center' }} />\r\n )}\r\n </div>\r\n ))}\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n}\r\n"],"names":["Notifications","toast","useToast","notifications","useQuery","seedQuery","update","useSeedMutation","markOne","i","ns","n","j","markAll","jsxs","jsx","Icon"],"mappings":"2EAKA,SAAwBA,GAAgB,CACtC,KAAM,CAAE,MAAAC,CAAA,EAAUC,EAAA,EACZ,CAAE,KAAMC,EAAgB,CAAA,GAAOC,EAASC,EAAU,eAAe,CAAC,EAClEC,EAASC,EAAgB,eAAe,EAIxCC,EAAWC,GAAMH,EAAQI,GAAOA,EAAG,IAAI,CAACC,EAAGC,IAAOA,IAAMH,EAAI,CAAE,GAAGE,EAAG,OAAQ,EAAA,EAAUA,CAAE,CAAC,EACzFE,EAAU,IAAM,CACpBP,EAAQI,GAAOA,EAAG,IAAKC,IAAO,CAAE,GAAGA,EAAG,OAAQ,EAAA,EAAQ,CAAC,EACvDV,EAAM,mCAAoC,SAAS,CACrD,EAEA,OACEa,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,gBAAa,EACxCA,EAAAA,IAAC,IAAA,CAAE,UAAU,WAAW,SAAA,gCAAA,CAA8B,CAAA,EACxD,EACAD,EAAAA,KAAC,MAAA,CAAI,UAAU,oBACb,SAAA,CAAAA,EAAAA,KAAC,SAAA,CAAO,UAAU,oBAAoB,QAASD,EAAS,SAAA,CAAAE,EAAAA,IAACC,EAAA,CAAK,KAAK,OAAA,CAAQ,EAAE,gBAAA,EAAc,EAC3FD,EAAAA,IAAC,SAAA,CAAO,UAAU,gBAAgB,QAAS,IAAMd,EAAM,wBAAyB,MAAM,EACpF,SAAAc,MAACC,EAAA,CAAK,KAAK,OAAO,CAAA,CACpB,CAAA,CAAA,CACF,CAAA,EACF,QAEC,MAAA,CAAI,UAAU,OACb,SAAAD,EAAAA,IAAC,OAAI,UAAU,aAAa,MAAO,CAAE,QAAS,CAAA,EAC3C,WAAc,IAAI,CAACJ,EAAGF,IACrBK,EAAAA,KAAC,MAAA,CAEC,UAAW,YAAYH,EAAE,OAAS,UAAY,EAAE,GAChD,QAAS,IAAMH,EAAQC,CAAC,EAExB,SAAA,CAAAM,EAAAA,IAAC,OAAA,CAAK,UAAW,aAAaJ,EAAE,KAAK,GAAI,SAAAI,EAAAA,IAACC,EAAA,CAAK,KAAML,EAAE,IAAA,CAAM,EAAE,EAC/DG,EAAAA,KAAC,MAAA,CAAI,UAAU,aACb,SAAA,CAAAC,EAAAA,IAAC,MAAA,CAAI,UAAU,cAAe,SAAAJ,EAAE,MAAM,EACtCI,EAAAA,IAAC,MAAA,CAAI,UAAU,aAAc,WAAE,KAAK,EACpCA,EAAAA,IAAC,MAAA,CAAI,UAAU,aAAc,WAAE,IAAA,CAAK,CAAA,EACtC,EACCJ,EAAE,QACDI,EAAAA,IAAC,OAAA,CAAK,UAAU,eAAe,MAAO,CAAE,SAAU,SAAU,OAAQ,OAAQ,UAAW,SAAS,CAAG,CAAA,CAAA,EAXhGJ,EAAE,IAAM,GAAGA,EAAE,KAAK,IAAIF,CAAC,EAAA,CAc/B,EACH,CAAA,CACF,CAAA,EACF,CAEJ"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{d as A,u as D,a as p,s as v,g as C,r,j as e,I,A as y,S as T}from"./index-BTPmxtwM.js";const j=[{name:"Applied",color:"var(--stage-1)"},{name:"Screening",color:"var(--stage-2)"},{name:"Assessment",color:"var(--stage-3)"},{name:"Interview",color:"var(--stage-4)"},{name:"Offer",color:"var(--stage-5)"},{name:"Hired",color:"var(--stage-6)"},{name:"Rejected",color:"var(--stage-7)"}];function O(){const{toast:h}=A(),u=D(),{data:l=[]}=p(v("candidates")),{data:x=[]}=p(v("jobs")),N=C("candidates"),[d,f]=r.useState(""),[c,o]=r.useState(null),[b,i]=r.useState(null),g=r.useMemo(()=>d?l.filter(a=>a.jobId===d):l,[l,d]),k=r.useMemo(()=>{const a=Object.fromEntries(j.map(n=>[n.name,[]]));for(const n of g)a[n.stage]&&a[n.stage].push(n);return a},[g]);function S(a){i(null);const n=c;if(o(null),!n)return;const s=l.find(t=>t.id===n);!s||s.stage===a||(N(t=>t.map(m=>m.id===n?{...m,stage:a,status:a}:m)),h(`${s.name} moved to ${a}`,"success"))}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:"Pipeline"}),e.jsx("p",{className:"page-sub",children:"Drag candidates between stages to update their status"})]}),e.jsxs("div",{className:"page-head-actions",children:[e.jsxs("select",{className:"select",value:d,onChange:a=>f(a.target.value),children:[e.jsx("option",{value:"",children:"All Jobs"}),x.filter(a=>a.status==="Open").map(a=>e.jsx("option",{value:a.id,children:a.title},a.id))]}),e.jsxs("button",{className:"btn btn-primary",onClick:()=>u("/candidates",{state:{openAdd:!0}}),children:[e.jsx(I,{name:"plus"})," Add Candidate"]})]})]}),e.jsx("div",{className:"kanban",children:j.map(a=>{const n=k[a.name]??[];return e.jsxs("div",{className:"kanban-col",children:[e.jsxs("div",{className:"kanban-col-head",children:[e.jsx("span",{className:"k-dot",style:{background:a.color}}),e.jsx("h4",{children:a.name}),e.jsx("span",{className:"k-count",children:n.length})]}),e.jsx("div",{className:`kanban-cards${b===a.name?" drag-over":""}`,onDragOver:s=>{s.preventDefault(),i(a.name)},onDragLeave:()=>i(s=>s===a.name?null:s),onDrop:s=>{s.preventDefault(),S(a.name)},children:n.map(s=>e.jsxs("div",{className:`k-card${c===s.id?" dragging":""}`,draggable:!0,onDragStart:t=>{o(s.id),t.dataTransfer.effectAllowed="move",t.dataTransfer.setData("text/plain",s.id)},onDragEnd:()=>{o(null),i(null)},onClick:()=>{c||u("/candidates",{state:{openCandidate:s.id}})},children:[e.jsxs("div",{className:"k-card-top",children:[e.jsx(y,{name:s.name,initials:s.initials,color:s.color}),e.jsxs("div",{children:[e.jsx("div",{className:"kc-name",children:s.name}),e.jsx("div",{className:"kc-role",children:s.currentTitle})]})]}),e.jsx("div",{className:"kc-role",children:s.jobTitle}),e.jsx("div",{className:"k-tags",children:s.skills.slice(0,3).map(t=>e.jsx("span",{className:"tag",children:t},t))}),e.jsxs("div",{className:"k-card-meta",children:[e.jsx("span",{className:"cell-sub",children:s.currentCompany}),e.jsx(T,{score:s.aiScore})]})]},s.id))})]},a.name)})})]})}export{j as KANBAN_STAGES,O as default};
//# sourceMappingURL=Pipeline-Bhzm15UM.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{r as u,j as e}from"./index-BTPmxtwM.js";function d({tabs:n,value:c,onChange:o,className:r="tabs"}){const l=u.useId();return e.jsx("div",{className:r,role:"tablist",children:n.map(s=>{const a=s.key??s,i=s.label??s,t=a===c;return e.jsxs("button",{id:`${l}-${a}`,role:"tab","aria-selected":t,className:`tab${t?" active":""}`,onClick:()=>o(a),children:[i,s.count!=null&&e.jsx("span",{className:"tab-count",children:s.count})]},a)})})}export{d as T};
//# sourceMappingURL=Tabs-DVZeUemd.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"Tabs-DVZeUemd.js","sources":["../../src/ui/Tabs.jsx"],"sourcesContent":["/* ============================================================\r\n Tabs.jsx — new primitive.\r\n\r\n js/ui.js had no tab component, so settings (10 tabs), candidates (8), inbox\r\n (7) and rbac each hand-rolled one by pre-rendering every pane and toggling\r\n `.active`. One component replaces four ad-hoc implementations, and only the\r\n active pane is mounted — which also means a chart in a hidden pane no longer\r\n draws into a zero-width canvas.\r\n ============================================================ */\r\n\r\nimport { useId, useState } from 'react'\r\n\r\nexport function Tabs({ tabs, value, onChange, className = 'tabs' }) {\r\n const id = useId()\r\n return (\r\n <div className={className} role=\"tablist\">\r\n {tabs.map((t) => {\r\n const key = t.key ?? t\r\n const label = t.label ?? t\r\n const active = key === value\r\n return (\r\n <button\r\n key={key}\r\n id={`${id}-${key}`}\r\n role=\"tab\"\r\n aria-selected={active}\r\n className={`tab${active ? ' active' : ''}`}\r\n onClick={() => onChange(key)}\r\n >\r\n {label}\r\n {t.count != null && <span className=\"tab-count\">{t.count}</span>}\r\n </button>\r\n )\r\n })}\r\n </div>\r\n )\r\n}\r\n\r\n/** Uncontrolled convenience wrapper: <TabPanel tabs={[{key,label,render}]} /> */\r\nexport default function TabPanel({ tabs, initial, className }) {\r\n const [value, setValue] = useState(initial ?? tabs[0]?.key)\r\n const active = tabs.find((t) => t.key === value) ?? tabs[0]\r\n return (\r\n <>\r\n <Tabs tabs={tabs} value={value} onChange={setValue} className={className} />\r\n <div role=\"tabpanel\">{active?.render?.()}</div>\r\n </>\r\n )\r\n}\r\n"],"names":["Tabs","tabs","value","onChange","className","id","useId","jsx","t","key","label","active","jsxs"],"mappings":"+CAYO,SAASA,EAAK,CAAE,KAAAC,EAAM,MAAAC,EAAO,SAAAC,EAAU,UAAAC,EAAY,QAAU,CAClE,MAAMC,EAAKC,EAAAA,MAAA,EACX,OACEC,MAAC,OAAI,UAAAH,EAAsB,KAAK,UAC7B,SAAAH,EAAK,IAAKO,GAAM,CACf,MAAMC,EAAMD,EAAE,KAAOA,EACfE,EAAQF,EAAE,OAASA,EACnBG,EAASF,IAAQP,EACvB,OACEU,EAAAA,KAAC,SAAA,CAEC,GAAI,GAAGP,CAAE,IAAII,CAAG,GAChB,KAAK,MACL,gBAAeE,EACf,UAAW,MAAMA,EAAS,UAAY,EAAE,GACxC,QAAS,IAAMR,EAASM,CAAG,EAE1B,SAAA,CAAAC,EACAF,EAAE,OAAS,MAAQD,EAAAA,IAAC,QAAK,UAAU,YAAa,WAAE,KAAA,CAAM,CAAA,CAAA,EARpDE,CAAA,CAWX,CAAC,CAAA,CACH,CAEJ"}

View File

@ -1,2 +0,0 @@
import{d as P,a as S,s as F,g as R,r as u,j as e,I as v,Q as O,E as f,h as _,A as k,S as D,B as I,y as M,z as q,q as B}from"./index-BTPmxtwM.js";import{t as L,A as $,C as H,l as Q}from"./Candidates-DDm6mZmg.js";import"./useMutation-CMO99S-s.js";import"./Modal-B8aWFZt2.js";import"./DataTable-D5imKbZq.js";import"./Tabs-DVZeUemd.js";import"./jobPosts-CNShlpwX.js";const T=100,y=["Applied","Screening","Assessment","Interview","Offer","Hired"],w={PENDING:"Applied",CLOSED:"Applied",PROCESS:"Screening",ONHOLD:"Screening",APPROVED:"Hired",REJECTED:"Rejected"};function G(t){const a=parseInt(t,10);return Number.isFinite(a)?a:null}function z(t,a){const r=t.name||a.name,i=(t.job_posts||[]).map(h=>h.title).find(Boolean),o=w[t.application_status]||a.stage,d=G(t.experience);return{...a,userId:t.user_id,name:r,initials:q(r),color:M(r),email:t.email||a.email,experience:d??a.experience,stage:o,status:o,currentTitle:i||a.currentTitle,jobTitle:i||a.jobTitle}}function J(t,a){if(!a.length)return[];const r=new Map;for(const i of t){const o=i.user_id??`inbox-${i.inbox_id}`;r.has(o)||r.set(o,i)}return[...r.values()].map((i,o)=>z(i,a[o%a.length]))}function ee(){const{toast:t}=P(),{data:a=[]}=S(F("candidates")),r=R("candidates"),[i,o]=u.useState(""),[d,h]=u.useState(""),[N,c]=u.useState(null),[b,x]=u.useState(null),p=S({queryKey:B.candidates.list({limit:T}),queryFn:()=>Q({limit:T})}),g=u.useMemo(()=>J(L(p.data),a),[p.data,a]),C=u.useMemo(()=>g.filter(s=>!(d&&s.department!==d||i&&!(s.name+s.currentCompany+s.skills.join(" ")).toLowerCase().includes(i.toLowerCase()))),[g,i,d]);function A(s){r(n=>n.map(l=>l.id===s.id?{...l,favorite:!l.favorite}:l)),c(n=>n&&n.id===s.id?{...n,favorite:!n.favorite}:n),t(s.favorite?"Removed from favorites":`${s.name} added to favorites`,"success")}function E(s){const n=y.indexOf(s.stage);if(n===-1||n>=y.length-1){t(`${s.name} cannot be advanced further`,"warning");return}const l=y[n+1];r(m=>m.map(j=>j.id===s.id?{...j,stage:l,status:l}:j)),c(m=>m&&m.id===s.id?{...m,stage:l,status:l}:m),t(`${s.name} moved to ${l}`,"success")}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:"Talent Pool"}),e.jsxs("p",{className:"page-sub",children:[g.length," silver-medalists & passive candidates to re-engage"]})]}),e.jsx("div",{className:"page-head-actions",children:e.jsxs("button",{className:"btn btn-primary",onClick:()=>t("Talent campaign created","success"),children:[e.jsx(v,{name:"send"})," Start Campaign"]})})]}),e.jsx("div",{className:"card mb-18",children:e.jsx("div",{className:"card-body",style:{padding:16},children:e.jsxs("div",{className:"toolbar",style:{marginBottom:0},children:[e.jsxs("div",{className:"toolbar-search",children:[e.jsx(v,{name:"search"}),e.jsx("input",{value:i,onChange:s=>o(s.target.value),placeholder:"Search by name, skill, company…"})]}),e.jsxs("select",{className:"select",value:d,onChange:s=>h(s.target.value),children:[e.jsx("option",{value:"",children:"All Departments"}),O.map(s=>e.jsx("option",{children:s},s))]})]})})}),e.jsx("div",{className:"grid g-3",children:C.length===0?e.jsx("div",{style:{gridColumn:"1/-1"},children:p.isError?e.jsx(f,{title:"Could not load talent pool",children:_(p.error,"Please try again.")}):p.isPending?e.jsx(f,{title:"Loading talent pool…",children:"Fetching candidates."}):e.jsx(f,{title:"No talent found",children:"Try a different search or department."})}):C.map(s=>e.jsx("div",{className:"card",style:{cursor:"pointer"},onClick:()=>c(s),children:e.jsxs("div",{className:"card-body",children:[e.jsxs("div",{className:"flex items-center gap-12",style:{marginBottom:12},children:[e.jsx(k,{name:s.name,initials:s.initials,color:s.color,className:"avatar-lg"}),e.jsxs("div",{style:{flex:1,minWidth:0},children:[e.jsx("div",{className:"lr-title",children:s.name}),e.jsx("div",{className:"lr-sub",children:s.currentTitle})]}),e.jsx(D,{score:s.aiScore})]}),e.jsx("div",{className:"k-tags",style:{marginBottom:12},children:s.skills.slice(0,4).map(n=>e.jsx("span",{className:"tag",children:n},n))}),e.jsx("div",{className:"divider",style:{margin:"12px 0"}}),e.jsxs("div",{className:"flex items-center",style:{justifyContent:"space-between"},children:[e.jsxs("span",{className:"cell-sub",children:[e.jsx(v,{name:"briefcase"})," ",s.experience," yrs"]}),e.jsx("span",{className:"cell-sub",children:s.currentCompany}),e.jsx(I,{className:"b-gray",children:s.source})]})]})},s.id))}),b&&e.jsx($,{candidate:b,onClose:()=>x(null),onProfile:s=>{x(null),c(s)}}),N&&e.jsx(H,{candidate:N,onClose:()=>c(null),onAdvance:E,onToggleFav:A,onAtsMatch:s=>{c(null),x(s)}})]})}export{ee as default};
//# sourceMappingURL=TalentPool-CbXp920L.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{a1 as i}from"./index-BTPmxtwM.js";function f({search:a,top:e,skip:t,ids:r,activeOnly:o=!0}={}){const n=Array.isArray(r)?r.filter(Boolean).join(","):r;return i("/job/fetch",{params:{search:a,top:e,skip:t,ids:n||void 0,active_only:o}})}export{f as l};
//# sourceMappingURL=jobPosts-CNShlpwX.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"jobPosts-CNShlpwX.js","sources":["../../src/api/jobPosts.js"],"sourcesContent":["import { request } from '../lib/apiClient'\r\n\r\n/**\r\n * Active job posts — Job Matching hydrates suggestions and the manual picker.\r\n *\r\n * Permissioned with job_board.view (not jobs.*). `ids` is a comma-joined list\r\n * so one round trip can resolve a whole suggestion rail.\r\n */\r\nexport function list({ search, top, skip, ids, activeOnly = true } = {}) {\r\n const idParam = Array.isArray(ids) ? ids.filter(Boolean).join(',') : ids\r\n return request('/job/fetch', {\r\n params: {\r\n search,\r\n top,\r\n skip,\r\n ids: idParam || undefined,\r\n active_only: activeOnly,\r\n },\r\n })\r\n}\r\n"],"names":["list","search","top","skip","ids","activeOnly","idParam","request"],"mappings":"yCAQO,SAASA,EAAK,CAAE,OAAAC,EAAQ,IAAAC,EAAK,KAAAC,EAAM,IAAAC,EAAK,WAAAC,EAAa,EAAI,EAAK,GAAI,CACvE,MAAMC,EAAU,MAAM,QAAQF,CAAG,EAAIA,EAAI,OAAO,OAAO,EAAE,KAAK,GAAG,EAAIA,EACrE,OAAOG,EAAQ,aAAc,CAC3B,OAAQ,CACN,OAAAN,EACA,IAAAC,EACA,KAAAC,EACA,IAAKG,GAAW,OAChB,YAAaD,CACnB,CACA,CAAG,CACH"}

View File

@ -1,2 +0,0 @@
import{a1 as e}from"./index-BTPmxtwM.js";function o(){return e("/roles/fetch")}function s(r){return e("/roles/create",{method:"POST",body:r})}export{s as c,o as l};
//# sourceMappingURL=roles-DSy_gQji.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"roles-DSy_gQji.js","sources":["../../src/api/roles.js"],"sourcesContent":["import { request } from '../lib/apiClient'\r\n\r\n/** Roles with their expanded `bundles` and resolved `effective_permissions`. */\r\nexport function listRoles() {\r\n return request('/roles/fetch')\r\n}\r\nexport function createRole(body) {\r\n return request('/roles/create', { method: 'POST', body })\r\n}\r\nexport function updateRole(recordId, body) {\r\n return request('/roles/update', { method: 'PUT', params: { record_id: recordId }, body })\r\n}\r\nexport function deleteRole(recordId) {\r\n return request('/roles/delete', { method: 'DELETE', params: { record_id: recordId } })\r\n}\r\n\r\n/** Permission bundles (41 seeded), each resolving to a set of tag names. */\r\nexport function listPermissions() {\r\n return request('/permissions/fetch')\r\n}\r\nexport function createPermission(body) {\r\n return request('/permissions/create', { method: 'POST', body })\r\n}\r\nexport function updatePermission(recordId, body) {\r\n return request('/permissions/update', { method: 'PUT', params: { record_id: recordId }, body })\r\n}\r\n\r\n/** The 104-tag catalog: 13 modules x 8 actions. */\r\nexport function listPermissionTags() {\r\n return request('/permission-tags/fetch')\r\n}\r\n"],"names":["listRoles","request","createRole","body"],"mappings":"yCAGO,SAASA,GAAY,CAC1B,OAAOC,EAAQ,cAAc,CAC/B,CACO,SAASC,EAAWC,EAAM,CAC/B,OAAOF,EAAQ,gBAAiB,CAAE,OAAQ,OAAQ,KAAAE,CAAI,CAAE,CAC1D"}

View File

@ -1,2 +0,0 @@
var R=i=>{throw TypeError(i)};var E=(i,t,s)=>t.has(i)||R("Cannot "+s);var e=(i,t,s)=>(E(i,t,"read from private field"),s?s.call(i):t.get(i)),b=(i,t,s)=>t.has(i)?R("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(i):t.set(i,s),p=(i,t,s,r)=>(E(i,t,"write to private field"),r?r.call(i,s):t.set(i,s),s),y=(i,t,s)=>(E(i,t,"access private method"),s);import{ao as q,ap as U,aq as j,ar as k,as as P,e as L,r as v,at as A,au as D}from"./index-BTPmxtwM.js";var a,c,o,h,n,C,S,w,I=(w=class extends q{constructor(t,s){super();b(this,n);b(this,a);b(this,c);b(this,o);b(this,h);p(this,a,t),this.setOptions(s),this.bindMethods(),y(this,n,C).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(t){var r;const s=this.options;this.options=e(this,a).defaultMutationOptions(t),U(this.options,s)||e(this,a).getMutationCache().notify({type:"observerOptionsUpdated",mutation:e(this,o),observer:this}),s!=null&&s.mutationKey&&this.options.mutationKey&&j(s.mutationKey)!==j(this.options.mutationKey)?this.reset():((r=e(this,o))==null?void 0:r.state.status)==="pending"&&e(this,o).setOptions(this.options)}onUnsubscribe(){var t;this.hasListeners()||(t=e(this,o))==null||t.removeObserver(this)}onMutationUpdate(t){y(this,n,C).call(this),y(this,n,S).call(this,t)}getCurrentResult(){return e(this,c)}reset(){var t;(t=e(this,o))==null||t.removeObserver(this),p(this,o,void 0),y(this,n,C).call(this),y(this,n,S).call(this)}mutate(t,s){var r;return p(this,h,s),(r=e(this,o))==null||r.removeObserver(this),p(this,o,e(this,a).getMutationCache().build(e(this,a),this.options)),e(this,o).addObserver(this),e(this,o).execute(t)}},a=new WeakMap,c=new WeakMap,o=new WeakMap,h=new WeakMap,n=new WeakSet,C=function(){var s;const t=((s=e(this,o))==null?void 0:s.state)??k();p(this,c,{...t,isPending:t.status==="pending",isSuccess:t.status==="success",isError:t.status==="error",isIdle:t.status==="idle",mutate:this.mutate,reset:this.reset})},S=function(t){P.batch(()=>{var s,r,u,f,d,O,x,K;if(e(this,h)&&this.hasListeners()){const m=e(this,c).variables,M=e(this,c).context,g={client:e(this,a),meta:this.options.meta,mutationKey:this.options.mutationKey};if((t==null?void 0:t.type)==="success"){try{(r=(s=e(this,h)).onSuccess)==null||r.call(s,t.data,m,M,g)}catch(l){Promise.reject(l)}try{(f=(u=e(this,h)).onSettled)==null||f.call(u,t.data,null,m,M,g)}catch(l){Promise.reject(l)}}else if((t==null?void 0:t.type)==="error"){try{(O=(d=e(this,h)).onError)==null||O.call(d,t.error,m,M,g)}catch(l){Promise.reject(l)}try{(K=(x=e(this,h)).onSettled)==null||K.call(x,void 0,t.error,m,M,g)}catch(l){Promise.reject(l)}}}this.listeners.forEach(m=>{m(e(this,c))})})},w);function z(i,t){const s=L(),[r]=v.useState(()=>new I(s,i));v.useEffect(()=>{r.setOptions(i)},[r,i]);const u=v.useSyncExternalStore(v.useCallback(d=>r.subscribe(P.batchCalls(d)),[r]),()=>r.getCurrentResult(),()=>r.getCurrentResult()),f=v.useCallback((d,O)=>{r.mutate(d,O).catch(A)},[r]);if(u.error&&D(r.options.throwOnError,[u.error]))throw u.error;return{...u,mutate:f,mutateAsync:u.mutate}}export{z as u};
//# sourceMappingURL=useMutation-CMO99S-s.js.map

File diff suppressed because one or more lines are too long

View File

@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-BTPmxtwM.js"></script>
<script type="module" crossorigin src="/assets/index-D5Q45_Qc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>

View File

@ -6,17 +6,17 @@ import {
mergeProps,
render,
setupStyleSheet
} from "./chunk-KZEKDZA2.js";
} from "./chunk-TLA2YRDJ.js";
import {
onlineManager,
useQueryClient
} from "./chunk-BMD7DZVU.js";
} from "./chunk-Y5A5GOKP.js";
import {
require_jsx_runtime
} from "./chunk-IBN7FD72.js";
} from "./chunk-4N5S3525.js";
import {
require_react
} from "./chunk-5PE4DLTS.js";
} from "./chunk-6333SFGK.js";
import {
__privateAdd,
__privateGet,
@ -108,7 +108,7 @@ var TanstackQueryDevtools = (_a = class {
if (__privateGet(this, _Component)) {
Devtools = __privateGet(this, _Component);
} else {
Devtools = lazy(() => import("./SO26Z5QU-LP72JTJM.js"));
Devtools = lazy(() => import("./SO26Z5QU-ZOVSYIPF.js"));
__privateSet(this, _Component, Devtools);
}
setupStyleSheet(__privateGet(this, _styleNonce), __privateGet(this, _shadowDOMTarget));
@ -248,7 +248,7 @@ var TanstackQueryDevtoolsPanel = (_a2 = class {
if (__privateGet(this, _Component2)) {
Devtools = __privateGet(this, _Component2);
} else {
Devtools = lazy(() => import("./MYKLHYJZ-6Q5OZ5GF.js"));
Devtools = lazy(() => import("./MYKLHYJZ-QSK7XAY4.js"));
__privateSet(this, _Component2, Devtools);
}
setupStyleSheet(__privateGet(this, _styleNonce2), __privateGet(this, _shadowDOMTarget2));

File diff suppressed because one or more lines are too long

View File

@ -57,9 +57,9 @@ import {
useSuspenseInfiniteQuery,
useSuspenseQueries,
useSuspenseQuery
} from "./chunk-BMD7DZVU.js";
import "./chunk-IBN7FD72.js";
import "./chunk-5PE4DLTS.js";
} from "./chunk-Y5A5GOKP.js";
import "./chunk-4N5S3525.js";
import "./chunk-6333SFGK.js";
import "./chunk-QWN5BXRD.js";
export {
CancelledError,

View File

@ -1,62 +0,0 @@
import {
ContentView,
ParentPanel,
PiPProvider,
QueryDevtoolsContext,
THEME_PREFERENCE,
ThemeContext,
createLocalStorage
} from "./chunk-OZGH6CAY.js";
import {
createComponent,
createMemo,
getPreferredColorScheme
} from "./chunk-KZEKDZA2.js";
import "./chunk-QWN5BXRD.js";
// node_modules/@tanstack/query-devtools/build/DevtoolsPanelComponent/MYKLHYJZ.js
var DevtoolsPanelComponent = (props) => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: "TanstackQueryDevtools"
});
const colorScheme = getPreferredColorScheme();
const theme = createMemo(() => {
const preference = props.theme || localStore.theme_preference || THEME_PREFERENCE;
if (preference !== "system") return preference;
return colorScheme();
});
return createComponent(QueryDevtoolsContext.Provider, {
value: props,
get children() {
return createComponent(PiPProvider, {
disabled: true,
localStore,
setLocalStore,
get children() {
return createComponent(ThemeContext.Provider, {
value: theme,
get children() {
return createComponent(ParentPanel, {
get children() {
return createComponent(ContentView, {
localStore,
setLocalStore,
get onClose() {
return props.onClose;
},
showPanelViewOnly: true
});
}
});
}
});
}
});
}
});
};
var DevtoolsPanelComponent_default = DevtoolsPanelComponent;
export {
DevtoolsPanelComponent_default as default
};
//# sourceMappingURL=MYKLHYJZ-6Q5OZ5GF.js.map

View File

@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../@tanstack/query-devtools/build/DevtoolsPanelComponent/MYKLHYJZ.js"],
"sourcesContent": ["import { createLocalStorage, THEME_PREFERENCE, QueryDevtoolsContext, PiPProvider, ThemeContext, ParentPanel, ContentView } from '../chunk/OJ5GAW4I.js';\nimport { getPreferredColorScheme, createMemo, createComponent } from '../chunk/M4GWWVAC.js';\n\n// src/DevtoolsPanelComponent.tsx\nvar DevtoolsPanelComponent = (props) => {\n const [localStore, setLocalStore] = createLocalStorage({\n prefix: \"TanstackQueryDevtools\"\n });\n const colorScheme = getPreferredColorScheme();\n const theme = createMemo(() => {\n const preference = props.theme || localStore.theme_preference || THEME_PREFERENCE;\n if (preference !== \"system\") return preference;\n return colorScheme();\n });\n return createComponent(QueryDevtoolsContext.Provider, {\n value: props,\n get children() {\n return createComponent(PiPProvider, {\n disabled: true,\n localStore,\n setLocalStore,\n get children() {\n return createComponent(ThemeContext.Provider, {\n value: theme,\n get children() {\n return createComponent(ParentPanel, {\n get children() {\n return createComponent(ContentView, {\n localStore,\n setLocalStore,\n get onClose() {\n return props.onClose;\n },\n showPanelViewOnly: true\n });\n }\n });\n }\n });\n }\n });\n }\n });\n};\nvar DevtoolsPanelComponent_default = DevtoolsPanelComponent;\n\nexport { DevtoolsPanelComponent_default as default };\n"],
"mappings": ";;;;;;;;;;;;;;;;;AAIA,IAAI,yBAAyB,CAAC,UAAU;AACtC,QAAM,CAAC,YAAY,aAAa,IAAI,mBAAmB;AAAA,IACrD,QAAQ;AAAA,EACV,CAAC;AACD,QAAM,cAAc,wBAAwB;AAC5C,QAAM,QAAQ,WAAW,MAAM;AAC7B,UAAM,aAAa,MAAM,SAAS,WAAW,oBAAoB;AACjE,QAAI,eAAe,SAAU,QAAO;AACpC,WAAO,YAAY;AAAA,EACrB,CAAC;AACD,SAAO,gBAAgB,qBAAqB,UAAU;AAAA,IACpD,OAAO;AAAA,IACP,IAAI,WAAW;AACb,aAAO,gBAAgB,aAAa;AAAA,QAClC,UAAU;AAAA,QACV;AAAA,QACA;AAAA,QACA,IAAI,WAAW;AACb,iBAAO,gBAAgB,aAAa,UAAU;AAAA,YAC5C,OAAO;AAAA,YACP,IAAI,WAAW;AACb,qBAAO,gBAAgB,aAAa;AAAA,gBAClC,IAAI,WAAW;AACb,yBAAO,gBAAgB,aAAa;AAAA,oBAClC;AAAA,oBACA;AAAA,oBACA,IAAI,UAAU;AACZ,6BAAO,MAAM;AAAA,oBACf;AAAA,oBACA,mBAAmB;AAAA,kBACrB,CAAC;AAAA,gBACH;AAAA,cACF,CAAC;AAAA,YACH;AAAA,UACF,CAAC;AAAA,QACH;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AACA,IAAI,iCAAiC;",
"names": []
}

View File

@ -1,52 +0,0 @@
import {
Devtools,
PiPProvider,
QueryDevtoolsContext,
THEME_PREFERENCE,
ThemeContext,
createLocalStorage
} from "./chunk-OZGH6CAY.js";
import {
createComponent,
createMemo,
getPreferredColorScheme
} from "./chunk-KZEKDZA2.js";
import "./chunk-QWN5BXRD.js";
// node_modules/@tanstack/query-devtools/build/DevtoolsComponent/SO26Z5QU.js
var DevtoolsComponent = (props) => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: "TanstackQueryDevtools"
});
const colorScheme = getPreferredColorScheme();
const theme = createMemo(() => {
const preference = props.theme || localStore.theme_preference || THEME_PREFERENCE;
if (preference !== "system") return preference;
return colorScheme();
});
return createComponent(QueryDevtoolsContext.Provider, {
value: props,
get children() {
return createComponent(PiPProvider, {
localStore,
setLocalStore,
get children() {
return createComponent(ThemeContext.Provider, {
value: theme,
get children() {
return createComponent(Devtools, {
localStore,
setLocalStore
});
}
});
}
});
}
});
};
var DevtoolsComponent_default = DevtoolsComponent;
export {
DevtoolsComponent_default as default
};
//# sourceMappingURL=SO26Z5QU-LP72JTJM.js.map

Some files were not shown because too many files have changed in this diff Show More