Dashboard seems to work
parent
6e1ecb6d72
commit
29ee228eaa
|
|
@ -110,6 +110,7 @@ backend/
|
||||||
├── Dockerfile # image for the Taskiq worker / scheduler
|
├── Dockerfile # image for the Taskiq worker / scheduler
|
||||||
├── alembic.ini # generated by alembic_setup.py, not hand-written
|
├── alembic.ini # generated by alembic_setup.py, not hand-written
|
||||||
├── migrations/ # generated env.py + versions/
|
├── 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
|
├── LLM_CONTEXT_PROMPT.md # house-style prompt to paste into an LLM before editing
|
||||||
│
|
│
|
||||||
├── users/ # accounts, login, signup, RBAC enforcement
|
├── users/ # accounts, login, signup, RBAC enforcement
|
||||||
|
|
@ -117,10 +118,15 @@ backend/
|
||||||
├── forget_password/ # reset-code request → verify → new password
|
├── forget_password/ # reset-code request → verify → new password
|
||||||
├── notifications/ # email-confirmation tokens and mail
|
├── notifications/ # email-confirmation tokens and mail
|
||||||
├── inbox/ # mailbox sync, attachments, applications
|
├── inbox/ # mailbox sync, attachments, applications
|
||||||
|
├── analytics/ # dashboard KPIs + charts (views only — no tables)
|
||||||
|
├── offer/ # offers + offer_status_history
|
||||||
├── job/
|
├── job/
|
||||||
│ ├── app.py # routes for both sub-domains
|
│ ├── app.py # routes for both sub-domains
|
||||||
│ ├── job_post/ # job ads + Buffer publishing
|
│ ├── 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
|
├── agent/ # LangGraph CV → job-post matching agent
|
||||||
└── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task
|
└── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task
|
||||||
```
|
```
|
||||||
|
|
@ -602,6 +608,22 @@ python alembic_setup.py current
|
||||||
python alembic_setup.py head
|
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
|
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
|
migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is
|
||||||
excluded from autogenerate, as is anything outside the configured schemas.
|
excluded from autogenerate, as is anything outside the configured schemas.
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -30,6 +30,10 @@ SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
|
||||||
"mailer-daemon", "postmaster", "bounce")
|
"mailer-daemon", "postmaster", "bounce")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Inbox(SQLModel, table=True):
|
class Inbox(SQLModel, table=True):
|
||||||
__tablename__ = "inbox"
|
__tablename__ = "inbox"
|
||||||
|
|
||||||
|
|
@ -42,8 +46,12 @@ class Inbox(SQLModel, table=True):
|
||||||
message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
|
message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
|
||||||
messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox")
|
messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox")
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
# tz-AWARE, matching every other timestamp the analytics layer filters on.
|
||||||
updated_at: datetime = Field(default_factory=datetime.now)
|
# 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)
|
favorite: Optional[bool] = Field(default=False)
|
||||||
rating: Optional[float] = Field(default=0.0)
|
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))
|
result=await session.execute(select(cls).where(cls.id==iid))
|
||||||
return result.scalars().first()
|
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
|
@classmethod
|
||||||
async def get_inbox_by_message_id(cls,session:AsyncSession,message_id):
|
async def get_inbox_by_message_id(cls,session:AsyncSession,message_id):
|
||||||
try:
|
try:
|
||||||
|
|
@ -161,7 +183,7 @@ class Inbox(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
for key,value in fields.items():
|
for key,value in fields.items():
|
||||||
setattr(row,key,value)
|
setattr(row,key,value)
|
||||||
row.updated_at=datetime.now()
|
row.updated_at=_now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|
@ -175,7 +197,7 @@ class Inbox_Alerts(SQLModel, table=True):
|
||||||
alert_sender_name: str
|
alert_sender_name: str
|
||||||
alert_sender_email: str
|
alert_sender_email: str
|
||||||
is_read: bool = Field(default=False)
|
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")
|
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))
|
matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
|
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
|
||||||
candidate_education: str | None = Field(default=None)
|
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")
|
inbox: list[Inbox] = Relationship(back_populates="messages")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -533,3 +563,74 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return 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
|
||||||
|
|
|
||||||
|
|
@ -104,10 +104,13 @@ def serialize_application(message: Inbox_Messages) -> dict:
|
||||||
"match_status": message.match_status,
|
"match_status": message.match_status,
|
||||||
"match_error": message.match_error,
|
"match_error": message.match_error,
|
||||||
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
"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,
|
"phone": message.candidate_phone_number,
|
||||||
"experience": message.experience or "",
|
"experience": message.experience or "",
|
||||||
"current_employment": message.current_employment or "",
|
"current_employment": message.current_employment or "",
|
||||||
"recruiter": None,
|
"recruiter": str(message.recruiter_id) if message.recruiter_id else None,
|
||||||
"duplicate": None,
|
"duplicate": message.is_duplicate,
|
||||||
|
"processing_state": message.processing_state,
|
||||||
|
"source_channel_id": message.source_channel_id,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,32 @@ class ActivityLog:
|
||||||
return row
|
return row
|
||||||
return None
|
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:
|
if activity_id:
|
||||||
row=await Activity.get_activity_by_id(self.session,activity_id)
|
row=await Activity.get_activity_by_id(self.session,activity_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Activity not found")
|
raise HTTPException(status_code=404,detail="Activity not found")
|
||||||
return serialize_activity(row)
|
return serialize_activity(row)
|
||||||
if inbox_id is None:
|
if inbox_id is not 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))
|
||||||
rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id))
|
return [serialize_activity(r) for r in rows]
|
||||||
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):
|
async def create_activity(self,payload):
|
||||||
link=await self._resolve_inbox(payload)
|
link=await self._resolve_inbox(payload)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ from job.interviews.views import Interview
|
||||||
from job.notes.views import Note
|
from job.notes.views import Note
|
||||||
from job.activity.views import ActivityLog
|
from job.activity.views import ActivityLog
|
||||||
from job.feedback.views import FeedbackView
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, require_permission
|
||||||
from job.job_post.views import JobPost,JobPostCreate
|
from job.job_post.views import JobPost,JobPostCreate
|
||||||
|
|
@ -84,6 +87,32 @@ class FeedbackUpdate(BaseModel):
|
||||||
reviewed_by: UUID | None = None
|
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")
|
@router.get("/jobs/alias")
|
||||||
async def get_job_alias():
|
async def get_job_alias():
|
||||||
|
|
@ -294,13 +323,29 @@ async def update_candidate(
|
||||||
async def fetch_interview(
|
async def fetch_interview(
|
||||||
interview_id:str=Query(None),
|
interview_id:str=Query(None),
|
||||||
inbox_id:int=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)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Interview(session=session)
|
service=Interview(session=session)
|
||||||
data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id)
|
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):
|
||||||
total=1 if isinstance(data,dict) else len(data)
|
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})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -396,13 +441,21 @@ async def update_note(
|
||||||
async def fetch_activity(
|
async def fetch_activity(
|
||||||
activity_id:str=Query(None),
|
activity_id:str=Query(None),
|
||||||
inbox_id:int=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)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=ActivityLog(session=session)
|
service=ActivityLog(session=session)
|
||||||
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id)
|
if not activity_id and inbox_id is None and top is not None:
|
||||||
total=1 if isinstance(data,dict) else len(data)
|
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})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -475,3 +528,141 @@ async def update_feedback(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -2,7 +2,7 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime
|
from sqlalchemy import DateTime, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
|
|
@ -148,6 +148,34 @@ class Interviews(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
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
|
@classmethod
|
||||||
async def insert_interview(cls, session: AsyncSession, fields: dict):
|
async def insert_interview(cls, session: AsyncSession, fields: dict):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
@ -269,6 +297,19 @@ class Activity(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
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
|
@classmethod
|
||||||
async def insert_activity(cls, session: AsyncSession, fields: dict):
|
async def insert_activity(cls, session: AsyncSession, fields: dict):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
@ -353,4 +394,85 @@ class Feedback(SQLModel, table=True):
|
||||||
return row
|
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
|
import users.models as _users_models # noqa: E402, F401
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
def serialize_interview(row) -> dict:
|
def serialize_interview(row) -> dict:
|
||||||
|
inbox=getattr(row,"inbox",None)
|
||||||
|
user=getattr(inbox,"user",None) if inbox else None
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"inbox_id": row.inbox_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_time": row.interview_time.isoformat() if row.interview_time else None,
|
||||||
"interview_type": row.interview_type,
|
"interview_type": row.interview_type,
|
||||||
"interview_status": row.interview_status,
|
"interview_status": row.interview_status,
|
||||||
|
"candidate_name": user.name if user else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,31 @@ class Interview:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
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:
|
if interview_id:
|
||||||
row=await Interviews.get_interview_by_id(self.session,interview_id)
|
row=await Interviews.get_interview_by_id(self.session,interview_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Interview not found")
|
raise HTTPException(status_code=404,detail="Interview not found")
|
||||||
return serialize_interview(row)
|
return serialize_interview(row)
|
||||||
if inbox_id is None:
|
if inbox_id is not 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))
|
||||||
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
|
return [serialize_interview(r) for r in rows]
|
||||||
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):
|
async def create_interview(self,payload):
|
||||||
fields={
|
fields={
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@ class JobPosts(SQLModel, table=True):
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
title: str = Field(index=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(
|
user: Optional["Users"] = Relationship(
|
||||||
back_populates="job_posts",
|
back_populates="job_posts",
|
||||||
sa_relationship_kwargs={"lazy": "joined"},
|
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
|
||||||
)
|
)
|
||||||
|
|
||||||
platform: str = Field(default="linkedin")
|
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))
|
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
status: str = Field(default="draft")
|
status: str = Field(default="draft")
|
||||||
buffer_error: str | None = Field(default=None)
|
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_by: uuid.UUID = Field(foreign_key="users.id")
|
||||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=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))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ from role.app import router as role_router
|
||||||
from forget_password.app import router as forget_password_router
|
from forget_password.app import router as forget_password_router
|
||||||
from job.app import router as candidate_router
|
from job.app import router as candidate_router
|
||||||
from notifications.app import router as confirmation_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")
|
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||||
logger=logging.getLogger("main")
|
logger=logging.getLogger("main")
|
||||||
|
|
@ -79,3 +81,5 @@ app.include_router(role_router)
|
||||||
app.include_router(forget_password_router)
|
app.include_router(forget_password_router)
|
||||||
app.include_router(confirmation_router)
|
app.include_router(confirmation_router)
|
||||||
app.include_router(candidate_router)
|
app.include_router(candidate_router)
|
||||||
|
app.include_router(analytics_router)
|
||||||
|
app.include_router(offer_router)
|
||||||
|
|
|
||||||
|
|
@ -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 = '';
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class EnumRoles(str, Enum):
|
class EnumRoles(str, Enum):
|
||||||
"""Canonical keys for the eight seeded system roles. `Roles.role_name` is a varchar."""
|
"""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)
|
module: str = Field(max_length=32, nullable=False, index=True)
|
||||||
action: str = Field(max_length=32, nullable=False)
|
action: str = Field(max_length=32, nullable=False)
|
||||||
description: str | None = Field(default=None)
|
description: str | None = Field(default=None)
|
||||||
created_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=datetime.now)
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
is_deleted: bool = Field(default=False)
|
is_deleted: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
@ -104,8 +108,8 @@ class Permissions(SQLModel, table=True):
|
||||||
description: str | None = Field(default=None)
|
description: str | None = Field(default=None)
|
||||||
permission_tags: list | None = Field(default=None, sa_column=Column(JSONB))
|
permission_tags: list | None = Field(default=None, sa_column=Column(JSONB))
|
||||||
is_system: bool = Field(default=False)
|
is_system: bool = Field(default=False)
|
||||||
created_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=datetime.now)
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
is_deleted: bool = Field(default=False)
|
is_deleted: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
@ -182,7 +186,7 @@ class Permissions(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
row.updated_at = datetime.now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|
@ -195,7 +199,7 @@ class Permissions(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
row.is_deleted = True
|
row.is_deleted = True
|
||||||
row.is_active = False
|
row.is_active = False
|
||||||
row.updated_at = datetime.now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|
@ -210,8 +214,8 @@ class Roles(SQLModel, table=True):
|
||||||
description: str | None = Field(default=None)
|
description: str | None = Field(default=None)
|
||||||
permissions: list | None = Field(default=None, sa_column=Column(JSONB))
|
permissions: list | None = Field(default=None, sa_column=Column(JSONB))
|
||||||
is_system: bool = Field(default=False)
|
is_system: bool = Field(default=False)
|
||||||
created_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=datetime.now)
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
is_deleted: bool = Field(default=False)
|
is_deleted: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
@ -280,7 +284,7 @@ class Roles(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
row.updated_at = datetime.now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|
@ -293,7 +297,7 @@ class Roles(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
row.is_deleted = True
|
row.is_deleted = True
|
||||||
row.is_active = False
|
row.is_active = False
|
||||||
row.updated_at = datetime.now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING,List,Optional
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
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 inbox.models import Inbox
|
||||||
from job.candidate.models import Feedback, Notes
|
from job.candidate.models import Feedback, Notes
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Users(SQLModel, table=True):
|
class Users(SQLModel, table=True):
|
||||||
__tablename__ = "users"
|
__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
|
# 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,
|
# 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.
|
# 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(
|
job_posts: List[JobPosts] = Relationship(
|
||||||
back_populates="user",
|
back_populates="user",
|
||||||
sa_relationship_kwargs={"lazy": "selectin"},
|
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
||||||
)
|
)
|
||||||
inbox: List["Inbox"] = Relationship(
|
inbox: List["Inbox"] = Relationship(
|
||||||
back_populates="user",
|
back_populates="user",
|
||||||
|
|
@ -50,8 +57,8 @@ class Users(SQLModel, table=True):
|
||||||
|
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
created_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=datetime.now)
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
is_active: bool = Field(default=False)
|
is_active: bool = Field(default=False)
|
||||||
is_deleted: bool = Field(default=False)
|
is_deleted: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
@ -140,7 +147,7 @@ class Users(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
setattr(user, key, value)
|
setattr(user, key, value)
|
||||||
user.updated_at = datetime.now()
|
user.updated_at = _now()
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
|
@ -153,7 +160,7 @@ class Users(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
user.is_deleted = True
|
user.is_deleted = True
|
||||||
user.is_active = False
|
user.is_active = False
|
||||||
user.updated_at = datetime.now()
|
user.updated_at = _now()
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import{d,r as c,af as n,j as e,I as t,B as o}from"./index-BTPmxtwM.js";import{M as m}from"./Modal-B8aWFZt2.js";function p(){const{toast:l}=d(),[a,i]=c.useState(null),r=n.filter(s=>s.status==="Beta").length;return e.jsxs("div",{className:"page",children:[e.jsxs("div",{className:"page-head",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"page-title",children:"AI Studio"}),e.jsx("p",{className:"page-sub",children:"Next-generation AI modules — designed and API-ready for backend integration"})]}),e.jsx("div",{className:"page-head-actions",children:e.jsxs("span",{className:"integration-status pending",children:[e.jsx("span",{className:"pulse"}),r," in Beta"]})})]}),e.jsx("div",{className:"card brand-hero mb-18",children:e.jsxs("div",{className:"card-body",style:{display:"flex",alignItems:"center",gap:20,flexWrap:"wrap"},children:[e.jsx("div",{className:"ai-logo",style:{margin:0,width:56,height:56},children:e.jsx(t,{name:"sparkles"})}),e.jsxs("div",{style:{flex:1,minWidth:220},children:[e.jsx("h2",{style:{fontSize:19,marginBottom:4},children:"Everything is API-ready"}),e.jsx("p",{style:{opacity:.88},children:"Each module below ships with a complete, production-grade interface. Connect your model endpoint to activate them — no UI work required."})]}),e.jsxs("button",{className:"btn btn-on-brand",onClick:()=>l("Integration guide opened","info"),children:[e.jsx(t,{name:"external"})," Integration Guide"]})]})}),e.jsx("div",{className:"grid g-3",children:n.map(s=>e.jsx("div",{className:"card",style:{cursor:"pointer"},onClick:()=>i(s),children:e.jsxs("div",{className:"card-body",children:[e.jsxs("div",{className:"flex items-center",style:{justifyContent:"space-between",marginBottom:12},children:[e.jsx("span",{className:`kpi-icn ${s.cls}`,style:{width:46,height:46,borderRadius:13},children:e.jsx(t,{name:s.icon})}),e.jsx(o,{className:s.status==="Beta"?"b-indigo":"b-gray",children:s.status})]}),e.jsx("div",{className:"lr-title",style:{fontSize:15},children:s.name}),e.jsx("div",{className:"lr-sub",style:{marginTop:5,lineHeight:1.5},children:s.desc}),e.jsxs("div",{style:{marginTop:14,color:"var(--primary)",fontWeight:600,fontSize:13},children:[s.status==="Beta"?"Try it":"Join waitlist"," ",e.jsx(t,{name:"arrow-right"})]})]})},s.name))}),a&&e.jsxs(m,{title:a.name,subtitle:`${a.status} · AI Module`,size:"modal-lg",onClose:()=>i(null),footer:e.jsx("button",{className:"btn btn-secondary",onClick:()=>i(null),children:"Close"}),children:[e.jsxs("div",{className:"flex items-center gap-16",style:{marginBottom:18},children:[e.jsx("span",{className:`kpi-icn ${a.cls}`,style:{width:56,height:56,borderRadius:16},children:e.jsx(t,{name:a.icon})}),e.jsxs("div",{children:[e.jsx("div",{className:"fw-600",style:{fontSize:16},children:a.name}),e.jsx("div",{className:"text-muted",children:a.desc})]})]}),e.jsx("div",{className:"card",style:{boxShadow:"none",background:"var(--bg-sunken)"},children:e.jsxs("div",{className:"card-body",children:[e.jsx("div",{className:"form-section-title",style:{marginTop:0},children:"API Contract (preview)"}),e.jsx("pre",{className:"resume-thumb",style:{maxHeight:"none"},children:`POST /api/ai/${a.name.toLowerCase().replace(/ /g,"-")}
|
|
||||||
{
|
|
||||||
"context": { "jobId": "JOB-1001", "candidateIds": [...] },
|
|
||||||
"options": { "model": "claude-opus", "stream": true }
|
|
||||||
}
|
|
||||||
|
|
||||||
→ 200 OK
|
|
||||||
{
|
|
||||||
"result": { ... },
|
|
||||||
"usage": { "tokens": 1240 }
|
|
||||||
}`})]})}),e.jsxs("p",{className:"text-muted text-sm",style:{marginTop:14},children:[e.jsx(t,{name:"lock"})," This feature’s UI is complete. Backend wiring is the only remaining step."]})]})]})}export{p as default};
|
|
||||||
//# sourceMappingURL=AiStudio-CIA10Amn.js.map
|
|
||||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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 (0–100) 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 candidate’s 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
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
@ -23,7 +23,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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="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>" />
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,17 @@ import {
|
||||||
mergeProps,
|
mergeProps,
|
||||||
render,
|
render,
|
||||||
setupStyleSheet
|
setupStyleSheet
|
||||||
} from "./chunk-KZEKDZA2.js";
|
} from "./chunk-TLA2YRDJ.js";
|
||||||
import {
|
import {
|
||||||
onlineManager,
|
onlineManager,
|
||||||
useQueryClient
|
useQueryClient
|
||||||
} from "./chunk-BMD7DZVU.js";
|
} from "./chunk-Y5A5GOKP.js";
|
||||||
import {
|
import {
|
||||||
require_jsx_runtime
|
require_jsx_runtime
|
||||||
} from "./chunk-IBN7FD72.js";
|
} from "./chunk-4N5S3525.js";
|
||||||
import {
|
import {
|
||||||
require_react
|
require_react
|
||||||
} from "./chunk-5PE4DLTS.js";
|
} from "./chunk-6333SFGK.js";
|
||||||
import {
|
import {
|
||||||
__privateAdd,
|
__privateAdd,
|
||||||
__privateGet,
|
__privateGet,
|
||||||
|
|
@ -108,7 +108,7 @@ var TanstackQueryDevtools = (_a = class {
|
||||||
if (__privateGet(this, _Component)) {
|
if (__privateGet(this, _Component)) {
|
||||||
Devtools = __privateGet(this, _Component);
|
Devtools = __privateGet(this, _Component);
|
||||||
} else {
|
} else {
|
||||||
Devtools = lazy(() => import("./SO26Z5QU-LP72JTJM.js"));
|
Devtools = lazy(() => import("./SO26Z5QU-ZOVSYIPF.js"));
|
||||||
__privateSet(this, _Component, Devtools);
|
__privateSet(this, _Component, Devtools);
|
||||||
}
|
}
|
||||||
setupStyleSheet(__privateGet(this, _styleNonce), __privateGet(this, _shadowDOMTarget));
|
setupStyleSheet(__privateGet(this, _styleNonce), __privateGet(this, _shadowDOMTarget));
|
||||||
|
|
@ -248,7 +248,7 @@ var TanstackQueryDevtoolsPanel = (_a2 = class {
|
||||||
if (__privateGet(this, _Component2)) {
|
if (__privateGet(this, _Component2)) {
|
||||||
Devtools = __privateGet(this, _Component2);
|
Devtools = __privateGet(this, _Component2);
|
||||||
} else {
|
} else {
|
||||||
Devtools = lazy(() => import("./MYKLHYJZ-6Q5OZ5GF.js"));
|
Devtools = lazy(() => import("./MYKLHYJZ-QSK7XAY4.js"));
|
||||||
__privateSet(this, _Component2, Devtools);
|
__privateSet(this, _Component2, Devtools);
|
||||||
}
|
}
|
||||||
setupStyleSheet(__privateGet(this, _styleNonce2), __privateGet(this, _shadowDOMTarget2));
|
setupStyleSheet(__privateGet(this, _styleNonce2), __privateGet(this, _shadowDOMTarget2));
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -57,9 +57,9 @@ import {
|
||||||
useSuspenseInfiniteQuery,
|
useSuspenseInfiniteQuery,
|
||||||
useSuspenseQueries,
|
useSuspenseQueries,
|
||||||
useSuspenseQuery
|
useSuspenseQuery
|
||||||
} from "./chunk-BMD7DZVU.js";
|
} from "./chunk-Y5A5GOKP.js";
|
||||||
import "./chunk-IBN7FD72.js";
|
import "./chunk-4N5S3525.js";
|
||||||
import "./chunk-5PE4DLTS.js";
|
import "./chunk-6333SFGK.js";
|
||||||
import "./chunk-QWN5BXRD.js";
|
import "./chunk-QWN5BXRD.js";
|
||||||
export {
|
export {
|
||||||
CancelledError,
|
CancelledError,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue