pull/29/head
ahmed.mujtaba 2026-08-28 16:28:55 +05:00
commit 12fa4f87fc
61 changed files with 1801 additions and 721 deletions

3
.gitignore vendored
View File

@ -57,6 +57,9 @@ temp/
node_modules/ node_modules/
frontend/dist/ frontend/dist/
# Uploaded content — user data, never in git
backend/uploads/
**.pdf **.pdf
# Per-machine alembic autogen revisions only — the old bare `**_**_**.py` # Per-machine alembic autogen revisions only — the old bare `**_**_**.py`
# also swallowed any module with two underscores (e.g. test_talent_plugins.py). # also swallowed any module with two underscores (e.g. test_talent_plugins.py).

View File

@ -275,6 +275,128 @@ async def cv_upload(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv-bank/upload")
async def cv_bank_upload(
file: UploadFile = File(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Store a CV in the bank: the file plus its parsed text, nothing else.
No job, no user account, no inbox entry, no scoring the CV waits until a
recruiter picks it up. Email/name are captured only if the CV contains
them. The PDF bytes go INTO the database (cv_bank_files), never onto the
container filesystem, so production redeploys cannot lose a stored CV."""
from pathlib import PurePosixPath,PureWindowsPath
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from job.candidate.plugins import extract_candidate_email
try:
content=await file.read()
if len(content)>15*1024*1024:
raise HTTPException(status_code=413,detail="CV must be under 15 MB")
reader=FileRead(session=session,filename=file.filename,file=content)
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
text=parsed.get("text") or ""
detected,_=extract_candidate_email(text)
# Basename against both separator styles — a Windows client sends
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv(
session,
candidate_email=detected or "",
candidate_name="",
full_text=text,
file_name=original,
created_by=current_user.get("id"),
pdf_bytes=content,
)
return JSONResponse(content={"data":{
"id":str(row.id),
"file_name":row.file_name,
"candidate_email":row.candidate_email or None,
"created_at":row.created_at.isoformat() if row.created_at else None,
},"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/cv-bank/fetch")
async def cv_bank_fetch(
top: int = Query(100, ge=1, le=500),
skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""The stored-CV bank, newest first. Download the file via
GET /documents/download?manual_upload_candidate_id=<id>."""
from job.candidate.models import Manual_UPLOAD_CANDIDATE
try:
rows,total=await Manual_UPLOAD_CANDIDATE.list_bank(session,limit=top,offset=skip)
data=[{
"id":str(r.id),
"file_name":r.file_name,
"candidate_email":r.candidate_email or None,
"candidate_name":r.candidate_name or None,
"created_at":r.created_at.isoformat() if r.created_at else None,
} for r in rows]
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("/candidate/cv-bank/file")
async def cv_bank_file(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""The stored CV's bytes, straight from the database. Content-Disposition
carries the original filename so browser saves are named sensibly; the
frontend preview re-types the blob and renders it inline."""
from urllib.parse import quote
from job.candidate.models import CvBankFiles,Manual_UPLOAD_CANDIDATE
try:
row=await Manual_UPLOAD_CANDIDATE.get_by_id(session,id)
if not row or row.apply_via!="cv_bank":
raise HTTPException(status_code=404,detail="CV not found in the bank")
file_row=await CvBankFiles.get(session,row.id)
if not file_row:
raise HTTPException(status_code=404,detail="CV file is missing")
name=file_row.file_name or row.file_name or "cv.pdf"
return Response(
content=file_row.data,
media_type=file_row.content_type or "application/pdf",
headers={"Content-Disposition":f"attachment; filename*=UTF-8''{quote(name)}"},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.delete("/candidate/cv-bank/delete")
async def cv_bank_delete(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
session: AsyncSession = Depends(get_session),
):
from job.candidate.models import Manual_UPLOAD_CANDIDATE
try:
row=await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,id)
if not row:
raise HTTPException(status_code=404,detail="CV not found in the bank")
# Legacy rows from before bytes moved into the DB still carry a disk file.
FileRead.discard_upload(row.file_path)
return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/inbox-match") @router.post("/candidate/inbox-match")
async def candidate_inbox_match( async def candidate_inbox_match(
inbox_message_id: str = Query(...), inbox_message_id: str = Query(...),
@ -314,6 +436,54 @@ async def post_job(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/image/upload")
async def upload_job_image(
job_post_id: str = Form(...),
file: UploadFile = File(...),
current_user: dict = Depends(require_permission(
PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOBS_EDIT, require_all=False,
)),
session: AsyncSession = Depends(get_session),
):
"""Attach (or replace) the cover image of a job post. Stored in the
job_post_images table; the create flow calls this right after /job/post-job."""
try:
content=await file.read()
service=JobPost(session=session)
data=await service.save_job_image(
job_post_id,file.filename,file.content_type,content,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.get("/job/image/fetch")
async def fetch_job_image(
job_post_id: str = Query(...),
current_user: dict = Depends(require_permission(
PermissionTag.JOBS_VIEW, PermissionTag.JOB_BOARD_VIEW, require_all=False,
)),
session: AsyncSession = Depends(get_session),
):
"""The stored cover image, served inline from the database; 404 when the
post has none."""
try:
service=JobPost(session=session)
content,media_type=await service.get_job_image(job_post_id)
return Response(
content=content,
media_type=media_type,
headers={"Content-Disposition":"inline"},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
class JobAssistRequest(BaseModel): class JobAssistRequest(BaseModel):
field: Literal[ field: Literal[
"title", "department", "location", "salary", "title", "department", "location", "salary",

View File

@ -372,6 +372,132 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
out[key] = first out[key] = first
return out return out
# ---- CV bank -----------------------------------------------------------
# apply_via="cv_bank" rows are a private store of CVs with NO job, NO user
# account and NO inbox entry — deliberately invisible to Candidates,
# Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait
# until a recruiter picks them up; email is captured only when the CV
# contains one.
@classmethod
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
candidate_name, full_text, file_name,
created_by, pdf_bytes,
content_type="application/pdf"):
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit —
the PDF lives in the database, never on the container filesystem, so a
redeploy cannot lose a stored CV. file_path stays "" by design.
When the CV carries an email, the candidate ACCOUNT is created/reused
(same pattern as create_manual_upload_candidate) so the person shows
up on the Candidates screen; unlike an application there is still no
inbox entry, no scoring, and no setup email. A CV with no detectable
email banks fine and simply stays account-less."""
import os
from role.models import EnumRoles, Roles
from users.models import Users
from users.plugins import hash_password
email = (candidate_email or "").strip().lower()
user = None
if email:
user = await Users.get_user_by_email(session, email)
if not user:
role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value)
user = await Users.insert_user(session, {
"name": (candidate_name or "").strip() or email,
"email": email,
"role_id": role.id if role else 8,
"password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")),
"is_active": True,
"is_deleted": False,
})
elif user.is_deleted or not user.is_active:
# get_user_by_email returns soft-deleted accounts too; a fresh
# bank upload means the person is relevant again.
user.is_deleted = False
user.is_active = True
session.add(user)
row = cls(
candidate_email=email,
candidate_name=(candidate_name or "").strip() or (email or ""),
job_post_id=None,
full_text=full_text or "",
linkedin_slug=primary_slug_from_text(full_text or ""),
apply_via="cv_bank",
user_id=user.id if user else None,
created_by=cls._as_uuid(created_by),
status="BANKED",
file_name=(file_name or "").strip(),
file_path="",
)
session.add(row)
await session.flush()
session.add(CvBankFiles(
manual_upload_candidate_id=row.id,
content_type=content_type,
file_name=(file_name or "").strip(),
data=pdf_bytes,
))
await session.commit()
await session.refresh(row)
return row
@classmethod
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
total = (
await session.execute(
select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank")
)
).scalar() or 0
result = await session.execute(
select(cls)
.where(cls.apply_via == "cv_bank")
.order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all()), total
@classmethod
async def delete_bank_cv(cls, session: AsyncSession, record_id):
"""Hard delete, bank rows only — never reachable for application rows.
The cv_bank_files row goes with it via ON DELETE CASCADE."""
row = await cls.get_by_id(session, record_id)
if not row or row.apply_via != "cv_bank":
return None
file_row = await CvBankFiles.get(session, row.id)
if file_row:
await session.delete(file_row)
await session.delete(row)
await session.commit()
return row
class CvBankFiles(SQLModel, table=True):
"""PDF bytes of a CV-bank entry — in the database so production redeploys
(ephemeral container filesystems) can never lose a stored CV. Created in
prod by migrations/manual/010_cv_bank_files.sql."""
__tablename__ = "cv_bank_files"
manual_upload_candidate_id: uuid.UUID = Field(
primary_key=True, foreign_key="manual_upload_candidate.id",
)
content_type: str = Field(default="application/pdf")
file_name: str | None = Field(default=None)
data: bytes
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
async def get(cls, session: AsyncSession, manual_upload_candidate_id):
result = await session.execute(
select(cls).where(cls.manual_upload_candidate_id == manual_upload_candidate_id)
)
return result.scalars().first()
class Candidates(SQLModel, table=True): class Candidates(SQLModel, table=True):

View File

@ -195,11 +195,18 @@ class FileRead:
await self.session.commit() await self.session.commit()
created_at=datetime.now(timezone.utc).isoformat() created_at=datetime.now(timezone.utc).isoformat()
# Enqueue failure must not fail the upload: the CV row is already
# persisted, and with the broker down (optional locally) the kiq call
# raises a connection error. Suggestions just arrive later, or never.
try:
task=await match_uploaded_cv.kicker().with_labels( task=await match_uploaded_cv.kicker().with_labels(
created_at=created_at, created_at=created_at,
correlation_id=str(row.id), correlation_id=str(row.id),
queue=CV_QUEUE_NAME, queue=CV_QUEUE_NAME,
).kiq(str(row.id),force=False) ).kiq(str(row.id),force=False)
except Exception as e:
logger.warning("cv match enqueue skipped for %s: %s",row.id,e)
task=None
account_setup=None account_setup=None
if new_user_email: if new_user_email:
@ -229,7 +236,7 @@ class FileRead:
return { return {
"queued":True, "queued":True,
"inbox_message_id":str(row.id), "inbox_message_id":str(row.id),
"task_id":task.task_id, "task_id":task.task_id if task else None,
"filename":parsed.get("filename"), "filename":parsed.get("filename"),
"num_pages":parsed.get("num_pages"), "num_pages":parsed.get("num_pages"),
"candidate_email":email, "candidate_email":email,

View File

@ -262,6 +262,50 @@ class JobPosts(SQLModel, table=True):
return await cls.get_job_post_by_id(session, record_id) return await cls.get_job_post_by_id(session, record_id)
class JobPostImages(SQLModel, table=True):
"""Cover image of a job post, stored as bytes IN the database.
Deliberately not on disk: production containers have ephemeral filesystems,
so a file-backed image dies on every redeploy. One row per post the PK is
the job_posts FK, which makes re-upload a plain replace. Created in prod by
migrations/manual/009_job_post_images.sql (autogen is off there)."""
__tablename__ = "job_post_images"
job_post_id: uuid.UUID = Field(primary_key=True, foreign_key="job_posts.id")
content_type: str
file_name: str | None = Field(default=None)
data: bytes
uploaded_by: uuid.UUID | None = Field(default=None, 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))
@classmethod
async def get(cls, session: AsyncSession, job_post_id: uuid.UUID):
result = await session.execute(select(cls).where(cls.job_post_id == job_post_id))
return result.scalars().first()
@classmethod
async def upsert(cls, session: AsyncSession, job_post_id: uuid.UUID, *,
content_type: str, file_name: str | None, data: bytes,
uploaded_by: uuid.UUID | None):
row = await cls.get(session, job_post_id)
if row:
row.content_type = content_type
row.file_name = file_name
row.data = data
row.uploaded_by = uploaded_by
row.updated_at = _now()
else:
row = cls(
job_post_id=job_post_id, content_type=content_type,
file_name=file_name, data=data, uploaded_by=uploaded_by,
)
session.add(row)
await session.commit()
return row
class SocialPlatform(SQLModel, table=True): class SocialPlatform(SQLModel, table=True):
"""Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist.""" """Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist."""

View File

@ -100,7 +100,6 @@ def render_job_post(payload) -> str:
experience_max = payload.get("experience_max") experience_max = payload.get("experience_max")
requirements = [str(r).strip() for r in (payload.get("requirements") or []) if str(r).strip()] requirements = [str(r).strip() for r in (payload.get("requirements") or []) if str(r).strip()]
optional_skills = [str(s).strip() for s in (payload.get("optional_skills") or []) if str(s).strip()] optional_skills = [str(s).strip() for s in (payload.get("optional_skills") or []) if str(s).strip()]
salary = (payload.get("salary") or "Anonymous").strip() or "Anonymous"
description = (payload.get("description") or "").strip() description = (payload.get("description") or "").strip()
lines = [f"We're hiring: {title}", ""] lines = [f"We're hiring: {title}", ""]
@ -136,9 +135,6 @@ def render_job_post(payload) -> str:
lines.append(f"{item}") lines.append(f"{item}")
lines.append("") lines.append("")
lines.append(f"Salary: {salary}")
lines.append("")
if description: if description:
lines.append(description) lines.append(description)
lines.append("") lines.append("")

View File

@ -2,6 +2,7 @@ from datetime import date, time
import logging import logging
import os import os
import uuid import uuid
from pathlib import Path
import httpx import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
@ -9,7 +10,7 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
from job.job_post.models import JobPosts,SocialPlatform from job.job_post.models import JobPostImages,JobPosts,SocialPlatform
from users.models import Users from users.models import Users
from job.job_post.plugins import ( from job.job_post.plugins import (
BufferError, BufferError,
@ -26,6 +27,20 @@ from job.job_post.serializers import serialize_job_post, serialize_job_row
load_dotenv() load_dotenv()
logger=logging.getLogger("job.job_post") logger=logging.getLogger("job.job_post")
# Cover images live in the job_post_images table (bytea), NOT on disk:
# production containers have ephemeral filesystems, so a file-backed image
# would vanish on every redeploy. One row per post; re-upload replaces it.
ALLOWED_IMAGE_TYPES={"image/png","image/jpeg","image/webp","image/gif"}
IMAGE_TYPE_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"image/gif"}
MAX_JOB_IMAGE_BYTES=5*1024*1024
def _job_image_key(job_post_id) -> uuid.UUID:
try:
return uuid.UUID(str(job_post_id))
except ValueError as e:
raise HTTPException(status_code=422,detail="job_post_id must be a UUID") from e
class JobPostCreate(BaseModel): class JobPostCreate(BaseModel):
title: str title: str
@ -226,6 +241,43 @@ class JobPost:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
return {"id":str(row.id),"deleted":True} return {"id":str(row.id),"deleted":True}
async def save_job_image(self,job_post_id,filename,content_type,content,current_user):
if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated")
key=_job_image_key(job_post_id)
media=(content_type or "").lower()
if media not in ALLOWED_IMAGE_TYPES:
# Fall back to the filename extension; browsers occasionally send
# application/octet-stream for perfectly valid images.
suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower()
media=IMAGE_TYPE_BY_EXT.get(suffix)
if not media:
raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF")
if not content:
raise HTTPException(status_code=400,detail="Empty image upload")
if len(content)>MAX_JOB_IMAGE_BYTES:
raise HTTPException(status_code=413,detail="Image must be under 5 MB")
rows,total=await JobPosts.fetch_job_posts(self.session,ids=[str(key)],active_only=False)
if not total:
raise HTTPException(status_code=404,detail="Job post not found")
raw_user=(current_user or {}).get("id")
uploaded_by=uuid.UUID(str(raw_user)) if raw_user else None
await JobPostImages.upsert(
self.session,key,
content_type=media,
file_name=Path((filename or "").replace("\\","/")).name or None,
data=content,
uploaded_by=uploaded_by,
)
return {"job_post_id":str(key),"has_image":True}
async def get_job_image(self,job_post_id):
key=_job_image_key(job_post_id)
row=await JobPostImages.get(self.session,key)
if not row:
raise HTTPException(status_code=404,detail="No image for this job post")
return row.data,row.content_type
async def set_job_status(self,job_post_id,payload,current_user): async def set_job_status(self,job_post_id,payload,current_user):
if not current_user: if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated") raise HTTPException(status_code=401,detail="Not authenticated")

View File

@ -0,0 +1,21 @@
-- 009_job_post_images.sql
-- Cover images of job posts, stored IN the database (bytea) rather than on the
-- container filesystem, which is ephemeral in production — a disk-backed image
-- would vanish on every redeploy. One row per post: the PK doubles as the FK,
-- so a re-upload is a plain replace. 5 MB cap and type checks are enforced by
-- the API layer (backend/job/job_post/views.py save_job_image).
--
-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
-- and recorded in manual_migrations. Matches the SQLModel JobPostImages model in
-- backend/job/job_post/models.py (needed here because prod boots with
-- DB_AUTOGENERATE=false and never autogenerates new tables).
CREATE TABLE IF NOT EXISTS app.job_post_images (
job_post_id uuid PRIMARY KEY REFERENCES app.job_posts(id) ON DELETE CASCADE,
content_type varchar NOT NULL,
file_name varchar,
data bytea NOT NULL,
uploaded_by uuid REFERENCES app.users(id),
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1,21 @@
-- 010_cv_bank_files.sql
-- PDF bytes of CV-bank entries, stored IN the database. The bank's metadata
-- row lives in app.manual_upload_candidate (apply_via = 'cv_bank'); keeping
-- the file itself on the container filesystem would lose every stored CV on
-- redeploy, so bank uploads write the bytes here instead and no disk file is
-- created at all. PK doubles as the FK: one file per bank row, removed
-- automatically with it.
--
-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
-- and recorded in manual_migrations (prod boots with DB_AUTOGENERATE=false and
-- never autogenerates tables). Matches CvBankFiles in
-- backend/job/candidate/models.py.
CREATE TABLE IF NOT EXISTS app.cv_bank_files (
manual_upload_candidate_id uuid PRIMARY KEY
REFERENCES app.manual_upload_candidate(id) ON DELETE CASCADE,
content_type varchar NOT NULL DEFAULT 'application/pdf',
file_name varchar,
data bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW()
);

View File

@ -418,11 +418,17 @@ def _match_tokens(*texts) -> set[str]:
def relevance_score(job: dict, profile: dict) -> int: def relevance_score(job: dict, profile: dict) -> int:
"""0-100 job-fit rank for sorting, computed when a profile is persisted. """0-100 job-fit rank for sorting, computed when a profile is persisted.
Deterministic and free. Title component: the job title as an exact PHRASE Deterministic and free. Title component: a current title CONTAINING every
in the person's current title scores 55, in their headline 45; scattered job-title token scores 55 containment, not exact phrase, because job
titles rarely reappear verbatim ("Generative Engineer" vs the pool's
"Generative AI Engineer"; seen live: the phrase rule dropped every real
match to the scattered tier and compressed the whole pool into the 40s).
The job title as an exact phrase in the headline scores 45; scattered
token overlap caps at 35 a keyword-stuffed headline ("AI/ML Engineer | token overlap caps at 35 a keyword-stuffed headline ("AI/ML Engineer |
Python | FastAPI | ...") must not outrank someone whose title IS the job Python | FastAPI | ...") must not outrank someone whose title IS the job
title, which is exactly what token overlap alone did on live data. title, which is exactly what token overlap alone did on live data. The
headline tier stays phrase-only for the same reason: stuffed headlines
contain every token of every hot title.
Skills component (up to 45): GRADED token overlap between the content Skills component (up to 45): GRADED token overlap between the content
words of the job's requirements + optional skills and the person's words of the job's requirements + optional skills and the person's
@ -432,16 +438,20 @@ def relevance_score(job: dict, profile: dict) -> int:
whole live pool on exactly 60. whole live pool on exactly 60.
""" """
job_title = _clean_phrase(job.get("title")) job_title = _clean_phrase(job.get("title"))
job_title_tokens = set(job_title.split())
title_text = _clean_phrase(profile.get("current_title")) title_text = _clean_phrase(profile.get("current_title"))
headline_text = _clean_phrase(profile.get("headline")) headline_text = _clean_phrase(profile.get("headline"))
if job_title and job_title in title_text: if job_title and job_title_tokens <= set(title_text.split()):
title_component = 55.0 title_component = 55.0
elif job_title and job_title in headline_text: elif job_title and job_title in headline_text:
title_component = 45.0 title_component = 45.0
else: else:
title_tokens = set(job_title.split())
role_tokens = set(title_text.split()) | set(headline_text.split()) role_tokens = set(title_text.split()) | set(headline_text.split())
ratio = len(title_tokens & role_tokens) / len(title_tokens) if title_tokens else 0.0 ratio = (
len(job_title_tokens & role_tokens) / len(job_title_tokens)
if job_title_tokens
else 0.0
)
title_component = 35 * ratio title_component = 35 * ratio
job_tokens = _match_tokens( job_tokens = _match_tokens(

View File

@ -404,7 +404,25 @@ def test_headline_phrase_scores_below_title_phrase():
scattered = plugins.relevance_score(job, {"current_title": "Engineer", "headline": "Agentic AI | Python"}) scattered = plugins.relevance_score(job, {"current_title": "Engineer", "headline": "Agentic AI | Python"})
assert in_title == 55 assert in_title == 55
assert in_headline == 45 assert in_headline == 45
assert scattered == 35 # both tokens present but never as the phrase assert scattered == 35 # tokens split across title and headline never combine
def test_title_containment_scores_like_an_exact_title():
# Live case: job "Generative Engineer", pool titled "Generative AI
# Engineer" — the exact phrase never occurs, so every genuine match fell
# to the scattered 35 tier and the whole pool compressed into the 40s.
job = {"title": "Generative Engineer", "requirements": [], "optional_skills": []}
interleaved = plugins.relevance_score(job, {"current_title": "Generative AI Engineer"})
senior = plugins.relevance_score(job, {"current_title": "Senior Generative AI Engineer"})
assert interleaved == 55
assert senior == 55
# Containment applies to the TITLE only: the same tokens scattered across
# a keyword-stuffed headline still cap at the 35 tier.
stuffed = plugins.relevance_score(
job,
{"current_title": "Developer", "headline": "Generative AI | Engineer | Python"},
)
assert stuffed == 35
# ---------------------------------------------------------------- detail extraction # ---------------------------------------------------------------- detail extraction

View File

@ -16,15 +16,16 @@
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric- <!-- Type: Inter Tight (headings) + Inter as the metric-compatible
compatible stand-in for Neue Montreal, which is a licensed face. stand-in for Neue Montreal, which is a licensed face; if Neue
If Neue Montreal is installed locally it wins via the CSS stack. --> Montreal is installed locally it wins via the CSS stack.
Belleza is loaded for the wordmark only. -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<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&family=Inter+Tight:wght@500;600;700&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-CsixWp5R.js"></script> <script type="module" crossorigin src="/assets/index-OBhFgWnT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--H0MdQBQ.css"> <link rel="stylesheet" crossorigin href="/assets/index-C1VjJy57.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -16,12 +16,13 @@
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric- <!-- Type: Inter Tight (headings) + Inter as the metric-compatible
compatible stand-in for Neue Montreal, which is a licensed face. stand-in for Neue Montreal, which is a licensed face; if Neue
If Neue Montreal is installed locally it wins via the CSS stack. --> Montreal is installed locally it wins via the CSS stack.
Belleza is loaded for the wordmark only. -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<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&family=Inter+Tight:wght@500;600;700&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>" />
</head> </head>
<body> <body>

View File

@ -12,7 +12,7 @@
function returns the parsed {data, total, status_code} envelope. function returns the parsed {data, total, status_code} envelope.
============================================================ */ ============================================================ */
import { downloadFile, request } from '../lib/apiClient' import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
/** Active job posts for pickers. Needs job_board.view OR candidates.view. /** Active job posts for pickers. Needs job_board.view OR candidates.view.
* *
@ -53,6 +53,45 @@ export function scoreUploads(jobId, files) {
return request('/candidate/score', { method: 'POST', body: form }) return request('/candidate/score', { method: 'POST', body: form })
} }
/**
* CV bank a private store of CVs with NO job, NO user account and NO inbox
* entry (POST /candidate/cv-bank/upload). Nothing is scored; the file just
* waits until a recruiter picks it up. Email is captured only when the CV
* contains one. One file per request. Needs candidates.create.
*/
export function uploadToCvBank(file) {
const form = new FormData()
form.append('file', file, file.name)
return request('/candidate/cv-bank/upload', { method: 'POST', body: form })
}
/** The stored-CV bank, newest first — GET /candidate/cv-bank/fetch. */
export function listCvBank({ top = 100, skip = 0 } = {}) {
return request('/candidate/cv-bank/fetch', { params: { top, skip } })
}
/** Permanently remove a stored CV (file included). Needs candidates.delete. */
export function deleteCvBankCv(id) {
return request('/candidate/cv-bank/delete', { method: 'DELETE', params: { id } })
}
/** Browser-save a stored CV's PDF — served from the database (cv_bank_files). */
export function downloadCvBankCv(id) {
return downloadFile('/candidate/cv-bank/file', { params: { id } })
}
/**
* Object URL of a stored CV for IN-APP preview (no download). The route sends
* an attachment disposition, so the blob is re-typed to application/pdf for
* the browser's inline viewer. Caller revokes the URL when the preview closes.
*/
export function viewCvBankCv(id) {
return fetchBlobUrl('/candidate/cv-bank/file', {
params: { id },
type: 'application/pdf',
})
}
/** /**
* Score the decoded attachments of inbox messages against a job post. * Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id` * Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`

View File

@ -1,4 +1,4 @@
import { downloadFile, request } from '../lib/apiClient' import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
/** /**
* Job requisitions backend/job/app.py `GET /jobs/fetch`. * Job requisitions backend/job/app.py `GET /jobs/fetch`.
@ -57,7 +57,6 @@ export function toJobView(row) {
experienceMin: row.experience_min, experienceMin: row.experience_min,
experienceMax: row.experience_max, experienceMax: row.experience_max,
experience: experienceLabel(row.experience_min, row.experience_max), experience: experienceLabel(row.experience_min, row.experience_max),
salary: row.salary,
skills: row.requirements ?? [], skills: row.requirements ?? [],
optionalSkills: row.optional_skills ?? [], optionalSkills: row.optional_skills ?? [],
description: row.description, description: row.description,
@ -97,6 +96,19 @@ export function remove(jobPostId) {
}) })
} }
/** Attach or replace a job post's cover image — POST /job/image/upload (multipart). */
export function uploadImage(jobPostId, file) {
const fd = new FormData()
fd.append('job_post_id', jobPostId)
fd.append('file', file)
return request('/job/image/upload', { method: 'POST', body: fd })
}
/** Object URL of the cover image, or null when the post has none. Caller revokes. */
export function fetchImageUrl(jobPostId) {
return fetchBlobUrl('/job/image/fetch', { params: { job_post_id: jobPostId } })
}
/** `status` is the Jobs UI label (Open / Closed / On Hold) or a raw requisition_status. */ /** `status` is the Jobs UI label (Open / Closed / On Hold) or a raw requisition_status. */
export function setStatus(jobPostId, status) { export function setStatus(jobPostId, status) {
const requisition_status = LABEL_TO_STATUS[status] ?? status const requisition_status = LABEL_TO_STATUS[status] ?? status

View File

@ -7,6 +7,7 @@ import AiDock from './AiDock'
import { ROUTE_BY_PATH } from './routes' import { ROUTE_BY_PATH } from './routes'
import { useBadges, useHotkeys, useNavOpen, useRouteMeta, useSidebarCollapsed } from './useShell' import { useBadges, useHotkeys, useNavOpen, useRouteMeta, useSidebarCollapsed } from './useShell'
import Icon from '../ui/icons' import Icon from '../ui/icons'
import ErrorBoundary from '../components/ErrorBoundary'
import Spinner from '../components/Spinner' import Spinner from '../components/Spinner'
export default function AppLayout() { export default function AppLayout() {
@ -38,6 +39,7 @@ export default function AppLayout() {
return ( return (
<div id="app"> <div id="app">
<a className="skip-link" href="#main-content">Skip to content</a>
<Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
mobileOpen={navOpen} mobileOpen={navOpen}
@ -48,9 +50,12 @@ export default function AppLayout() {
<div className="main-wrap"> <div className="main-wrap">
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} /> <Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} />
<main className="content" id="main-content" ref={contentRef}> <main className="content" id="main-content" ref={contentRef}>
{/* Keyed by pathname: navigating away from a crashed screen resets it. */}
<ErrorBoundary key={location.pathname}>
<Suspense fallback={<div className="route-loading"><Spinner label="Loading" /></div>}> <Suspense fallback={<div className="route-loading"><Spinner label="Loading" /></div>}>
<Outlet /> <Outlet />
</Suspense> </Suspense>
</ErrorBoundary>
</main> </main>
</div> </div>

View File

@ -34,7 +34,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
</button> </button>
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav" aria-label="Primary">
{NAV_GROUPS.map((group) => { {NAV_GROUPS.map((group) => {
const items = visible.filter((r) => r.group === group) const items = visible.filter((r) => r.group === group)
if (!items.length) return null if (!items.length) return null

View File

@ -137,11 +137,11 @@ export default function Topbar({ onOpenNav, searchRef }) {
<div className="notif-row"><div className="notif-text">No notifications yet.</div></div> <div className="notif-row"><div className="notif-text">No notifications yet.</div></div>
)} )}
{notifications.map((n) => ( {notifications.map((n) => (
<div <button
key={n.id} key={n.id}
type="button"
className={`notif-row${n.unread ? ' unread' : ''}`} className={`notif-row${n.unread ? ' unread' : ''}`}
onClick={() => openNotif(n)} onClick={() => openNotif(n)}
style={{ cursor: 'pointer' }}
> >
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span> <span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
<div className="notif-body"> <div className="notif-body">
@ -149,7 +149,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
{n.text && <div className="notif-text">{n.text}</div>} {n.text && <div className="notif-text">{n.text}</div>}
<div className="notif-time">{n.time}</div> <div className="notif-time">{n.time}</div>
</div> </div>
</div> </button>
))} ))}
</div> </div>
<div className="dropdown-foot"> <div className="dropdown-foot">

View File

@ -36,6 +36,16 @@ export function useNavOpen() {
} }
}, [navOpen]) }, [navOpen])
// The drawer only exists ≤900px (styles.css). If the window grows past the
// breakpoint while it is open, nav-open would strand the scrim over the
// desktop layout with body scroll locked — close it instead.
useEffect(() => {
const mq = window.matchMedia('(max-width: 900px)')
const onChange = (e) => { if (!e.matches) setNavOpen(false) }
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return [navOpen, setNavOpen] return [navOpen, setNavOpen]
} }

View File

@ -0,0 +1,39 @@
/* ============================================================
ErrorBoundary a throw in any lazy screen used to blank the whole app.
Mounted around the route outlet in AppLayout, keyed by pathname so simply
navigating away resets it.
============================================================ */
import { Component } from 'react'
import { EmptyState } from '../ui/primitives'
export default class ErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error }
}
componentDidCatch(error, info) {
console.error('Screen crashed:', error, info?.componentStack)
}
render() {
if (this.state.error) {
return (
<div className="page">
<EmptyState icon="alert" title="Something went wrong">
This screen hit an unexpected error. The rest of the app is fine
try again, or head back to the dashboard.
</EmptyState>
<div style={{ textAlign: 'center' }}>
<button className="btn btn-secondary" onClick={() => this.setState({ error: null })}>
Try again
</button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@ -35,7 +35,7 @@ export default function PasswordField({
className="pw-toggle" className="pw-toggle"
onClick={() => setVisible((v) => !v)} onClick={() => setVisible((v) => !v)}
aria-label={visible ? 'Hide password' : 'Show password'} aria-label={visible ? 'Hide password' : 'Show password'}
tabIndex={-1} aria-pressed={visible}
> >
{visible ? ( {visible ? (
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">

View File

@ -187,6 +187,60 @@ export async function downloadFile(path, { params, auth = true, filename } = {})
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
/**
* Authenticated binary GET returning an object URL for <img>/<video>/<iframe>
* use a plain src attribute cannot carry the bearer token. Returns null on
* 404 (the resource legitimately not existing, e.g. a job with no cover
* image). `type` re-wraps the blob with that MIME type: routes that send
* application/octet-stream would otherwise trigger a download instead of the
* browser's inline viewer. Callers own the URL: revoke it when done.
*/
export async function fetchBlobUrl(path, { params, auth = true, type } = {}) {
if (auth && isExpiring()) {
try {
await refreshSession()
} catch {
/* fall through — the 401 path below makes the final call */
}
}
const send = async () => {
const headers = { Accept: '*/*' }
const bearer = auth ? getAccessToken() : null
if (bearer) headers.Authorization = `Bearer ${bearer}`
return fetch(buildUrl(path, params), { method: 'GET', headers })
}
let res
try {
res = await send()
} catch (err) {
if (err?.name === 'AbortError') throw err
throw new ApiError('Unable to reach the server. Check your connection.', 0, null)
}
if (res.status === 401 && auth) {
try {
await refreshSession()
} catch (err) {
if (err instanceof SessionExpiredError) onSessionExpired()
throw err
}
res = await send()
if (res.status === 401) {
onSessionExpired()
throw new ApiError('Session expired', 401, null)
}
}
if (res.status === 404) return null
if (!res.ok) {
throw new ApiError(res.statusText || 'Request failed', res.status, null)
}
const blob = await res.blob()
return URL.createObjectURL(type ? new Blob([blob], { type }) : blob)
}
function filenameFromDisposition(header) { function filenameFromDisposition(header) {
if (!header) return null if (!header) return null
const star = /filename\*=UTF-8''([^;]+)/i.exec(header) const star = /filename\*=UTF-8''([^;]+)/i.exec(header)

View File

@ -42,6 +42,10 @@ export const qk = {
list: (p = {}) => ['assessments', 'list', p], list: (p = {}) => ['assessments', 'list', p],
counts: () => ['assessments', 'counts'], counts: () => ['assessments', 'counts'],
}, },
cvBank: {
all: () => ['cvBank'],
list: () => ['cvBank', 'list'],
},
notifications: { notifications: {
all: () => ['notifications'], all: () => ['notifications'],
list: (p = {}) => ['notifications', 'list', p], list: (p = {}) => ['notifications', 'list', p],

View File

@ -3,9 +3,10 @@ import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// The frozen design contract (ADR 0013 §1): 1,269 lines, 93 tokens, dual // Design contract (ADR 0013 §1), amended 2026-08-25: the COLOR palette and
// themes, WCAG 2.1 AA verified across 23 routes. Content-frozen new CSS may // color tokens remain frozen. Typography and spacing were re-tokenized in the
// only use existing var(--) tokens. // ui-polish pass (--fs-*/--lh-*/--space-*/--gap); new CSS must use var(--)
// tokens only no raw font sizes, spacing, or colors.
import './styles/styles.css' import './styles/styles.css'
import './styles/auth.css' import './styles/auth.css'

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import Chat from '../app/ai/Chat' import Chat from '../app/ai/Chat'
import PageHeader from '../ui/PageHeader'
import { Icon } from '../ui/primitives' import { Icon } from '../ui/primitives'
export default function AiAssistant() { export default function AiAssistant() {
@ -8,20 +9,18 @@ export default function AiAssistant() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="AI Assistant"
<h1 className="page-title">AI Assistant</h1> sub="Your recruiting copilot — powered by AI (interface preview)"
<p className="page-sub">Your recruiting copilot powered by AI (interface preview)</p> actions={<>
</div>
<div className="page-head-actions">
<span className="integration-status pending"> <span className="integration-status pending">
<span className="pulse" />Model endpoint · Not connected <span className="pulse" />Model endpoint · Not connected
</span> </span>
<button className="btn btn-secondary" onClick={() => setResetKey((k) => k + 1)}> <button className="btn btn-secondary" onClick={() => setResetKey((k) => k + 1)}>
<Icon name="plus" /> New Chat <Icon name="plus" /> New Chat
</button> </button>
</div> </>}
</div> />
<div className="card"> <div className="card">
<div className="card-body"> <div className="card-body">
<Chat resetKey={resetKey} /> <Chat resetKey={resetKey} />

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, Icon } from '../ui/primitives' import { Badge, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { aiModules } from '../data/seed' import { aiModules } from '../data/seed'
@ -11,20 +12,16 @@ export default function AiStudio() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="AI Studio"
<h1 className="page-title">AI Studio</h1> sub="Next-generation AI modules — designed and API-ready for backend integration"
<p className="page-sub"> actions={
Next-generation AI modules designed and API-ready for backend integration
</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />{betaCount} in Beta</span> <span className="integration-status pending"><span className="pulse" />{betaCount} in Beta</span>
</div> }
</div> />
<div className="card brand-hero mb-18"> <div className="card brand-hero mb-18">
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap' }}> <div className="card-body flex items-center flex-wrap" style={{ gap: 20 }}>
<div className="ai-logo" style={{ margin: 0, width: 56, height: 56 }}><Icon name="sparkles" /></div> <div className="ai-logo" style={{ margin: 0, width: 56, height: 56 }}><Icon name="sparkles" /></div>
<div style={{ flex: 1, minWidth: 220 }}> <div style={{ flex: 1, minWidth: 220 }}>
<h2 style={{ fontSize: 19, marginBottom: 4 }}>Everything is API-ready</h2> <h2 style={{ fontSize: 19, marginBottom: 4 }}>Everything is API-ready</h2>
@ -78,7 +75,7 @@ export default function AiStudio() {
</div> </div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}> <div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body"> <div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>API Contract (preview)</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>API Contract (preview)</h3>
<pre className="resume-thumb" style={{ maxHeight: 'none' }}> <pre className="resume-thumb" style={{ maxHeight: 'none' }}>
{`POST /api/ai/${detail.name.toLowerCase().replace(/ /g, '-')} {`POST /api/ai/${detail.name.toLowerCase().replace(/ /g, '-')}
{ {

View File

@ -31,6 +31,7 @@ import { useMutation, useQueries, useQuery } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart' import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts' import Charts from '../lib/charts'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { EmptyState, Icon } from '../ui/primitives' import { EmptyState, Icon } from '../ui/primitives'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -396,12 +397,10 @@ export default function Analytics() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Analytics"
<h1 className="page-title">Analytics</h1> sub="Deep-dive metrics across your recruitment funnel"
<p className="page-sub">Deep-dive metrics across your recruitment funnel</p> actions={<>
</div>
<div className="page-head-actions">
<div className="pill-tabs"> <div className="pill-tabs">
{RANGES.map((r) => ( {RANGES.map((r) => (
<span <span
@ -426,8 +425,8 @@ export default function Analytics() {
<option key={r.id} value={r.id}>{r.name}</option> <option key={r.id} value={r.id}>{r.name}</option>
))} ))}
</select> </select>
</div> </>}
</div> />
{kpisQuery.isError && ( {kpisQuery.isError && (
<div className="card mb-18"> <div className="card mb-18">

View File

@ -13,7 +13,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, ProgressBar, ScoreChip } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, ProgressBar, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout' import { useFormState } from '../components/AuthLayout'
@ -168,10 +169,11 @@ export default function Assessments() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (a) => ( render: (a) => (
<div className="row-actions"> <div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(a)}><Icon name="eye" /></button> <button className="act-btn" data-tip="View" aria-label="View assessment" onClick={() => setViewing(a)}><Icon name="eye" /></button>
<button <button
className="act-btn" className="act-btn"
data-tip="Remind" data-tip="Remind"
aria-label="Send reminder"
disabled={!canEdit || remind.isPending} disabled={!canEdit || remind.isPending}
title={!canEdit ? 'Requires assessments.edit' : undefined} title={!canEdit ? 'Requires assessments.edit' : undefined}
onClick={() => remind.mutate(a.id)} onClick={() => remind.mutate(a.id)}
@ -185,12 +187,10 @@ export default function Assessments() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Assessments"
<h1 className="page-title">Assessments</h1> sub="Coding tests, take-homes, and evaluations"
<p className="page-sub">Coding tests, take-homes, and evaluations</p> actions={
</div>
<div className="page-head-actions">
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={!canCreate} disabled={!canCreate}
@ -199,8 +199,8 @@ export default function Assessments() {
> >
<Icon name="plus" /> Assign Assessment <Icon name="plus" /> Assign Assessment
</button> </button>
</div> }
</div> />
<div className="grid g-kpi mb-18"> <div className="grid g-kpi mb-18">
<KpiCard label="Total Assigned" value={totalCount} icon="file" tone="i-indigo" /> <KpiCard label="Total Assigned" value={totalCount} icon="file" tone="i-indigo" />
@ -212,7 +212,7 @@ export default function Assessments() {
<div className="card"> <div className="card">
{listQuery.isPending && ( {listQuery.isPending && (
<div className="card-body"> <div className="card-body">
<EmptyState icon="check-square" title="Loading…">Fetching assessments from the server.</EmptyState> <SkeletonRows rows={6} />
</div> </div>
)} )}
{listQuery.isError && ( {listQuery.isError && (
@ -297,7 +297,7 @@ export default function Assessments() {
<div style={{ textAlign: 'center', padding: '10px 0' }}> <div style={{ textAlign: 'center', padding: '10px 0' }}>
<div <div
style={{ style={{
fontSize: 44, fontWeight: 800, letterSpacing: -1, fontFamily: 'var(--font-display)', fontSize: 44, fontWeight: 600, letterSpacing: '-0.02em',
color: viewing.score >= 70 ? 'var(--success)' : 'var(--warning)', color: viewing.score >= 70 ? 'var(--success)' : 'var(--warning)',
}} }}
> >
@ -308,7 +308,7 @@ export default function Assessments() {
<div className="mb-18"><ProgressBar pct={viewing.score} /></div> <div className="mb-18"><ProgressBar pct={viewing.score} /></div>
{viewing.sectionScores.length > 0 && ( {viewing.sectionScores.length > 0 && (
<> <>
<div className="form-section-title" style={{ marginTop: 0 }}>Section Breakdown</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Section Breakdown</h3>
{viewing.sectionScores.map((s) => ( {viewing.sectionScores.map((s) => (
<div className="flex items-center gap-12" style={{ marginBottom: 10 }} key={s.label}> <div className="flex items-center gap-12" style={{ marginBottom: 10 }} key={s.label}>
<span style={{ width: 130, fontSize: 13 }}>{s.label}</span> <span style={{ width: 130, fontSize: 13 }}>{s.label}</span>
@ -320,11 +320,9 @@ export default function Assessments() {
)} )}
</> </>
) : ( ) : (
<div className="empty-state"> <EmptyState icon="clock" title="Assessment not completed">
<Icon name="clock" /> Results will appear once the candidate submits.
<h3>Assessment not completed</h3> </EmptyState>
<p>Results will appear once the candidate submits.</p>
</div>
)} )}
</Modal> </Modal>
)} )}

View File

@ -16,6 +16,7 @@ import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import { Avatar, EmptyState, Icon } from '../ui/primitives' import { Avatar, EmptyState, Icon } from '../ui/primitives'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -122,15 +123,10 @@ export default function Calendar() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Calendar"
<h1 className="page-title">Calendar</h1> sub={`Interview schedule at a glance${monthQuery.isSuccess ? ` · ${events.length} this month` : ''}`}
<p className="page-sub"> actions={<>
Interview schedule at a glance
{monthQuery.isSuccess ? ` · ${events.length} this month` : ''}
</p>
</div>
<div className="page-head-actions">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<button className="btn btn-icon btn-secondary" onClick={() => step(-1)} aria-label="Previous month"> <button className="btn btn-icon btn-secondary" onClick={() => step(-1)} aria-label="Previous month">
<Icon name="chevron-left" /> <Icon name="chevron-left" />
@ -152,8 +148,8 @@ export default function Calendar() {
> >
<Icon name="plus" /> Schedule <Icon name="plus" /> Schedule
</button> </button>
</div> </>}
</div> />
{monthQuery.isError ? ( {monthQuery.isError ? (
<div className="card"> <div className="card">

View File

@ -278,7 +278,7 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
</Badge> </Badge>
)} )}
{canEdit && ( {canEdit && (
<button className="act-btn" data-tip="Amend this form" onClick={() => onEdit(r)}> <button className="act-btn" data-tip="Amend this form" aria-label="Amend this form" onClick={() => onEdit(r)}>
<Icon name="edit" /> <Icon name="edit" />
</button> </button>
)} )}

View File

@ -378,7 +378,10 @@ export default function CandidateProfile({
<> <>
<div style={LABEL}>Assigned Role</div> <div style={LABEL}>Assigned Role</div>
<div className="k-tags" style={{ marginBottom: 14 }}> <div className="k-tags" style={{ marginBottom: 14 }}>
<span className="tag" style={{ background: 'var(--primary-soft)', color: 'var(--primary-fg)' }}> {/* --primary-fg is the on-solid-primary text color (white in
light theme) on --primary-soft it was white-on-mint,
unreadable. Soft chips pair with --primary (see .b-indigo). */}
<span className="tag" style={{ background: 'var(--primary-soft)', color: 'var(--primary)' }}>
{live.assigned_job_post.title} {live.assigned_job_post.title}
</span> </span>
</div> </div>
@ -424,13 +427,13 @@ export default function CandidateProfile({
<h3 style={{ marginBottom: 4 }}>{c.name}</h3> <h3 style={{ marginBottom: 4 }}>{c.name}</h3>
<p className="text-muted">{c.currentTitle} · {c.location}</p> <p className="text-muted">{c.currentTitle} · {c.location}</p>
<div className="divider" /> <div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Summary</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Summary</h3>
<p className="text-muted"> <p className="text-muted">
Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience
across {c.department.toLowerCase()}. Passionate about building high-quality products across {c.department.toLowerCase()}. Passionate about building high-quality products
and collaborating with cross-functional teams. and collaborating with cross-functional teams.
</p> </p>
<div className="form-section-title">Experience</div> <h3 className="form-section-title">Experience</h3>
<div className="info-item"> <div className="info-item">
<div className="iv">{c.currentTitle} {c.currentCompany}</div> <div className="iv">{c.currentTitle} {c.currentCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2021 Present</div> <div className="il" style={{ textTransform: 'none' }}>2021 Present</div>
@ -439,7 +442,7 @@ export default function CandidateProfile({
<div className="iv">Associate {priorCompany}</div> <div className="iv">Associate {priorCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2018 2021</div> <div className="il" style={{ textTransform: 'none' }}>2018 2021</div>
</div> </div>
<div className="form-section-title">Education</div> <h3 className="form-section-title">Education</h3>
<div className="iv">{c.education}</div> <div className="iv">{c.education}</div>
</div> </div>
</div> </div>
@ -583,7 +586,7 @@ export default function CandidateProfile({
<Icon name="file" /> <Icon name="file" />
</span> </span>
<div className="lr-main"><div className="lr-title">{d.n}</div><div className="lr-sub">{d.s}</div></div> <div className="lr-main"><div className="lr-title">{d.n}</div><div className="lr-sub">{d.s}</div></div>
<button className="act-btn" onClick={() => toast(`Downloading ${d.n}`, 'info')}> <button className="act-btn" data-tip="Download" aria-label={`Download ${d.n}`} onClick={() => toast(`Downloading ${d.n}`, 'info')}>
<Icon name="download" /> <Icon name="download" />
</button> </button>
</div> </div>
@ -932,7 +935,7 @@ function InterviewTab({ userId, inboxId, rows }) {
)} )}
<div className="divider" /> <div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Schedule an interview</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Schedule an interview</h3>
<div className="form-grid"> <div className="form-grid">
<div className="form-field"> <div className="form-field">
<label>Type</label> <label>Type</label>
@ -1076,7 +1079,7 @@ function NoteRow({ note: n, userId }) {
</div> </div>
{mine && ( {mine && (
<div className="lr-right"> <div className="lr-right">
<button className="act-btn" data-tip="Edit note" onClick={() => setEditing(true)}> <button className="act-btn" data-tip="Edit note" aria-label="Edit note" onClick={() => setEditing(true)}>
<Icon name="edit" /> <Icon name="edit" />
</button> </button>
</div> </div>
@ -1135,7 +1138,7 @@ function ActivityTab({ userId, inboxId, rows }) {
)} )}
<div className="divider" /> <div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Log activity</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Log activity</h3>
<div className="form-grid"> <div className="form-grid">
<div className="form-field"> <div className="form-field">
<label>Type</label> <label>Type</label>
@ -1199,6 +1202,7 @@ function DocumentsTab({ rows, inboxId }) {
className="act-btn" className="act-btn"
disabled={!inboxId || download.isPending} disabled={!inboxId || download.isPending}
title={!inboxId ? 'No application id for download' : 'Download'} title={!inboxId ? 'No application id for download' : 'Download'}
aria-label={`Download ${d.name}`}
onClick={() => download.mutate({ index: i, filename: d.name })} onClick={() => download.mutate({ index: i, filename: d.name })}
> >
<Icon name="download" /> <Icon name="download" />
@ -1312,7 +1316,7 @@ function FeedbackRow({ row: f, userId }) {
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{f.review ? <Badge>{f.review}</Badge> : null} {f.review ? <Badge>{f.review}</Badge> : null}
{mine && ( {mine && (
<button className="act-btn" data-tip="Revise scorecard" onClick={() => setEditing(true)}> <button className="act-btn" data-tip="Revise scorecard" aria-label="Revise scorecard" onClick={() => setEditing(true)}>
<Icon name="edit" /> <Icon name="edit" />
</button> </button>
)} )}
@ -1358,7 +1362,7 @@ function FeedbackTab({ userId, inboxId, rows }) {
)} )}
<div className="divider" /> <div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</h3>
<div className="form-grid"> <div className="form-grid">
<div className="form-field"> <div className="form-field">
<label>Recommendation</label> <label>Recommendation</label>

View File

@ -14,8 +14,9 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable' import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile' import { useJobTitles } from './ScoredCandidateProfile'
@ -297,14 +298,10 @@ export default function Candidates() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Candidates"
<h1 className="page-title">Candidates</h1> sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>}
<p className="page-sub"> actions={<>
{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}> <button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<Icon name="download" /> Export <Icon name="download" /> Export
</button> </button>
@ -314,11 +311,11 @@ export default function Candidates() {
<button className="btn btn-primary" onClick={() => setAdding(true)}> <button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate <Icon name="plus" /> Add Candidate
</button> </button>
</div> </>}
</div> />
{recentChips.length > 0 && ( {recentChips.length > 0 && (
<div className="flex items-center gap-8" style={{ marginBottom: 14, flexWrap: 'wrap' }}> <div className="flex items-center gap-8 flex-wrap mb-12">
<span className="text-muted text-sm fw-600">Recently viewed:</span> <span className="text-muted text-sm fw-600">Recently viewed:</span>
{recentChips.map((c) => ( {recentChips.map((c) => (
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}> <button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
@ -361,7 +358,7 @@ export default function Candidates() {
{candidatesQuery.isPending && ( {candidatesQuery.isPending && (
<div className="card-body"> <div className="card-body">
<EmptyState icon="users" title="Loading…">Fetching candidates from the server.</EmptyState> <SkeletonRows rows={6} />
</div> </div>
)} )}
{candidatesQuery.isError && ( {candidatesQuery.isError && (
@ -376,30 +373,7 @@ export default function Candidates() {
<div className="dt"> <div className="dt">
<div className="table-wrap"> <div className="table-wrap">
<table className="data"> <table className="data">
<thead> <DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody> <tbody>
{t.pageRows.length === 0 ? ( {t.pageRows.length === 0 ? (
<tr> <tr>
@ -413,7 +387,7 @@ export default function Candidates() {
t.pageRows.map((c) => ( t.pageRows.map((c) => (
<tr <tr
key={c.id} key={c.id}
style={{ cursor: 'pointer' }} className="row-click"
onClick={() => openProfile(c)} onClick={() => openProfile(c)}
> >
<td> <td>
@ -431,7 +405,7 @@ export default function Candidates() {
</div> </div>
</td> </td>
<td> <td>
<span className="text-sm">{c.email ?? '—'}</span> <span className="text-sm cell-clip" title={c.email || undefined}>{c.email ?? '—'}</span>
</td> </td>
<td> <td>
{c.isActive {c.isActive
@ -458,7 +432,7 @@ export default function Candidates() {
setPage={setPage} setPage={setPage}
pageButtons={pageWindow(currentPage, pages)} pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize} pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setPage(1) }} onPageSizeChange={(n) => { setPageSize(n); setPage((p) => pageAfterSizeChange(p, total, n)) }}
pageSizeMax={500} pageSizeMax={500}
/> />
</div> </div>
@ -636,7 +610,7 @@ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
</div> </div>
</div> </div>
<div> <div>
<div className="form-section-title" style={{ marginTop: 0 }}>Assessment</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Assessment</h3>
<p className="text-muted" style={{ fontSize: 13 }}>{critique ?? '—'}</p> <p className="text-muted" style={{ fontSize: 13 }}>{critique ?? '—'}</p>
</div> </div>
</div> </div>
@ -646,9 +620,9 @@ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
<div className="info-item"><div className="il">Scored On</div><div className="iv">{scoredOn ?? '—'}</div></div> <div className="info-item"><div className="il">Scored On</div><div className="iv">{scoredOn ?? '—'}</div></div>
</div> </div>
<div className="form-section-title" style={{ marginTop: 0 }}> <h3 className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({matched.length}) Matched Skills ({matched.length})
</div> </h3>
<div className="k-tags" style={{ marginBottom: 16 }}> <div className="k-tags" style={{ marginBottom: 16 }}>
{matched.length {matched.length
? matched.map((s) => ( ? matched.map((s) => (
@ -657,9 +631,9 @@ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
: <span className="text-muted"></span>} : <span className="text-muted"></span>}
</div> </div>
<div className="form-section-title" style={{ marginTop: 0 }}> <h3 className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({missing.length}) Missing Skills ({missing.length})
</div> </h3>
<div className="k-tags"> <div className="k-tags">
{missing.length {missing.length
? missing.map((s) => ( ? missing.map((s) => (
@ -935,9 +909,9 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
{/* Role selection sits directly above the CV because it is what the CV {/* Role selection sits directly above the CV because it is what the CV
gets scored against same card UI as Job Matching's role list. gets scored against same card UI as Job Matching's role list.
(.req is scoped to `.form-field label .req`, so tint it here.) */} (.req is scoped to `.form-field label .req`, so tint it here.) */}
<div className="form-section-title"> <h3 className="form-section-title">
Applied Job <span className="req" style={{ color: 'var(--danger)' }}>*</span> Applied Job <span className="req" style={{ color: 'var(--danger)' }}>*</span>
</div> </h3>
{posts.length > 0 && ( {posts.length > 0 && (
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 10 }}> <div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 10 }}>
<Icon name="search" /> <Icon name="search" />
@ -983,9 +957,9 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
)} )}
<FieldError>{form.errors.job}</FieldError> <FieldError>{form.errors.job}</FieldError>
<div className="form-section-title"> <h3 className="form-section-title">
CV / Resume <span className="req" style={{ color: 'var(--danger)' }}>*</span> CV / Resume <span className="req" style={{ color: 'var(--danger)' }}>*</span>
</div> </h3>
<input <input
ref={fileInput} ref={fileInput}
type="file" type="file"
@ -1013,7 +987,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
<div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}> <div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}>
<Icon name="upload" /> <Icon name="upload" />
</div> </div>
<h3 style={{ fontSize: 15 }}>Drop the CV here or click to browse</h3> <h3>Drop the CV here or click to browse</h3>
<p className="text-muted text-sm"> <p className="text-muted text-sm">
PDF only · text-based resumes · up to {MAX_CV_MB} MB PDF only · text-based resumes · up to {MAX_CV_MB} MB
</p> </p>
@ -1021,7 +995,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
{cv && ( {cv && (
<div className="upload-row"> <div className="upload-row">
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span> <span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}> <div className="flex-1">
<div className="fw-600 text-sm">{cv.name}</div> <div className="fw-600 text-sm">{cv.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(cv.size / 1024))} KB</div> <div className="cell-sub">{Math.max(1, Math.round(cv.size / 1024))} KB</div>
</div> </div>

View File

@ -1,17 +1,23 @@
/* ============================================================ /* ============================================================
CV Import real upload score persist flow. CV Import two modes behind one dropzone.
Files go to POST /candidate/score as one multipart batch: the backend Score mode: files go to POST /candidate/score as one multipart batch
extracts each PDF, scores it against the selected job with the ATS engine, each PDF is extracted, scored against the selected job, persisted a row
and persists a row per file. Unreadable/oversized/non-PDF files come back per file. Unreadable/oversized files come back as "failed" rows, and
as status "failed" rows instead of failing the batch, and re-uploading the re-uploading the same bytes updates the existing record.
same bytes updates the existing record (content-hash dedupe) so there is
no separate "import" step and no duplicate modal anymore. No-Job mode ("store in CV bank"): each file goes to POST
/candidate/cv-bank/upload individually parsed and stored as a private
bank row: no job, no user account, no inbox entry, no scoring. The bank
is listed right below the dropzone and is where stored CVs are browsed,
downloaded and (later) picked up for a job.
============================================================ */ ============================================================ */
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import JobCandidates from './JobCandidates' import JobCandidates from './JobCandidates'
@ -19,13 +25,23 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
const STEPS = [ /* Sentinel for the job picker: store CVs without scoring or assignment. */
const NO_JOB = '__none__'
const SCORE_STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' }, { i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' }, { i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' },
{ i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' }, { i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' },
{ i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' }, { i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' },
] ]
const STORE_STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'users', t: 'Details captured', d: 'Candidate email is picked up when the CV contains one' },
{ i: 'target', t: 'Nothing else happens', d: 'No scoring, no candidate account, no inbox entry — just stored' },
{ i: 'user-plus', t: 'Saved to CV bank', d: 'Browse, download or remove stored CVs in the bank below' },
]
async function fetchJobs() { async function fetchJobs() {
const res = await candidatesApi.listJobs() const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
@ -92,11 +108,48 @@ export default function CvImport() {
}, },
}) })
/* No-Job mode: one request per file, so one unreadable CV fails alone and
the rest of the batch still lands in the bank. */
const storing = useMutation({
mutationFn: async ({ files, rowIds }) => {
const results = []
for (let k = 0; k < files.length; k++) {
try {
const res = await candidatesApi.uploadToCvBank(files[k])
results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null })
} catch {
results.push({ rowId: rowIds[k], ok: false, error: 'FAILED' })
}
}
return results
},
onSuccess: (results) => {
setQueue((q) =>
q.map((item) => {
const r = results.find((x) => x.rowId === item.id)
if (!r) return item
return r.ok
? { ...item, status: 'Stored', email: r.email }
: { ...item, status: 'Failed', error: r.error }
}),
)
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
const ok = results.filter((r) => r.ok).length
const bad = results.length - ok
toast(
bad
? `${ok} stored, ${bad} failed — see the queue for details`
: `${ok} CV${ok === 1 ? '' : 's'} stored in the CV bank`,
bad ? 'warning' : 'success',
)
},
})
function handleFiles(fileList) { function handleFiles(fileList) {
const all = Array.from(fileList || []) const all = Array.from(fileList || [])
if (!all.length) return if (!all.length) return
if (!jobId) { if (!jobId) {
toast('Select a job to score against first', 'warning') toast('Select a job — or "No job" to just store the CVs', 'warning')
return return
} }
const bad = all.filter((f) => { const bad = all.filter((f) => {
@ -112,49 +165,51 @@ export default function CvImport() {
} }
const files = all const files = all
const noJob = jobId === NO_JOB
const items = files.map((f) => ({ const items = files.map((f) => ({
id: `UP-${++rowSeq}-${Date.now()}`, id: `UP-${++rowSeq}-${Date.now()}`,
name: f.name, name: f.name,
file: f.name, file: f.name,
size: fmtSize(f.size), size: fmtSize(f.size),
status: 'Scoring', status: noJob ? 'Storing' : 'Scoring',
atsScore: null, atsScore: null,
critique: null, critique: null,
email: null,
error: null, error: null,
})) }))
setQueue((q) => [...q, ...items]) setQueue((q) => [...q, ...items])
scoring.mutate({ job: jobId, files, rowIds: items.map((i) => i.id) }) const rowIds = items.map((i) => i.id)
if (noJob) storing.mutate({ files, rowIds })
else scoring.mutate({ job: jobId, files, rowIds })
} }
const scored = queue.filter((i) => i.status === 'Ready').length const scored = queue.filter((i) => i.status === 'Ready').length
const stored = queue.filter((i) => i.status === 'Stored').length
const failed = queue.filter((i) => i.status === 'Failed').length const failed = queue.filter((i) => i.status === 'Failed').length
const selectedJob = jobs.find((j) => j.id === jobId) const selectedJob = jobs.find((j) => j.id === jobId)
const noJobMode = jobId === NO_JOB
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="CV Import"
<h1 className="page-title">CV Import</h1> sub="Upload resume PDFs — score them against a job, or store them in the CV bank"
<p className="page-sub">Upload resume PDFs parsed, scored against a job, and saved automatically</p> actions={<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>}
</div> />
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>
</div>
</div>
<div className="grid g-2-1"> <div className="grid g-2-1">
<div> <div>
<div className="card mb-18"> <div className="card mb-18">
<div className="card-body"> <div className="card-body">
<div className="flex items-center gap-8" style={{ marginBottom: 16 }}> <div className="flex items-center gap-8 mb-16">
<span className="fw-600 text-sm" style={{ flexShrink: 0 }}>Score against</span> <span className="fw-600 text-sm" style={{ flexShrink: 0 }}>Score against</span>
<select <select
className="select" className="select flex-1"
style={{ flex: 1 }}
value={jobId} value={jobId}
onChange={(e) => setJobId(e.target.value)} onChange={(e) => setJobId(e.target.value)}
> >
<option value="">Select a job post</option> <option value="">Select a job post</option>
<option value={NO_JOB}>No job store in CV bank</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)} {jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select> </select>
</div> </div>
@ -208,7 +263,9 @@ export default function CvImport() {
<div> <div>
<h3>Processing Queue</h3> <h3>Processing Queue</h3>
<span className="ch-sub"> <span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {scored} scored {queue.length} file{queue.length === 1 ? '' : 's'}
{scored ? ` · ${scored} scored` : ''}
{stored ? ` · ${stored} stored` : ''}
{failed ? ` · ${failed} failed` : ''} {failed ? ` · ${failed} failed` : ''}
{selectedJob ? ` · vs ${selectedJob.title}` : ''} {selectedJob ? ` · vs ${selectedJob.title}` : ''}
</span> </span>
@ -223,7 +280,7 @@ export default function CvImport() {
<span className="fw-600 text-sm">{i.name}</span> <span className="fw-600 text-sm">{i.name}</span>
</div> </div>
<div className="cell-sub">{i.file} · {i.size}</div> <div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Scoring' && ( {(i.status === 'Scoring' || i.status === 'Storing') && (
<div className="upload-progress" style={{ marginTop: 6 }}> <div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: '66%' }} /> <div className="upload-progress-fill" style={{ width: '66%' }} />
</div> </div>
@ -231,17 +288,24 @@ export default function CvImport() {
{i.status === 'Ready' && i.critique && ( {i.status === 'Ready' && i.critique && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div> <div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div>
)} )}
{i.status === 'Stored' && (
<div className="cell-sub" style={{ marginTop: 4 }}>
{i.email ? `Candidate email: ${i.email}` : 'No email in the CV — stored anyway'}
</div>
)}
{i.status === 'Failed' && ( {i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be scored</div> <div className="cell-sub" style={{ marginTop: 4 }}>Could not be processed</div>
)} )}
</div> </div>
<div style={{ textAlign: 'right', flexShrink: 0 }}> <div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' && <ScoreChip score={i.atsScore} />} {i.status === 'Ready' && <ScoreChip score={i.atsScore} />}
{i.status === 'Scoring' && <Badge className="b-blue">Scoring</Badge>} {i.status === 'Scoring' && <Badge className="b-blue">Scoring</Badge>}
{i.status === 'Storing' && <Badge className="b-blue">Storing</Badge>}
{i.status === 'Failed' && <Badge className="b-red">{i.error}</Badge>} {i.status === 'Failed' && <Badge className="b-red">{i.error}</Badge>}
</div> </div>
<div style={{ flexShrink: 0 }}> <div style={{ flexShrink: 0 }}>
{i.status === 'Ready' && <Badge className="b-green">Saved</Badge>} {i.status === 'Ready' && <Badge className="b-green">Saved</Badge>}
{i.status === 'Stored' && <Badge className="b-green">Stored</Badge>}
</div> </div>
</div> </div>
))} ))}
@ -251,7 +315,7 @@ export default function CvImport() {
{queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && ( {queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && (
<EmptyState icon="briefcase" title="No job posts yet"> <EmptyState icon="briefcase" title="No job posts yet">
Create a job post first resumes are always scored against a job. Create a job post to score against or pick No job store in CV bank above to just save CVs.
</EmptyState> </EmptyState>
)} )}
</div> </div>
@ -260,7 +324,7 @@ export default function CvImport() {
<div className="card-head"><div><h3>Auto-Processing</h3><span className="ch-sub">What happens on upload</span></div></div> <div className="card-head"><div><h3>Auto-Processing</h3><span className="ch-sub">What happens on upload</span></div></div>
<div className="card-body"> <div className="card-body">
<div className="timeline"> <div className="timeline">
{STEPS.map((s) => ( {(noJobMode ? STORE_STEPS : SCORE_STEPS).map((s) => (
<div className="tl-item" key={s.t}> <div className="tl-item" key={s.t}>
<div className="tl-dot"><Icon name={s.i} /></div> <div className="tl-dot"><Icon name={s.i} /></div>
<div className="tl-title">{s.t}</div> <div className="tl-title">{s.t}</div>
@ -272,10 +336,141 @@ export default function CvImport() {
</div> </div>
</div> </div>
{/* Everything ever scored against the selected job this batch, earlier {/* No-Job mode swaps the scored grid for the bank itself. */}
{noJobMode ? (
<CvBank />
) : (
/* Everything ever scored against the selected job this batch, earlier
uploads and synced inbox CVs alike. The scoring mutation invalidates uploads and synced inbox CVs alike. The scoring mutation invalidates
qk.candidates.all(), so the grid refreshes as each batch lands. */} qk.candidates.all(), so the grid refreshes as each batch lands. */
<JobCandidates jobId={jobId} jobTitle={selectedJob?.title} /> <JobCandidates jobId={jobId} jobTitle={selectedJob?.title} />
)}
</div>
)
}
/* The stored-CV bank a private store with no job, account or inbox entry.
This list is the bank's home: browse, download, or remove; picking a CV up
for a job later is a future action. */
function CvBank() {
const { toast } = useToast()
const qc = useQueryClient()
const [preview, setPreview] = useState(null) // { name, url } url is an object URL we own
const bankQuery = useQuery({
queryKey: qk.cvBank.list(),
queryFn: () => candidatesApi.listCvBank({ top: 200 }),
})
const rows = Array.isArray(bankQuery.data?.data) ? bankQuery.data.data : []
async function view(row) {
try {
const url = await candidatesApi.viewCvBankCv(row.id)
if (!url) {
toast('The CV file could not be found', 'error')
return
}
setPreview({ name: row.file_name || 'CV', url })
} catch (err) {
toast(friendlyAuthError(err, 'Could not open the CV'), 'error')
}
}
function closePreview() {
if (preview) URL.revokeObjectURL(preview.url)
setPreview(null)
}
const removing = useMutation({
mutationFn: (id) => candidatesApi.deleteCvBankCv(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
toast('CV removed from the bank', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
})
async function download(row) {
try {
await candidatesApi.downloadCvBankCv(row.id)
} catch (err) {
toast(friendlyAuthError(err, 'Could not download the CV'), 'error')
}
}
return (
<div className="card mt-18">
<div className="card-head">
<div>
<h3>CV Bank</h3>
<span className="ch-sub">
{bankQuery.isSuccess ? `${bankQuery.data?.total ?? rows.length} stored CV${(bankQuery.data?.total ?? rows.length) === 1 ? '' : 's'} · no job attached` : 'Stored CVs with no job attached'}
</span>
</div>
</div>
<div className="card-body">
{bankQuery.isLoading && <p className="text-muted text-sm">Loading stored CVs</p>}
{bankQuery.isError && (
<p className="text-muted text-sm">{friendlyAuthError(bankQuery.error, 'Could not load the CV bank')}</p>
)}
{bankQuery.isSuccess && rows.length === 0 && (
<EmptyState icon="file" title="The CV bank is empty">
Drop CVs above with No job store in CV bank selected and they will be kept here.
</EmptyState>
)}
{rows.map((r) => (
<div className="upload-row" key={r.id}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{r.file_name || 'CV'}</div>
<div className="cell-sub">
{r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
</div>
</div>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
<Icon name="eye" />
</button>
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
<Icon name="download" />
</button>
<button
className="act-btn"
data-tip="Remove"
aria-label="Remove CV from bank"
disabled={removing.isPending}
onClick={() => {
if (window.confirm(`Remove “${r.file_name}” from the CV bank? The file is deleted permanently.`)) {
removing.mutate(r.id)
}
}}
>
<Icon name="trash" />
</button>
</div>
</div>
))}
</div>
{preview && (
<Modal
title={preview.name}
subtitle="CV preview"
size="modal-lg"
onClose={closePreview}
footer={
<button className="btn btn-secondary" onClick={closePreview}>Close</button>
}
>
{/* Blob URL re-typed to application/pdf, so the browser's built-in
viewer renders inline instead of triggering a download. */}
<iframe
src={preview.url}
title={`Preview of ${preview.name}`}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 10, background: 'var(--bg-sunken)' }}
/>
</Modal>
)}
</div> </div>
) )
} }

View File

@ -5,7 +5,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart' import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts' import Charts from '../lib/charts'
import Dropdown from '../ui/Dropdown' import Dropdown from '../ui/Dropdown'
import { Avatar, Badge, EmptyState, Icon, KpiTile, ProgressBar } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, KpiTile, PRIORITY_CLASS, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
@ -17,7 +18,6 @@ import * as jobsApi from '../api/jobs'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
const POLL_MS = 60_000 const POLL_MS = 60_000
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
function asObject(data) { function asObject(data) {
return data && typeof data === 'object' && !Array.isArray(data) ? data : null return data && typeof data === 'object' && !Array.isArray(data) ? data : null
@ -373,14 +373,10 @@ export default function Dashboard() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title={<>{greetingFor()}, {firstName} 👋</>}
<h1 className="page-title">{greetingFor()}, {firstName} 👋</h1> sub={<>Heres whats happening with your hiring today {todayLabel}</>}
<p className="page-sub"> actions={<>
Heres whats happening with your hiring today {todayLabel}
</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/reports"> <Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export <Icon name="download" /> Export
</Link> </Link>
@ -404,8 +400,8 @@ export default function Dashboard() {
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}> <Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job <Icon name="plus" /> Create Job
</Link> </Link>
</div> </>}
</div> />
<div className="grid g-kpi-7"> <div className="grid g-kpi-7">
{tiles.map((c) => <KpiTile key={c.label} {...c} />)} {tiles.map((c) => <KpiTile key={c.label} {...c} />)}

View File

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import PageHeader from '../ui/PageHeader'
import { Icon } from '../ui/primitives' import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -23,16 +24,11 @@ export default function Help() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader title="Help Center" sub="Find answers and get support" />
<div>
<h1 className="page-title">Help Center</h1>
<p className="page-sub">Find answers and get support</p>
</div>
</div>
<div className="card brand-hero mb-18"> <div className="card brand-hero mb-18">
<div className="card-body" style={{ padding: 32, textAlign: 'center' }}> <div className="card-body" style={{ padding: 32, textAlign: 'center' }}>
<h2 style={{ fontSize: 22, marginBottom: 8 }}>How can we help you?</h2> <h2 className="mb-8">How can we help you?</h2>
<p style={{ opacity: 0.85, marginBottom: 18 }}> <p style={{ opacity: 0.85, marginBottom: 18 }}>
Search our knowledge base or browse the topics below Search our knowledge base or browse the topics below
</p> </p>

View File

@ -11,9 +11,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable' import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -89,25 +90,28 @@ function startOfDay(d) {
/** /**
* Outlook-style list timestamps in the client's local timezone. * Outlook-style list timestamps in the client's local timezone.
* Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy + AM/PM time. * Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy only like Outlook.
* The date-plus-time form was ~105px wide, and in the 380px queue rail that
* squeezed .ii-main to 148px: sender names painted over the timestamp and the
* meta chips wrapped one-per-line. The full timestamp is in the detail pane.
*/ */
function outlookListTime(value) { function outlookListTime(value) {
if (!value) return '—' if (!value) return '—'
const d = value instanceof Date ? value : new Date(value) const d = value instanceof Date ? value : new Date(value)
if (Number.isNaN(d.getTime())) return '—' if (Number.isNaN(d.getTime())) return '—'
const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000)
if (daysAgo < 7) {
const weekday = d.toLocaleDateString(undefined, { weekday: 'short' })
const time = d.toLocaleTimeString(undefined, { const time = d.toLocaleTimeString(undefined, {
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
}) })
const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000)
if (daysAgo < 7) {
const weekday = d.toLocaleDateString(undefined, { weekday: 'short' })
return `${weekday} ${time}` return `${weekday} ${time}`
} }
const dd = String(d.getDate()).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0')
return `${dd}/${mm}/${d.getFullYear()} ${time}` return `${dd}/${mm}/${d.getFullYear()}`
} }
/** /**
@ -949,16 +953,14 @@ export default function Inbox() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Recruitment Inbox"
<h1 className="page-title">Recruitment Inbox</h1> sub={
<p className="page-sub"> isForms
{isForms
? 'Google Form applicants — same queue energy, profile-first cards' ? 'Google Form applicants — same queue energy, profile-first cards'
: 'Every candidate, every source — one unified queue'} : 'Every candidate, every source — one unified queue'
</p> }
</div> actions={<>
<div className="page-head-actions">
<div className="pill-tabs" role="tablist" aria-label="Inbox channel"> <div className="pill-tabs" role="tablist" aria-label="Inbox channel">
{CHANNELS.map((c) => ( {CHANNELS.map((c) => (
<button <button
@ -994,8 +996,8 @@ export default function Inbox() {
<button className="btn btn-primary" onClick={() => navigate('/import')}> <button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs <Icon name="upload" /> Upload CVs
</button> </button>
</div> </>}
</div> />
<div className="card"> <div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}> <div style={{ margin: '0 16px', paddingTop: 8 }}>
@ -1057,9 +1059,7 @@ export default function Inbox() {
</div> </div>
<div> <div>
{activeQuery.isPending && ( {activeQuery.isPending && (
<EmptyState icon="inbox" title="Loading…"> <SkeletonRows rows={6} />
{isForms ? 'Fetching form applicants from the sheet mirror.' : 'Fetching applications from the server.'}
</EmptyState>
)} )}
{activeQuery.isError && ( {activeQuery.isError && (
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}> <EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
@ -1087,7 +1087,7 @@ export default function Inbox() {
<Avatar name={i.name} initials={i.initials} color={i.color} /> <Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main"> <div className="ii-main">
<div className="ii-name"> <div className="ii-name">
{i.name}{' '} <span className="truncate min-w-0" title={i.name}>{i.name}</span>
{i.duplicate && ( {i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span> <span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
)} )}
@ -1131,7 +1131,7 @@ export default function Inbox() {
pageSizeMax={PAGE_SIZE_MAX} pageSizeMax={PAGE_SIZE_MAX}
onPageSizeChange={(n) => { onPageSizeChange={(n) => {
setPageSize(n) setPageSize(n)
setPage(1) setPage((p) => pageAfterSizeChange(p, total, n))
setSelectedId(null) setSelectedId(null)
selection.clear() selection.clear()
}} }}

View File

@ -25,8 +25,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, Stars } from '../ui/primitives' import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -39,13 +40,6 @@ import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
const FETCH_TOP = 200 const FETCH_TOP = 200
const STATUS_CLASS = {
Scheduled: 'b-blue',
Completed: 'b-green',
Cancelled: 'b-red',
'No Show': 'b-amber',
}
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */ /** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
function toInstant(date, time) { function toInstant(date, time) {
if (!date) return null if (!date) return null
@ -229,14 +223,14 @@ export default function Interviews() {
}, },
{ {
key: 'status', label: 'Status', sortable: true, key: 'status', label: 'Status', sortable: true,
render: (iv) => <Badge className={STATUS_CLASS[iv.status] ?? 'b-gray'}>{iv.status}</Badge>, render: (iv) => <Badge>{iv.status}</Badge>,
}, },
{ {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (iv) => ( render: (iv) => (
<div className="row-actions"> <div className="row-actions">
<button <button
className="act-btn" data-tip="View candidate" className="act-btn" data-tip="View candidate" aria-label="View candidate"
disabled={!iv.userId} disabled={!iv.userId}
onClick={() => navigate('/candidates', { state: { openCandidate: iv.userId } })} onClick={() => navigate('/candidates', { state: { openCandidate: iv.userId } })}
> >
@ -245,14 +239,14 @@ export default function Interviews() {
{iv.status === 'Scheduled' && ( {iv.status === 'Scheduled' && (
<> <>
<button <button
className="act-btn" data-tip="Mark completed" className="act-btn" data-tip="Mark completed" aria-label="Mark completed"
disabled={setStatusMutation.isPending} disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })} onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })}
> >
<Icon name="check" /> <Icon name="check" />
</button> </button>
<button <button
className="act-btn" data-tip="Cancel interview" className="act-btn" data-tip="Cancel interview" aria-label="Cancel interview"
disabled={setStatusMutation.isPending} disabled={setStatusMutation.isPending}
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Cancelled' })} onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Cancelled' })}
> >
@ -260,7 +254,7 @@ export default function Interviews() {
</button> </button>
</> </>
)} )}
<button className="act-btn" data-tip="Scorecard" onClick={() => setFeedbackFor(iv)}> <button className="act-btn" data-tip="Scorecard" aria-label="Scorecard" onClick={() => setFeedbackFor(iv)}>
<Icon name="star" /> <Icon name="star" />
</button> </button>
</div> </div>
@ -272,18 +266,16 @@ export default function Interviews() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Interviews"
<h1 className="page-title">Interviews</h1> sub="Manage and track all interview activity"
<p className="page-sub">Manage and track all interview activity</p> actions={<>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/calendar"><Icon name="calendar" /> Calendar View</Link> <Link className="btn btn-secondary" to="/calendar"><Icon name="calendar" /> Calendar View</Link>
<button className="btn btn-primary" onClick={() => setScheduling(true)}> <button className="btn btn-primary" onClick={() => setScheduling(true)}>
<Icon name="plus" /> Schedule Interview <Icon name="plus" /> Schedule Interview
</button> </button>
</div> </>}
</div> />
<div className="grid g-kpi mb-18"> <div className="grid g-kpi mb-18">
<KpiCard label="Scheduled" value={allQuery.isPending ? '—' : stats.scheduled} icon="calendar" tone="i-blue" /> <KpiCard label="Scheduled" value={allQuery.isPending ? '—' : stats.scheduled} icon="calendar" tone="i-blue" />
@ -314,7 +306,7 @@ export default function Interviews() {
{listQuery.isPending && ( {listQuery.isPending && (
<div className="card-body"> <div className="card-body">
<EmptyState icon="calendar" title="Loading…">Fetching interviews from the server.</EmptyState> <SkeletonRows rows={6} />
</div> </div>
)} )}
{listError && ( {listError && (
@ -484,14 +476,14 @@ function Scorecard({ interview: iv, onClose, onSaved, toast }) {
color={avatarColor(iv.candidate)} color={avatarColor(iv.candidate)}
className="avatar-lg" className="avatar-lg"
/> />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div> <div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div>
<div className="ph-role">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div> <div className="ph-role">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div>
</div> </div>
<Badge className={STATUS_CLASS[iv.status] ?? 'b-gray'}>{iv.status}</Badge> <Badge>{iv.status}</Badge>
</div> </div>
<div style={{ marginBottom: 18 }}> <div className="mb-18">
<Tabs <Tabs
value={tab} value={tab}
onChange={setTab} onChange={setTab}
@ -519,14 +511,14 @@ function Scorecard({ interview: iv, onClose, onSaved, toast }) {
)} )}
{template && ( {template && (
<> <>
<div className="form-field" style={{ marginBottom: 8 }}> <div className="form-field mb-8">
<label>Evaluation Template</label> <label>Evaluation Template</label>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)}> <select value={templateName} onChange={(e) => setTemplateName(e.target.value)}>
{templates.map((t) => <option key={t.id}>{t.name}</option>)} {templates.map((t) => <option key={t.id}>{t.name}</option>)}
</select> </select>
</div> </div>
<CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} /> <CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} />
<div className="form-field" style={{ marginTop: 8 }}> <div className="form-field mt-8">
<label>Comments</label> <label>Comments</label>
<textarea <textarea
placeholder="Strengths, concerns, and areas explored…" placeholder="Strengths, concerns, and areas explored…"
@ -534,7 +526,7 @@ function Scorecard({ interview: iv, onClose, onSaved, toast }) {
onChange={(e) => setComments(e.target.value)} onChange={(e) => setComments(e.target.value)}
/> />
</div> </div>
<div className="form-field" style={{ marginTop: 14 }}> <div className="form-field mt-12">
<label>Overall Recommendation</label> <label>Overall Recommendation</label>
<div className="seg" style={{ marginTop: 4 }}> <div className="seg" style={{ marginTop: 4 }}>
{['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'].map((r) => ( {['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'].map((r) => (
@ -662,7 +654,7 @@ function ScheduleForm({ applications, loading, busy, onClose, onSubmit, toast })
</div> </div>
</div> </div>
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}> <p className="text-muted text-sm mt-16">
Interviews attach to an application, so only candidates with an assigned job post appear here. Interviews attach to an application, so only candidates with an assigned job post appear here.
Duration, meeting mode and interviewers are not stored by the interview record. Duration, meeting mode and interviewers are not stored by the interview record.
</p> </p>

View File

@ -27,6 +27,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, KpiCard } from '../ui/primitives' import { Badge, EmptyState, Icon, KpiCard } from '../ui/primitives'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -235,11 +236,12 @@ export default function JobBoard() {
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
data-tip="Open live post" data-tip="Open live post"
aria-label="Open live post"
> >
<Icon name="external" /> <Icon name="external" />
</a> </a>
) : ( ) : (
<button className="act-btn" data-tip="Not published yet" disabled> <button className="act-btn" data-tip="Not published yet" aria-label="Not published yet" disabled>
<Icon name="external" /> <Icon name="external" />
</button> </button>
)} )}
@ -250,12 +252,10 @@ export default function JobBoard() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Job Board"
<h1 className="page-title">Job Board</h1> sub="Where each requisition was published, and whether it landed"
<p className="page-sub">Where each requisition was published, and whether it landed</p> actions={<>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/analytics"><Icon name="trending-up" /> Analytics</Link> <Link className="btn btn-secondary" to="/analytics"><Icon name="trending-up" /> Analytics</Link>
<button <button
className="btn btn-primary" className="btn btn-primary"
@ -263,8 +263,8 @@ export default function JobBoard() {
> >
<Icon name="send" /> Publish a Job <Icon name="send" /> Publish a Job
</button> </button>
</div> </>}
</div> />
<div className="grid g-kpi mb-18"> <div className="grid g-kpi mb-18">
<KpiCard label="Total Posts" value={postsQuery.isPending ? '—' : stats.total} icon="layers" tone="i-indigo" /> <KpiCard label="Total Posts" value={postsQuery.isPending ? '—' : stats.total} icon="layers" tone="i-indigo" />
@ -309,12 +309,12 @@ export default function JobBoard() {
<span className={`kpi-icn ${d.connected ? 'i-green' : 'i-indigo'}`} style={{ width: 34, height: 34, borderRadius: 9 }}> <span className={`kpi-icn ${d.connected ? 'i-green' : 'i-indigo'}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name={d.connected ? 'check-circle' : 'layers'} /> <Icon name={d.connected ? 'check-circle' : 'layers'} />
</span> </span>
<div style={{ minWidth: 0 }}> <div className="min-w-0">
<div className="lr-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.name}</div> <div className="lr-title truncate" title={d.name}>{d.name}</div>
<div className="lr-sub">{d.service || (d.connected ? 'channel' : 'not connected')}</div> <div className="lr-sub">{d.service || (d.connected ? 'channel' : 'not connected')}</div>
</div> </div>
</div> </div>
<div className="flex items-center" style={{ justifyContent: 'space-between' }}> <div className="flex items-center justify-between">
<span className="cell-sub">{d.posts} post{d.posts === 1 ? '' : 's'}</span> <span className="cell-sub">{d.posts} post{d.posts === 1 ? '' : 's'}</span>
<Badge className={d.connected ? 'b-green' : 'b-gray'}> <Badge className={d.connected ? 'b-green' : 'b-gray'}>
{d.connected ? 'Connected' : 'Available'} {d.connected ? 'Connected' : 'Available'}

View File

@ -54,7 +54,7 @@ function MiniRing({ score, size = 46 }) {
style={{ style={{
width: size - 8, height: size - 8, borderRadius: '50%', width: size - 8, height: size - 8, borderRadius: '50%',
background: 'var(--bg-elev)', display: 'grid', placeItems: 'center', background: 'var(--bg-elev)', display: 'grid', placeItems: 'center',
fontWeight: 800, fontSize: size >= 56 ? 17 : 13.5, letterSpacing: '-.3px', fontWeight: 700, fontSize: size >= 56 ? 17 : 13.5, letterSpacing: '-0.01em',
color: ringColor(score), color: ringColor(score),
}} }}
> >
@ -95,7 +95,7 @@ function CandidateCard({ c, onView }) {
<span className="cand-meta"><Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}</span> <span className="cand-meta"><Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}</span>
<span className="cand-company">{c.currentCompany ?? ''}</span> <span className="cand-company">{c.currentCompany ?? ''}</span>
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge> <Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}> <button className="act-btn" data-tip="View" aria-label="View candidate" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" /> <Icon name="eye" />
</button> </button>
</div> </div>
@ -122,7 +122,7 @@ function FailedCard({ c, onView }) {
<Badge className="b-red">{c.errorCode ?? 'FAILED'}</Badge> <Badge className="b-red">{c.errorCode ?? 'FAILED'}</Badge>
<span className="cand-company" /> <span className="cand-company" />
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge> <Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}> <button className="act-btn" data-tip="View" aria-label="View details" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" /> <Icon name="eye" />
</button> </button>
</div> </div>
@ -146,7 +146,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
> >
<div className="profile-hero"> <div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" /> <Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="ph-name">{displayName(c.name)}</div> <div className="ph-name">{displayName(c.name)}</div>
<div className="ph-role">{roleLine}</div> <div className="ph-role">{roleLine}</div>
<div className="ph-tags"> <div className="ph-tags">
@ -184,7 +184,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
</div> </div>
{completed ? ( {completed ? (
<> <>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</h3>
<p className="text-muted">{c.critique ?? '—'}</p> <p className="text-muted">{c.critique ?? '—'}</p>
</> </>
) : ( ) : (
@ -197,25 +197,25 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
{tab === 'Job Match' && completed && ( {tab === 'Job Match' && completed && (
<> <>
<div className="form-section-title" style={{ marginTop: 0 }}>Job-Match Score</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Job-Match Score</h3>
<p className="text-muted text-sm" style={{ marginBottom: 14 }}> <p className="text-muted text-sm mb-12">
Match this candidate against the job the CV was scored for. Match this candidate against the job the CV was scored for.
</p> </p>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}> <div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body flex items-center gap-12"> <div className="card-body flex items-center gap-12">
<MiniRing score={c.aiScore} size={56} /> <MiniRing score={c.aiScore} size={56} />
<div style={{ flex: 1, minWidth: 0 }}> <div className="flex-1">
<div className="fw-600" style={{ marginBottom: 8 }}>{jobTitle ?? 'Selected job'}</div> <div className="fw-600 mb-8">{jobTitle ?? 'Selected job'}</div>
<ProgressBar pct={c.aiScore} /> <ProgressBar pct={c.aiScore} />
</div> </div>
{band && <Badge className={bandCls}>{band}</Badge>} {band && <Badge className={bandCls}>{band}</Badge>}
</div> </div>
</div> </div>
<div className="form-section-title" style={{ marginTop: 0 }}> <h3 className="form-section-title" style={{ marginTop: 0 }}>
Matched must-have skills ({c.matchedSkills.length}) Matched must-have skills ({c.matchedSkills.length})
</div> </h3>
<div className="k-tags" style={{ marginBottom: 16 }}> <div className="k-tags mb-16">
{c.matchedSkills.length {c.matchedSkills.length
? c.matchedSkills.map((s) => ( ? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span> <span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
@ -223,10 +223,10 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
: <span className="text-muted"></span>} : <span className="text-muted"></span>}
</div> </div>
<div className="form-section-title" style={{ marginTop: 0 }}> <h3 className="form-section-title" style={{ marginTop: 0 }}>
Missing must-have skills ({c.missingSkills.length}) Missing must-have skills ({c.missingSkills.length})
</div> </h3>
<div className="k-tags" style={{ marginBottom: 16 }}> <div className="k-tags mb-16">
{c.missingSkills.length {c.missingSkills.length
? c.missingSkills.map((s) => ( ? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span> <span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
@ -286,9 +286,9 @@ export default function JobCandidates({ jobId, jobTitle }) {
if (!jobId) return null if (!jobId) return null
return ( return (
<div style={{ marginTop: 24 }}> <div className="mt-24">
<div style={{ marginBottom: 14 }}> <div className="mb-12">
<h2 className="page-title" style={{ fontSize: 20 }}>Candidates</h2> <h2 className="page-title page-title-sm">Candidates</h2>
<p className="page-sub"> <p className="page-sub">
{rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed {rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed
{jobTitle ? ` · vs ${jobTitle}` : ''} {jobTitle ? ` · vs ${jobTitle}` : ''}

View File

@ -14,7 +14,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import AiFieldAssist from '../ui/AiFieldAssist' import AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout' import { useFormState } from '../components/AuthLayout'
@ -104,11 +105,24 @@ export default function Jobs() {
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps }, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
const createJob = useMutation({ const createJob = useMutation({
mutationFn: (payload) => jobPostsApi.create(payload), mutationFn: async ({ payload, imageFile }) => {
onSuccess: () => { const res = await jobPostsApi.create(payload)
// The cover image rides along after the row exists. Its failure must not
// read as "create failed" the job IS created so it downgrades to a flag.
if (imageFile && res?.data?.id) {
try {
await jobsApi.uploadImage(res.data.id, imageFile)
} catch {
return { ...res, imageFailed: true }
}
}
return res
},
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
setCreating(false) setCreating(false)
toast('Job created', 'success') if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
else toast('Job created', 'success')
}, },
onError: (err) => { onError: (err) => {
// 502: row was created but Buffer publish failed refresh the board and // 502: row was created but Buffer publish failed refresh the board and
@ -221,11 +235,32 @@ export default function Jobs() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (j) => ( render: (j) => (
<div className="row-actions"> <div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button> <button className="act-btn" data-tip="View" aria-label="View job" onClick={() => setViewing(j)}><Icon name="eye" /></button>
{canEdit && ( {canEdit && (
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button> <button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={() => setEditing(j)}><Icon name="edit" /></button>
)} )}
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button> {canEdit && (j.status === 'Closed' ? (
<button
className="act-btn"
data-tip="Reopen"
aria-label="Reopen job"
disabled={setJobStatus.isPending}
onClick={() => setJobStatus.mutate({ id: j.id, status: 'Open' })}
><Icon name="refresh" /></button>
) : (
<button
className="act-btn"
data-tip="Close job"
aria-label="Close job"
disabled={setJobStatus.isPending}
onClick={() => {
if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) {
setJobStatus.mutate({ id: j.id, status: 'Closed' })
}
}}
><Icon name="x-circle" /></button>
))}
<button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
</div> </div>
), ),
}, },
@ -233,12 +268,10 @@ export default function Jobs() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Jobs"
<h1 className="page-title">Jobs</h1> sub={`${jobs.length} requisitions · ${openCount} currently open`}
<p className="page-sub">{jobs.length} requisitions · {openCount} currently open</p> actions={<>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={exportJobs} disabled={exporting}> <button className="btn btn-secondary" onClick={exportJobs} disabled={exporting}>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'} <Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button> </button>
@ -247,13 +280,13 @@ export default function Jobs() {
<Icon name="plus" /> Create Job <Icon name="sparkles" /> <Icon name="plus" /> Create Job <Icon name="sparkles" />
</button> </button>
)} )}
</div> </>}
</div> />
<div className="card"> <div className="card">
{jobsQuery.isPending && ( {jobsQuery.isPending && (
<div className="card-body"> <div className="card-body">
<EmptyState icon="briefcase" title="Loading…">Fetching requisitions from the server.</EmptyState> <SkeletonRows rows={6} />
</div> </div>
)} )}
{jobsQuery.isError && ( {jobsQuery.isError && (
@ -327,7 +360,7 @@ export default function Jobs() {
departmentOptions={departmentOptions} departmentOptions={departmentOptions}
busy={createJob.isPending} busy={createJob.isPending}
onClose={() => setCreating(false)} onClose={() => setCreating(false)}
onSubmit={(payload) => createJob.mutate(payload)} onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
/> />
)} )}
</div> </div>
@ -351,7 +384,6 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
vacancies: '1', vacancies: '1',
experience_min: '', experience_min: '',
experience_max: '', experience_max: '',
salary: '',
requirements: '', requirements: '',
optional_skills: '', optional_skills: '',
description: '', description: '',
@ -418,7 +450,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
// No channel_id / platform: the backend saves an internal-only requisition // No channel_id / platform: the backend saves an internal-only requisition
// and skips Buffer entirely. Publishing happens later from the Job Board. // and skips Buffer entirely. Publishing happens later from the Job Board.
// Image is UI-only for now not sent to the API. // The cover image is uploaded separately right after the row exists.
onSubmit({ onSubmit({
title: v.title.trim(), title: v.title.trim(),
department: v.department.trim() || null, department: v.department.trim() || null,
@ -427,11 +459,10 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
vacancies, vacancies,
experience_min: expMin, experience_min: expMin,
experience_max: expMax, experience_max: expMax,
salary: v.salary.trim() || 'Anonymous',
requirements: splitLines(v.requirements), requirements: splitLines(v.requirements),
optional_skills: splitLines(v.optional_skills), optional_skills: splitLines(v.optional_skills),
description: v.description.trim() || null, description: v.description.trim() || null,
}) }, imageFile)
} }
const field = (name) => ({ const field = (name) => ({
@ -448,7 +479,6 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
employment_type: form.values.employment_type, employment_type: form.values.employment_type,
experience_min: form.values.experience_min, experience_min: form.values.experience_min,
experience_max: form.values.experience_max, experience_max: form.values.experience_max,
salary: form.values.salary,
requirements: form.values.requirements, requirements: form.values.requirements,
optional_skills: form.values.optional_skills, optional_skills: form.values.optional_skills,
description: form.values.description, description: form.values.description,
@ -535,14 +565,6 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
<input type="number" min="0" {...field('experience_max')} className={form.errors.experience_max ? 'err' : ''} placeholder="5" /> <input type="number" min="0" {...field('experience_max')} className={form.errors.experience_max ? 'err' : ''} placeholder="5" />
<FieldError>{form.errors.experience_max}</FieldError> <FieldError>{form.errors.experience_max}</FieldError>
</div> </div>
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Salary</label>
{assist('salary')}
</div>
<input {...field('salary')} placeholder="Anonymous" />
</div>
<div className="form-field col-span-2"> <div className="form-field col-span-2">
<div className="field-label-row"> <div className="field-label-row">
<label>Requirements</label> <label>Requirements</label>
@ -620,7 +642,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
) : ( ) : (
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span> <span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
)} )}
<div style={{ flex: 1, minWidth: 0 }}> <div className="flex-1">
<div className="fw-600 text-sm">{imageFile.name}</div> <div className="fw-600 text-sm">{imageFile.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(imageFile.size / 1024))} KB</div> <div className="cell-sub">{Math.max(1, Math.round(imageFile.size / 1024))} KB</div>
</div> </div>
@ -640,7 +662,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
</div> </div>
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}> <p className="text-muted text-sm mt-16">
Saves the requisition to the board publish to a channel later from the Job Board. Saves the requisition to the board publish to a channel later from the Job Board.
</p> </p>
</form> </form>
@ -655,7 +677,6 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
location: j.location || '', location: j.location || '',
employment_type: j.type || '', employment_type: j.type || '',
vacancies: j.vacancies != null ? String(j.vacancies) : '1', vacancies: j.vacancies != null ? String(j.vacancies) : '1',
salary: j.salary || '',
experience_min: j.experienceMin != null ? String(j.experienceMin) : '', experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
experience_max: j.experienceMax != null ? String(j.experienceMax) : '', experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
description: j.description || '', description: j.description || '',
@ -668,7 +689,6 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
employment_type: form.values.employment_type, employment_type: form.values.employment_type,
experience_min: form.values.experience_min, experience_min: form.values.experience_min,
experience_max: form.values.experience_max, experience_max: form.values.experience_max,
salary: form.values.salary,
description: form.values.description, description: form.values.description,
}) })
@ -697,7 +717,6 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
location: form.values.location.trim() || null, location: form.values.location.trim() || null,
employment_type: form.values.employment_type || null, employment_type: form.values.employment_type || null,
vacancies: Number(form.values.vacancies) || 1, vacancies: Number(form.values.vacancies) || 1,
salary: form.values.salary.trim() || null,
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min), experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max), experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
description: form.values.description.trim() || null, description: form.values.description.trim() || null,
@ -755,13 +774,6 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
<label>Vacancies</label> <label>Vacancies</label>
<input type="number" min="1" value={form.values.vacancies} onChange={(e) => form.setField('vacancies', e.target.value)} disabled={busy} /> <input type="number" min="1" value={form.values.vacancies} onChange={(e) => form.setField('vacancies', e.target.value)} disabled={busy} />
</div> </div>
<div className="form-field">
<div className="field-label-row">
<label>Salary</label>
{assist('salary')}
</div>
<input value={form.values.salary} onChange={(e) => form.setField('salary', e.target.value)} disabled={busy} />
</div>
<div className="form-field"> <div className="form-field">
<label>Experience min</label> <label>Experience min</label>
<input type="number" min="0" value={form.values.experience_min} onChange={(e) => form.setField('experience_min', e.target.value)} disabled={busy} /> <input type="number" min="0" value={form.values.experience_min} onChange={(e) => form.setField('experience_min', e.target.value)} disabled={busy} />
@ -849,7 +861,7 @@ function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
return ( return (
<> <>
<div className="divider" /> <div className="divider" />
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div style={SECTION_LABEL}>Recruiter ownership</div> <div style={SECTION_LABEL}>Recruiter ownership</div>
{currentQuery.isError ? ( {currentQuery.isError ? (
<p className="text-muted text-sm"> <p className="text-muted text-sm">
@ -900,6 +912,27 @@ function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
) )
} }
/* Cover image, when the post has one fetched with the bearer token into an
object URL, because a bare <img src> cannot carry auth headers. null (404)
simply renders nothing. */
function JobCover({ jobId }) {
const [url, setUrl] = useState(null)
useEffect(() => {
let alive = true
let objectUrl = null
jobsApi.fetchImageUrl(jobId)
.then((u) => {
if (!alive) { if (u) URL.revokeObjectURL(u); return }
objectUrl = u
setUrl(u)
})
.catch(() => {})
return () => { alive = false; if (objectUrl) URL.revokeObjectURL(objectUrl) }
}, [jobId])
if (!url) return null
return <img src={url} alt="Job cover" className="job-cover" />
}
function JobDetail({ function JobDetail({
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete, job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
}) { }) {
@ -924,6 +957,8 @@ function JobDetail({
</> </>
} }
> >
<JobCover jobId={j.id} />
<div className="flex items-center gap-16 mb-18"> <div className="flex items-center gap-16 mb-18">
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}> <span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
<Icon name="briefcase" /> <Icon name="briefcase" />
@ -954,7 +989,6 @@ function JobDetail({
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div> <div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div> <div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div> <div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
<div className="info-item"><div className="il">Salary</div><div className="iv">{j.salary || '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div> <div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div> <div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div> <div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
@ -967,7 +1001,7 @@ function JobDetail({
{j.description && ( {j.description && (
<> <>
<div className="divider" /> <div className="divider" />
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div style={SECTION_LABEL}>Description</div> <div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p> <p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div> </div>

View File

@ -3,8 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable' import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
@ -66,17 +67,13 @@ export default function Managers() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Hiring Managers"
<h1 className="page-title">Hiring Managers</h1> sub={`${managers.length} managers · ${totalReqs} active requisitions`}
<p className="page-sub"> />
{total} manager{total === 1 ? '' : 's'}
</p>
</div>
</div>
{managersQuery.isPending && ( {managersQuery.isPending && (
<EmptyState icon="managers" title="Loading…">Fetching hiring managers.</EmptyState> <div className="card"><div className="card-body"><SkeletonRows rows={4} /></div></div>
)} )}
{managersQuery.isError && ( {managersQuery.isError && (
<EmptyState icon="managers" title="Couldnt load hiring managers"> <EmptyState icon="managers" title="Couldnt load hiring managers">
@ -94,9 +91,9 @@ export default function Managers() {
{managers.map((m) => ( {managers.map((m) => (
<div className="card" key={m.id}> <div className="card" key={m.id}>
<div className="card-body"> <div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}> <div className="flex items-center gap-12 mb-12">
<Avatar name={m.name} className="avatar-lg" /> <Avatar name={m.name} className="avatar-lg" />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="lr-title">{m.name}</div> <div className="lr-title">{m.name}</div>
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div> <div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
</div> </div>
@ -106,8 +103,10 @@ export default function Managers() {
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div> <div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
</div> </div>
<div className="divider" style={{ margin: '12px 0' }} /> <div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}> <div className="flex items-center justify-between gap-8">
<span className="cell-sub"><Icon name="mail" /> {m.email ? m.email.split('@')[0] : '—'}</span> <span className="cell-sub truncate min-w-0" title={m.email || undefined}>
<Icon name="mail" /> {m.email || '—'}
</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button> <button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
</div> </div>
</div> </div>
@ -125,7 +124,7 @@ export default function Managers() {
setPage={setPage} setPage={setPage}
pageButtons={pageWindow(currentPage, pages)} pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize} pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setPage(1) }} onPageSizeChange={(n) => { setPageSize(n); setPage((p) => pageAfterSizeChange(p, total, n)) }}
pageSizeMax={PAGE_SIZE_MAX} pageSizeMax={PAGE_SIZE_MAX}
/> />
</div> </div>
@ -242,7 +241,7 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
</div> </div>
</div> </div>
<div className="form-section-title" style={{ marginTop: 0 }}>Hiring Manager Portal</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Hiring Manager Portal</h3>
<div className="grid g-2" style={{ gap: 10, marginBottom: 16 }}> <div className="grid g-2" style={{ gap: 10, marginBottom: 16 }}>
<button className="btn btn-secondary" onClick={() => go('/jobs', { openCreate: true })}> <button className="btn btn-secondary" onClick={() => go('/jobs', { openCreate: true })}>
<Icon name="plus" /> Raise Requisition <Icon name="plus" /> Raise Requisition
@ -258,7 +257,7 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
</button> </button>
</div> </div>
<div className="form-section-title">Open requisitions</div> <h3 className="form-section-title">Open requisitions</h3>
<p className="text-muted text-sm" style={{ marginBottom: 10 }}> <p className="text-muted text-sm" style={{ marginBottom: 10 }}>
Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager&apos;s own. Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager&apos;s own.
</p> </p>

View File

@ -12,6 +12,7 @@ import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
@ -375,15 +376,10 @@ export default function Matching() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader title="Job Matching" sub="Route applications to the right open role" />
<div>
<h1>Job Matching</h1>
<p className="page-sub">Route applications to the right open role</p>
</div>
</div>
{!canEdit && ( {!canEdit && (
<div className="alert alert-danger" style={{ marginBottom: 14 }}> <div className="alert alert-danger mb-12">
Your account does not hold <code>inbox.edit</code>, which the server requires to Your account does not hold <code>inbox.edit</code>, which the server requires to
assign, unassign, or retry a match. Controls below stay disabled. assign, unassign, or retry a match. Controls below stay disabled.
</div> </div>
@ -435,7 +431,7 @@ export default function Matching() {
> >
<Avatar name={i.name} initials={i.initials} color={i.color} /> <Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main"> <div className="ii-main">
<div className="ii-name">{i.name}</div> <div className="ii-name"><span className="truncate min-w-0" title={i.name}>{i.name}</span></div>
<div className="ii-pos">{i.position}</div> <div className="ii-pos">{i.position}</div>
<div className="ii-meta"> <div className="ii-meta">
<SourceChip item={i} /> <SourceChip item={i} />
@ -554,7 +550,7 @@ function MatchingWorkspace({
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}> <div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" /> <Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}> <div className="flex-1">
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div> <div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div> <div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}> <div className="ph-tags" style={{ marginTop: 8 }}>
@ -577,7 +573,7 @@ function MatchingWorkspace({
marginBottom: 18, marginBottom: 18,
}} }}
> >
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}> <div className="card-body flex items-center gap-12 justify-between flex-wrap">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<Icon name="check-circle" /> <Icon name="check-circle" />
<div> <div>
@ -609,8 +605,8 @@ function MatchingWorkspace({
> >
<div style={{ flex: '1 1 320px', minWidth: 0 }}> <div style={{ flex: '1 1 320px', minWidth: 0 }}>
{matchFailed ? ( {matchFailed ? (
<div className="alert alert-danger" style={{ marginBottom: 16 }}> <div className="alert alert-danger mb-16">
<div style={{ marginBottom: 8 }}>{detail?.matchError || 'Matching failed for this application.'}</div> <div className="mb-8">{detail?.matchError || 'Matching failed for this application.'}</div>
<button <button
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching} disabled={!canEdit || rematching}
@ -621,7 +617,7 @@ function MatchingWorkspace({
</button> </button>
</div> </div>
) : ( ) : (
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div> <div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
<p style={{ marginBottom: 4 }}> <p style={{ marginBottom: 4 }}>
{detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'} {detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'}
@ -639,7 +635,7 @@ function MatchingWorkspace({
</button> </button>
)} )}
{whyOpen && ( {whyOpen && (
<p className="text-muted text-sm" style={{ marginTop: 8 }}> <p className="text-muted text-sm mt-8">
{detail?.matchReasoning || listRow?.matchReasoning} {detail?.matchReasoning || listRow?.matchReasoning}
</p> </p>
)} )}
@ -649,7 +645,7 @@ function MatchingWorkspace({
{/* Email first: it is the application itself, and the resume is its {/* Email first: it is the application itself, and the resume is its
attachment. Reading order follows that. */} attachment. Reading order follows that. */}
{(detail?.subject || detail?.body) && ( {(detail?.subject || detail?.body) && (
<div style={{ marginBottom: 16 }}> <div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>Email</div> <div className="fw-600" style={{ marginBottom: 6 }}>Email</div>
<div className="email-head">Subject: {detail.subject || '(no subject)'}</div> <div className="email-head">Subject: {detail.subject || '(no subject)'}</div>
{looksLikeHtml(detail.bodyHtml) ? ( {looksLikeHtml(detail.bodyHtml) ? (
@ -669,7 +665,7 @@ function MatchingWorkspace({
</div> </div>
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}> <div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div> <div className="fw-600 mb-8">Suggested roles</div>
{suggestionCards.length === 0 && !manualPost ? ( {suggestionCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No suggested roles"> <EmptyState icon="alert" title="No suggested roles">
<p>No job post was suggested. Choose a role manually.</p> <p>No job post was suggested. Choose a role manually.</p>

View File

@ -1,7 +1,8 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { EmptyState, Icon } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -50,12 +51,10 @@ export default function Notifications() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Notifications"
<h1 className="page-title">Notifications</h1> sub="Stay on top of hiring activity"
<p className="page-sub">Stay on top of hiring activity</p> actions={
</div>
<div className="page-head-actions">
<button <button
className="btn btn-secondary" className="btn btn-secondary"
disabled={markAll.isPending} disabled={markAll.isPending}
@ -63,13 +62,13 @@ export default function Notifications() {
> >
<Icon name="check" /> Mark all read <Icon name="check" /> Mark all read
</button> </button>
</div> }
</div> />
<div className="card"> <div className="card">
{query.isPending && ( {query.isPending && (
<div className="card-body"> <div className="card-body">
<EmptyState icon="bell" title="Loading…">Fetching notifications.</EmptyState> <SkeletonRows rows={5} />
</div> </div>
)} )}
{query.isError && ( {query.isError && (
@ -107,6 +106,7 @@ export default function Notifications() {
<button <button
className="act-btn" className="act-btn"
data-tip="Dismiss" data-tip="Dismiss"
aria-label="Dismiss notification"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
remove.mutate(n.id) remove.mutate(n.id)

View File

@ -24,7 +24,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -224,12 +225,13 @@ export default function Offers() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (o) => ( render: (o) => (
<div className="row-actions"> <div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(o)}> <button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
<Icon name="eye" /> <Icon name="eye" />
</button> </button>
<button <button
className="act-btn" className="act-btn"
data-tip={o.status === 'draft' ? 'Send offer' : 'Resend'} data-tip={o.status === 'draft' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'draft' ? 'Send offer' : 'Resend offer'}
disabled={busy || ['accepted', 'declined'].includes(o.status)} disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={() => issue.mutate(o.id)} onClick={() => issue.mutate(o.id)}
> >
@ -242,17 +244,15 @@ export default function Offers() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Offers"
<h1 className="page-title">Offers</h1> sub="Track offer letters and acceptance"
<p className="page-sub">Track offer letters and acceptance</p> actions={
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setCreating(true)}> <button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> Create Offer <Icon name="plus" /> Create Offer
</button> </button>
</div> }
</div> />
<div className="grid g-kpi mb-18"> <div className="grid g-kpi mb-18">
<KpiCard label="Offers Sent" value={allQuery.isPending ? '—' : stats.sent} icon="send" tone="i-indigo" /> <KpiCard label="Offers Sent" value={allQuery.isPending ? '—' : stats.sent} icon="send" tone="i-indigo" />
@ -283,7 +283,7 @@ export default function Offers() {
{offersQuery.isPending && ( {offersQuery.isPending && (
<div className="card-body"> <div className="card-body">
<EmptyState icon="file" title="Loading…">Fetching offers from the server.</EmptyState> <SkeletonRows rows={6} />
</div> </div>
)} )}
{offersQuery.isError && ( {offersQuery.isError && (
@ -370,7 +370,7 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) {
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}> <div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
<div className="card-body"> <div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>Compensation Package</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Compensation Package</h3>
<div className="info-grid"> <div className="info-grid">
<div className="info-item"> <div className="info-item">
<div className="il">Base Salary</div> <div className="il">Base Salary</div>

View File

@ -18,6 +18,7 @@ import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -178,17 +179,15 @@ export default function Pipeline() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Pipeline"
<h1 className="page-title">Pipeline</h1> sub={<>
<p className="page-sub">
{canEdit {canEdit
? 'Drag candidates between stages to update their status' ? 'Drag candidates between stages to update their status'
: 'Read-only — moving a candidate needs the pipeline.edit permission'} : 'Read-only — moving a candidate needs the pipeline.edit permission'}
{total > candidates.length && ` · showing ${candidates.length} of ${total} applications`} {total > candidates.length && ` · showing ${candidates.length} of ${total} applications`}
</p> </>}
</div> actions={<>
<div className="page-head-actions">
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}> <select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option> <option value="">All Jobs</option>
{jobs.map((j) => ( {jobs.map((j) => (
@ -201,8 +200,8 @@ export default function Pipeline() {
> >
<Icon name="plus" /> Add Candidate <Icon name="plus" /> Add Candidate
</button> </button>
</div> </>}
</div> />
{board.isError ? ( {board.isError ? (
<EmptyState title="Could not load the pipeline"> <EmptyState title="Could not load the pipeline">

View File

@ -23,6 +23,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout' import { useFormState } from '../components/AuthLayout'
@ -129,19 +130,15 @@ export default function Rbac() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Access Control"
<h1 className="page-title">Access Control</h1> sub={<>Roles, permission bundles and the {totalTags || '104'}-tag vocabulary, live from the server</>}
<p className="page-sub"> actions={
Roles, permission bundles and the {totalTags || '104'}-tag vocabulary, live from the server
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setCreating(true)}> <button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> New Role <Icon name="plus" /> New Role
</button> </button>
</div> }
</div> />
{rolesQuery.isPending && ( {rolesQuery.isPending && (
<div className="card"><div className="card-body"> <div className="card"><div className="card-body">
@ -439,12 +436,12 @@ function RoleForm({ title, subtitle, role, bundles, bundlesLoading, busy, onClos
</label> </label>
</div> </div>
<div className="form-section-title"> <h3 className="form-section-title">
Permission bundles Permission bundles
<span className="text-muted text-sm" style={{ marginLeft: 8, fontWeight: 400 }}> <span className="text-muted text-sm" style={{ marginLeft: 8, fontWeight: 400 }}>
{picked.size} selected · {grantedTags.size} tags resolved {picked.size} selected · {grantedTags.size} tags resolved
</span> </span>
</div> </h3>
{bundlesLoading && <p className="text-muted">Loading bundles</p>} {bundlesLoading && <p className="text-muted">Loading bundles</p>}
{!bundlesLoading && bundles.length === 0 && ( {!bundlesLoading && bundles.length === 0 && (

View File

@ -26,6 +26,7 @@ import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart' import Chart from '../ui/Chart'
import Charts from '../lib/charts' import Charts from '../lib/charts'
import PageHeader from '../ui/PageHeader'
import { Avatar, EmptyState, Icon, KpiCard } from '../ui/primitives' import { Avatar, EmptyState, Icon, KpiCard } from '../ui/primitives'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -188,20 +189,18 @@ export default function RecruiterHub() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Recruiter Hub"
<h1 className="page-title">Recruiter Hub</h1> sub="Per-recruiter performance, scoped server-side"
<p className="page-sub">Per-recruiter performance, scoped server-side</p> actions={
</div>
<div className="page-head-actions">
<select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}> <select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}>
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)} {recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
</select> </select>
</div> }
</div> />
<div className="card brand-hero mb-18"> <div className="card brand-hero mb-18">
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}> <div className="card-body flex items-center flex-wrap" style={{ gap: 18 }}>
<Avatar name={name} initials={initialsOf(name)} color="rgba(255,255,255,.18)" className="avatar-lg" /> <Avatar name={name} initials={initialsOf(name)} color="rgba(255,255,255,.18)" className="avatar-lg" />
<div style={{ flex: 1, minWidth: 200 }}> <div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{name}</div> <div style={{ fontSize: 20, fontWeight: 700 }}>{name}</div>
@ -212,13 +211,13 @@ export default function RecruiterHub() {
</div> </div>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800 }}> <div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
{loading ? '—' : pct(k?.hires ?? 0, k?.total_candidates ?? 0)} {loading ? '—' : pct(k?.hires ?? 0, k?.total_candidates ?? 0)}
</div> </div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Applicant hire</div> <div style={{ opacity: 0.85, fontSize: 12 }}>Applicant hire</div>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800 }}> <div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
{loading ? '—' : pct(k?.offers_accepted ?? 0, k?.offers_sent ?? 0)} {loading ? '—' : pct(k?.offers_accepted ?? 0, k?.offers_sent ?? 0)}
</div> </div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div> <div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div>

View File

@ -36,6 +36,7 @@ import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts' import Charts from '../lib/charts'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { EmptyState, Icon, KpiCard, ProgressBar } from '../ui/primitives' import { EmptyState, Icon, KpiCard, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
@ -461,17 +462,15 @@ export default function Reports() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Reports"
<h1 className="page-title">Reports</h1> sub="Recruitment metrics across the selected window"
<p className="page-sub">Recruitment metrics across the selected window</p> actions={
</div>
<div className="page-head-actions">
<select className="select" value={rangeKey} onChange={(e) => setRangeKey(e.target.value)}> <select className="select" value={rangeKey} onChange={(e) => setRangeKey(e.target.value)}>
{RANGES.map((r) => <option key={r.key} value={r.key}>{r.label}</option>)} {RANGES.map((r) => <option key={r.key} value={r.key}>{r.label}</option>)}
</select> </select>
</div> }
</div> />
{kpisQuery.isError && ( {kpisQuery.isError && (
<div className="card mb-18"><div className="card-body"> <div className="card mb-18"><div className="card-body">

View File

@ -9,6 +9,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -115,13 +116,10 @@ export default function Settings() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Settings"
<h1 className="page-title">Settings</h1> sub="Configure your workspace and team preferences"
<p className="page-sub">Configure your workspace and team preferences</p> actions={showOrgSave && (
</div>
<div className="page-head-actions">
{showOrgSave && (
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={!canConfigure || save.isPending} disabled={!canConfigure || save.isPending}
@ -130,8 +128,7 @@ export default function Settings() {
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Changes'} <Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Changes'}
</button> </button>
)} )}
</div> />
</div>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} /> <Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
@ -150,13 +147,35 @@ export default function Settings() {
) )
} }
/* The option label IS the stored value (org_settings rows hold the full string),
so existing saved values like "(GMT-08:00) Pacific Time" must stay verbatim. */
const TIMEZONES = [
'(GMT-08:00) Pacific Time',
'(GMT-07:00) Mountain Time',
'(GMT-06:00) Central Time',
'(GMT-05:00) Eastern Time',
'(GMT+00:00) UTC',
'(GMT+00:00) London',
'(GMT+01:00) Central European Time',
'(GMT+03:00) Arabia Standard Time',
'(GMT+04:00) Gulf Standard Time',
'(GMT+05:00) Pakistan Standard Time',
'(GMT+05:30) India Standard Time',
'(GMT+08:00) Singapore Standard Time',
'(GMT+09:00) Japan Standard Time',
'(GMT+10:00) Australian Eastern Time',
]
// Codes match the offer form's currency set (Offers.jsx).
const CURRENCIES = ['USD ($)', 'EUR (€)', 'GBP (£)', 'PKR (₨)', 'AED (د.إ)']
function General({ registerSave }) { function General({ registerSave }) {
const defaults = { const defaults = {
'general.company_name': 'Utopia Brands Inc.', 'general.company_name': 'Utopia Brands Inc.',
'general.website': 'https://utopiabrands.com', 'general.website': 'https://utopiabrands.com',
'general.industry': 'Consumer Goods', 'general.industry': 'Consumer Goods',
'general.company_size': '201500', 'general.company_size': '201500',
'general.timezone': '(GMT-08:00) Pacific Time', 'general.timezone': '(GMT+05:00) Pakistan Standard Time',
'general.currency': 'USD ($)', 'general.currency': 'USD ($)',
'general.auto_archive_stale_jobs': true, 'general.auto_archive_stale_jobs': true,
'general.duplicate_detection': true, 'general.duplicate_detection': true,
@ -207,15 +226,13 @@ function General({ registerSave }) {
<div className="form-field"> <div className="form-field">
<label>Default Time Zone</label> <label>Default Time Zone</label>
<select value={draft['general.timezone'] ?? ''} onChange={(e) => setField('general.timezone', e.target.value)}> <select value={draft['general.timezone'] ?? ''} onChange={(e) => setField('general.timezone', e.target.value)}>
<option>(GMT-08:00) Pacific Time</option> {TIMEZONES.map((tz) => <option key={tz}>{tz}</option>)}
<option>(GMT-05:00) Eastern Time</option>
<option>(GMT+00:00) UTC</option>
</select> </select>
</div> </div>
<div className="form-field"> <div className="form-field">
<label>Default Currency</label> <label>Default Currency</label>
<select value={draft['general.currency'] ?? ''} onChange={(e) => setField('general.currency', e.target.value)}> <select value={draft['general.currency'] ?? ''} onChange={(e) => setField('general.currency', e.target.value)}>
<option>USD ($)</option><option>EUR ()</option><option>GBP (£)</option> {CURRENCIES.map((c) => <option key={c}>{c}</option>)}
</select> </select>
</div> </div>
</div> </div>
@ -264,14 +281,10 @@ function Users() {
{usersQuery.isError ? ( {usersQuery.isError ? (
<div className="card-body"> <div className="card-body">
<div className="empty-state"> <EmptyState icon="alert" title="Couldnt load users">
<Icon name="alert" />
<h3>Couldnt load users</h3>
<p>
{friendlyAuthError(usersQuery.error, 'The server did not return the user list.')} {friendlyAuthError(usersQuery.error, 'The server did not return the user list.')}
{' '}This tab needs the <code>rbac_users.view</code> permission. {' '}This tab needs the <code>rbac_users.view</code> permission.
</p> </EmptyState>
</div>
</div> </div>
) : ( ) : (
<div className="table-wrap"> <div className="table-wrap">
@ -288,9 +301,9 @@ function Users() {
<td> <td>
<div className="user-cell"> <div className="user-cell">
<Avatar name={u.name} /> <Avatar name={u.name} />
<div> <div className="min-w-0">
<div className="cell-primary">{u.name}</div> <div className="cell-primary">{u.name}</div>
<div className="cell-sub">{u.email}</div> <div className="cell-sub" title={u.email || undefined}>{u.email}</div>
</div> </div>
</div> </div>
</td> </td>
@ -822,12 +835,12 @@ function Notifications({ registerSave }) {
return ( return (
<div className="card"> <div className="card">
<div className="card-body"> <div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>Email Notifications</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Email Notifications</h3>
<ToggleRow title="New applications" desc="Get notified when a candidate applies" checked={draft['notifications.email_new_applications']} onChange={(v) => setField('notifications.email_new_applications', v)} /> <ToggleRow title="New applications" desc="Get notified when a candidate applies" checked={draft['notifications.email_new_applications']} onChange={(v) => setField('notifications.email_new_applications', v)} />
<ToggleRow title="Interview reminders" desc="Reminders 30 minutes before interviews" checked={draft['notifications.email_interview_reminders']} onChange={(v) => setField('notifications.email_interview_reminders', v)} /> <ToggleRow title="Interview reminders" desc="Reminders 30 minutes before interviews" checked={draft['notifications.email_interview_reminders']} onChange={(v) => setField('notifications.email_interview_reminders', v)} />
<ToggleRow title="Offer responses" desc="When candidates accept or decline offers" checked={draft['notifications.email_offer_responses']} onChange={(v) => setField('notifications.email_offer_responses', v)} /> <ToggleRow title="Offer responses" desc="When candidates accept or decline offers" checked={draft['notifications.email_offer_responses']} onChange={(v) => setField('notifications.email_offer_responses', v)} />
<ToggleRow title="Weekly digest" desc="A summary of hiring activity every Monday" checked={draft['notifications.email_weekly_digest']} onChange={(v) => setField('notifications.email_weekly_digest', v)} /> <ToggleRow title="Weekly digest" desc="A summary of hiring activity every Monday" checked={draft['notifications.email_weekly_digest']} onChange={(v) => setField('notifications.email_weekly_digest', v)} />
<div className="form-section-title">In-App Notifications</div> <h3 className="form-section-title">In-App Notifications</h3>
<ToggleRow title="Mentions" desc="When a teammate @mentions you" checked={draft['notifications.inapp_mentions']} onChange={(v) => setField('notifications.inapp_mentions', v)} /> <ToggleRow title="Mentions" desc="When a teammate @mentions you" checked={draft['notifications.inapp_mentions']} onChange={(v) => setField('notifications.inapp_mentions', v)} />
<ToggleRow title="Stage changes" desc="When a candidate moves stages" checked={draft['notifications.inapp_stage_changes']} onChange={(v) => setField('notifications.inapp_stage_changes', v)} /> <ToggleRow title="Stage changes" desc="When a candidate moves stages" checked={draft['notifications.inapp_stage_changes']} onChange={(v) => setField('notifications.inapp_stage_changes', v)} />
<ToggleRow title="Task assignments" desc="When you are assigned a task" checked={draft['notifications.inapp_task_assignments']} onChange={(v) => setField('notifications.inapp_task_assignments', v)} /> <ToggleRow title="Task assignments" desc="When you are assigned a task" checked={draft['notifications.inapp_task_assignments']} onChange={(v) => setField('notifications.inapp_task_assignments', v)} />
@ -1047,7 +1060,7 @@ function Appearance() {
return ( return (
<div className="card"> <div className="card">
<div className="card-body"> <div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>Theme</div> <h3 className="form-section-title" style={{ marginTop: 0 }}>Theme</h3>
<div className="grid g-3" style={{ marginBottom: 8 }}> <div className="grid g-3" style={{ marginBottom: 8 }}>
<div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('light')}> <div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('light')}>
<div style={{ height: 80, background: '#f1f7f4', borderBottom: '1px solid var(--border)', display: 'flex' }}> <div style={{ height: 80, background: '#f1f7f4', borderBottom: '1px solid var(--border)', display: 'flex' }}>

View File

@ -12,6 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon } from '../ui/primitives' import { Badge, EmptyState, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
@ -123,7 +124,7 @@ function MatchRing({ score, size = 46 }) {
style={{ style={{
width: size - 8, height: size - 8, borderRadius: '50%', width: size - 8, height: size - 8, borderRadius: '50%',
background: 'var(--bg-elev)', display: 'grid', placeItems: 'center', background: 'var(--bg-elev)', display: 'grid', placeItems: 'center',
fontWeight: 800, fontSize: 13.5, letterSpacing: '-.3px', fontWeight: 700, fontSize: 13.5, letterSpacing: '-0.01em',
color, color,
}} }}
> >
@ -186,6 +187,7 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
<a <a
className="act-btn" className="act-btn"
data-tip="Open LinkedIn profile" data-tip="Open LinkedIn profile"
aria-label="Open LinkedIn profile"
href={p.linkedinUrl} href={p.linkedinUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@ -196,6 +198,7 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
<button <button
className="act-btn" className="act-btn"
data-tip="View profile" data-tip="View profile"
aria-label="View profile"
onClick={(e) => { e.stopPropagation(); onView(p) }} onClick={(e) => { e.stopPropagation(); onView(p) }}
> >
<Icon name="eye" /> <Icon name="eye" />
@ -203,6 +206,7 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
<button <button
className="act-btn" className="act-btn"
data-tip="Dismiss" data-tip="Dismiss"
aria-label="Dismiss profile"
disabled={dismissing} disabled={dismissing}
onClick={(e) => { e.stopPropagation(); onDismiss(p) }} onClick={(e) => { e.stopPropagation(); onDismiss(p) }}
> >
@ -267,14 +271,14 @@ function TalentProfileDetail({ profileId, onClose }) {
{p.summary && ( {p.summary && (
<> <>
<div className="form-section-title">About</div> <h3 className="form-section-title">About</h3>
<p className="text-muted" style={{ whiteSpace: 'pre-line' }}>{p.summary}</p> <p className="text-muted" style={{ whiteSpace: 'pre-line' }}>{p.summary}</p>
</> </>
)} )}
{p.skills.length > 0 && ( {p.skills.length > 0 && (
<> <>
<div className="form-section-title">Skills ({p.skills.length})</div> <h3 className="form-section-title">Skills ({p.skills.length})</h3>
<div className="k-tags" style={{ marginBottom: 16 }}> <div className="k-tags" style={{ marginBottom: 16 }}>
{p.skills.map((s) => <span className="tag" key={s}>{s}</span>)} {p.skills.map((s) => <span className="tag" key={s}>{s}</span>)}
</div> </div>
@ -283,7 +287,7 @@ function TalentProfileDetail({ profileId, onClose }) {
{p.experience.length > 0 && ( {p.experience.length > 0 && (
<> <>
<div className="form-section-title">Experience ({p.experience.length})</div> <h3 className="form-section-title">Experience ({p.experience.length})</h3>
{p.experience.map((e, i) => ( {p.experience.map((e, i) => (
<div key={i} style={{ marginBottom: 14 }}> <div key={i} style={{ marginBottom: 14 }}>
<div className="fw-600"> <div className="fw-600">
@ -307,7 +311,7 @@ function TalentProfileDetail({ profileId, onClose }) {
{p.education.length > 0 && ( {p.education.length > 0 && (
<> <>
<div className="form-section-title">Education ({p.education.length})</div> <h3 className="form-section-title">Education ({p.education.length})</h3>
{p.education.map((e, i) => ( {p.education.map((e, i) => (
<div key={i} style={{ marginBottom: 12 }}> <div key={i} style={{ marginBottom: 12 }}>
<div className="fw-600">{e.school ?? '—'}</div> <div className="fw-600">{e.school ?? '—'}</div>
@ -440,19 +444,15 @@ export default function Talent() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Find Talent"
<h1 className="page-title">Find Talent</h1> sub="Source matching LinkedIn profiles for a job via Apify"
<p className="page-sub">Source matching LinkedIn profiles for a job via Apify</p> actions={<span className="integration-status pending"><span className="pulse" />LinkedIn Sourcing · Live</span>}
</div> />
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />LinkedIn Sourcing · Live</span>
</div>
</div>
<div className="card mb-18"> <div className="card mb-18">
<div className="card-body"> <div className="card-body">
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}> <div className="flex items-center gap-8 flex-wrap">
<select <select
className="select" className="select"
style={{ flex: 1, minWidth: 180 }} style={{ flex: 1, minWidth: 180 }}
@ -543,13 +543,14 @@ export default function Talent() {
) : ( ) : (
<> <>
<div className="flex items-center gap-8 mb-18"> <div className="flex items-center gap-8 mb-18">
<div className="toolbar-search">
<Icon name="search" />
<input <input
className="input"
style={{ maxWidth: 320 }}
placeholder="Filter by name, headline, company…" placeholder="Filter by name, headline, company…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div>
<span className="text-muted text-sm"> <span className="text-muted text-sm">
{visible.length} of {profiles.length} profile{profiles.length === 1 ? '' : 's'} {visible.length} of {profiles.length} profile{profiles.length === 1 ? '' : 's'}
</span> </span>

View File

@ -35,6 +35,7 @@ import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable' import { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
@ -206,17 +207,15 @@ export default function TalentPool() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Talent Pool"
<h1 className="page-title">Talent Pool</h1> sub={<>{pool.length} silver-medalists &amp; passive candidates to re-engage</>}
<p className="page-sub">{pool.length} silver-medalists &amp; passive candidates to re-engage</p> actions={
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}> <button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
<Icon name="send" /> Start Campaign <Icon name="send" /> Start Campaign
</button> </button>
</div> }
</div> />
<div className="card mb-18"> <div className="card mb-18">
<div className="card-body" style={{ padding: 16 }}> <div className="card-body" style={{ padding: 16 }}>

View File

@ -17,7 +17,8 @@ import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon, PRIORITY_CLASS, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout' import { useFormState } from '../components/AuthLayout'
@ -28,7 +29,6 @@ import * as savedSearchesApi from '../api/savedSearches'
import { fmtDate, fmtShort } from '../data/seed' import { fmtDate, fmtShort } from '../data/seed'
const FILTERS = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low'] const FILTERS = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
// Mirrors backend/tasks/views.py CREATOR_ROLES the server is the enforcer, // Mirrors backend/tasks/views.py CREATOR_ROLES the server is the enforcer,
// this only keeps the button honest. // this only keeps the button honest.
const CREATOR_ROLES = ['system_administrator', 'hr_administrator', 'recruiter'] const CREATOR_ROLES = ['system_administrator', 'hr_administrator', 'recruiter']
@ -156,12 +156,10 @@ export default function Tasks() {
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <PageHeader
<div> title="Tasks"
<h1 className="page-title">Tasks</h1> sub={`${openCount} open · ${overdueCount} overdue`}
<p className="page-sub">{openCount} open · {overdueCount} overdue</p> actions={
</div>
<div className="page-head-actions">
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={!canCreate} disabled={!canCreate}
@ -170,8 +168,8 @@ export default function Tasks() {
> >
<Icon name="plus" /> New Task <Icon name="plus" /> New Task
</button> </button>
</div> }
</div> />
<div className="grid g-2-1"> <div className="grid g-2-1">
<div className="card"> <div className="card">
@ -187,7 +185,7 @@ export default function Tasks() {
<div className="card-body"> <div className="card-body">
<div className="list-tight"> <div className="list-tight">
{tasksQuery.isPending ? ( {tasksQuery.isPending ? (
<EmptyState icon="check-square" title="Loading…">Fetching tasks from the server.</EmptyState> <SkeletonRows rows={5} />
) : tasksQuery.isError ? ( ) : tasksQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load tasks"> <EmptyState icon="alert" title="Couldnt load tasks">
{friendlyAuthError(tasksQuery.error, 'Request failed')} {friendlyAuthError(tasksQuery.error, 'Request failed')}
@ -241,7 +239,7 @@ export default function Tasks() {
<div className="card" style={{ alignSelf: 'start' }}> <div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head"> <div className="card-head">
<div><h3>Saved Searches</h3><span className="ch-sub">Quick candidate filters</span></div> <div><h3>Saved Searches</h3><span className="ch-sub">Quick candidate filters</span></div>
<button className="act-btn" onClick={() => setAddingSearch(true)}><Icon name="plus" /></button> <button className="act-btn" aria-label="Add saved search" data-tip="Add saved search" onClick={() => setAddingSearch(true)}><Icon name="plus" /></button>
</div> </div>
<div className="card-body"> <div className="card-body">
{savedQuery.isPending && <EmptyState icon="bookmark" title="Loading…">Fetching saved searches.</EmptyState>} {savedQuery.isPending && <EmptyState icon="bookmark" title="Loading…">Fetching saved searches.</EmptyState>}
@ -277,6 +275,7 @@ export default function Tasks() {
<button <button
className="act-btn" className="act-btn"
data-tip="Delete" data-tip="Delete"
aria-label="Delete saved search"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
deleteSearch.mutate(s.id) deleteSearch.mutate(s.id)

View File

@ -50,11 +50,11 @@
} }
.auth-aside-copy h1 { .auth-aside-copy h1 {
font-family: 'Belleza', Georgia, serif; font-family: var(--font-display);
font-size: clamp(32px, 4vw, 44px); font-size: clamp(32px, 4vw, 44px);
line-height: 1.15; line-height: 1.12;
font-weight: 400; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.025em;
margin: 0 0 14px; margin: 0 0 14px;
color: #fff; color: #fff;
} }
@ -115,7 +115,7 @@
} }
.auth-brand .brand-name { .auth-brand .brand-name {
font-family: 'Belleza', Georgia, serif; font-family: var(--font-brand);
font-weight: 400; font-weight: 400;
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }

View File

@ -7,8 +7,9 @@
Secondary #25e9a5 mint · #8e92ff periwinkle Secondary #25e9a5 mint · #8e92ff periwinkle
Neutral #ffffff · #000000 Neutral #ffffff · #000000
Typography Typography
Display / main headings ....... Belleza Display / main headings ....... Inter Tight (semibold, tight tracking)
UI, body & sub-headings ....... Neue Montreal (Inter fallback) UI, body & sub-headings ....... Neue Montreal (Inter fallback)
Wordmark only ................. Belleza
Theme model Theme model
Light white/mint surfaces, deep green as the action colour. Light white/mint surfaces, deep green as the action colour.
@ -84,9 +85,33 @@
--topbar-h: 64px; --topbar-h: 64px;
--font: 'Neue Montreal', 'PP Neue Montreal', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; --font: 'Neue Montreal', 'PP Neue Montreal', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-display: 'Belleza', 'Neue Montreal', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-display: 'Inter Tight', 'Neue Montreal', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-brand: 'Belleza', 'Inter Tight', 'Inter', sans-serif;
--mono: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, monospace; --mono: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
/* Type scale 9 steps that the 20-size census collapses onto.
Theme-invariant; declared once here, never redefined in dark. */
--fs-2xs: 11px; /* eyebrow labels, micro-meta */
--fs-xs: 12px; /* badges, chips, cell-sub */
--fs-sm: 13px; /* secondary body, form labels, small buttons */
--fs-base: 14px; /* body, tables, buttons, nav */
--fs-md: 15px; /* card titles */
--fs-lg: 18px; /* section headings, empty states */
--fs-xl: 22px; /* modal titles */
--fs-2xl: 25px; /* profile hero name, mobile page title */
--fs-3xl: 30px; /* page title (Inter Tight) */
--lh-display: 1.15; /* display headings */
--lh-heading: 1.25; /* h2h6, card titles */
--lh-body: 1.5; /* running copy */
--lh-label: 1.35; /* eyebrows, form labels, table headers */
/* Spacing scale. --gap is the app's signature 18px stack rhythm
(grid gap, mt/mb-18, card-head padding) a sanctioned step, kept. */
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 24px; --space-7: 32px; --space-8: 40px;
--gap: 18px;
/* Categorical chart series brand hues darkened to hold >=3:1 /* Categorical chart series brand hues darkened to hold >=3:1
against white so lines and small marks stay legible. */ against white so lines and small marks stay legible. */
--c1: #004d43; --c2: #0f9d76; --c3: #5b60e8; --c4: #6f8f14; --c1: #004d43; --c2: #0f9d76; --c3: #5b60e8; --c4: #6f8f14;
@ -180,8 +205,8 @@ body {
font-family: var(--font); font-family: var(--font);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-size: 14px; font-size: var(--fs-base);
line-height: 1.5; line-height: var(--lh-body);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
/* Grey flash on tap (iOS/Android WebKit + Chromium); we provide our own /* Grey flash on tap (iOS/Android WebKit + Chromium); we provide our own
@ -195,18 +220,22 @@ button {
font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; font-family: inherit; cursor: pointer; border: none; background: none; color: inherit;
touch-action: manipulation; /* removes the legacy 300ms tap delay */ touch-action: manipulation; /* removes the legacy 300ms tap delay */
} }
input, select, textarea { font-family: inherit; font-size: 14px; color: var(--text); } input, select, textarea { font-family: inherit; font-size: var(--fs-base); color: var(--text); }
a { color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; } a { color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; }
img, svg, video, canvas { max-width: 100%; } img, svg, video, canvas { max-width: 100%; }
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 14px; height: 14px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 20px; border: 2px solid transparent; background-clip: padding-box; } ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 20px; border: 3px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); background-clip: padding-box; } ::-webkit-scrollbar-thumb:hover { background: var(--text-3); background-clip: padding-box; }
::selection { background: var(--brand-lime); color: var(--brand-ink); } ::selection { background: var(--brand-lime); color: var(--brand-ink); }
/* Firefox / non-WebKit scrollbars (WebKit ignores these). */ /* Firefox-only scrollbar colors. Chrome 121+ also passes
@supports (scrollbar-color: auto) { @supports (scrollbar-color: auto), and any non-auto scrollbar-color/width
* { scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; } makes Chromium IGNORE the ::-webkit-scrollbar rules above the old
`scrollbar-width: thin` here is what forced the skinny native bars.
Gate on -moz-appearance so only Firefox takes this path. */
@supports (-moz-appearance: none) {
* { scrollbar-color: var(--border-strong) transparent; }
} }
/* ============================================================ /* ============================================================
@ -238,34 +267,59 @@ img, svg, video, canvas { max-width: 100%; }
/* ============================================================ /* ============================================================
BRAND TYPOGRAPHY BRAND TYPOGRAPHY
Guideline: "main heading always in main typeface belleza / Display face is Inter Tight a headline grotesque that shares
content & sub-heading always in neue montreal". Inter's skeleton, so titles read as the same voice as the UI,
Belleza is a display face reserved for page-level and modal only set semibold with negative tracking. Belleza (the brand
titles, hero numbers and the wordmark. Everything structural guideline's decorative face) is confined to the wordmark: at
(labels, tables, controls) stays in the UI face for legibility. text sizes and weight 400 it read thin and informal against a
dense data UI. Everything structural (labels, tables, controls)
stays in the UI face for legibility.
============================================================ */ ============================================================ */
.page-title, .page-title,
.modal-head h2, .modal-head h2,
.brand-name,
.ph-name, .ph-name,
.ai-hero h2, .ai-hero h2,
.empty-state h3 { .empty-state h3 {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 400; font-weight: 600;
letter-spacing: 0; letter-spacing: -0.015em;
} }
.page-title { font-size: 30px; line-height: 1.15; } .page-title { font-size: var(--fs-3xl); line-height: var(--lh-display); letter-spacing: -0.02em; }
.modal-head h2 { font-size: 22px; } .page-title-sm { font-size: var(--fs-xl); letter-spacing: -0.015em; } /* embedded sub-page titles (h2) */
.ph-name { font-size: 25px; } .modal-head h2 { font-size: var(--fs-xl); }
.brand-name { font-size: 18px; font-weight: 400; letter-spacing: .2px; } .ph-name { font-size: var(--fs-2xl); }
.brand-name { font-family: var(--font-brand); font-size: var(--fs-lg); font-weight: 400; letter-spacing: .2px; }
/* Metrics read as data, not prose keep them in the UI face, /* Metrics read as data hero numbers use the display face (Inter
tabular-lining so digits don't jitter as values update. */ Tight keeps Inter's figures), everything else stays in the UI face;
all tabular-lining so digits don't jitter as values update. */
.kpi-value, .stat-mini-val, .ats-ring .ats-num, .cell-mono, .mono, .kpi-value, .stat-mini-val, .ats-ring .ats-num, .cell-mono, .mono,
table.data td, .k-count, .nav-badge { table.data td, .k-count, .nav-badge {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Base heading scale. Any heading a class doesn't reach lands on a sane
step instead of the UA default (which rendered bare h3/h4 LARGER and
bolder than the styled card titles beside them). Component rules
(.page-title, .modal-head h2, .card-head h3, ) win by specificity. */
h1, h2, h3, h4, h5, h6 { line-height: var(--lh-heading); }
h1 { font-family: var(--font-display); font-weight: 600; font-size: var(--fs-3xl); line-height: var(--lh-display); letter-spacing: -0.02em; }
h2 { font-size: var(--fs-xl); font-weight: 600; }
h3 { font-size: var(--fs-md); font-weight: 600; }
h4 { font-size: var(--fs-base); font-weight: 600; }
h5, h6 { font-size: var(--fs-sm); font-weight: 600; }
/* Eyebrow labels one concept, one rule. Previously eleven near-identical
declarations at five sizes and five letter-spacings. Selectors keep only
their unique bits (padding, borders, sticky) at their own definitions. */
.nav-section-label, .search-group-label, .form-section-title,
table.data thead th, .rbac-matrix th, .cal-dow, .info-item .il,
.hf-tile .hf-k, .hf-block-title, .hf-sign .hf-sign-role,
.hf-rate-head > div, .hf-rate-foot > div:first-child {
font-size: var(--fs-2xs); font-weight: 700; text-transform: uppercase;
letter-spacing: .6px; line-height: var(--lh-label); color: var(--text-3);
}
/* ============================================================ /* ============================================================
FOCUS & MOTION (accessibility) FOCUS & MOTION (accessibility)
============================================================ */ ============================================================ */
@ -335,25 +389,26 @@ table.data td, .k-count, .nav-badge {
.sidebar-nav::-webkit-scrollbar { width: 6px; } .sidebar-nav::-webkit-scrollbar { width: 6px; }
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); } .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); }
.nav-section-label { .nav-section-label {
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .9px; letter-spacing: .8px; /* brand moment — wider than the base eyebrow */
color: var(--text-3); padding: 14px 12px 6px; padding: 20px 12px 6px; /* clear air between nav groups */
} }
.sidebar-nav > div:first-child .nav-section-label { padding-top: 4px; }
.nav-item { .nav-item {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 9px 12px; border-radius: 9px; margin-bottom: 2px; padding: 9px 12px; border-radius: 9px; margin-bottom: 2px;
color: var(--sidebar-fg); font-weight: 500; font-size: 13.5px; color: var(--sidebar-fg); font-weight: 500; font-size: var(--fs-base);
transition: background .14s, color .14s; position: relative; white-space: nowrap; transition: background .14s, color .14s; position: relative; white-space: nowrap;
} }
.nav-item svg { width: 19px; height: 19px; flex-shrink: 0; stroke-width: 1.9; } .nav-item svg { width: 19px; height: 19px; flex-shrink: 0; stroke-width: 1.9; }
.nav-item:hover { background: rgba(255,255,255,.05); color: #fff; } .nav-item:hover { background: rgba(255,255,255,.05); color: #fff; }
.nav-item.active { background: var(--sidebar-active-bg); color: #fff; } .nav-item.active { background: var(--sidebar-active-bg); color: #fff; font-weight: 600; }
.nav-item.active::before { .nav-item.active::before {
content: ''; position: absolute; left: -12px; top: 50%; transform: translateY(-50%); content: ''; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
width: 3px; height: 20px; background: var(--sidebar-rail); border-radius: 0 3px 3px 0; width: 3px; height: 20px; background: var(--sidebar-rail); border-radius: 0 3px 3px 0;
} }
.nav-badge { .nav-badge {
margin-left: auto; background: rgba(255,255,255,.12); color: #fff; margin-left: auto; background: rgba(255,255,255,.12); color: #fff;
font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 20px; min-width: 22px; text-align: center; font-size: var(--fs-xs); font-weight: 600; padding: 1px 8px; border-radius: 20px; min-width: 22px; text-align: center;
} }
.nav-badge-alert { background: var(--danger); color: var(--danger-fg); } .nav-badge-alert { background: var(--danger); color: var(--danger-fg); }
.nav-badge-ai { background: var(--brand-lime); color: var(--brand-ink); font-weight: 700; letter-spacing: .3px; } .nav-badge-ai { background: var(--brand-lime); color: var(--brand-ink); font-weight: 700; letter-spacing: .3px; }
@ -393,7 +448,10 @@ table.data td, .k-count, .nav-badge {
display: flex; align-items: center; gap: 16px; padding: 0 22px; display: flex; align-items: center; gap: 16px; padding: 0 22px;
position: sticky; top: 0; z-index: 50; position: sticky; top: 0; z-index: 50;
} }
.menu-toggle { display: none; } /* Double class: .icon-btn { display: grid } is declared later with equal
specificity and used to win, leaving the hamburger visible on desktop
where clicking it stranded the scrim over the page with scroll locked. */
.icon-btn.menu-toggle { display: none; }
.topbar-search { position: relative; flex: 1; max-width: 480px; display: flex; align-items: center; } .topbar-search { position: relative; flex: 1; max-width: 480px; display: flex; align-items: center; }
.topbar-search > svg { position: absolute; left: 14px; width: 18px; height: 18px; color: var(--text-3); pointer-events: none; } .topbar-search > svg { position: absolute; left: 14px; width: 18px; height: 18px; color: var(--text-3); pointer-events: none; }
.topbar-search input { .topbar-search input {
@ -412,7 +470,7 @@ table.data td, .k-count, .nav-badge {
box-shadow: var(--shadow-lg); max-height: 420px; overflow-y: auto; display: none; z-index: 80; padding: 6px; box-shadow: var(--shadow-lg); max-height: 420px; overflow-y: auto; display: none; z-index: 80; padding: 6px;
} }
.search-results.open { display: block; } .search-results.open { display: block; }
.search-group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); padding: 8px 10px 4px; } .search-group-label { padding: 8px 10px 4px; }
.search-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; } .search-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.search-item:hover { background: var(--bg-sunken); } .search-item:hover { background: var(--bg-sunken); }
.search-item .si-title { font-weight: 600; font-size: 13px; } .search-item .si-title { font-weight: 600; font-size: 13px; }
@ -458,38 +516,45 @@ table.data td, .k-count, .nav-badge {
.dp-name { font-weight: 600; } .dp-name { font-weight: 600; }
.dp-email { font-size: 12px; color: var(--text-3); } .dp-email { font-size: 12px; color: var(--text-3); }
.dropdown-divider { height: 1px; background: var(--border); margin: 6px 0; } .dropdown-divider { height: 1px; background: var(--border); margin: 6px 0; }
.dropdown-link { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; font-size: 13.5px; font-weight: 500; width: 100%; text-align: left; color: var(--text); } .dropdown-link { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; font-size: var(--fs-base); font-weight: 500; width: 100%; text-align: left; color: var(--text); }
.dropdown-link svg { width: 17px; height: 17px; color: var(--text-3); } .dropdown-link svg { width: 17px; height: 17px; color: var(--text-3); }
.dropdown-link:hover { background: var(--bg-sunken); } .dropdown-link:hover { background: var(--bg-sunken); }
.dropdown-link.danger { color: var(--danger); } .dropdown-link.danger { color: var(--danger); }
.dropdown-link.danger svg { color: var(--danger); } .dropdown-link.danger svg { color: var(--danger); }
.link-btn { color: var(--primary); font-size: 12px; font-weight: 600; } .link-btn { color: var(--primary); font-size: 12px; font-weight: 600; }
.notif-row { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; } /* Rendered as a <button> when clickable (keyboard-reachable); the global
button reset keeps the visuals, these two keep the layout. */
.notif-row { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; width: 100%; text-align: left; }
.notif-row:hover { background: var(--bg-sunken); } .notif-row:hover { background: var(--bg-sunken); }
.notif-row.unread { background: var(--primary-soft); } .notif-row.unread { background: var(--primary-soft); }
[data-theme="dark"] .notif-row.unread { background: var(--primary-soft); } [data-theme="dark"] .notif-row.unread { background: var(--primary-soft); }
.notif-icn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0; } .notif-icn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0; }
.notif-icn svg { width: 16px; height: 16px; } .notif-icn svg { width: 16px; height: 16px; }
.notif-body { flex: 1; min-width: 0; } .notif-body { flex: 1; min-width: 0; }
.notif-title { font-size: 13px; font-weight: 600; } .notif-title { font-size: var(--fs-sm); font-weight: 600; }
.notif-text { font-size: 12.5px; color: var(--text-2); } .notif-text { font-size: var(--fs-sm); color: var(--text-2); }
.notif-time { font-size: 11px; color: var(--text-3); margin-top: 3px; } .notif-time { font-size: 11px; color: var(--text-3); margin-top: 3px; }
.dd-scroll { max-height: 360px; overflow-y: auto; overscroll-behavior: contain; } .dd-scroll { max-height: 360px; overflow-y: auto; overscroll-behavior: contain; }
/* ================= CONTENT / PAGE ================= */ /* ================= CONTENT / PAGE ================= */
.content { flex: 1; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; padding: 26px 30px 60px; } /* No overscroll-behavior here: #app is min-height, so .content never overflows
and the DOCUMENT is the real scroller. `contain` on this (non-scrolling)
scroll container blocked wheel chaining the wheel was dead everywhere
except dragging the scrollbar thumb. */
.content { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 26px 30px 60px; }
.page { animation: fadeUp .3s ease; } .page { animation: fadeUp .3s ease; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; flex-wrap: wrap; } .page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-5); margin-bottom: var(--space-6); flex-wrap: wrap; }
.page-sub { color: var(--text-2); font-size: 14px; margin-top: 3px; } .page-head-main { min-width: 0; }
.page-sub { color: var(--text-2); font-size: var(--fs-base); line-height: var(--lh-body); margin-top: 3px; max-width: 65ch; }
.page-head-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .page-head-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--text-3); margin-bottom: 10px; } .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: var(--fs-sm); color: var(--text-3); margin-bottom: 10px; }
.breadcrumb svg { width: 14px; height: 14px; } .breadcrumb svg { width: 14px; height: 14px; }
/* ================= BUTTONS ================= */ /* ================= BUTTONS ================= */
.btn { .btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 9px 16px; border-radius: 10px; font-weight: 600; font-size: 13.5px; padding: 9px 16px; border-radius: var(--radius); font-weight: 600; font-size: var(--fs-base);
transition: .15s; white-space: nowrap; border: 1px solid transparent; transition: .15s; white-space: nowrap; border: 1px solid transparent;
} }
.btn svg { width: 17px; height: 17px; } .btn svg { width: 17px; height: 17px; }
@ -502,26 +567,26 @@ table.data td, .k-count, .nav-badge {
.btn-danger { background: var(--danger); color: var(--danger-fg); } .btn-danger { background: var(--danger); color: var(--danger-fg); }
.btn-danger:hover { filter: brightness(.94); } .btn-danger:hover { filter: brightness(.94); }
.btn-block { width: 100%; margin-top: 12px; } .btn-block { width: 100%; margin-top: 12px; }
.btn-sm { padding: 6px 12px; font-size: 12.5px; } .btn-sm { padding: 6px 12px; font-size: var(--fs-sm); }
.btn-icon { padding: 8px; width: 34px; height: 34px; } .btn-icon { padding: 8px; width: 34px; height: 34px; }
.btn:disabled { opacity: .5; cursor: not-allowed; } .btn:disabled { opacity: .5; cursor: not-allowed; }
/* ================= CARDS ================= */ /* ================= CARDS ================= */
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); } .card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
.card-pad { padding: 20px; } .card-pad { padding: var(--space-5); }
.card-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--border); gap: 12px; } .card-head { display: flex; align-items: center; justify-content: space-between; padding: var(--gap) var(--space-5); border-bottom: 1px solid var(--border); gap: var(--space-3); }
.card-head h3 { font-size: 15px; font-weight: 700; letter-spacing: -.2px; } .card-head h3 { font-size: var(--fs-md); font-weight: 600; letter-spacing: -.2px; }
.card-head .ch-sub { font-size: 12.5px; color: var(--text-3); font-weight: 400; } .card-head .ch-sub { font-size: var(--fs-sm); color: var(--text-3); font-weight: 400; }
.card-body { padding: 20px; } .card-body { padding: var(--space-5); }
.grid { display: grid; gap: 18px; } .grid { display: grid; gap: var(--gap); }
.g-kpi { grid-template-columns: repeat(4, 1fr); } .g-kpi { grid-template-columns: repeat(4, 1fr); }
.g-3 { grid-template-columns: repeat(3, 1fr); } .g-3 { grid-template-columns: repeat(3, 1fr); }
.g-2 { grid-template-columns: repeat(2, 1fr); } .g-2 { grid-template-columns: repeat(2, 1fr); }
.g-2-1 { grid-template-columns: 2fr 1fr; } .g-2-1 { grid-template-columns: 2fr 1fr; }
.g-1-2 { grid-template-columns: 1fr 2fr; } .g-1-2 { grid-template-columns: 1fr 2fr; }
.mt-18 { margin-top: 18px; } .mt-18 { margin-top: var(--gap); }
.mb-18 { margin-bottom: 18px; } .mb-18 { margin-bottom: var(--gap); }
/* KPI card */ /* KPI card */
.kpi { .kpi {
@ -530,12 +595,12 @@ table.data td, .k-count, .nav-badge {
} }
.kpi:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); } .kpi:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.kpi-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .kpi-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.kpi-label { font-size: 12.5px; color: var(--text-2); font-weight: 500; } .kpi-label { font-size: var(--fs-sm); color: var(--text-2); font-weight: 500; }
.kpi-icn { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; } .kpi-icn { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; }
.kpi-icn svg { width: 20px; height: 20px; } .kpi-icn svg { width: 20px; height: 20px; }
.kpi-value { font-size: 28px; font-weight: 700; letter-spacing: -1px; line-height: 1; } .kpi-value { font-family: var(--font-display); font-size: 28px; font-weight: 600; letter-spacing: -0.02em; line-height: 1; }
.kpi-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; } .kpi-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: var(--fs-sm); }
.trend { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; padding: 2px 7px; border-radius: 6px; font-size: 12px; } .trend { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; padding: 2px 7px; border-radius: var(--radius-sm); font-size: var(--fs-xs); }
.trend svg { width: 13px; height: 13px; } .trend svg { width: 13px; height: 13px; }
.trend-up { color: var(--success); background: var(--success-soft); } .trend-up { color: var(--success); background: var(--success-soft); }
.trend-down { color: var(--danger); background: var(--danger-soft); } .trend-down { color: var(--danger); background: var(--danger-soft); }
@ -550,7 +615,7 @@ table.data td, .k-count, .nav-badge {
.i-teal { background: var(--teal-soft); color: var(--teal); } .i-teal { background: var(--teal-soft); color: var(--teal); }
/* ================= BADGES ================= */ /* ================= BADGES ================= */
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; } .badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: var(--fs-xs); font-weight: 600; white-space: nowrap; }
.badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } .badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge-plain::before { display: none; } .badge-plain::before { display: none; }
.b-green { color: var(--success); background: var(--success-soft); } .b-green { color: var(--success); background: var(--success-soft); }
@ -566,24 +631,26 @@ table.data td, .k-count, .nav-badge {
/* Horizontal scroll is the right answer for wide data tables on phones; /* Horizontal scroll is the right answer for wide data tables on phones;
make the gesture smooth and keep it from chaining to the page. */ make the gesture smooth and keep it from chaining to the page. */
.table-wrap { overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; } .table-wrap { overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; }
table.data { width: 100%; border-collapse: collapse; font-size: 13.5px; } table.data { width: 100%; border-collapse: collapse; font-size: var(--fs-base); }
table.data thead th { table.data thead th {
text-align: left; padding: 12px 16px; font-size: 11.5px; font-weight: 700; text-align: left; padding: var(--space-3) var(--space-4);
text-transform: uppercase; letter-spacing: .5px; color: var(--text-3);
border-bottom: 1px solid var(--border); white-space: nowrap; background: var(--bg-elev); position: sticky; top: 0; border-bottom: 1px solid var(--border); white-space: nowrap; background: var(--bg-elev); position: sticky; top: 0;
} }
table.data thead th.sortable { cursor: pointer; user-select: none; } table.data thead th.sortable { cursor: pointer; user-select: none; }
table.data thead th.sortable:hover { color: var(--text); } table.data thead th.sortable:hover { color: var(--text); }
.sort-ind { display: inline-block; margin-left: 4px; opacity: .4; font-size: 10px; } .sort-ind { display: inline-block; margin-left: 4px; opacity: .4; font-size: 10px; }
th.sorted-asc .sort-ind, th.sorted-desc .sort-ind { opacity: 1; color: var(--primary); } th.sorted-asc .sort-ind, th.sorted-desc .sort-ind { opacity: 1; color: var(--primary); }
table.data tbody td { padding: 13px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; } table.data tbody td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); vertical-align: middle; }
table.data tbody tr { transition: background .12s; } table.data tbody tr { transition: background .12s; }
table.data tbody tr:hover { background: var(--bg-sunken); } table.data tbody tr:hover { background: var(--bg-sunken); }
table.data tbody tr:last-child td { border-bottom: none; } table.data tbody tr:last-child td { border-bottom: none; }
.cell-primary { font-weight: 600; color: var(--text); } .cell-primary { font-weight: 600; color: var(--text); }
.cell-sub { font-size: 12px; color: var(--text-3); } .cell-sub { font-size: var(--fs-xs); color: var(--text-3); line-height: 1.4; }
.cell-mono { font-family: var(--mono); font-size: 12.5px; color: var(--text-2); } .cell-mono { font-family: var(--mono); font-size: var(--fs-sm); color: var(--text-2); }
.user-cell { display: flex; align-items: center; gap: 11px; } .user-cell { display: flex; align-items: center; gap: 11px; min-width: 0; }
/* Long emails/roles under a name clip with an ellipsis on every viewport
this was previously only applied inside the 640px media query. */
.user-cell .cell-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 240px; }
.user-cell .avatar { width: 34px; height: 34px; font-size: 12px; } .user-cell .avatar { width: 34px; height: 34px; font-size: 12px; }
.row-actions { display: flex; gap: 4px; justify-content: flex-end; } .row-actions { display: flex; gap: 4px; justify-content: flex-end; }
.act-btn { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--text-3); transition: .12s; } .act-btn { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--text-3); transition: .12s; }
@ -592,7 +659,7 @@ table.data tbody tr:last-child td { border-bottom: none; }
.act-btn svg { width: 16px; height: 16px; } .act-btn svg { width: 16px; height: 16px; }
/* Toolbar */ /* Toolbar */
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; } .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: var(--space-4); }
.toolbar-search { position: relative; flex: 1; min-width: 200px; max-width: 340px; } .toolbar-search { position: relative; flex: 1; min-width: 200px; max-width: 340px; }
.toolbar-search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-3); } .toolbar-search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-3); }
.toolbar-search input { width: 100%; padding: 8px 12px 8px 36px; border-radius: 9px; background: var(--bg-elev); border: 1px solid var(--border-strong); outline: none; } .toolbar-search input { width: 100%; padding: 8px 12px 8px 36px; border-radius: 9px; background: var(--bg-elev); border: 1px solid var(--border-strong); outline: none; }
@ -607,7 +674,9 @@ table.data tbody tr:last-child td { border-bottom: none; }
/* Pagination */ /* Pagination */
.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); flex-wrap: wrap; gap: 12px; } .pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); flex-wrap: wrap; gap: 12px; }
.page-info { font-size: 13px; color: var(--text-2); } .page-info { font-size: 13px; color: var(--text-2); }
.page-controls { display: flex; gap: 4px; align-items: center; } .page-controls { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; min-width: 0; }
.page-nav { display: flex; align-items: center; gap: 4px; min-width: 0; flex: 1 1 auto; justify-content: flex-end; }
.page-nums { display: flex; gap: 4px; align-items: center; min-width: 0; }
.page-size { display: flex; align-items: center; gap: 8px; margin-right: 8px; flex-shrink: 0; } .page-size { display: flex; align-items: center; gap: 8px; margin-right: 8px; flex-shrink: 0; }
.page-size-label { font-size: 13px; color: var(--text-2); white-space: nowrap; } .page-size-label { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.page-size-select { height: 34px; padding: 0 28px 0 10px; font-size: 13px; } .page-size-select { height: 34px; padding: 0 28px 0 10px; font-size: 13px; }
@ -672,14 +741,14 @@ canvas { width: 100%; max-width: 100%; display: block; }
.modal-head p { font-size: 13px; color: var(--text-3); margin-top: 3px; } .modal-head p { font-size: 13px; color: var(--text-3); margin-top: 3px; }
.modal-close { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; color: var(--text-3); } .modal-close { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; color: var(--text-3); }
.modal-close:hover { background: var(--bg-sunken); color: var(--text); } .modal-close:hover { background: var(--bg-sunken); color: var(--text); }
.modal-body { padding: 24px; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } .modal-body { padding: var(--space-6); overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
.modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; border-top: 1px solid var(--border); background: var(--bg-sunken); border-radius: 0 0 var(--radius-xl) var(--radius-xl); } .modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; border-top: 1px solid var(--border); background: var(--bg-sunken); border-radius: 0 0 var(--radius-xl) var(--radius-xl); }
/* ================= FORMS ================= */ /* ================= FORMS ================= */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
.form-field { display: flex; flex-direction: column; gap: 6px; } .form-field { display: flex; flex-direction: column; gap: 6px; }
.form-field.col-span-2 { grid-column: 1 / -1; } .form-field.col-span-2 { grid-column: 1 / -1; }
.form-field label { font-size: 12.5px; font-weight: 600; color: var(--text-2); } .form-field label { font-size: var(--fs-sm); font-weight: 600; color: var(--text-2); line-height: var(--lh-label); }
.form-field label .req { color: var(--danger); } .form-field label .req { color: var(--danger); }
.form-field input, .form-field select, .form-field textarea { .form-field input, .form-field select, .form-field textarea {
padding: 9px 12px; border-radius: 9px; background: var(--bg-elev); border: 1px solid var(--border-strong); outline: none; transition: .15s; width: 100%; padding: 9px 12px; border-radius: 9px; background: var(--bg-elev); border: 1px solid var(--border-strong); outline: none; transition: .15s; width: 100%;
@ -688,9 +757,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
.form-field textarea { resize: vertical; min-height: 84px; } .form-field textarea { resize: vertical; min-height: 84px; }
.form-field input:focus, .form-field select:focus, .form-field textarea:focus { border-color: var(--primary); box-shadow: var(--ring); } .form-field input:focus, .form-field select:focus, .form-field textarea:focus { border-color: var(--primary); box-shadow: var(--ring); }
.form-field input.err, .form-field select.err, .form-field textarea.err { border-color: var(--danger); } .form-field input.err, .form-field select.err, .form-field textarea.err { border-color: var(--danger); }
.field-error { font-size: 11.5px; color: var(--danger); display: none; } .field-error { font-size: var(--fs-xs); color: var(--danger); display: none; }
.field-error.show { display: block; } .field-error.show { display: block; }
.form-section-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); margin: 22px 0 4px; grid-column: 1/-1; } .form-section-title { margin: var(--space-5) 0 var(--space-1); grid-column: 1/-1; }
/* AI field assist (ui/AiFieldAssist.jsx) */ /* AI field assist (ui/AiFieldAssist.jsx) */
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
@ -728,14 +797,14 @@ canvas { width: 100%; max-width: 100%; display: block; }
.setting-info p { font-size: 13px; color: var(--text-3); margin-top: 2px; } .setting-info p { font-size: 13px; color: var(--text-3); margin-top: 2px; }
/* ================= TABS ================= */ /* ================= TABS ================= */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 22px; overflow-x: auto; } .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: var(--space-5); overflow-x: auto; }
.tab { display: inline-flex; align-items: center; gap: 12px; padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; } .tab { display: inline-flex; align-items: center; gap: 8px; padding: 11px 16px; font-weight: 600; font-size: var(--fs-base); color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; }
.tab:hover { color: var(--text); } .tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); } .tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-pane { display: none; animation: fadeUp .25s; } .tab-pane { display: none; animation: fadeUp .25s; }
.tab-pane.active { display: block; } .tab-pane.active { display: block; }
.pill-tabs { display: inline-flex; gap: 4px; background: var(--bg-sunken); padding: 4px; border-radius: 11px; } .pill-tabs { display: inline-flex; gap: 4px; background: var(--bg-sunken); padding: 4px; border-radius: 11px; }
.pill-tab { padding: 7px 14px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--text-2); transition: .15s; display: inline-flex; align-items: center; gap: 6px; border: none; background: transparent; cursor: pointer; } .pill-tab { padding: 7px 14px; border-radius: 8px; font-weight: 600; font-size: var(--fs-sm); color: var(--text-2); transition: .15s; display: inline-flex; align-items: center; gap: 6px; border: none; background: transparent; cursor: pointer; }
.pill-tab svg { width: 14px; height: 14px; } .pill-tab svg { width: 14px; height: 14px; }
.pill-tab.active { background: var(--bg-elev); color: var(--text); box-shadow: var(--shadow-sm); } .pill-tab.active { background: var(--bg-elev); color: var(--text); box-shadow: var(--shadow-sm); }
@ -745,7 +814,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.kanban-col-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px; position: sticky; top: 0; } .kanban-col-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px; position: sticky; top: 0; }
.kanban-col-head .k-dot { width: 9px; height: 9px; border-radius: 50%; } .kanban-col-head .k-dot { width: 9px; height: 9px; border-radius: 50%; }
.kanban-col-head h4 { font-size: 13.5px; font-weight: 700; } .kanban-col-head h4 { font-size: 13.5px; font-weight: 700; }
.k-count { margin-left: auto; background: var(--bg-elev); color: var(--text-2); font-size: 12px; font-weight: 700; padding: 1px 9px; border-radius: 20px; } .k-count { margin-left: auto; background: var(--bg-elev); color: var(--text-2); font-size: var(--fs-xs); font-weight: 700; padding: 1px 9px; border-radius: 20px; }
.kanban-cards { padding: 0 12px 12px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; min-height: 60px; } .kanban-cards { padding: 0 12px 12px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; min-height: 60px; }
.kanban-cards.drag-over { background: var(--primary-soft); border-radius: 10px; outline: 2px dashed var(--primary); outline-offset: -4px; } .kanban-cards.drag-over { background: var(--primary-soft); border-radius: 10px; outline: 2px dashed var(--primary); outline-offset: -4px; }
.k-card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 11px; padding: 13px; cursor: grab; box-shadow: var(--shadow-sm); transition: .15s; } .k-card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 11px; padding: 13px; cursor: grab; box-shadow: var(--shadow-sm); transition: .15s; }
@ -753,11 +822,11 @@ canvas { width: 100%; max-width: 100%; display: block; }
.k-card.dragging { opacity: .5; transform: rotate(2deg); cursor: grabbing; } .k-card.dragging { opacity: .5; transform: rotate(2deg); cursor: grabbing; }
.k-card-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .k-card-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.k-card-top .avatar { width: 32px; height: 32px; font-size: 11px; } .k-card-top .avatar { width: 32px; height: 32px; font-size: 11px; }
.kc-name { font-weight: 600; font-size: 13.5px; } .kc-name { font-weight: 600; font-size: var(--fs-base); }
.kc-role { font-size: 12px; color: var(--text-3); } .kc-role { font-size: 12px; color: var(--text-3); }
.k-card-meta { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); } .k-card-meta { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.k-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; } .k-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; }
.tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: var(--bg-sunken); color: var(--text-2); } .tag { font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--text-2); }
/* ================= MISC ================= */ /* ================= MISC ================= */
.list-tight > * + * { border-top: 1px solid var(--border); } .list-tight > * + * { border-top: 1px solid var(--border); }
@ -765,8 +834,8 @@ canvas { width: 100%; max-width: 100%; display: block; }
.list-row:first-child { padding-top: 0; } .list-row:first-child { padding-top: 0; }
.list-row .avatar { width: 38px; height: 38px; font-size: 13px; } .list-row .avatar { width: 38px; height: 38px; font-size: 13px; }
.lr-main { flex: 1; min-width: 0; } .lr-main { flex: 1; min-width: 0; }
.lr-title { font-weight: 600; font-size: 13.5px; } .lr-title { font-weight: 600; font-size: var(--fs-base); }
.lr-sub { font-size: 12.5px; color: var(--text-3); } .lr-sub { font-size: var(--fs-sm); color: var(--text-3); }
.lr-right { text-align: right; flex-shrink: 0; } .lr-right { text-align: right; flex-shrink: 0; }
.timeline { position: relative; padding-left: 28px; } .timeline { position: relative; padding-left: 28px; }
.timeline::before { content: ''; position: absolute; left: 9px; top: 4px; bottom: 4px; width: 2px; background: var(--border); } .timeline::before { content: ''; position: absolute; left: 9px; top: 4px; bottom: 4px; width: 2px; background: var(--border); }
@ -774,14 +843,14 @@ canvas { width: 100%; max-width: 100%; display: block; }
.tl-item:last-child { padding-bottom: 0; } .tl-item:last-child { padding-bottom: 0; }
.tl-dot { position: absolute; left: -28px; top: 2px; width: 20px; height: 20px; border-radius: 50%; background: var(--bg-elev); border: 2px solid var(--primary); display: grid; place-items: center; } .tl-dot { position: absolute; left: -28px; top: 2px; width: 20px; height: 20px; border-radius: 50%; background: var(--bg-elev); border: 2px solid var(--primary); display: grid; place-items: center; }
.tl-dot svg { width: 11px; height: 11px; color: var(--primary); } .tl-dot svg { width: 11px; height: 11px; color: var(--primary); }
.tl-title { font-weight: 600; font-size: 13.5px; } .tl-title { font-weight: 600; font-size: var(--fs-base); }
.tl-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; } .tl-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.tl-desc { font-size: 13px; color: var(--text-2); margin-top: 5px; } .tl-desc { font-size: 13px; color: var(--text-2); margin-top: 5px; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-3); } .empty-state { text-align: center; padding: 60px 20px; color: var(--text-3); }
.empty-state svg { width: 48px; height: 48px; margin-bottom: 14px; opacity: .5; } .empty-state svg { width: 48px; height: 48px; margin-bottom: 14px; opacity: .5; }
.empty-state h3 { font-size: 18px; color: var(--text-2); margin-bottom: 6px; } .empty-state h3 { font-size: var(--fs-lg); color: var(--text-2); margin-bottom: 6px; }
.empty-state p { margin: 0; } .empty-state p { max-width: 46ch; margin: 0 auto; line-height: var(--lh-body); }
.empty-state-body { margin-top: 4px; } .empty-state-body { margin-top: 4px; }
.empty-state-body p { margin: 0 0 10px; } .empty-state-body p { margin: 0 0 10px; }
@ -791,21 +860,71 @@ canvas { width: 100%; max-width: 100%; display: block; }
.more-count { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; font-weight: 700; border: 2px solid var(--bg-elev); margin-left: -8px; } .more-count { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; font-weight: 700; border: 2px solid var(--bg-elev); margin-left: -8px; }
.stat-mini { display: flex; flex-direction: column; gap: 4px; } .stat-mini { display: flex; flex-direction: column; gap: 4px; }
.stat-mini-val { font-size: 22px; font-weight: 700; letter-spacing: -.5px; } .stat-mini-val { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -0.015em; }
.stat-mini-lbl { font-size: 12.5px; color: var(--text-3); } .stat-mini-lbl { font-size: var(--fs-sm); color: var(--text-3); }
.divider { height: 1px; background: var(--border); margin: 16px 0; } .divider { height: 1px; background: var(--border); margin: 16px 0; }
.flex { display: flex; } .flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; } .items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1 1 0%; min-width: 0; }
.min-w-0 { min-width: 0; }
.w-full { width: 100%; }
.gap-4 { gap: var(--space-1); }
.gap-8 { gap: 8px; } .gap-12 { gap: 12px; } .gap-16 { gap: 16px; } .gap-8 { gap: 8px; } .gap-12 { gap: 12px; } .gap-16 { gap: 16px; }
.mt-8 { margin-top: var(--space-2); } .mb-8 { margin-bottom: var(--space-2); }
.mt-12 { margin-top: var(--space-3); } .mb-12 { margin-bottom: var(--space-3); }
.mt-16 { margin-top: var(--space-4); } .mb-16 { margin-bottom: var(--space-4); }
.mt-24 { margin-top: var(--space-6); } .mb-24 { margin-bottom: var(--space-6); }
.text-muted { color: var(--text-3); } .text-muted { color: var(--text-3); }
.fw-600 { font-weight: 600; } .fw-600 { font-weight: 600; }
.text-sm { font-size: 12.5px; } .text-sm { font-size: var(--fs-sm); }
.mono { font-family: var(--mono); } .mono { font-family: var(--mono); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* Inline clamp for table cells (emails, long titles); pair with title="". */
.cell-clip { display: inline-block; max-width: 26ch; vertical-align: bottom;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-click { cursor: pointer; }
/* Classes referenced from JSX that previously had no definition. */
.dt { display: flow-root; }
.route-loading { display: grid; place-items: center; min-height: 40vh; }
.tab-count { font-size: var(--fs-xs); font-weight: 600; padding: 1px 8px;
border-radius: 20px; background: var(--bg-sunken); color: var(--text-2); }
.tab.active .tab-count { background: var(--primary-soft); color: var(--primary); }
/* Skeleton loading (primitives.jsx SkeletonRows). The sweep uses --border,
which tracks the theme, so no per-theme rules are needed. */
.skeleton {
position: relative; overflow: hidden;
background: var(--bg-sunken); border-radius: var(--radius-sm);
}
.skeleton::after {
content: ''; position: absolute; inset: 0; transform: translateX(-100%);
background: linear-gradient(90deg, transparent, var(--border), transparent);
animation: skeletonSweep 1.4s infinite;
}
@keyframes skeletonSweep { to { transform: translateX(100%); } }
.skeleton-row { display: flex; align-items: center; gap: 12px; padding: 13px 0; }
.skeleton-row + .skeleton-row { border-top: 1px solid var(--border); }
.skeleton-avatar { width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0; }
.skeleton-line { display: block; height: 11px; }
.skeleton-line + .skeleton-line { margin-top: 7px; }
/* Keyboard users jump straight past the 20+ sidebar links. */
.skip-link { position: absolute; left: -9999px; z-index: 1000; }
.skip-link:focus {
left: 12px; top: 12px; position: fixed; background: var(--bg-elev);
color: var(--text); padding: 10px 16px; border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg); outline: 2px solid var(--primary);
}
/* Calendar */ /* Calendar */
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.cal-dow { background: var(--bg-elev); padding: 10px; text-align: center; font-size: 11.5px; font-weight: 700; text-transform: uppercase; color: var(--text-3); letter-spacing: .5px; } .cal-dow { background: var(--bg-elev); padding: 10px; text-align: center; }
.cal-cell { background: var(--bg-elev); min-height: 108px; padding: 8px; position: relative; transition: .12s; } .cal-cell { background: var(--bg-elev); min-height: 108px; padding: 8px; position: relative; transition: .12s; }
.cal-cell:hover { background: var(--bg-sunken); } .cal-cell:hover { background: var(--bg-sunken); }
.cal-cell.other { background: var(--bg-sunken); } .cal-cell.other { background: var(--bg-sunken); }
@ -823,8 +942,8 @@ canvas { width: 100%; max-width: 100%; display: block; }
.toast-icn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0; } .toast-icn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0; }
.toast-icn svg { width: 18px; height: 18px; } .toast-icn svg { width: 18px; height: 18px; }
.toast-body { flex: 1; } .toast-body { flex: 1; }
.toast-title { font-weight: 600; font-size: 13.5px; } .toast-title { font-weight: 600; font-size: var(--fs-base); }
.toast-msg { font-size: 12.5px; color: var(--text-3); } .toast-msg { font-size: var(--fs-sm); color: var(--text-3); }
.toast-close { color: var(--text-3); width: 24px; height: 24px; display: grid; place-items: center; border-radius: 6px; } .toast-close { color: var(--text-3); width: 24px; height: 24px; display: grid; place-items: center; border-radius: 6px; }
.toast-close:hover { background: var(--bg-sunken); } .toast-close:hover { background: var(--bg-sunken); }
@ -851,8 +970,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.ph-role { color: var(--text-2); font-size: 14px; } .ph-role { color: var(--text-2); font-size: 14px; }
.ph-tags { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; } .ph-tags { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.info-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 14px 24px; } .info-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 14px 24px; }
.info-item .il { font-size: 12px; color: var(--text-3); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; } .info-item .iv { font-size: var(--fs-base); font-weight: 500; margin-top: 3px; }
.info-item .iv { font-size: 14px; font-weight: 500; margin-top: 3px; }
/* ================= ENTERPRISE PHASE 2 ================= */ /* ================= ENTERPRISE PHASE 2 ================= */
@ -867,7 +985,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
.inbox-item.unread::before { content: ''; position: absolute; left: 6px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--primary); } .inbox-item.unread::before { content: ''; position: absolute; left: 6px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--primary); }
.inbox-item.unread .ii-name { font-weight: 700; } .inbox-item.unread .ii-name { font-weight: 700; }
.ii-main { flex: 1; min-width: 0; } .ii-main { flex: 1; min-width: 0; }
.ii-name { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 6px; } /* The name text is wrapped in a .truncate span in the JSX email-style
sender names have no break points and used to paint over the timestamp. */
.ii-name { font-weight: 600; font-size: var(--fs-base); display: flex; align-items: center; gap: 6px; min-width: 0; }
.ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; } .ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; } .ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
@ -882,6 +1002,16 @@ canvas { width: 100%; max-width: 100%; display: block; }
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.inbox-queue .toolbar-search { min-width: 0; } .inbox-queue .toolbar-search { min-width: 0; }
.inbox-queue .pagination {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 10px 12px;
overflow: visible;
}
.inbox-queue .page-info { width: 100%; }
.inbox-queue .page-controls { flex-wrap: nowrap; }
.inbox-queue .page-nav { justify-content: flex-end; flex: 1 1 auto; }
.inbox-bulk-bar { .inbox-bulk-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -916,7 +1046,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
11px copy keeps its contrast in both modes. */ 11px copy keeps its contrast in both modes. */
.source-chip { .source-chip {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: 20px;
--chip: var(--text-3); --chip: var(--text-3);
color: var(--text-2); color: var(--text-2);
background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */ background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */
@ -925,7 +1055,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.source-chip svg { width: 12px; height: 12px; color: var(--chip); } .source-chip svg { width: 12px; height: 12px; color: var(--chip); }
.source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); } .source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); }
.integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: 12.5px; font-weight: 600; background: var(--success-soft); color: var(--success); } .integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: var(--fs-sm); font-weight: 600; background: var(--success-soft); color: var(--success); }
.integration-status.pending { background: var(--warning-soft); color: var(--warning); } .integration-status.pending { background: var(--warning-soft); color: var(--warning); }
.integration-status .pulse { width: 8px; height: 8px; border-radius: 50%; background: currentColor; position: relative; } .integration-status .pulse { width: 8px; height: 8px; border-radius: 50%; background: currentColor; position: relative; }
.integration-status .pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: currentColor; animation: pulse 1.8s infinite; } .integration-status .pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: currentColor; animation: pulse 1.8s infinite; }
@ -951,6 +1081,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
.email-plain { border-radius: 0 0 10px 10px; } .email-plain { border-radius: 0 0 10px 10px; }
/* Upload dropzone */ /* Upload dropzone */
/* Job detail cover image — banner above the info grid. */
.job-cover { display: block; width: 100%; max-height: 200px; object-fit: cover; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-sunken); margin-bottom: 18px; }
.dropzone { border: 2px dashed var(--border-strong); border-radius: var(--radius-lg); padding: 48px 24px; text-align: center; transition: .18s; background: var(--bg-sunken); cursor: pointer; } .dropzone { border: 2px dashed var(--border-strong); border-radius: var(--radius-lg); padding: 48px 24px; text-align: center; transition: .18s; background: var(--bg-sunken); cursor: pointer; }
.dropzone.drag { border-color: var(--primary); background: var(--primary-soft); transform: scale(1.005); } .dropzone.drag { border-color: var(--primary); background: var(--primary-soft); transform: scale(1.005); }
.dropzone .dz-icn { width: 64px; height: 64px; border-radius: 18px; background: var(--primary-soft); color: var(--primary); display: grid; place-items: center; margin: 0 auto 16px; } .dropzone .dz-icn { width: 64px; height: 64px; border-radius: 18px; background: var(--primary-soft); color: var(--primary); display: grid; place-items: center; margin: 0 auto 16px; }
@ -965,9 +1098,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
background: conic-gradient(var(--c) calc(var(--pct)*1%), var(--bg-sunken) 0); } background: conic-gradient(var(--c) calc(var(--pct)*1%), var(--bg-sunken) 0); }
.ats-ring::after { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--bg-elev); } .ats-ring::after { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--bg-elev); }
.ats-ring .ats-val { position: relative; z-index: 1; text-align: center; } .ats-ring .ats-val { position: relative; z-index: 1; text-align: center; }
.ats-ring .ats-num { font-size: 30px; font-weight: 800; letter-spacing: -1px; line-height: 1; } .ats-ring .ats-num { font-family: var(--font-display); font-size: 30px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.ats-ring .ats-lbl { font-size: 11px; color: var(--text-3); font-weight: 600; } .ats-ring .ats-lbl { font-size: 11px; color: var(--text-3); font-weight: 600; }
.skill-pill { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 8px; } .skill-pill { display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-xs); font-weight: 600; padding: 4px 10px; border-radius: var(--radius-sm); }
.skill-pill svg { width: 12px; height: 12px; } .skill-pill svg { width: 12px; height: 12px; }
.skill-matched { background: var(--success-soft); color: var(--success); } .skill-matched { background: var(--success-soft); color: var(--success); }
.skill-missing { background: var(--danger-soft); color: var(--danger); } .skill-missing { background: var(--danger-soft); color: var(--danger); }
@ -982,7 +1115,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.cand-name { font-weight: 700; font-size: 14.5px; letter-spacing: -.1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cand-name { font-weight: 700; font-size: 14.5px; letter-spacing: -.1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cand-role { font-size: 12.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cand-role { font-size: 12.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cand-skills { display: flex; flex-wrap: wrap; gap: 6px; align-content: flex-start; height: 54px; overflow: hidden; margin-bottom: 12px; } .cand-skills { display: flex; flex-wrap: wrap; gap: 6px; align-content: flex-start; height: 54px; overflow: hidden; margin-bottom: 12px; }
.cand-chip { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 10px; border-radius: 7px; font-size: 12px; font-weight: 600; background: var(--bg-sunken); color: var(--text-2); white-space: nowrap; } .cand-chip { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 10px; border-radius: var(--radius-sm); font-size: var(--fs-xs); font-weight: 600; background: var(--bg-sunken); color: var(--text-2); white-space: nowrap; }
.cand-chip svg { width: 11px; height: 11px; } .cand-chip svg { width: 11px; height: 11px; }
.cand-chip.miss { background: var(--danger-soft); color: var(--danger); } .cand-chip.miss { background: var(--danger-soft); color: var(--danger); }
.cand-chip.more { background: transparent; color: var(--text-3); padding: 0 4px; } .cand-chip.more { background: transparent; color: var(--text-3); padding: 0 4px; }
@ -1040,7 +1173,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* Role chips use the avatar ramp, which inverts with the theme. */ /* Role chips use the avatar ramp, which inverts with the theme. */
.role-badge { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; color: var(--avatar-fg); flex-shrink: 0; } .role-badge { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; color: var(--avatar-fg); flex-shrink: 0; }
.rbac-matrix { width: 100%; border-collapse: collapse; font-size: 13px; } .rbac-matrix { width: 100%; border-collapse: collapse; font-size: 13px; }
.rbac-matrix th { padding: 12px 8px; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--text-3); border-bottom: 1px solid var(--border); text-align: center; font-weight: 700; } .rbac-matrix th { padding: 12px 8px; border-bottom: 1px solid var(--border); text-align: center; }
.rbac-matrix th:first-child { text-align: left; padding-left: 16px; } .rbac-matrix th:first-child { text-align: left; padding-left: 16px; }
.rbac-matrix td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: center; } .rbac-matrix td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: center; }
.rbac-matrix td:first-child { text-align: left; padding-left: 16px; font-weight: 600; } .rbac-matrix td:first-child { text-align: left; padding-left: 16px; font-weight: 600; }
@ -1135,14 +1268,14 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* Filter panel */ /* Filter panel */
.filter-panel { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .filter-panel { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.filter-panel .form-field label { font-size: 11.5px; } .filter-panel .form-field label { font-size: var(--fs-xs); }
.star-btn { color: var(--text-3); transition: .12s; } .star-btn { color: var(--text-3); transition: .12s; }
.star-btn.on { color: var(--warning); } .star-btn.on { color: var(--warning); }
.star-btn.on svg { fill: currentColor; } .star-btn.on svg { fill: currentColor; }
/* Segmented control */ /* Segmented control */
.seg { display: inline-flex; background: var(--bg-sunken); padding: 3px; border-radius: 10px; } .seg { display: inline-flex; background: var(--bg-sunken); padding: 3px; border-radius: 10px; }
.seg button { padding: 6px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); } .seg button { padding: 6px 14px; border-radius: 8px; font-size: var(--fs-sm); font-weight: 600; color: var(--text-2); }
.seg button.active { background: var(--bg-elev); color: var(--text); box-shadow: var(--shadow-sm); } .seg button.active { background: var(--bg-elev); color: var(--text); box-shadow: var(--shadow-sm); }
.rating-stars { display: inline-flex; gap: 3px; } .rating-stars { display: inline-flex; gap: 3px; }
@ -1225,22 +1358,27 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* ============================================================ /* ============================================================
RESPONSIVE RESPONSIVE
1600 ultrawide · 1200 laptop · 980 tablet-landscape 1200 laptop · 980 tablet-landscape · 900 tablet-portrait (sidebar
900 tablet-portrait (sidebar goes off-canvas) · 640 phone goes off-canvas) · 640 phone · 400 small phone · plus a
400 small phone · plus a landscape-phone height rule landscape-phone height rule. Late bolt-on sections (Dashboard v2,
hiring forms) keep their 1400/1200/860 steps next to their base
rules and share ONE consolidated 640/400 block at the end of file.
============================================================ */ ============================================================ */
/* Ultrawide: stop dashboards stretching to unreadable line lengths. */ /* Cap the measure on every desktop, not only ultrawide between 900 and
@media (min-width: 1600px) { 1600px the content used to run full-bleed. No-op below ~1500px viewports;
.content > .page { max-width: 1560px; margin-inline: auto; } agrees with .cand-page's own 1440px cap. */
} .content > .page { max-width: 1440px; margin-inline: auto; }
/* 1200 laptop. .g-3 steps 32 here and 21 at 900; it used to jump
straight to one column, wasting the whole 9001200 band. */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.g-kpi { grid-template-columns: repeat(2, 1fr); } .g-kpi { grid-template-columns: repeat(2, 1fr); }
.g-3 { grid-template-columns: 1fr; } .g-3 { grid-template-columns: repeat(2, 1fr); }
.g-2-1, .g-1-2, .g-2 { grid-template-columns: 1fr; } .g-2-1, .g-1-2, .g-2 { grid-template-columns: 1fr; }
.filter-panel { grid-template-columns: repeat(3, 1fr); } .filter-panel { grid-template-columns: repeat(3, 1fr); }
} }
/* ≤980 — tablet landscape */
@media (max-width: 980px) { @media (max-width: 980px) {
.split { grid-template-columns: 1fr; } .split { grid-template-columns: 1fr; }
.split-list { border-right: none; border-bottom: 1px solid var(--border); max-height: 380px; } .split-list { border-right: none; border-bottom: 1px solid var(--border); max-height: 380px; }
@ -1248,7 +1386,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
.filter-panel { grid-template-columns: repeat(2, 1fr); } .filter-panel { grid-template-columns: repeat(2, 1fr); }
.rbac-matrix { min-width: 620px; } /* scrolls inside .table-wrap */ .rbac-matrix { min-width: 620px; } /* scrolls inside .table-wrap */
} }
/* ≤900 — tablet portrait: the sidebar goes off-canvas */
@media (max-width: 900px) { @media (max-width: 900px) {
.g-3 { grid-template-columns: 1fr; }
.sidebar { .sidebar {
/* above the scrim (55) but below the AI dock (310) and modals (300), /* above the scrim (55) but below the AI dock (310) and modals (300),
so an open drawer never sits on top of a dialog. */ so an open drawer never sits on top of a dialog. */
@ -1259,18 +1399,20 @@ canvas { width: 100%; max-width: 100%; display: block; }
.sidebar.mobile-open { transform: translateX(0); box-shadow: var(--shadow-lg); } .sidebar.mobile-open { transform: translateX(0); box-shadow: var(--shadow-lg); }
/* The FAB floated over the scrim and stayed tappable behind the drawer. */ /* The FAB floated over the scrim and stayed tappable behind the drawer. */
.nav-open .ai-fab { display: none; } .nav-open .ai-fab { display: none; }
.menu-toggle { display: grid; } .icon-btn.menu-toggle { display: grid; }
.search-kbd { display: none; } .search-kbd { display: none; }
.content { padding: 20px max(16px, env(safe-area-inset-left)) 50px max(16px, env(safe-area-inset-right)); } .content { padding: 20px max(16px, env(safe-area-inset-left)) 50px max(16px, env(safe-area-inset-right)); }
.profile-meta { display: none; } .profile-meta { display: none; }
.topbar { padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right)); } .topbar { padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right)); }
.chat-wrap { height: calc(100vh - 170px); height: calc(100dvh - 170px); } .chat-wrap { height: calc(100vh - 170px); height: calc(100dvh - 170px); }
} }
/* 640 phone (core shell + components; late-section 640 rules live in the
consolidated block at the end of the file, after their base rules) */
@media (max-width: 640px) { @media (max-width: 640px) {
.g-kpi { grid-template-columns: 1fr; } .g-kpi { grid-template-columns: 1fr; }
.topbar { padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); gap: 6px; } .topbar { padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); gap: 6px; }
.form-grid, .info-grid, .filter-panel { grid-template-columns: 1fr; } .form-grid, .info-grid, .filter-panel { grid-template-columns: 1fr; }
.page-title { font-size: 25px; } .page-title { font-size: var(--fs-2xl); }
.page-head { gap: 12px; margin-bottom: 18px; } .page-head { gap: 12px; margin-bottom: 18px; }
.page-head-actions { width: 100%; } .page-head-actions { width: 100%; }
.page-head-actions .btn { flex: 1 1 auto; justify-content: center; } .page-head-actions .btn { flex: 1 1 auto; justify-content: center; }
@ -1417,9 +1559,6 @@ canvas { width: 100%; max-width: 100%; display: block; }
.g-kpi-7 { grid-template-columns: repeat(2, 1fr); } .g-kpi-7 { grid-template-columns: repeat(2, 1fr); }
.pipe-split { grid-template-columns: 1fr; } .pipe-split { grid-template-columns: 1fr; }
} }
@media (max-width: 640px) {
.g-kpi-7 { grid-template-columns: 1fr; }
}
/* ============================================================ /* ============================================================
Hiring forms the candidate profile Forms tab (CandidateForms.jsx). Hiring forms the candidate profile Forms tab (CandidateForms.jsx).
@ -1430,7 +1569,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* Score summary: stat tiles, hero = combined overall */ /* Score summary: stat tiles, hero = combined overall */
.hf-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; } .hf-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; }
.hf-tile { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; min-width: 0; } .hf-tile { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; min-width: 0; }
.hf-tile .hf-k { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .hf-tile .hf-k { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hf-tile .hf-v { font-size: 20px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; } .hf-tile .hf-v { font-size: 20px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; }
.hf-tile .hf-v small { font-size: 12px; font-weight: 600; color: var(--text-3); margin-left: 2px; } .hf-tile .hf-v small { font-size: 12px; font-weight: 600; color: var(--text-3); margin-left: 2px; }
.hf-tile .hf-sub { font-size: 11.5px; color: var(--text-3); margin-top: 4px; } .hf-tile .hf-sub { font-size: 11.5px; color: var(--text-3); margin-top: 4px; }
@ -1440,7 +1579,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* Bordered section card, titled like the paper form's section headers */ /* Bordered section card, titled like the paper form's section headers */
.hf-block { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-top: 14px; } .hf-block { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-top: 14px; }
.hf-block-title { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); margin-bottom: 12px; display: flex; align-items: center; gap: 10px; } .hf-block-title { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); } .hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); }
.hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; } .hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; }
@ -1448,7 +1587,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; } .hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.hf-rate-head, .hf-rate-row, .hf-rate-foot { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 96px); align-items: center; } .hf-rate-head, .hf-rate-row, .hf-rate-foot { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 96px); align-items: center; }
.hf-rate-head { background: var(--bg-sunken); } .hf-rate-head { background: var(--bg-sunken); }
.hf-rate-head > div { padding: 8px 10px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--text-3); text-align: center; line-height: 1.25; } .hf-rate-head > div { padding: 8px 10px; text-align: center; }
.hf-rate-head > div:first-child { text-align: left; } .hf-rate-head > div:first-child { text-align: left; }
.hf-rate-row { border-top: 1px solid var(--border); } .hf-rate-row { border-top: 1px solid var(--border); }
.hf-rate-row > div:first-child { padding: 9px 10px; font-size: 13px; } .hf-rate-row > div:first-child { padding: 9px 10px; font-size: 13px; }
@ -1457,7 +1596,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.hf-dot:hover { border-color: var(--primary); } .hf-dot:hover { border-color: var(--primary); }
.hf-dot.on { background: var(--primary); border-color: var(--primary); box-shadow: inset 0 0 0 3.5px var(--bg-elev); } .hf-dot.on { background: var(--primary); border-color: var(--primary); box-shadow: inset 0 0 0 3.5px var(--bg-elev); }
.hf-rate-foot { border-top: 1px solid var(--border); background: var(--bg-sunken); } .hf-rate-foot { border-top: 1px solid var(--border); background: var(--bg-sunken); }
.hf-rate-foot > div:first-child { padding: 8px 10px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--text-3); } .hf-rate-foot > div:first-child { padding: 8px 10px; }
.hf-rate-foot .hf-avg { grid-column: 2 / -1; text-align: center; font-size: 13.5px; font-weight: 700; font-variant-numeric: tabular-nums; padding: 8px 0; } .hf-rate-foot .hf-avg { grid-column: 2 / -1; text-align: center; font-size: 13.5px; font-weight: 700; font-variant-numeric: tabular-nums; padding: 8px 0; }
/* Completion dot on the form switcher */ /* Completion dot on the form switcher */
@ -1466,7 +1605,6 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* Approvals: four signature slots */ /* Approvals: four signature slots */
.hf-sign-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .hf-sign-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.hf-sign { display: flex; flex-direction: column; gap: 6px; } .hf-sign { display: flex; flex-direction: column; gap: 6px; }
.hf-sign .hf-sign-role { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: var(--text-3); }
@media (max-width: 860px) { @media (max-width: 860px) {
/* Rating columns keep their full 96px width here the word headers still /* Rating columns keep their full 96px width here the word headers still
@ -1492,26 +1630,29 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* The four-form switcher must wrap rather than overflow on narrow screens. */ /* The four-form switcher must wrap rather than overflow on narrow screens. */
.cand-page .seg { flex-wrap: wrap; } .cand-page .seg { flex-wrap: wrap; }
/* Rating-table scale header: full words down to 640px, bare numbers below. */
.hf-scale-short { display: none; }
/* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
/* ============================================================
640 / 400 consolidated phone rules for the late bolt-on sections
(Dashboard v2 grid, candidate page, hiring forms). Kept in ONE block
at the end of the file so they always follow their base rules; the
core shell's 640 rules live in the RESPONSIVE section above.
============================================================ */
@media (max-width: 640px) { @media (max-width: 640px) {
.g-kpi-7 { grid-template-columns: 1fr; }
.cand-page-actions { width: 100%; } .cand-page-actions { width: 100%; }
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; } .cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
.hf-sign-grid { grid-template-columns: 1fr; } .hf-sign-grid { grid-template-columns: 1fr; }
.hf-summary { grid-template-columns: repeat(2, 1fr); } .hf-summary { grid-template-columns: repeat(2, 1fr); }
.hf-scale-full { display: none; }
.hf-scale-short { display: inline; font-size: var(--fs-xs); }
.hf-rate-head, .hf-rate-row, .hf-rate-foot { grid-template-columns: minmax(0, 1fr) repeat(4, 44px); }
.hf-cta { width: 100%; max-width: 420px; justify-content: center; }
} }
@media (max-width: 400px) { @media (max-width: 400px) {
.hf-summary { grid-template-columns: 1fr; } .hf-summary { grid-template-columns: 1fr; }
} }
/* Rating-table scale header: full words down to 640px, bare numbers below. */
.hf-scale-short { display: none; }
@media (max-width: 640px) {
.hf-scale-full { display: none; }
.hf-scale-short { display: inline; font-size: 12px; }
.hf-rate-head, .hf-rate-row, .hf-rate-foot { grid-template-columns: minmax(0, 1fr) repeat(4, 44px); }
}
/* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
@media (max-width: 640px) {
.hf-cta { width: 100%; max-width: 420px; justify-content: center; }
}

View File

@ -29,8 +29,12 @@ export function useDataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE }) {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const size = Math.max(1, pageSize || DEFAULT_PAGE_SIZE) const size = Math.max(1, pageSize || DEFAULT_PAGE_SIZE)
// The prototype reset to page 1 inside its imperative update(rows). // New data (filters) starts on page 1. Changing page size keeps the current
useEffect(() => setPage(1), [rows, size]) // page and only clamps if that page no longer exists.
useEffect(() => setPage(1), [rows])
useEffect(() => {
setPage((p) => Math.min(p, Math.max(1, Math.ceil((rows?.length ?? 0) / size))))
}, [size, rows?.length])
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (!sort.key) return rows if (!sort.key) return rows
@ -71,14 +75,26 @@ export function useDataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE }) {
} }
} }
/** 1 … cur-1 cur cur+1 … n — the prototype's windowing, unchanged. */ /** Sliding window of ~3 numbers so both arrows fit a narrow Inbox pane. */
export function pageWindow(cur, pages) { export function pageWindow(cur, pages) {
const list = [] const maxBtns = 3
for (let i = 1; i <= pages; i++) { if (pages <= maxBtns) {
if (i === 1 || i === pages || Math.abs(i - cur) <= 1) list.push(i) return Array.from({ length: Math.max(1, pages) }, (_, i) => i + 1)
else if (list[list.length - 1] !== '…') list.push('…')
} }
return list let start = Math.max(1, cur - 1)
let end = start + maxBtns - 1
if (end > pages) {
end = pages
start = Math.max(1, end - maxBtns + 1)
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
}
/** Keep the current page when Per page changes; clamp if it is past the end. */
export function pageAfterSizeChange(currentPage, total, nextSize) {
const size = Math.max(1, nextSize)
const pages = Math.max(1, Math.ceil((total || 0) / size))
return Math.min(Math.max(1, currentPage || 1), pages)
} }
/** Local draft so typing "50" does not fire a GET for 5, then 50. */ /** Local draft so typing "50" does not fire a GET for 5, then 50. */
@ -136,9 +152,11 @@ export function Pagination({
<span className="page-size-total">of <b>{total}</b></span> <span className="page-size-total">of <b>{total}</b></span>
</> </>
)} )}
<button className="page-btn" disabled={page === 1} onClick={() => setPage(page - 1)} aria-label="Previous page"> <div className="page-nav">
<button className="page-btn" disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<Icon name="chevron-left" /> <Icon name="chevron-left" />
</button> </button>
<div className="page-nums">
{pageButtons.map((p, i) => {pageButtons.map((p, i) =>
p === '…' ? ( p === '…' ? (
<span key={`gap-${i}`} className="page-btn" style={{ cursor: 'default' }}></span> <span key={`gap-${i}`} className="page-btn" style={{ cursor: 'default' }}></span>
@ -153,41 +171,39 @@ export function Pagination({
</button> </button>
), ),
)} )}
<button className="page-btn" disabled={page === pages} onClick={() => setPage(page + 1)} aria-label="Next page"> </div>
<button className="page-btn" disabled={page >= pages} onClick={() => setPage(page + 1)} aria-label="Next page">
<Icon name="chevron-right" /> <Icon name="chevron-right" />
</button> </button>
</div> </div>
</div> </div>
</div>
) )
} }
export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, pageSizeMax = 100, empty }) { /** The sortable <thead>, exported so useDataTable consumers with custom
const [size, setSize] = useState(pageSize) tbody markup (Candidates' checkbox column) stop copying it verbatim. */
const t = useDataTable({ columns, rows, pageSize: size }) export function DataTableHead({ columns, sort, toggleSort }) {
return ( return (
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead> <thead>
<tr> <tr>
{columns.map((c) => { {columns.map((c) => {
const isSorted = t.sort.key === c.key const isSorted = sort.key === c.key
const cls = [ const cls = [
c.sortable ? 'sortable' : '', c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '', isSorted ? (sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ') ].filter(Boolean).join(' ')
return ( return (
<th <th
key={c.key} key={c.key}
className={cls} className={cls}
style={{ textAlign: c.align || 'left' }} style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined} onClick={c.sortable ? () => toggleSort(c.key) : undefined}
> >
{c.label} {c.label}
{c.sortable && ( {c.sortable && (
<span className="sort-ind"> <span className="sort-ind">
{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'} {isSorted ? (sort.dir === 1 ? '▲' : '▼') : '⇅'}
</span> </span>
)} )}
</th> </th>
@ -195,6 +211,18 @@ export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE,
})} })}
</tr> </tr>
</thead> </thead>
)
}
export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, pageSizeMax = 100, empty, onRowClick }) {
const [size, setSize] = useState(pageSize)
const t = useDataTable({ columns, rows, pageSize: size })
return (
<div className="dt">
<div className="table-wrap">
<table className="data">
<DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
<tbody> <tbody>
{t.pageRows.length === 0 ? ( {t.pageRows.length === 0 ? (
<tr> <tr>
@ -204,7 +232,15 @@ export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE,
</tr> </tr>
) : ( ) : (
t.pageRows.map((row, i) => ( t.pageRows.map((row, i) => (
<tr key={row.id ?? i}> <tr
key={row.id ?? i}
className={onRowClick ? 'row-click' : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
onKeyDown={onRowClick ? (e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row)
} : undefined}
>
{columns.map((c) => ( {columns.map((c) => (
<td key={c.key} style={{ textAlign: c.align || 'left' }}> <td key={c.key} style={{ textAlign: c.align || 'left' }}>
{c.render ? c.render(row) : (row[c.key] ?? '')} {c.render ? c.render(row) : (row[c.key] ?? '')}

View File

@ -3,7 +3,7 @@
js/app.js:200-215, including its "only one open at a time" behaviour. js/app.js:200-215, including its "only one open at a time" behaviour.
============================================================ */ ============================================================ */
import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from 'react' import { cloneElement, createContext, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
const GroupContext = createContext(null) const GroupContext = createContext(null)
@ -43,9 +43,13 @@ export default function Dropdown({ trigger, children, className = '', panelClass
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]) }, [open])
// The trigger is a render prop returning a single button; stamp the
// disclosure ARIA on it here so no call site has to remember to.
const triggerNode = trigger({ open, toggle: () => setOpen(!open) })
return ( return (
<div className={`dropdown ${open ? 'open' : ''} ${className}`} ref={ref}> <div className={`dropdown ${open ? 'open' : ''} ${className}`} ref={ref}>
{trigger({ open, toggle: () => setOpen(!open) })} {cloneElement(triggerNode, { 'aria-expanded': open, 'aria-haspopup': 'true' })}
<div className={`dropdown-menu ${panelClassName}`}>{children}</div> <div className={`dropdown-menu ${panelClassName}`}>{children}</div>
</div> </div>
) )

View File

@ -0,0 +1,24 @@
/* ============================================================
PageHeader.jsx the one page-top pattern.
Every screen used to hand-write the same .page-head block, and the copies
drifted (a bare <h1> on Matching rendered in the wrong face entirely).
Emits the exact class structure the stylesheet already targets, so this is
markup dedup, not a redesign. `title`/`sub`/`actions` accept any node
selects, buttons and status chips ride through unchanged.
============================================================ */
export default function PageHeader({ title, sub, crumb, actions }) {
return (
<>
{crumb && <div className="breadcrumb">{crumb}</div>}
<div className="page-head">
<div className="page-head-main">
<h1 className="page-title">{title}</h1>
{sub && <p className="page-sub">{sub}</p>}
</div>
{actions && <div className="page-head-actions">{actions}</div>}
</div>
</>
)
}

View File

@ -6,24 +6,48 @@
`.active`. One component replaces four ad-hoc implementations, and only the `.active`. One component replaces four ad-hoc implementations, and only the
active pane is mounted which also means a chart in a hidden pane no longer active pane is mounted which also means a chart in a hidden pane no longer
draws into a zero-width canvas. draws into a zero-width canvas.
ARIA: tabs are indexed (`{base}-tab-{i}` / `{base}-panel-{i}`) so ids stay
valid whatever the key strings contain. A controlled <Tabs> without a
TabPanel emits aria-controls ids that nothing renders inert, not an
error. Arrow keys move selection (selection follows focus); the roving
tabindex keeps the strip a single Tab stop.
============================================================ */ ============================================================ */
import { useId, useState } from 'react' import { useId, useState } from 'react'
export function Tabs({ tabs, value, onChange, className = 'tabs' }) { export function Tabs({ tabs, value, onChange, className = 'tabs', idBase }) {
const id = useId() const autoId = useId()
const base = idBase ?? autoId
const activeIndex = Math.max(0, tabs.findIndex((t) => (t.key ?? t) === value))
function onKeyDown(e) {
let next = null
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (activeIndex + 1) % tabs.length
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (activeIndex - 1 + tabs.length) % tabs.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = tabs.length - 1
if (next === null) return
e.preventDefault()
const t = tabs[next]
onChange(t.key ?? t)
document.getElementById(`${base}-tab-${next}`)?.focus()
}
return ( return (
<div className={className} role="tablist"> <div className={className} role="tablist" onKeyDown={onKeyDown}>
{tabs.map((t) => { {tabs.map((t, i) => {
const key = t.key ?? t const key = t.key ?? t
const label = t.label ?? t const label = t.label ?? t
const active = key === value const active = key === value
return ( return (
<button <button
key={key} key={key}
id={`${id}-${key}`} id={`${base}-tab-${i}`}
role="tab" role="tab"
aria-selected={active} aria-selected={active}
aria-controls={`${base}-panel-${i}`}
tabIndex={active ? 0 : -1}
className={`tab${active ? ' active' : ''}`} className={`tab${active ? ' active' : ''}`}
onClick={() => onChange(key)} onClick={() => onChange(key)}
> >
@ -37,12 +61,16 @@ export function Tabs({ tabs, value, onChange, className = 'tabs' }) {
/** Uncontrolled convenience wrapper: <TabPanel tabs={[{key,label,render}]} /> */ /** Uncontrolled convenience wrapper: <TabPanel tabs={[{key,label,render}]} /> */
export default function TabPanel({ tabs, initial, className }) { export default function TabPanel({ tabs, initial, className }) {
const base = useId()
const [value, setValue] = useState(initial ?? tabs[0]?.key) const [value, setValue] = useState(initial ?? tabs[0]?.key)
const active = tabs.find((t) => t.key === value) ?? tabs[0] const activeIndex = Math.max(0, tabs.findIndex((t) => t.key === value))
const active = tabs[activeIndex] ?? tabs[0]
return ( return (
<> <>
<Tabs tabs={tabs} value={value} onChange={setValue} className={className} /> <Tabs tabs={tabs} value={value} onChange={setValue} className={className} idBase={base} />
<div role="tabpanel">{active?.render?.()}</div> <div role="tabpanel" id={`${base}-panel-${activeIndex}`} aria-labelledby={`${base}-tab-${activeIndex}`}>
{active?.render?.()}
</div>
</> </>
) )
} }

View File

@ -77,7 +77,7 @@ export default function ToastProvider({ children }) {
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
{createPortal( {createPortal(
<div className="toast-root"> <div className="toast-root" role="status" aria-live="polite">
{items.map((t) => { {items.map((t) => {
const cfg = CONFIG[t.type] || CONFIG.info const cfg = CONFIG[t.type] || CONFIG.info
return ( return (

View File

@ -43,6 +43,10 @@ export const STATUS_CLASS = {
'Strong Hire': 'b-green', Hire: 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red', 'Strong Hire': 'b-green', Hire: 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red',
} }
// Shared task/candidate priority map Dashboard and Tasks each used to
// define an identical private copy.
export const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
export function Badge({ children, className }) { export function Badge({ children, className }) {
const cls = className || STATUS_CLASS[children] || 'b-gray' const cls = className || STATUS_CLASS[children] || 'b-gray'
return <span className={`badge ${cls}`}>{children}</span> return <span className={`badge ${cls}`}>{children}</span>
@ -72,6 +76,23 @@ export function ProgressBar({ pct, className }) {
) )
} }
/** Shimmer placeholder for a loading table/list — one row per record slot. */
export function SkeletonRows({ rows = 5 }) {
return (
<div role="status" aria-label="Loading">
{Array.from({ length: rows }, (_, i) => (
<div className="skeleton-row" aria-hidden="true" key={i}>
<span className="skeleton skeleton-avatar" />
<div className="flex-1">
<span className="skeleton skeleton-line" style={{ width: `${52 - (i % 3) * 9}%` }} />
<span className="skeleton skeleton-line" style={{ width: `${34 - (i % 3) * 6}%` }} />
</div>
</div>
))}
</div>
)
}
export function EmptyState({ icon = 'search', title = 'No results found', children }) { export function EmptyState({ icon = 'search', title = 'No results found', children }) {
const body = children ?? 'Try adjusting your filters or search.' const body = children ?? 'Try adjusting your filters or search.'
const isSimple = body == null || typeof body === 'string' || typeof body === 'number' const isSimple = body == null || typeof body === 'string' || typeof body === 'number'

View File

@ -20,10 +20,21 @@ export default defineConfig({
port: 5173, port: 5173,
// Same-origin style for local Vite when VITE_API_BASE is empty. // Same-origin style for local Vite when VITE_API_BASE is empty.
// Requires API published on the host (docker-compose.host-ports.yml). // Requires API published on the host (docker-compose.host-ports.yml).
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
// (e.g. 8001 locally because another service holds 8000).
proxy: { proxy: {
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3)(/|$)': { '^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3)(/|$)': {
target: 'http://127.0.0.1:8000', target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
changeOrigin: true, changeOrigin: true,
// Several API prefixes double as SPA routes (/jobs, /inbox, …).
// A browser NAVIGATION to one of them — refresh, pasted URL —
// sends Accept: text/html and wants the app shell, not the API;
// without this it proxied and 500'd. fetch/XHR traffic never
// asks for text/html (downloads go fetch→blob), so only real
// page loads are bypassed.
bypass(req) {
if (req.headers.accept?.includes('text/html')) return '/index.html'
},
}, },
}, },
}, },