Merge pull request 'SQS_BROKER' (#73) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 39s
Details
Deploy to S3 / deploy (push) Successful in 39s
Details
Reviewed-on: #73pull/74/head
commit
3086f3fca7
|
|
@ -1,36 +0,0 @@
|
|||
name: CI
|
||||
|
||||
# Same checks deploy-to-s3.yml gates on, run before a change reaches main.
|
||||
# main itself is excluded because the deploy workflow already runs them there;
|
||||
# without branches-ignore every merge would run the suite twice.
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run checks
|
||||
run: bash scripts/ci-checks.sh
|
||||
|
|
@ -1,64 +1,25 @@
|
|||
name: Deploy to S3
|
||||
|
||||
# main only. Everything else is covered by ci.yml, which runs the same checks
|
||||
# without deploying.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# Nothing was verified before this existed: a frontend that failed to compile
|
||||
# would zip and ship exactly like a working one. `deploy` now needs this job,
|
||||
# so a red main does not reach the bucket.
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# 22 to match frontend/Dockerfile, so CI resolves the same tree the
|
||||
# production image builds from.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# 3.11 is the floor in pyproject.toml and the version the project's conda
|
||||
# env runs.
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run checks
|
||||
run: bash scripts/ci-checks.sh
|
||||
|
||||
deploy:
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# frontend/node_modules is excluded, and that is safe because of what
|
||||
# happens to this object downstream. CodeDeploy pulls it, extracts to
|
||||
# /opt/codedeploy-extracted-5, copies the tree to
|
||||
# /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group and runs
|
||||
# `docker compose --env-file ./backend/.env up -d --build`. The only Node
|
||||
# service is the frontend, whose image does `npm ci` from the lockfile,
|
||||
# and frontend/.dockerignore excludes node_modules/ from the build context
|
||||
# outright. So the committed tree was carried into every artifact and then
|
||||
# thrown away unread. It was 90 MB of a 33 MB compressed upload.
|
||||
#
|
||||
# node_modules is still tracked in git, which is the reason it was here at
|
||||
# all. Untracking it is a separate change and affects other branches.
|
||||
- name: Configure AWS credentials
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
echo "AWS credentials configured"
|
||||
|
||||
- name: Archive project
|
||||
run: |
|
||||
apt-get update -y
|
||||
|
|
@ -66,8 +27,7 @@ jobs:
|
|||
zip -r utopia-ai-hr-ats-portal.zip . \
|
||||
-x ".git/*" \
|
||||
-x ".gitea/*" \
|
||||
-x ".gitignore" \
|
||||
-x "frontend/node_modules/*" \
|
||||
-x ".gitignore/*" \
|
||||
-x "*.DS_Store"
|
||||
|
||||
- name: Install AWS CLI
|
||||
|
|
@ -79,11 +39,6 @@ jobs:
|
|||
./aws/install
|
||||
aws --version
|
||||
|
||||
# The credentials live only on this step. There used to be a separate
|
||||
# "Configure AWS credentials" step above that set the same three variables
|
||||
# and then only echoed a message — env: is scoped to its own step, so
|
||||
# those values were discarded before anything could use them. It was doing
|
||||
# nothing, and it read as though credentials were set up globally.
|
||||
- name: Upload files to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
|
||||
|
|
@ -92,3 +47,10 @@ jobs:
|
|||
run: |
|
||||
echo "Uploading repo contents to S3..."
|
||||
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import logging
|
||||
|
||||
from job.assignment.models import ApplicationAssignments, JobAssignments
|
||||
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
|
||||
|
|
@ -18,6 +19,8 @@ JOB_OWNER_COLUMN = {
|
|||
"hiring_manager": "hiring_manager_id",
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Assignment:
|
||||
def __init__(self,session:AsyncSession):
|
||||
|
|
@ -102,7 +105,21 @@ class Assignment:
|
|||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
|
||||
column=JOB_OWNER_COLUMN[role]
|
||||
await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
|
||||
updated=await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
|
||||
if updated:
|
||||
try:
|
||||
from notifications.views import notify_job_assignment
|
||||
label="hiring manager" if role=="hiring_manager" else "recruiter"
|
||||
await notify_job_assignment(
|
||||
self.session,updated,
|
||||
role_label=label,
|
||||
actor_id=assigned_by,
|
||||
previous_ids=[
|
||||
job.hiring_manager_id if role=="hiring_manager" else job.current_recruiter_id
|
||||
],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s", exc)
|
||||
names=await Users.names_by_ids(
|
||||
self.session,[row.user_id,row.assigned_by] if row else [],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -111,7 +111,19 @@ class HistoryRecorder:
|
|||
"actor_kind": actor_kind or "user",
|
||||
"meta": meta,
|
||||
}
|
||||
return await CandidateHistory.insert_event(self.session, fields, commit=commit)
|
||||
row = await CandidateHistory.insert_event(self.session, fields, commit=commit)
|
||||
try:
|
||||
from notifications.views import notify_candidate_history
|
||||
await notify_candidate_history(
|
||||
self.session,
|
||||
row,
|
||||
inbox_id=inbox_id,
|
||||
manual_upload_candidate_id=manual_upload_candidate_id,
|
||||
commit=commit,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("candidate history notification failed for %s", event_type)
|
||||
return row
|
||||
except Exception:
|
||||
logger.exception("candidate history record failed for %s", event_type)
|
||||
if commit:
|
||||
|
|
|
|||
|
|
@ -270,7 +270,8 @@ class JobPosts(SQLModel, table=True):
|
|||
rows. Duplicate emails (case-insensitive) count once per job — the
|
||||
furthest pipeline stage is kept. Flagged is_duplicate rows are skipped.
|
||||
Rows with no email still count, each as themselves. Jobs with zero
|
||||
applicants still appear (LEFT JOIN).
|
||||
applicants still appear (LEFT JOIN). `reapplied` is how many unique
|
||||
applicants on the job also applied to at least one other job.
|
||||
"""
|
||||
from g_sheet.models import FormData
|
||||
from inbox.models import Inbox_Messages
|
||||
|
|
@ -299,7 +300,7 @@ class JobPosts(SQLModel, table=True):
|
|||
func.concat("noid:", cast(row_id, String)),
|
||||
)
|
||||
|
||||
inbox_q = (
|
||||
inbox_base = (
|
||||
select(
|
||||
Inbox_Messages.assigned_job_post_id.label("job_post_id"),
|
||||
dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"),
|
||||
|
|
@ -316,7 +317,7 @@ class JobPosts(SQLModel, table=True):
|
|||
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""),
|
||||
Users.email,
|
||||
)
|
||||
manual_q = (
|
||||
manual_base = (
|
||||
select(
|
||||
Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"),
|
||||
dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"),
|
||||
|
|
@ -332,7 +333,7 @@ class JobPosts(SQLModel, table=True):
|
|||
(FormData.processing_state == "rejected", "REJECTED"),
|
||||
else_="PENDING",
|
||||
)
|
||||
form_q = (
|
||||
form_base = (
|
||||
select(
|
||||
FormData.job_post_id.label("job_post_id"),
|
||||
dup_key(FormData.candidate_email, FormData.id).label("dup_key"),
|
||||
|
|
@ -342,12 +343,23 @@ class JobPosts(SQLModel, table=True):
|
|||
.where(FormData.manual_upload_candidate_id.is_(None))
|
||||
.where(FormData.is_duplicate == False) # noqa: E712
|
||||
)
|
||||
# Unfiltered: an applicant on this page who also applied to a job
|
||||
# not in the current page still counts as a reapplicant.
|
||||
all_apps = union_all(inbox_base, manual_base, form_base).subquery("all_applications")
|
||||
inbox_q, manual_q, form_q = inbox_base, manual_base, form_base
|
||||
if job_uids:
|
||||
inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
|
||||
manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
|
||||
form_q = form_q.where(FormData.job_post_id.in_(job_uids))
|
||||
inbox_q = inbox_base.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
|
||||
manual_q = manual_base.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
|
||||
form_q = form_base.where(FormData.job_post_id.in_(job_uids))
|
||||
|
||||
apps = union_all(inbox_q, manual_q, form_q).subquery("applications")
|
||||
repeat_keys = (
|
||||
select(all_apps.c.dup_key)
|
||||
.where(~all_apps.c.dup_key.like("noid:%"))
|
||||
.group_by(all_apps.c.dup_key)
|
||||
.having(func.count(func.distinct(all_apps.c.job_post_id)) > 1)
|
||||
.subquery("repeat_emails")
|
||||
)
|
||||
stage_rank = case(
|
||||
(apps.c.stage == "HIRED", 9),
|
||||
(apps.c.stage == "APPROVED", 8),
|
||||
|
|
@ -389,6 +401,16 @@ class JobPosts(SQLModel, table=True):
|
|||
.group_by(unique_apps.c.job_post_id)
|
||||
.subquery("job_stage_stats")
|
||||
)
|
||||
reapplied_stats = (
|
||||
select(
|
||||
unique_apps.c.job_post_id,
|
||||
func.count().label("reapplied"),
|
||||
)
|
||||
.select_from(unique_apps)
|
||||
.join(repeat_keys, repeat_keys.c.dup_key == unique_apps.c.dup_key)
|
||||
.group_by(unique_apps.c.job_post_id)
|
||||
.subquery("job_reapplied")
|
||||
)
|
||||
|
||||
# Alias so this join does not collide with the Users join inside
|
||||
# the manual-upload subquery above.
|
||||
|
|
@ -413,9 +435,11 @@ class JobPosts(SQLModel, table=True):
|
|||
func.coalesce(stats.c.rejected, 0).label("rejected"),
|
||||
func.coalesce(stats.c.approved, 0).label("approved"),
|
||||
func.coalesce(stats.c.hired, 0).label("hired"),
|
||||
func.coalesce(reapplied_stats.c.reapplied, 0).label("reapplied"),
|
||||
)
|
||||
.select_from(cls)
|
||||
.outerjoin(stats, stats.c.job_post_id == cls.id)
|
||||
.outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
|
||||
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
|
||||
.where(cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ def serialize_job_stats(row) -> dict:
|
|||
"rejected": int(row["rejected"] or 0),
|
||||
"approved": int(row["approved"] or 0),
|
||||
"hired": int(row["hired"] or 0),
|
||||
"reapplied": int(row.get("reapplied") or 0),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,12 @@ class JobPost:
|
|||
if rec:
|
||||
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
|
||||
|
||||
try:
|
||||
from notifications.views import notify_job_created
|
||||
await notify_job_created(self.session,row,actor_id=assigned_by)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
|
||||
# A new opening is the moment the CV Bank is worth reading. Ranking it
|
||||
# here is what turns the bank from a pile someone has to remember into
|
||||
# something that offers itself up. Fire-and-forget: the job is already
|
||||
|
|
@ -440,6 +446,22 @@ class JobPost:
|
|||
await assignment.record_job_owner(
|
||||
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
|
||||
)
|
||||
if hm_changed or rec_changed:
|
||||
try:
|
||||
from notifications.views import notify_job_assignment
|
||||
labels=[]
|
||||
if hm_changed:
|
||||
labels.append("hiring manager")
|
||||
if rec_changed:
|
||||
labels.append("recruiter")
|
||||
await notify_job_assignment(
|
||||
self.session,row,
|
||||
role_label=" and ".join(labels),
|
||||
actor_id=assigned_by,
|
||||
previous_ids=[existing.hiring_manager_id,existing.current_recruiter_id],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
return await self._job_row(row)
|
||||
|
||||
async def delete_job(self,job_post_id,current_user):
|
||||
|
|
@ -499,25 +521,22 @@ class JobPost:
|
|||
)
|
||||
status=parsed.value
|
||||
actor=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
previous=None
|
||||
existing=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||
if existing:
|
||||
previous=existing.requisition_status
|
||||
row=await JobPosts.set_requisition_status(
|
||||
self.session,job_post_id,status,changed_by=actor,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
if status==RequisitionStatus.CLOSED.value:
|
||||
if previous!=status:
|
||||
try:
|
||||
from notifications.models import Notifications
|
||||
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)
|
||||
recipient=uuid.UUID(str(raw)) if raw else None
|
||||
if recipient:
|
||||
await Notifications.insert_notification(self.session,{
|
||||
"user_id":recipient,
|
||||
"kind":"approval",
|
||||
"title":"Requisition closed",
|
||||
"body":f"{row.title} was closed",
|
||||
"link_path":"/jobs",
|
||||
"job_post_id":row.id,
|
||||
})
|
||||
from notifications.views import notify_job_status
|
||||
await notify_job_status(
|
||||
self.session,row,
|
||||
from_status=previous,to_status=status,actor_id=actor,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
return await self._job_row(row)
|
||||
|
|
|
|||
|
|
@ -175,6 +175,30 @@ class Notifications(SQLModel, table=True):
|
|||
await session.commit()
|
||||
return await cls.get_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def insert_many(cls, session: AsyncSession, payloads, *, commit: bool = True):
|
||||
"""One row per payload. `commit=False` rides the caller's transaction."""
|
||||
rows = []
|
||||
for fields in payloads or []:
|
||||
uid = cls._as_uuid(fields.get("user_id"))
|
||||
if uid is None or not fields.get("kind") or not fields.get("title"):
|
||||
continue
|
||||
job_id = cls._as_uuid(fields.get("job_post_id")) if fields.get("job_post_id") else None
|
||||
row = cls(
|
||||
user_id=uid,
|
||||
kind=fields["kind"],
|
||||
title=fields["title"],
|
||||
body=fields.get("body"),
|
||||
link_path=fields.get("link_path"),
|
||||
inbox_id=fields.get("inbox_id"),
|
||||
job_post_id=job_id,
|
||||
)
|
||||
session.add(row)
|
||||
rows.append(row)
|
||||
if commit and rows:
|
||||
await session.commit()
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
async def mark_read(cls, session: AsyncSession, record_id, *, user_id):
|
||||
row = await cls.get_by_id(session, record_id, user_id=user_id)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import httpx
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from notifications.models import EmailConfirmationTokens,Notifications
|
||||
from role.models import EnumRoles
|
||||
from notifications.plugins import (
|
||||
CONFIRM_TOKEN_RESEND_SECONDS,
|
||||
CONFIRM_TOKEN_TTL_SECONDS,
|
||||
|
|
@ -26,6 +27,67 @@ from notifications.serializers import (
|
|||
)
|
||||
from users.models import Users
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Candidate-history event_type → in-app kind, title, and profile tab.
|
||||
_HISTORY_KIND = {
|
||||
"stage.changed": "application",
|
||||
"candidate.created": "application",
|
||||
"candidate.imported": "application",
|
||||
"interview.created": "interview",
|
||||
"interview.updated": "interview",
|
||||
"calendar.created": "interview",
|
||||
"calendar.rescheduled": "interview",
|
||||
"calendar.cancelled": "interview",
|
||||
"ats.scored": "assessment",
|
||||
"form.created": "approval",
|
||||
"form.updated": "approval",
|
||||
"note.created": "message",
|
||||
"note.updated": "message",
|
||||
"feedback.created": "message",
|
||||
"feedback.updated": "message",
|
||||
}
|
||||
_HISTORY_TITLE = {
|
||||
"stage.changed": "Stage changed",
|
||||
"note.created": "Note added",
|
||||
"note.updated": "Note updated",
|
||||
"feedback.created": "Feedback added",
|
||||
"feedback.updated": "Feedback updated",
|
||||
"interview.created": "Interview scheduled",
|
||||
"interview.updated": "Interview updated",
|
||||
"calendar.created": "Calendar event created",
|
||||
"calendar.rescheduled": "Interview rescheduled",
|
||||
"calendar.cancelled": "Interview cancelled",
|
||||
"favorite.changed": "Favorite updated",
|
||||
"rating.changed": "Rating updated",
|
||||
"candidate.created": "Candidate added",
|
||||
"candidate.imported": "Candidate imported",
|
||||
"document.uploaded": "Document uploaded",
|
||||
"ats.scored": "ATS score ready",
|
||||
"form.created": "Form submitted",
|
||||
"form.updated": "Form updated",
|
||||
}
|
||||
_HISTORY_TAB = {
|
||||
"stage.changed": "History",
|
||||
"interview.created": "Interview",
|
||||
"interview.updated": "Interview",
|
||||
"calendar.created": "Interview",
|
||||
"calendar.rescheduled": "Interview",
|
||||
"calendar.cancelled": "Interview",
|
||||
"note.created": "Notes",
|
||||
"note.updated": "Notes",
|
||||
"feedback.created": "Activity",
|
||||
"feedback.updated": "Activity",
|
||||
"form.created": "Forms",
|
||||
"form.updated": "Forms",
|
||||
"ats.scored": "Resume",
|
||||
"document.uploaded": "History",
|
||||
"candidate.created": "History",
|
||||
"candidate.imported": "History",
|
||||
"favorite.changed": "History",
|
||||
"rating.changed": "History",
|
||||
}
|
||||
|
||||
|
||||
class Confirmation:
|
||||
def __init__(self,session:AsyncSession):
|
||||
|
|
@ -118,6 +180,315 @@ def _user_id(current_user):
|
|||
return uid
|
||||
|
||||
|
||||
def _humanize_event(event_type):
|
||||
text = str(event_type or "").replace(".", " ").replace("_", " ").strip()
|
||||
return text[:1].upper() + text[1:] if text else "Update"
|
||||
|
||||
|
||||
def _candidate_link(user_id, tab="History"):
|
||||
path = f"/candidate/{user_id}"
|
||||
if tab:
|
||||
return f"{path}?tab={tab}"
|
||||
return path
|
||||
|
||||
|
||||
def _job_link(job_post_id, tab=None):
|
||||
path = f"/jobs?job={job_post_id}"
|
||||
if tab:
|
||||
return f"{path}&tab={tab}"
|
||||
return path
|
||||
|
||||
|
||||
async def system_admin_ids(session):
|
||||
return await Users.ids_by_role_names(session, [EnumRoles.SYSTEM_ADMINISTRATOR.value])
|
||||
|
||||
|
||||
async def job_recruiter_ids(session, job):
|
||||
"""Recruiters currently linked to the job post.
|
||||
|
||||
Uses the live pointer (current_recruiter_id) and open job_assignments
|
||||
rows with assignment_role=primary_recruiter.
|
||||
"""
|
||||
ids = set()
|
||||
if job is None:
|
||||
return ids
|
||||
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
|
||||
if uid is not None:
|
||||
ids.add(uid)
|
||||
from job.assignment.models import JobAssignments
|
||||
rows = await JobAssignments.fetch_by_job(
|
||||
session, job.id, current_only=True, assignment_role="primary_recruiter",
|
||||
)
|
||||
for row in rows:
|
||||
rid = _as_uuid(row.user_id)
|
||||
if rid is not None:
|
||||
ids.add(rid)
|
||||
return ids
|
||||
|
||||
|
||||
async def job_stakeholder_ids(session, jobs, *, extra_ids=None, include_admins=True):
|
||||
"""Recruiter, hiring manager, created_by, plus system admins.
|
||||
|
||||
`jobs` may be one row or an iterable. Extra ids cover people who just
|
||||
left an assignment so they still see the history entry.
|
||||
"""
|
||||
rows = jobs if isinstance(jobs, (list, tuple, set)) else [jobs]
|
||||
ids = set()
|
||||
for job in rows:
|
||||
if job is None:
|
||||
continue
|
||||
ids.update(await job_recruiter_ids(session, job))
|
||||
for raw in (job.hiring_manager_id, job.created_by):
|
||||
uid = _as_uuid(raw)
|
||||
if uid is not None:
|
||||
ids.add(uid)
|
||||
for raw in extra_ids or []:
|
||||
uid = _as_uuid(raw)
|
||||
if uid is not None:
|
||||
ids.add(uid)
|
||||
if include_admins:
|
||||
ids.update(await system_admin_ids(session))
|
||||
return ids
|
||||
|
||||
|
||||
async def notify_users(
|
||||
session,
|
||||
user_ids,
|
||||
*,
|
||||
kind,
|
||||
title,
|
||||
body=None,
|
||||
link_path=None,
|
||||
inbox_id=None,
|
||||
job_post_id=None,
|
||||
exclude_ids=None,
|
||||
commit=True,
|
||||
):
|
||||
"""Fan-out one in-app row per recipient. Failures never raise."""
|
||||
try:
|
||||
exclude = {_as_uuid(x) for x in (exclude_ids or [])}
|
||||
exclude.discard(None)
|
||||
seen = set()
|
||||
payloads = []
|
||||
for raw in user_ids or []:
|
||||
uid = _as_uuid(raw)
|
||||
if uid is None or uid in exclude or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
payloads.append({
|
||||
"user_id": uid,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"link_path": link_path,
|
||||
"inbox_id": inbox_id,
|
||||
"job_post_id": job_post_id,
|
||||
})
|
||||
if not payloads:
|
||||
return []
|
||||
return await Notifications.insert_many(session, payloads, commit=commit)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
async def notify_job_stakeholders(
|
||||
session,
|
||||
job,
|
||||
*,
|
||||
kind,
|
||||
title,
|
||||
body=None,
|
||||
link_path=None,
|
||||
extra_ids=None,
|
||||
exclude_ids=None,
|
||||
commit=True,
|
||||
):
|
||||
if job is None:
|
||||
return []
|
||||
recipients = await job_stakeholder_ids(session, job, extra_ids=extra_ids)
|
||||
return await notify_users(
|
||||
session,
|
||||
recipients,
|
||||
kind=kind,
|
||||
title=title,
|
||||
body=body,
|
||||
link_path=link_path or _job_link(job.id),
|
||||
job_post_id=job.id,
|
||||
exclude_ids=exclude_ids,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
|
||||
async def notify_job_created(session, job, *, actor_id=None):
|
||||
"""New requisition: recruiter, hiring manager, created_by, system admins.
|
||||
|
||||
created_by is included even when they are the actor — that is who asked.
|
||||
"""
|
||||
if job is None:
|
||||
return []
|
||||
title = (job.title or "A job post").strip() or "A job post"
|
||||
return await notify_job_stakeholders(
|
||||
session,
|
||||
job,
|
||||
kind="approval",
|
||||
title="New job post",
|
||||
body=f"{title} was created",
|
||||
link_path=_job_link(job.id),
|
||||
exclude_ids=None,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
async def notify_job_status(session, job, *, from_status, to_status, actor_id=None):
|
||||
"""Status history: assigned recruiter, created_by, and every system admin."""
|
||||
if job is None:
|
||||
return []
|
||||
title = (job.title or "A job post").strip() or "A job post"
|
||||
if from_status:
|
||||
body = f"{title} moved from {from_status} to {to_status}"
|
||||
heading = "Requisition closed" if to_status == "closed" else "Requisition updated"
|
||||
else:
|
||||
body = f"{title} is now {to_status}"
|
||||
heading = "Requisition updated"
|
||||
recipients = await job_recruiter_ids(session, job)
|
||||
created = _as_uuid(job.created_by)
|
||||
if created is not None:
|
||||
recipients.add(created)
|
||||
recipients.update(await system_admin_ids(session))
|
||||
return await notify_users(
|
||||
session,
|
||||
recipients,
|
||||
kind="approval",
|
||||
title=heading,
|
||||
body=body,
|
||||
link_path=_job_link(job.id, tab="history"),
|
||||
job_post_id=job.id,
|
||||
exclude_ids=None,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
async def notify_job_assignment(session, job, *, role_label, actor_id=None, previous_ids=None):
|
||||
if job is None:
|
||||
return []
|
||||
title = (job.title or "A job post").strip() or "A job post"
|
||||
return await notify_job_stakeholders(
|
||||
session,
|
||||
job,
|
||||
kind="approval",
|
||||
title="Job assignment updated",
|
||||
body=f"{title}: {role_label} changed",
|
||||
link_path=_job_link(job.id, tab="history"),
|
||||
extra_ids=previous_ids,
|
||||
exclude_ids=[actor_id] if actor_id else None,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
async def _job_ids_for_candidate_event(session, *, user_id, inbox_id, manual_upload_candidate_id):
|
||||
"""Resolve job posts for a history row without importing candidate views."""
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||
|
||||
ids = set()
|
||||
if inbox_id is not None:
|
||||
link = await Inbox.get_inbox_with_message(session, inbox_id)
|
||||
msg = getattr(link, "messages", None) if link is not None else None
|
||||
jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None
|
||||
if jid:
|
||||
ids.add(jid)
|
||||
if manual_upload_candidate_id is not None:
|
||||
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(session, manual_upload_candidate_id)
|
||||
if manual and manual.job_post_id:
|
||||
ids.add(manual.job_post_id)
|
||||
scoped = inbox_id is not None or manual_upload_candidate_id is not None
|
||||
if ids or user_id is None or scoped:
|
||||
return ids
|
||||
rows = await Inbox.get_candidate_profile(session=session, user_id=user_id, limit=1000, offset=0)
|
||||
records = rows if isinstance(rows, list) else ([rows] if rows else [])
|
||||
for rec in records:
|
||||
msg = getattr(rec, "messages", None)
|
||||
jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None
|
||||
if jid:
|
||||
ids.add(jid)
|
||||
manual = await Manual_UPLOAD_CANDIDATE.get_by_user_id(session, user_id)
|
||||
if manual and manual.job_post_id:
|
||||
ids.add(manual.job_post_id)
|
||||
return ids
|
||||
|
||||
|
||||
async def notify_candidate_history(
|
||||
session,
|
||||
row,
|
||||
*,
|
||||
inbox_id=None,
|
||||
manual_upload_candidate_id=None,
|
||||
commit=True,
|
||||
):
|
||||
"""One in-app row per job stakeholder for a candidate_history write."""
|
||||
if row is None:
|
||||
return []
|
||||
try:
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
event_type = row.event_type
|
||||
names = await Users.names_by_ids(session, [row.user_id])
|
||||
candidate_name = names.get(str(row.user_id)) or "A candidate"
|
||||
heading = _HISTORY_TITLE.get(event_type) or _humanize_event(event_type)
|
||||
kind = _HISTORY_KIND.get(event_type, "system")
|
||||
tab = _HISTORY_TAB.get(event_type, "History")
|
||||
if row.description:
|
||||
detail = row.description
|
||||
elif row.from_value and row.to_value:
|
||||
detail = f"{row.from_value} → {row.to_value}"
|
||||
elif row.to_value:
|
||||
detail = str(row.to_value)
|
||||
else:
|
||||
detail = heading
|
||||
body = f"{candidate_name}: {detail}"
|
||||
link_path = _candidate_link(row.user_id, tab=tab)
|
||||
|
||||
job_ids = await _job_ids_for_candidate_event(
|
||||
session,
|
||||
user_id=row.user_id,
|
||||
inbox_id=inbox_id if inbox_id is not None else row.inbox_id,
|
||||
manual_upload_candidate_id=(
|
||||
manual_upload_candidate_id
|
||||
if manual_upload_candidate_id is not None
|
||||
else row.manual_upload_candidate_id
|
||||
),
|
||||
)
|
||||
jobs = []
|
||||
if job_ids:
|
||||
jobs = await JobPosts.get_by_ids(session, list(job_ids), active_only=False)
|
||||
if jobs:
|
||||
recipients = await job_stakeholder_ids(session, jobs, include_admins=True)
|
||||
else:
|
||||
recipients = set(await system_admin_ids(session))
|
||||
|
||||
exclude = [row.user_id]
|
||||
if row.actor_id:
|
||||
exclude.append(row.actor_id)
|
||||
job_post_id = jobs[0].id if jobs else None
|
||||
inbox_value = row.inbox_id if row.inbox_id is not None else inbox_id
|
||||
return await notify_users(
|
||||
session,
|
||||
recipients,
|
||||
kind=kind,
|
||||
title=heading,
|
||||
body=body,
|
||||
link_path=link_path,
|
||||
inbox_id=inbox_value,
|
||||
job_post_id=job_post_id,
|
||||
exclude_ids=exclude,
|
||||
commit=commit,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
class Notification:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
|
|
|||
|
|
@ -135,6 +135,23 @@ class Users(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def ids_by_role_names(cls, session: AsyncSession, role_names):
|
||||
"""Non-deleted user ids whose Roles.role_name is in `role_names`."""
|
||||
names = sorted({(n or "").strip() for n in (role_names or []) if (n or "").strip()})
|
||||
if not names:
|
||||
return []
|
||||
lowers = [n.lower() for n in names]
|
||||
result = await session.execute(
|
||||
select(cls.id)
|
||||
.join(Roles, cls.role_id == Roles.id)
|
||||
.where(
|
||||
func.lower(Roles.role_name).in_(lowers),
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
@classmethod
|
||||
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||
"""Resolve {user_id: name} in a single query.
|
||||
|
|
|
|||
|
|
@ -60,5 +60,6 @@ export function toJobStatsView(row) {
|
|||
rejected: Number(row.rejected) || 0,
|
||||
approved: Number(row.approved) || 0,
|
||||
hired: Number(row.hired) || 0,
|
||||
reapplied: Number(row.reapplied) || 0,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,12 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
|||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const notifQuery = useQuery({ queryKey: qk.notifications.list({ top: 6 }), queryFn: fetchNotifications })
|
||||
const notifQuery = useQuery({
|
||||
queryKey: qk.notifications.list({ top: 6 }),
|
||||
queryFn: fetchNotifications,
|
||||
staleTime: 0,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const notifications = notifQuery.data?.items ?? []
|
||||
const unread = notifQuery.data?.unread ?? 0
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ export function useBadges() {
|
|||
return 0
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
|
|
@ -28,6 +29,12 @@ const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', '
|
|||
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
|
||||
function tabFromSearch(tabParam, visibleTabs, fallback) {
|
||||
if (!tabParam) return fallback
|
||||
const wanted = String(tabParam).trim()
|
||||
return visibleTabs.find((t) => t.toLowerCase() === wanted.toLowerCase()) || fallback
|
||||
}
|
||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||||
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
|
||||
|
|
@ -107,7 +114,12 @@ export default function CandidateProfile({
|
|||
const { can, user } = useAuth()
|
||||
const isManager = isHiringManager(user)
|
||||
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
|
||||
const [tab, setTab] = useState(isManager ? 'Forms' : 'Overview')
|
||||
const [searchParams] = useSearchParams()
|
||||
const [tab, setTab] = useState(() => tabFromSearch(
|
||||
variant === 'page' ? searchParams.get('tab') : null,
|
||||
visibleTabs,
|
||||
isManager ? 'Forms' : 'Overview',
|
||||
))
|
||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
|
||||
const isLive = Boolean(c.userId)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import AiFieldAssist from '../ui/AiFieldAssist'
|
||||
|
|
@ -83,6 +83,7 @@ export default function Jobs() {
|
|||
const { can } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
|
|
@ -106,26 +107,43 @@ export default function Jobs() {
|
|||
const [type, setType] = useState('')
|
||||
|
||||
const [viewing, setViewing] = useState(null)
|
||||
const [viewingTab, setViewingTab] = useState('details')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const canEdit = can('jobs.edit')
|
||||
const canDelete = can('jobs.delete')
|
||||
|
||||
// Deep-link intents from global search, the dashboard and the manager portal.
|
||||
// Consume once and replace history state: jobs refetch after a status PATCH
|
||||
// used to replay openCreate and pop the create modal over the detail view.
|
||||
// Deep-link intents from notifications, global search, the dashboard and the
|
||||
// manager portal. Consume once and replace history: jobs refetch after a
|
||||
// status PATCH used to replay openCreate and pop the create modal over the
|
||||
// detail view. `/jobs?job=` / `?tab=history` is the notification target.
|
||||
useEffect(() => {
|
||||
const st = location.state
|
||||
if (!st?.openCreate && !st?.openJob) return
|
||||
if (st.openCreate) setCreating(true)
|
||||
if (st.openJob) {
|
||||
const job = jobs.find((j) => j.id === st.openJob)
|
||||
if (job) setViewing(job)
|
||||
else if (!jobsQuery.isSuccess) return
|
||||
const jobId = searchParams.get('job') || st?.openJob
|
||||
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
||||
if (!st?.openCreate && !jobId) return
|
||||
if (st?.openCreate) setCreating(true)
|
||||
if (jobId) {
|
||||
const job = jobs.find((j) => j.id === jobId)
|
||||
if (job) {
|
||||
setViewing(job)
|
||||
setViewingTab(tab === 'history' ? 'history' : 'details')
|
||||
} else if (!jobsQuery.isSuccess) return
|
||||
}
|
||||
navigate('.', { replace: true, state: null })
|
||||
}, [location.state, jobs, jobsQuery.isSuccess, navigate])
|
||||
const next = new URLSearchParams(searchParams)
|
||||
let queryChanged = false
|
||||
if (next.has('job')) {
|
||||
next.delete('job')
|
||||
queryChanged = true
|
||||
}
|
||||
if (next.has('tab')) {
|
||||
next.delete('tab')
|
||||
queryChanged = true
|
||||
}
|
||||
if (queryChanged) setSearchParams(next, { replace: true })
|
||||
if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null })
|
||||
}, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewing) return
|
||||
|
|
@ -151,6 +169,7 @@ export default function Jobs() {
|
|||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.notifications.all() })
|
||||
setCreating(false)
|
||||
if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
|
||||
else toast('Job created', 'success')
|
||||
|
|
@ -184,6 +203,7 @@ export default function Jobs() {
|
|||
mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next),
|
||||
onSuccess: (_d, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.notifications.all() })
|
||||
toast(`Status set to ${vars.status}`, 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||||
|
|
@ -368,12 +388,14 @@ export default function Jobs() {
|
|||
|
||||
{viewing && (
|
||||
<JobDetail
|
||||
key={viewing.id}
|
||||
job={viewing}
|
||||
initialTab={viewingTab}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
statusBusy={setJobStatus.isPending}
|
||||
deleteBusy={deleteJob.isPending}
|
||||
onClose={() => setViewing(null)}
|
||||
onClose={() => { setViewing(null); setViewingTab('details') }}
|
||||
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
|
||||
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
||||
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
||||
|
|
@ -1320,10 +1342,10 @@ function JobCover({ jobId }) {
|
|||
}
|
||||
|
||||
function JobDetail({
|
||||
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||||
job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||||
statusLabels = jobsApi.JOB_STATUSES,
|
||||
}) {
|
||||
const [tab, setTab] = useState('details')
|
||||
const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
|
||||
const historyQuery = useQuery({
|
||||
queryKey: qk.assignments.job(j.id),
|
||||
queryFn: async () => {
|
||||
|
|
|
|||
|
|
@ -118,10 +118,16 @@ function JobDetail({ job }) {
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="progress-selected-totals">
|
||||
<div className="progress-selected-total">
|
||||
<strong>{job.total.toLocaleString()}</strong>
|
||||
<span>unique applicants</span>
|
||||
</div>
|
||||
<div className="progress-selected-total">
|
||||
<strong className={job.reapplied ? 'accent' : undefined}>{job.reapplied.toLocaleString()}</strong>
|
||||
<span>reapplied</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-wrap">
|
||||
|
|
@ -162,6 +168,11 @@ function JobDetail({ job }) {
|
|||
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
|
||||
<Metric value={job.offered.toLocaleString()} label="Offers made" accent />
|
||||
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
|
||||
<Metric
|
||||
value={job.reapplied.toLocaleString()}
|
||||
label={job.total ? `Reapplied (${Math.round((job.reapplied / job.total) * 100)}%)` : 'Reapplied'}
|
||||
accent={job.reapplied > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="progress-bottom-grid">
|
||||
|
|
@ -172,6 +183,10 @@ function JobDetail({ job }) {
|
|||
<span>Candidates on hold</span>
|
||||
<strong className="text-warning">{job.onHold}</strong>
|
||||
</div>
|
||||
<div className="progress-attention-item">
|
||||
<span>Reapplied</span>
|
||||
<strong className={job.reapplied ? 'text-warning' : undefined}>{job.reapplied}</strong>
|
||||
</div>
|
||||
<div className="progress-attention-item">
|
||||
<span>Job aging</span>
|
||||
<strong>
|
||||
|
|
|
|||
|
|
@ -2015,6 +2015,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
color: var(--text-2); font-size: var(--fs-sm); margin: 0;
|
||||
}
|
||||
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
|
||||
.progress-selected-totals { display: flex; gap: 22px; align-items: flex-start; }
|
||||
.progress-selected-total {
|
||||
display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 110px;
|
||||
}
|
||||
|
|
@ -2022,6 +2023,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
display: block; font-family: var(--font-display); font-size: 28px; font-weight: 600;
|
||||
letter-spacing: -.02em; line-height: 1; color: var(--text);
|
||||
}
|
||||
.progress-selected-total strong.accent { color: var(--warning); }
|
||||
.progress-selected-total span {
|
||||
display: block; font-size: var(--fs-xs); color: var(--text-2); margin-top: 4px;
|
||||
}
|
||||
|
|
@ -2130,7 +2132,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.progress-stage-row-pct { text-align: right; font-size: var(--fs-sm); color: var(--text-2); }
|
||||
|
||||
.progress-metric-grid {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
display: grid; grid-template-columns: repeat(5, minmax(90px, 1fr));
|
||||
gap: 1px; background: var(--border); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
|
|
@ -2169,6 +2171,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.progress-header-stats { width: 100%; justify-content: space-between; }
|
||||
.progress-stage-row { grid-template-columns: 96px minmax(80px, 1fr) 44px 36px; gap: 8px; }
|
||||
.progress-selected-total { align-items: flex-start; text-align: left; }
|
||||
.progress-selected-totals { width: 100%; justify-content: flex-start; }
|
||||
.progress-sidebar-controls { flex-wrap: wrap; }
|
||||
.progress-sort-chip { width: 100%; text-align: center; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue