Backend_CODEBASE #2

Merged
ahmed.mujtaba merged 7 commits from Backend_CODEBASE into main 2026-08-06 08:09:40 +00:00
139 changed files with 18515 additions and 4421 deletions

View File

@ -0,0 +1,122 @@
---
description: Strict HR-ATS backend house style — layering, routes, serializers, auth
globs: backend/**/*.py
alwaysApply: false
---
# HR-ATS Backend — singular pattern (mandatory)
Match existing modules (`users/`, `inbox/`) exactly. Do not introduce alternate frameworks, layers, or response shapes.
## Package layout (every domain)
```
backend/<domain>/
app.py # routes only — HTTP in/out
views.py # service class — business logic
models.py # SQLModel table + classmethod DB accessors
serializers.py # hand-rolled dict builders (no Pydantic response models)
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports
permissions.py # OAuth2 scheme + Depends aliases (auth domains only)
```
- Mount with bare `router = APIRouter()`; register in `main.py` via `app.include_router(...)`.
- No package `__init__.py`; run uvicorn from `backend/` so imports are top-level (`users.app`, `db_setup`).
- Config: `load_dotenv()` + `os.getenv(...)` in the module that needs it. Do not extend `db_setup.Settings` for non-DB keys.
## Layer duties (strict)
| Layer | Owns | Must NOT |
|---|---|---|
| `app.py` | Routes, request Pydantic models (inline), `JSONResponse`, call serializers for HTTP payloads, inject `CurrentUser` / `session` | Business rules, DB queries, JWT encode logic beyond calling plugins |
| `views.py` | Validate rules, call models, raise `HTTPException`, return ORM rows or serialized dicts for CRUD | Build login/token HTTP envelopes; call `serialize_token` |
| `models.py` | Table fields, `select`/`insert`/`update`/`soft_delete`, `selectinload` when relations are needed | HTTPExceptions, serializers, FastAPI |
| `serializers.py` | `serialize_*` → plain `dict` (`str(uuid)`, `.isoformat()` dates, never password) | DB access, Depends |
| `plugins.py` | Pure functions; raise library errors (e.g. `jwt.*`), not HTTP | Import FastAPI |
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser = Annotated[...]`, `require_permission` | Route handlers |
## Route pattern (`app.py`)
- Verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — not `/auth/token`, not REST nouns-only.
- Every handler uses this wrapper:
```python
try:
service=User(session=session)
data=await service.some_method(...)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
```
- List fetch: `{"data":items,"total":total,"status_code":200}`; single-by-id: `total: 1`.
- Login/refresh: build tokens in the route, then serialize:
```python
user=await service.authenticate_user(...)
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
return JSONResponse(content={**tokens,"status_code":200})
```
- Request schemas live **inline** in `app.py` (`UserCreate`, `TokenRefresh`, …). Not in `serializers.py`.
- Dependencies: default form `session: AsyncSession = Depends(get_session)` unless `Annotated` is required (auth form / `CurrentUser` before other defaults).
- Tight spacing house style: `service=User(session=session)`, `detail=str(e)`, `key=value` in calls — match neighbors, do not “pretty-reformat” whole files.
## Service pattern (`views.py`)
```python
class User:
def __init__(self,session:AsyncSession):
self.session=session
async def create_user(self,payload):
...
return serialize_user(user) # CRUD returns serialized dict
```
- Untyped method args (match existing). Raise `HTTPException(status_code=...,detail="...")`.
- Auth methods (`authenticate_user`, `refresh_access_token`) return the **ORM user** only — never `serialize_token`.
- After email lookup that lacks `selectinload(role)`, re-fetch via `get_user_by_id` before anything that touches `user.role`.
## Model accessors (`models.py`)
- `@classmethod async def get_* / insert_* / update_* / soft_delete_* / count_*`.
- Soft delete: set `is_deleted=True`, `is_active=False`.
- Use `selectinload(cls.role)` on fetches that will be serialized with role fields.
- Commit inside model write methods (existing pattern).
## Auth pattern
- Access + refresh JWTs via `plugins` (`type` claim must be checked in `decode_token`).
- `permissions.CurrentUser` on every protected `/users/*` route; login + refresh stay open.
- `get_current_user`: decode access → DB load by `sub` → reject missing/deleted/inactive → `serialize_user`.
- Login uses `OAuth2PasswordRequestForm`; username field carries email.
- OAuth2 fields (`access_token`, `refresh_token`, `token_type`, `expires_in`) at **response root**; user under `data`.
- `tokenUrl="users/login"` (no leading slash).
- No FastAPI in `plugins.py`. Translate `jwt.PyJWTError` → 401 in `permissions` / `views`.
## Dependencies / env
- Pin in `requirements.txt` under comment banners with trailing rationale comments.
- Secrets in `backend/.env`; document keys in `backend/.env.example`.
- JWT times: `datetime.now(timezone.utc)` only (never naive `datetime.now()` for token `iat`/`exp`).
## Hard bans
- No new abstraction layers (repositories, use-cases, DTOs beyond inline Pydantic requests).
- No Pydantic response models; no `jsonable_encoder` for these routes.
- No changing response envelope (`data` + `status_code`) or inventing `/api/v1` prefixes.
- No drive-by refactors or reformatting unrelated code.
- No RBAC second scheme — vocabulary and `require_permission` live in `users/permissions.py` only.
- Do not touch `inbox/` when the task is `users/` (and vice versa) unless asked.
- No `__init__.py` packages; no moving request models into `serializers.py`.
## When adding a new domain endpoint
1. Accessor on `models.py` if DB changes.
2. Method on service in `views.py`.
3. `serialize_*` in `serializers.py` if new shape.
4. Route in `app.py` with the standard try/except + `JSONResponse`.
5. Protect with `current_user: CurrentUser` if under an authenticated router.

10
.gitignore vendored
View File

@ -36,6 +36,11 @@ env/
.env
.env.*
!.env.example
!frontend/.env.development
!frontend/.env.production
# Postman/Insomnia environments holding real API keys
*.postman_environment.local.json
# Logs & temp
*.log
@ -43,4 +48,9 @@ tmp/
temp/
.cache/
# Frontend build
node_modules/
frontend/dist/
**.pdf
**_**_**.py

28
backend/.env.example Normal file
View File

@ -0,0 +1,28 @@
DB_USERNAME=
DB_PASSWORD=
DB_HOST=
DB_PORT=
DB_NAME=
EMAIL_URL=
JWT_SECRET_KEY=
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
JWT_RESET_TOKEN_EXPIRE_MINUTES=10
TEAMS_MAIL_API_URL=
TEAMS_API_TOKEN=
RESET_CODE_TTL_SECONDS=60
RESET_CODE_RESEND_SECONDS=30
RESET_CODE_MAX_ATTEMPTS=5
FRONTEND_URL=http://localhost:5173
CONFIRM_EMAIL_PATH=/auth/confirm-email
CONFIRM_TOKEN_TTL_SECONDS=86400
CONFIRM_TOKEN_RESEND_SECONDS=60
BUFFER_API=
BUFFER_API_URL=https://api.buffer.com
BUFFER_CHANNEL_ID=

View File

@ -0,0 +1,68 @@
from fastapi import APIRouter,Depends
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, EmailStr
from forget_password.views import ForgetPassword
from forget_password.permissions import ResetCredentials
from forget_password.serializers import serialize_reset_token
from users.plugins import create_reset_token
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
class ForgetPasswordRequest(BaseModel):
email: EmailStr
class ForgetPasswordVerify(BaseModel):
email: EmailStr
code: str
class ForgetPasswordNew(BaseModel):
password: str
@router.post("/users/forget-password")
async def request_reset_code(payload: ForgetPasswordRequest,session: AsyncSession = Depends(get_session)):
try:
service=ForgetPassword(session=session)
data=await service.request_code(payload.email)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/users/forget-password/verify-code")
async def verify_reset_code(payload: ForgetPasswordVerify,session: AsyncSession = Depends(get_session)):
try:
service=ForgetPassword(session=session)
email,code_id=await service.verify_code(payload.email,payload.code)
tokens=serialize_reset_token(create_reset_token(email,code_id=code_id),email)
return JSONResponse(content={**tokens,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/users/forget-password/new-password")
async def set_new_password(
payload: ForgetPasswordNew,
credentials: ResetCredentials,
session: AsyncSession = Depends(get_session),
):
try:
service=ForgetPassword(session=session)
data=await service.set_new_password(credentials.credentials,payload.password)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,106 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class PasswordResetCodes(SQLModel, table=True):
__tablename__ = "password_reset_codes"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
email: str = Field(index=True)
code_hash: str
expires_at: datetime = Field(sa_type=DateTime(timezone=True))
attempts: int = Field(default=0)
is_used: bool = Field(default=False)
verified_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id: str) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_code_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def get_active_code_by_email(cls, session: AsyncSession, email: str):
"""Newest unused code for email (expiry checked in Python by the service)."""
statement = (
select(cls)
.where(cls.email == email, cls.is_used == False) # noqa: E712
.order_by(cls.created_at.desc())
)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def insert_code(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_code_by_id(session, row.id)
@classmethod
async def increment_attempts(cls, session: AsyncSession, record_id: str):
row = await cls.get_code_by_id(session, record_id)
if not row:
return None
row.attempts = (row.attempts or 0) + 1
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def mark_verified(cls, session: AsyncSession, record_id: str):
row = await cls.get_code_by_id(session, record_id)
if not row:
return None
row.verified_at = _now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def mark_used(cls, session: AsyncSession, record_id: str):
row = await cls.get_code_by_id(session, record_id)
if not row:
return None
row.is_used = True
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def invalidate_codes_for_email(cls, session: AsyncSession, email: str):
statement = select(cls).where(cls.email == email, cls.is_used == False) # noqa: E712
result = await session.execute(statement)
rows = result.scalars().all()
now = _now()
for row in rows:
row.is_used = True
row.updated_at = now
session.add(row)
await session.commit()
return len(rows)

View File

@ -0,0 +1,11 @@
"""Bearer scheme for password-reset tokens (type=reset JWT)."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
reset_scheme = HTTPBearer()
ResetCredentials = Annotated[HTTPAuthorizationCredentials, Depends(reset_scheme)]

View File

@ -0,0 +1,77 @@
"""Forget-password helpers — OTP generation, hashing, and Teams mail send.
Pure module: no FastAPI imports and no HTTPException.
"""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
import httpx
from dotenv import load_dotenv
from users.plugins import hash_password, verify_password
load_dotenv()
TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL")
TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN")
RESET_CODE_TTL_SECONDS = int(os.getenv("RESET_CODE_TTL_SECONDS", "60"))
RESET_CODE_RESEND_SECONDS = int(os.getenv("RESET_CODE_RESEND_SECONDS", "30"))
RESET_CODE_MAX_ATTEMPTS = int(os.getenv("RESET_CODE_MAX_ATTEMPTS", "5"))
MAIL_ACCEPTED_STATUS = 202
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def code_expiry(*, now: datetime | None = None) -> datetime:
return (now or now_utc()) + timedelta(seconds=RESET_CODE_TTL_SECONDS)
def generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
def hash_code(code: str) -> str:
return hash_password(code)
def verify_code(code: str, code_hash: str) -> bool:
return verify_password(code, code_hash)
def render_reset_email(code: str, ttl: int) -> tuple[str, str]:
subject = "Your TalentFlow password reset code"
html = (
f"<p>Your password reset code is <strong>{code}</strong>.</p>"
f"<p>It expires in {ttl} seconds. If you did not request this, ignore this email.</p>"
)
return subject, html
async def send_reset_mail(to_email: str, subject: str, html: str) -> None:
if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN:
raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set")
fields = [
("subject", (None, subject)),
("body", (None, html)),
("content_type", (None, "html")),
("save_to_sent_items", (None, "false")),
("to", (None, to_email)),
]
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
TEAMS_MAIL_API_URL,
files=fields,
headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"},
)
if response.status_code != MAIL_ACCEPTED_STATUS:
raise httpx.HTTPStatusError(
response.text,
request=response.request,
response=response,
)

View File

@ -0,0 +1,27 @@
from forget_password.plugins import RESET_CODE_RESEND_SECONDS,RESET_CODE_TTL_SECONDS
from users.plugins import RESET_TOKEN_EXPIRE_SECONDS
def serialize_reset_request(email: str,expires_at) -> dict:
return {
"email": email,
"expires_at": expires_at.isoformat() if expires_at else None,
"expires_in": RESET_CODE_TTL_SECONDS,
"resend_after": RESET_CODE_RESEND_SECONDS,
}
def serialize_reset_token(reset_token: str,email: str) -> dict:
return {
"reset_token": reset_token,
"token_type": "bearer",
"expires_in": RESET_TOKEN_EXPIRE_SECONDS,
"data": {"email": email},
}
def serialize_reset_result(email: str) -> dict:
return {
"email": email,
"password_updated": True,
}

View File

@ -0,0 +1,109 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import httpx
import jwt
from forget_password.models import PasswordResetCodes
from forget_password.plugins import (
RESET_CODE_MAX_ATTEMPTS,
RESET_CODE_RESEND_SECONDS,
code_expiry,
generate_code,
hash_code,
now_utc,
render_reset_email,
send_reset_mail,
verify_code,
RESET_CODE_TTL_SECONDS,
)
from forget_password.serializers import serialize_reset_request,serialize_reset_result
from users.models import Users
from users.plugins import decode_token,hash_password
class ForgetPassword:
def __init__(self,session:AsyncSession):
self.session=session
async def request_code(self,email):
user=await Users.get_user_by_email(self.session,email)
if not user or user.is_deleted or not user.is_active:
raise HTTPException(status_code=404,detail="No account found for this email")
active=await PasswordResetCodes.get_active_code_by_email(self.session,email)
if active:
age=(now_utc()-active.created_at).total_seconds()
if age<RESET_CODE_RESEND_SECONDS and active.expires_at>now_utc():
raise HTTPException(status_code=429,detail="Please wait before requesting another code")
await PasswordResetCodes.invalidate_codes_for_email(self.session,email)
code=generate_code()
expires_at=code_expiry()
row=await PasswordResetCodes.insert_code(self.session,{
"email":email,
"code_hash":hash_code(code),
"expires_at":expires_at,
})
subject,html=render_reset_email(code,RESET_CODE_TTL_SECONDS)
try:
await send_reset_mail(email,subject,html)
except (httpx.HTTPError,RuntimeError) as e:
await PasswordResetCodes.mark_used(self.session,str(row.id))
raise HTTPException(status_code=502,detail="Failed to send reset email") from e
return serialize_reset_request(email,expires_at)
async def verify_code(self,email,code):
row=await PasswordResetCodes.get_active_code_by_email(self.session,email)
if not row:
raise HTTPException(status_code=400,detail="No active reset code for this email")
if row.expires_at<=now_utc():
raise HTTPException(status_code=400,detail="Reset code has expired")
if (row.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS:
raise HTTPException(status_code=429,detail="Too many invalid attempts")
if not verify_code(code,row.code_hash):
updated=await PasswordResetCodes.increment_attempts(self.session,str(row.id))
if updated and (updated.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS:
raise HTTPException(status_code=429,detail="Too many invalid attempts")
raise HTTPException(status_code=400,detail="Invalid reset code")
await PasswordResetCodes.mark_verified(self.session,str(row.id))
return email,str(row.id)
async def set_new_password(self,reset_token,password):
try:
payload=decode_token(reset_token,expected_type="reset")
except jwt.PyJWTError:
raise HTTPException(
status_code=401,
detail="Invalid or expired reset token",
headers={"WWW-Authenticate":"Bearer"},
)
email=payload.get("sub")
code_id=payload.get("crid")
if not email or not code_id:
raise HTTPException(
status_code=401,
detail="Invalid or expired reset token",
headers={"WWW-Authenticate":"Bearer"},
)
row=await PasswordResetCodes.get_code_by_id(self.session,code_id)
if not row or row.is_used or row.email!=email or not row.verified_at:
raise HTTPException(
status_code=401,
detail="Invalid or expired reset token",
headers={"WWW-Authenticate":"Bearer"},
)
user=await Users.get_user_by_email(self.session,email)
if not user or user.is_deleted:
raise HTTPException(status_code=404,detail="User not found")
await Users.update_user(self.session,str(user.id),{"password":hash_password(password)})
await PasswordResetCodes.mark_used(self.session,str(row.id))
return serialize_reset_result(email)

93
backend/job/app.py Normal file
View File

@ -0,0 +1,93 @@
from fastapi import APIRouter,Depends
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost
from job.job_post.plugins import PlatformAlias
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
class JobPostCreate(BaseModel):
title: str
experience_min: int | None = None
experience_max: int | None = None
requirements: list[str] = []
optional_skills: list[str] = []
salary: str = "Anonymous"
location: str | None = None
employment_type: str | None = None
platform: str = "linkedin"
description: str | None = None
platform: str | None = None
channel_id: str | None = None
mode: str = "addToQueue"
due_at: str | None = None
@model_validator(mode="after")
def validate_mode_and_due_at(self):
allowed = {"addToQueue", "shareNow", "customScheduled"}
if self.mode not in allowed:
raise ValueError(f"mode must be one of {sorted(allowed)}")
if self.mode == "customScheduled" and not self.due_at:
raise ValueError("due_at is required when mode is customScheduled")
return self
@router.get("/jobs/alias")
async def get_job_alias():
try:
alias_lst=[k.name for k in PlatformAlias]
return JSONResponse(content={"data":alias_lst,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv_upload")
async def cv_upload(
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
pass
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/post-job")
async def post_job(
payload: JobPostCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data=await service.post_job(payload.model_dump(),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/buffer/channels")
async def buffer_channels(
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data=await service.list_channels()
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,117 @@
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
from users.models import Users
def _now() -> datetime:
return datetime.now(timezone.utc)
class JobPosts(SQLModel, table=True):
__tablename__ = "job_posts"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
user: Optional["Users"] = Relationship(
back_populates="job_posts",
sa_relationship_kwargs={"lazy": "joined"},
)
platform: str = Field(default="linkedin")
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
employment_type: str | None = Field(default=None)
location: str | None = Field(default=None)
experience_min: int | None = Field(default=None)
experience_max: int | None = Field(default=None)
requirements: list[str] = Field(default_factory=list, sa_type=JSON)
optional_skills: list[str] = Field(default_factory=list, sa_type=JSON)
salary: str = Field(default="Anonymous")
description: str | None = Field(default=None)
post_text: str
channel_id: str
buffer_post_id: str | None = Field(default=None)
buffer_external_link: str | None = Field(default=None)
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
status: str = Field(default="draft")
buffer_error: str | None = Field(default=None)
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id: str) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_job_post_by_id(session, row.id)
@classmethod
async def mark_buffer_result(
cls,
session: AsyncSession,
record_id: str,
*,
buffer_post_id: str,
status: str,
external_link: str | None = None,
sent_at: datetime | None = None,
platform: str | None = None,
):
"""Record what Buffer reported.
`status` is the mapped Buffer PostStatus, not an assumption: a queued post lands
here as "scheduled" and only becomes "published" once Buffer says `sent`.
"""
row = await cls.get_job_post_by_id(session, record_id)
if not row:
return None
row.status = status
row.buffer_post_id = buffer_post_id
row.buffer_external_link = external_link
row.buffer_sent_at = sent_at
if platform:
row.platform = platform
row.buffer_error = None
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def mark_failed(cls, session: AsyncSession, record_id: str, error: str):
row = await cls.get_job_post_by_id(session, record_id)
if not row:
return None
row.status = "failed"
row.buffer_error = error
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
import users.models as _users_models

View File

@ -0,0 +1,306 @@
"""Buffer GraphQL helpers and LinkedIn job-post copy rendering.
Pure module: no FastAPI imports and no HTTPException.
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime
from enum import Enum
import httpx
from dotenv import load_dotenv
load_dotenv()
BUFFER_API = os.getenv("BUFFER_API")
BUFFER_API_URL = os.getenv("BUFFER_API_URL", "https://api.buffer.com")
BUFFER_CHANNEL_ID = os.getenv("BUFFER_CHANNEL_ID")
LINKEDIN_POST_MAX_CHARS = 3000
# Buffer's PostStatus -> the job_posts.status lifecycle. Only `sent` means the post is
# actually live on the network: the default `addToQueue` mode comes back as `scheduled`,
# so treating any successful mutation as "published" would record a post that nobody
# outside Buffer can see yet.
BUFFER_STATUS_TO_LOCAL = {
"sent": "published",
"sending": "publishing",
"scheduled": "scheduled",
"draft": "draft",
"needs_approval": "needs_approval",
"error": "failed",
}
def local_status(buffer_status) -> str:
"""Map a Buffer PostStatus onto our own. Unknown values stay uncommitted."""
return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled")
class PlatformAlias(str, Enum):
"""Shorthands people type, mapped to Buffer's own `Service` values.
Member name = what arrives in the request, value = what Buffer calls it. This is
spelling tolerance only, *not* the list of supported networks: anything absent here
still resolves, because `resolve_channel` matches against the services Buffer
actually reports. A newly connected network needs no entry.
Names that share a value (ig/insta) become Enum aliases, which is exactly the
intent -- lookup is by member name via `__members__`.
"""
fb = "facebook"
ig = "instagram"
insta = "instagram"
li = "linkedin"
x = "twitter"
tweet = "twitter"
yt = "youtube"
gbp = "googlebusiness"
google = "googlebusiness"
googlebusinessprofile = "googlebusiness"
def normalize_platform(value) -> str:
"""Case/punctuation-insensitive key for comparing a requested platform."""
key = re.sub(r"[^a-z0-9]", "", str(value or "").lower())
alias = PlatformAlias.__members__.get(key)
return alias.value if alias else key
async def resolve_channel(platform, channels=None) -> dict:
"""Return the connected channel for `platform`.
Nothing is special-cased per network: the request is matched against the services
Buffer reports, so any platform Buffer supports and the account has connected will
resolve. Falls back to matching the channel's handle or display name so
"ahmedmujtababaig" works as well as "linkedin".
"""
wanted = normalize_platform(platform)
if not wanted:
raise BufferError("No platform given")
if channels is None:
channels = await list_buffer_channels()
for field in ("service", "name", "displayName"):
for channel in channels:
if normalize_platform(channel.get(field)) == wanted:
return channel
available = sorted({c.get("service") for c in channels if c.get("service")})
raise BufferError(
f"No Buffer channel connected for platform {platform!r}. "
f"Connected: {', '.join(available) if available else 'none'}"
)
def parse_buffer_datetime(value):
"""Buffer sends ISO 8601 with a trailing `Z`, which fromisoformat wants as +00:00."""
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
class BufferError(RuntimeError):
def __init__(self, message: str, *, code: str | None = None):
super().__init__(message)
self.code = code
def render_job_post(payload) -> str:
title = (payload.get("title") or "").strip() or "Open Role"
location = (payload.get("location") or "").strip()
employment_type = (payload.get("employment_type") or "").strip()
experience_min = payload.get("experience_min")
experience_max = payload.get("experience_max")
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()]
salary = (payload.get("salary") or "Anonymous").strip() or "Anonymous"
description = (payload.get("description") or "").strip()
lines = [f"We're hiring: {title}", ""]
meta = []
if location:
meta.append(location)
if employment_type:
meta.append(employment_type)
if meta:
lines.append(" · ".join(meta))
lines.append("")
if experience_min is not None and experience_max is not None:
lines.append(f"Experience: {experience_min}{experience_max} years")
lines.append("")
elif experience_min is not None:
lines.append(f"Experience: {experience_min}+ years")
lines.append("")
elif experience_max is not None:
lines.append(f"Experience: up to {experience_max} years")
lines.append("")
if requirements:
lines.append("Requirements:")
for item in requirements:
lines.append(f"{item}")
lines.append("")
if optional_skills:
lines.append("Nice to have:")
for item in optional_skills:
lines.append(f"{item}")
lines.append("")
lines.append(f"Salary: {salary}")
lines.append("")
if description:
lines.append(description)
lines.append("")
lines.append("Interested? Apply via our careers page or reply to this post.")
lines.append("")
tags = []
for item in requirements:
tag = re.sub(r"[^A-Za-z0-9]+", "", item)
if tag:
tags.append(f"#{tag}")
if tags:
lines.append(" ".join(tags))
text = "\n".join(lines).strip()
if len(text) > LINKEDIN_POST_MAX_CHARS:
text = text[: LINKEDIN_POST_MAX_CHARS - 1].rstrip() + ""
return text
def build_create_post_query(text, channel_id, *, mode="addToQueue", due_at=None) -> str:
fields = [
f"text: {json.dumps(text)}",
f"channelId: {json.dumps(channel_id)}",
"schedulingType: automatic",
f"mode: {mode}",
]
if mode == "customScheduled" and due_at:
fields.append(f"dueAt: {json.dumps(due_at)}")
input_block = ",\n ".join(fields)
return (
"mutation CreatePost {\n"
" createPost(input: {\n"
f" {input_block}\n"
" }) {\n"
" ... on PostActionSuccess {\n"
" post { id text status sentAt externalLink channelService }\n"
" }\n"
" ... on MutationError { message }\n"
" }\n"
"}"
)
async def create_buffer_post(text, channel_id, *, mode="addToQueue", due_at=None) -> dict:
if not BUFFER_API:
raise RuntimeError("BUFFER_API is not configured")
query = build_create_post_query(text, channel_id, mode=mode, due_at=due_at)
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
BUFFER_API_URL,
json={"query": query},
headers={
"Authorization": f"Bearer {BUFFER_API}",
"Content-Type": "application/json",
},
)
if response.status_code != 200:
raise httpx.HTTPStatusError(
response.text,
request=response.request,
response=response,
)
body = response.json()
errors = body.get("errors")
if errors:
first = errors[0] if isinstance(errors, list) and errors else {}
msg = first.get("message") or "Buffer GraphQL error"
code = (first.get("extensions") or {}).get("code")
raise BufferError(msg, code=code)
create_post = (body.get("data") or {}).get("createPost") or {}
if "message" in create_post and "post" not in create_post:
raise BufferError(create_post.get("message") or "Buffer mutation error")
post = create_post.get("post")
if not post or not post.get("id"):
raise BufferError("Buffer did not return a post id")
return post
async def list_buffer_channels() -> list[dict]:
if not BUFFER_API:
raise RuntimeError("BUFFER_API is not configured")
async with httpx.AsyncClient(timeout=15.0) as client:
orgs_response = await client.post(
BUFFER_API_URL,
json={"query": "query { account { organizations { id name } } }"},
headers={
"Authorization": f"Bearer {BUFFER_API}",
"Content-Type": "application/json",
},
)
if orgs_response.status_code != 200:
raise httpx.HTTPStatusError(
orgs_response.text,
request=orgs_response.request,
response=orgs_response,
)
orgs_body = orgs_response.json()
if orgs_body.get("errors"):
first = orgs_body["errors"][0]
raise BufferError(
first.get("message") or "Buffer GraphQL error",
code=(first.get("extensions") or {}).get("code"),
)
organizations = ((orgs_body.get("data") or {}).get("account") or {}).get("organizations") or []
channels: list[dict] = []
for org in organizations:
org_id = org.get("id")
if not org_id:
continue
channels_query = (
"query GetChannels {\n"
f' channels(input:{{organizationId:{json.dumps(org_id)}}}) {{\n'
" id name displayName service isQueuePaused\n"
" }\n"
"}"
)
channels_response = await client.post(
BUFFER_API_URL,
json={"query": channels_query},
headers={
"Authorization": f"Bearer {BUFFER_API}",
"Content-Type": "application/json",
},
)
if channels_response.status_code != 200:
raise httpx.HTTPStatusError(
channels_response.text,
request=channels_response.request,
response=channels_response,
)
channels_body = channels_response.json()
if channels_body.get("errors"):
first = channels_body["errors"][0]
raise BufferError(
first.get("message") or "Buffer GraphQL error",
code=(first.get("extensions") or {}).get("code"),
)
for channel in (channels_body.get("data") or {}).get("channels") or []:
channels.append({
**channel,
"organization_id": org_id,
"organization_name": org.get("name"),
})
return channels

View File

@ -0,0 +1,24 @@
def serialize_job_post(row) -> dict:
return {
"id": str(row.id),
"title": row.title,
"employment_type": row.employment_type,
"location": row.location,
"experience_min": row.experience_min,
"experience_max": row.experience_max,
"requirements": list(row.requirements or []),
"optional_skills": list(row.optional_skills or []),
"salary": row.salary,
"description": row.description,
"post_text": row.post_text,
"channel_id": row.channel_id,
"platform": row.platform,
"buffer_post_id": row.buffer_post_id,
"buffer_external_link": row.buffer_external_link,
"buffer_sent_at": row.buffer_sent_at.isoformat() if row.buffer_sent_at else None,
"status": row.status,
"buffer_error": row.buffer_error,
"created_by": str(row.created_by) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View File

@ -0,0 +1,111 @@
import os
import httpx
from dotenv import load_dotenv
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from job.job_post.models import JobPosts
from job.job_post.plugins import (
BufferError,
create_buffer_post,
list_buffer_channels,
local_status,
normalize_platform,
parse_buffer_datetime,
render_job_post,
resolve_channel,
)
from job.job_post.serializers import serialize_job_post
load_dotenv()
class JobPost:
def __init__(self,session:AsyncSession):
self.session=session
self.buffer_api=os.getenv("BUFFER_API")
self.channel_id=os.getenv("BUFFER_CHANNEL_ID")
async def _resolve_target(self,payload):
"""Pick the Buffer channel to post to, and the service it belongs to.
Precedence: an explicit channel_id, then the requested platform, then the
configured default channel. Returns (channel_id, service) where service is
None if we did not have to look the channel up.
"""
if payload.get("channel_id"):
return payload["channel_id"],None
if payload.get("platform"):
channel=await resolve_channel(payload["platform"])
return channel["id"],channel.get("service")
if self.channel_id:
return self.channel_id,None
raise HTTPException(
status_code=400,
detail="Provide channel_id or platform, or configure BUFFER_CHANNEL_ID",
)
async def post_job(self,payload,current_user):
try:
channel_id,service=await self._resolve_target(payload)
except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail=f"Failed to resolve Buffer channel: {e}") from e
text=render_job_post(payload)
fields={
"title":payload.get("title"),
"employment_type":payload.get("employment_type"),
"location":payload.get("location"),
"experience_min":payload.get("experience_min"),
"experience_max":payload.get("experience_max"),
"requirements":list(payload.get("requirements") or []),
"optional_skills":list(payload.get("optional_skills") or []),
"salary":payload.get("salary") or "Anonymous",
"description":payload.get("description"),
"post_text":text,
"channel_id":channel_id,
"status":"draft",
"created_by":current_user["id"],
}
# Only set platform when it is actually known: passing None would override the
# column default and break the NOT NULL constraint. Buffer's channelService
# replaces this with the authoritative value once the post is created.
known_platform=service or normalize_platform(payload.get("platform"))
if known_platform:
fields["platform"]=known_platform
row=await JobPosts.insert_job_post(self.session,fields)
try:
post=await create_buffer_post(
text,
channel_id,
mode=payload.get("mode") or "addToQueue",
due_at=payload.get("due_at"),
)
except (httpx.HTTPError,BufferError,RuntimeError) as e:
# Include the reason: Buffer's rejections are actionable (duplicate text,
# daily limit, disconnected channel) and an opaque 502 sends the caller
# digging through job_posts.buffer_error to find out.
await JobPosts.mark_failed(self.session,str(row.id),str(e))
raise HTTPException(status_code=502,detail=f"Failed to publish job post to Buffer: {e}") from e
# Buffer accepting the mutation is not the same as the network publishing it:
# the default addToQueue mode returns `scheduled`, so the row only reads
# "published" once Buffer reports `sent`.
saved=await JobPosts.mark_buffer_result(
self.session,
str(row.id),
buffer_post_id=post["id"],
status=local_status(post.get("status")),
external_link=post.get("externalLink"),
sent_at=parse_buffer_datetime(post.get("sentAt")),
platform=post.get("channelService"),
)
return serialize_job_post(saved)
async def list_channels(self):
try:
return await list_buffer_channels()
except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e

View File

@ -8,6 +8,9 @@ from db_setup import lifespan
from inbox.app import router as inbox_router
from users.app import router as users_router
from role.app import router as role_router
from forget_password.app import router as forget_password_router
from job.app import router as candidate_router
from notifications.app import router as confirmation_router
# Without this the db/migration logs have no handler and are swallowed under uvicorn.
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
@ -25,3 +28,6 @@ app.add_middleware(
app.include_router(inbox_router)
app.include_router(users_router)
app.include_router(role_router)
app.include_router(forget_password_router)
app.include_router(confirmation_router)
app.include_router(candidate_router)

View File

@ -0,0 +1,43 @@
from fastapi import APIRouter,Depends
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, EmailStr
from notifications.views import Confirmation
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
class ConfirmEmailRequest(BaseModel):
token: str
class ConfirmEmailResend(BaseModel):
email: EmailStr
@router.post("/users/confirm-email")
async def confirm_email(payload: ConfirmEmailRequest,session: AsyncSession = Depends(get_session)):
try:
service=Confirmation(session=session)
data=await service.confirm(payload.token)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/users/confirm-email/resend")
async def resend_confirm_email(payload: ConfirmEmailResend,session: AsyncSession = Depends(get_session)):
try:
service=Confirmation(session=session)
data=await service.resend(payload.email)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,102 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class EmailConfirmationTokens(SQLModel, table=True):
__tablename__ = "email_confirmation_tokens"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(index=True, foreign_key="users.id")
email: str = Field(index=True)
token_hash: str
expires_at: datetime = Field(sa_type=DateTime(timezone=True))
is_used: bool = Field(default=False)
confirmed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id: str) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_token_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def get_active_token_by_user(cls, session: AsyncSession, user_id: str):
"""Newest unused token for a user (expiry checked in Python by the service)."""
uid = cls._as_uuid(user_id)
if uid is None:
return None
statement = (
select(cls)
.where(cls.user_id == uid, cls.is_used == False) # noqa: E712
.order_by(cls.created_at.desc())
)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def insert_token(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_token_by_id(session, row.id)
@classmethod
async def mark_confirmed(cls, session: AsyncSession, record_id: str):
row = await cls.get_token_by_id(session, record_id)
if not row:
return None
row.is_used = True
row.confirmed_at = _now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def mark_used(cls, session: AsyncSession, record_id: str):
"""Retire a token without confirming it — used when the mail send fails."""
row = await cls.get_token_by_id(session, record_id)
if not row:
return None
row.is_used = True
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def invalidate_tokens_for_user(cls, session: AsyncSession, user_id: str):
uid = cls._as_uuid(user_id)
if uid is None:
return 0
statement = select(cls).where(cls.user_id == uid, cls.is_used == False) # noqa: E712
result = await session.execute(statement)
rows = result.scalars().all()
now = _now()
for row in rows:
row.is_used = True
row.updated_at = now
session.add(row)
await session.commit()
return len(rows)

View File

@ -0,0 +1,115 @@
"""Confirmation helpers — token generation, hashing, link building, and Teams mail send.
Pure module: no FastAPI imports and no HTTPException.
`send_confirmation_mail` intentionally duplicates
`forget_password.plugins.send_reset_mail` rather than importing it: that helper is
domain-named, and each domain owns its own mail copy and its own env reads.
"""
from __future__ import annotations
import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import httpx
from dotenv import load_dotenv
from users.plugins import hash_password, verify_password
load_dotenv()
TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL")
TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
CONFIRM_EMAIL_PATH = os.getenv("CONFIRM_EMAIL_PATH", "/auth/confirm-email")
CONFIRM_TOKEN_TTL_SECONDS = int(os.getenv("CONFIRM_TOKEN_TTL_SECONDS", "86400"))
CONFIRM_TOKEN_RESEND_SECONDS = int(os.getenv("CONFIRM_TOKEN_RESEND_SECONDS", "60"))
MAIL_ACCEPTED_STATUS = 202
# 32 bytes -> 43 url-safe characters, well under users.plugins.BCRYPT_MAX_BYTES.
TOKEN_SECRET_BYTES = 32
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def confirmation_expiry(*, now: datetime | None = None) -> datetime:
return (now or now_utc()) + timedelta(seconds=CONFIRM_TOKEN_TTL_SECONDS)
def generate_token_secret() -> str:
return secrets.token_urlsafe(TOKEN_SECRET_BYTES)
def hash_token(secret: str) -> str:
return hash_password(secret)
def verify_token(secret: str, token_hash: str) -> bool:
return verify_password(secret, token_hash)
def compose_token(record_id, secret: str) -> str:
"""Link token. A bcrypt hash cannot be looked up, so the row id rides along."""
return f"{record_id}.{secret}"
def split_token(token: str) -> tuple[str | None, str | None]:
"""('<uuid>','<secret>') or (None,None). token_urlsafe never emits a dot."""
if not token or "." not in token:
return None, None
record_id, secret = token.split(".", 1)
if not record_id or not secret:
return None, None
try:
uuid.UUID(record_id)
except ValueError:
return None, None
return record_id, secret
def build_confirmation_link(token: str) -> str:
base = FRONTEND_URL.rstrip("/")
path = CONFIRM_EMAIL_PATH if CONFIRM_EMAIL_PATH.startswith("/") else f"/{CONFIRM_EMAIL_PATH}"
return f"{base}{path}?token={quote(token, safe='')}"
def render_confirmation_email(link: str, ttl: int) -> tuple[str, str]:
hours = max(1, ttl // 3600)
subject = "Confirm your TalentFlow account"
html = (
"<p>Welcome to TalentFlow. Confirm your email address to activate your account.</p>"
f'<p><a href="{link}">Confirm my email</a></p>'
f"<p>This link expires in {hours} hour(s). If you did not sign up, ignore this email.</p>"
f"<p>If the link does not open, paste this into your browser:<br>{link}</p>"
)
return subject, html
async def send_confirmation_mail(to_email: str, subject: str, html: str) -> None:
if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN:
raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set")
fields = [
("subject", (None, subject)),
("body", (None, html)),
("content_type", (None, "html")),
("save_to_sent_items", (None, "false")),
("to", (None, to_email)),
]
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
TEAMS_MAIL_API_URL,
files=fields,
headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"},
)
if response.status_code != MAIL_ACCEPTED_STATUS:
raise httpx.HTTPStatusError(
response.text,
request=response.request,
response=response,
)

View File

@ -0,0 +1,20 @@
from notifications.plugins import CONFIRM_TOKEN_RESEND_SECONDS,CONFIRM_TOKEN_TTL_SECONDS
def serialize_confirmation_request(email: str,expires_at) -> dict:
return {
"email": email,
"confirmation_sent": True,
"expires_at": expires_at.isoformat() if expires_at else None,
"expires_in": CONFIRM_TOKEN_TTL_SECONDS,
"resend_after": CONFIRM_TOKEN_RESEND_SECONDS,
}
def serialize_confirmation_result(user,*,already_confirmed: bool = False) -> dict:
"""Deliberately narrow: this is an unauthenticated response, so no role or permissions."""
return {
"email": user.email,
"is_active": user.is_active,
"already_confirmed": already_confirmed,
}

View File

@ -0,0 +1,94 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import httpx
from notifications.models import EmailConfirmationTokens
from notifications.plugins import (
CONFIRM_TOKEN_RESEND_SECONDS,
CONFIRM_TOKEN_TTL_SECONDS,
build_confirmation_link,
compose_token,
confirmation_expiry,
generate_token_secret,
hash_token,
now_utc,
render_confirmation_email,
send_confirmation_mail,
split_token,
verify_token,
)
from notifications.serializers import serialize_confirmation_request,serialize_confirmation_result
from users.models import Users
class Confirmation:
def __init__(self,session:AsyncSession):
self.session=session
async def send_confirmation(self,user):
"""Issue a fresh token for an already committed Users row and mail the link."""
await EmailConfirmationTokens.invalidate_tokens_for_user(self.session,str(user.id))
secret=generate_token_secret()
expires_at=confirmation_expiry()
row=await EmailConfirmationTokens.insert_token(self.session,{
"user_id":user.id,
"email":user.email,
"token_hash":hash_token(secret),
"expires_at":expires_at,
})
link=build_confirmation_link(compose_token(row.id,secret))
subject,html=render_confirmation_email(link,CONFIRM_TOKEN_TTL_SECONDS)
try:
await send_confirmation_mail(user.email,subject,html)
except (httpx.HTTPError,RuntimeError) as e:
await EmailConfirmationTokens.mark_used(self.session,str(row.id))
raise HTTPException(status_code=502,detail="Failed to send confirmation email") from e
return serialize_confirmation_request(user.email,expires_at)
async def confirm(self,token):
record_id,secret=split_token(token)
if not record_id:
raise HTTPException(status_code=400,detail="Invalid confirmation link")
row=await EmailConfirmationTokens.get_token_by_id(self.session,record_id)
if not row or not verify_token(secret,row.token_hash):
raise HTTPException(status_code=400,detail="Invalid confirmation link")
user=await Users.get_user_by_id(self.session,str(row.user_id))
if not user or user.is_deleted:
raise HTTPException(status_code=404,detail="User not found")
# Mail clients, link scanners and the back button all replay this link.
if row.is_used:
if row.confirmed_at and user.is_active:
return serialize_confirmation_result(user,already_confirmed=True)
raise HTTPException(status_code=400,detail="This confirmation link is no longer valid. Request a new one.")
if row.expires_at<=now_utc():
raise HTTPException(status_code=400,detail="Confirmation link has expired. Request a new one.")
if user.is_active:
await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id))
return serialize_confirmation_result(user,already_confirmed=True)
updated=await Users.update_user(self.session,str(user.id),{"is_active":True})
await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id))
return serialize_confirmation_result(updated)
async def resend(self,email):
user=await Users.get_user_by_email(self.session,email)
if not user or user.is_deleted:
raise HTTPException(status_code=404,detail="No account found for this email")
if user.is_active:
raise HTTPException(status_code=400,detail="This account is already confirmed")
active=await EmailConfirmationTokens.get_active_token_by_user(self.session,str(user.id))
if active:
age=(now_utc()-active.created_at).total_seconds()
if age<CONFIRM_TOKEN_RESEND_SECONDS and active.expires_at>now_utc():
raise HTTPException(status_code=429,detail="Please wait before requesting another confirmation email")
return await self.send_confirmation(user)

View File

@ -24,5 +24,5 @@ PyJWT==2.10.1 # access/refresh token encode+decode in users/plugins
python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.py
# --- other -----------------------------------------------------------------
httpx==0.28.1 # Graph email calls in inbox/views.py
httpx==0.28.1 # Graph email (inbox) + Teams mail send (forget_password/plugins.py)
bcrypt==5.0.0 # password hashing in users/plugins.py

View File

@ -22,6 +22,12 @@ class UserCreate(BaseModel):
is_active: bool = True
class UserSignup(BaseModel):
name: str
email: EmailStr
password: str
class UserUpdate(BaseModel):
name: str | None = None
email: EmailStr | None = None
@ -62,6 +68,19 @@ async def login(payload: UserLogin,session: AsyncSession = Depends(get_session))
raise HTTPException(status_code=500,detail=str(e))
@router.post("/users/signup")
async def signup(payload: UserSignup,session: AsyncSession = Depends(get_session)):
try:
service=User(session=session)
user=await service.signup_user(payload.model_dump())
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
return JSONResponse(content={**tokens,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/users/refresh")
async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)):
try:

View File

@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select
from role.models import Roles
from job.job_post.models import JobPosts
class Users(SQLModel, table=True):
__tablename__ = "users"
@ -17,10 +17,17 @@ class Users(SQLModel, table=True):
email: str = Field(unique=True)
role_id: int | None = Field(nullable=True, foreign_key="roles.id")
role: Roles | None = Relationship(back_populates="users")
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
job_posts: list[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
)
password: str
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
is_active: bool = Field(default=True)
is_active: bool = Field(default=False)
is_deleted: bool = Field(default=False)
@classmethod

View File

@ -194,6 +194,7 @@ def require_permission(*required: PermissionTag, require_all: bool = True):
async def dependency(current_user: CurrentUser) -> dict:
if current_user.get("role_id") is None:
raise HTTPException(status_code=403, detail="User has no role assigned")
granted = current_user.get("permissions") or []
if not has_permission(granted, *required, require_all=require_all):
if require_all and len(required) == 1:

View File

@ -28,7 +28,9 @@ JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
RESET_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_RESET_TOKEN_EXPIRE_MINUTES", "10"))
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60
RESET_TOKEN_EXPIRE_SECONDS = RESET_TOKEN_EXPIRE_MINUTES * 60
def _encode(raw: str) -> bytes:
@ -112,6 +114,15 @@ def create_refresh_token(user) -> str:
)
def create_reset_token(email: str, *, code_id: str) -> str:
return _create_token(
email,
token_type="reset",
expires_delta=timedelta(minutes=RESET_TOKEN_EXPIRE_MINUTES),
claims={"crid": code_id},
)
def decode_token(token: str, *, expected_type: str) -> dict:
payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM])
if payload.get("type") != expected_type:

View File

@ -1,5 +1,6 @@
from fastapi import HTTPException
from role.models import Roles
from notifications.views import Confirmation
from role.models import EnumRoles,Roles
from users.models import Users
from users.permissions import PermissionTag,has_permission
from users.serializers import serialize_user
@ -17,6 +18,8 @@ class User:
async def _check_role_assignment(self,current_user,role_id,existing_role_id=None):
if role_id==existing_role_id:
return
#"Take the current user's permission list. Check if rbac_users.manage is in it. If it is not → reject with 403."
if not has_permission(current_user.get("permissions") or [],PermissionTag.RBAC_USERS_MANAGE):
raise HTTPException(status_code=403,detail="Assigning a role requires rbac_users.manage")
if role_id is None:
@ -42,6 +45,21 @@ class User:
raise HTTPException(status_code=400,detail="Password is required")
return await Users.insert_user(self.session,fields)
async def signup_user(self,payload):
existing=await Users.get_user_by_email(self.session,payload.get("email"))
if existing:
raise HTTPException(status_code=409,detail="Email already registered")
fields=clean_user_payload(payload)
if not fields.get("password"):
raise HTTPException(status_code=400,detail="Password is required")
role=await Roles.get_role_by_name(self.session,EnumRoles.CANDIDATE.value)
fields["role_id"]=role.id if role else 8
user=await Users.insert_user(self.session,fields)
# Signup lands inactive; the mailed link is what flips is_active.
service=Confirmation(session=self.session)
await service.send_confirmation(user)
return user
async def get_users(self,top,skip,search=None):
users=await Users.get_users(self.session,top,skip,search)
return [serialize_user(u) for u in users]
@ -56,6 +74,7 @@ class User:
user=await Users.get_user_by_id(self.session,record_id)
if not user:
raise HTTPException(status_code=404,detail="User not found")
# this is for password hashing and dehashing
fields=clean_user_payload(payload,partial=True)
email=fields.get("email")
if email and email!=user.email:
@ -98,8 +117,10 @@ class User:
detail="Incorrect email or password",
headers={"WWW-Authenticate":"Bearer"},
)
if user.is_deleted or not user.is_active:
if user.is_deleted:
raise HTTPException(status_code=401,detail="User is inactive")
if not user.is_active:
raise HTTPException(status_code=401,detail="Please confirm your email address to activate your account")
return await Users.get_user_by_id(self.session,user.id)
async def refresh_access_token(self,refresh_token):

View File

@ -1,36 +0,0 @@
#!/usr/bin/env python3
"""Static dev server for the ATS dashboard.
Identical to `python3 -m http.server` except it disables caching. The stdlib
server answers conditional requests from Last-Modified, which has one-second
granularity so a file edited twice within the same second keeps serving the
stale copy and the browser never sees the change.
"""
import sys
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def send_header(self, keyword, value):
# Drop the validator entirely so conditional GETs can't 304.
if keyword.lower() == "last-modified":
return
super().send_header(keyword, value)
def log_message(self, fmt, *args):
pass
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 4173
directory = sys.argv[2] if len(sys.argv) > 2 else "."
handler = partial(NoCacheHandler, directory=directory)
print(f"Serving {directory} on http://localhost:{port} (no-cache)")
ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
{
"id": "e0ffe4a1-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
"name": "Buffer API (fill in your key)",
"values": [
{
"key": "buffer_api_url",
"value": "https://api.buffer.com",
"type": "default",
"enabled": true
},
{
"key": "buffer_token",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "org_id",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "channel_id",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "post_id",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "sent_post_id",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "queued_post_id",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "idea_id",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "posts_cursor",
"value": "",
"type": "default",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}

View File

@ -0,0 +1,185 @@
# Buffer API — working collection
Buffer's public API is **GraphQL, one endpoint, POST only**:
```
POST https://api.buffer.com
Authorization: Bearer <BUFFER_API>
Content-Type: application/json
```
There are no REST paths. The operation is decided entirely by the GraphQL document in the
body. Docs: <https://developers.buffer.com/guides> · Explorer: <https://developers.buffer.com/explorer.html>
## Files
| File | What it is |
|---|---|
| `Buffer-API.postman_collection.json` | 38 requests in 9 folders. Import into Postman/Insomnia/Bruno. |
| `Buffer-API.postman_environment.json` | Empty environment template — safe to commit. |
| `Buffer-API.postman_environment.local.json` | Same, pre-filled with the key + ids from `backend/.env`. **Gitignored — do not commit.** |
## Setup
1. Import the collection **and** `Buffer-API.postman_environment.local.json`, then select
that environment. (Or import the plain template and paste `BUFFER_API` from
`backend/.env` into `buffer_token`.)
2. Run **01 · Get Organizations** → fills `{{org_id}}`.
3. Run **02 · Get Channels** → fills `{{channel_id}}`.
Everything else works from there. Test scripts chain the ids for you:
| Variable | Filled by | Used by |
|---|---|---|
| `org_id` | 01 · Get Organizations | almost everything |
| `channel_id` | 02 · Get Channels | all create requests |
| `post_id` | 03 · Get Posts, every create request | Get Post by ID, Edit Post, **Delete Post** |
| `sent_post_id` | 03 · Get Sent Posts | 06 · Get Post Metrics |
| `queued_post_id` | 03 · Get Scheduled Posts, 04 · Add to Queue | 04 · Move Post in Queue |
| `posts_cursor` | 03 · Get Posts | 03 · Get Posts — Next Page |
So **Delete Post** always targets the last post you touched.
## The endpoints you asked for
| Need | Folder / request | Where the value is |
|---|---|---|
| **org_id** | 01 · Get Organizations | `data.account.organizations[].id` |
| **channel_id** | 02 · Get Channels | `data.channels[].id` |
| **create a post** | 04 · Create Post · … | `data.createPost``PostActionSuccess.post.id` |
| **delete a post** | 04 · Delete Post | `data.deletePost``DeletePostSuccess.id` |
| **list posts** | 03 · Get Posts | `data.posts.edges[].node` |
| **one post** | 03 · Get Post by ID | `data.post` |
| **edit a post** | 04 · Edit Post | `editPost` (not `updatePost`) |
### org_id
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"query { account { id email organizations { id name channelCount } } }"}'
```
### channel_id
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"query GetChannels($input: ChannelsInput!) { channels(input: $input) { id name service type isDisconnected isQueuePaused } }",
"variables":{"input":{"organizationId":"'"$BUFFER_ORG_ID"'"}}}'
```
### create post
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { __typename ... on PostActionSuccess { post { id status dueAt } } ... on MutationError { message } } }",
"variables":{"input":{"channelId":"'"$BUFFER_CHANNEL_ID"'","text":"Hello","schedulingType":"automatic","mode":"addToQueue","assets":[]}}}'
```
### delete post
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"mutation DeletePost($input: DeletePostInput!) { deletePost(input: $input) { __typename ... on DeletePostSuccess { id } ... on MutationError { message } } }",
"variables":{"input":{"id":"POST_ID"}}}'
```
### list posts
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"query GetPosts($first: Int, $after: String, $input: PostsInput!) { posts(first: $first, after: $after, input: $input) { edges { cursor node { id text status dueAt sentAt channelId externalLink } } pageInfo { hasNextPage endCursor } } }",
"variables":{"first":20,"input":{"organizationId":"'"$BUFFER_ORG_ID"'","filter":{"status":["scheduled"]}}}}'
```
## `.env` mapping
| `.env` key | Collection variable | Notes |
|---|---|---|
| `BUFFER_API` | `buffer_token` | The personal access token. Buffer → Settings → API. |
| `BUFFER_API_URL` | `buffer_api_url` | `https://api.buffer.com` — correct as-is. |
| `BUFFER_CHANNEL_ID` | `channel_id` | Currently the LinkedIn profile `ahmedmujtababaig`. |
| — | `org_id` | **Not in `.env`.** `CLIENT_ID` in `backend/.env` holds this value, but it is the *organization id*, not an OAuth client id — the naming is misleading. Consider renaming it to `BUFFER_ORG_ID`. |
`plugins.py` re-derives the org id on every `list_buffer_channels()` call, so nothing is
broken today; caching it in `BUFFER_ORG_ID` would save one round trip per request.
## Enums worth memorising
| Enum | Values |
|---|---|
| `ShareMode` (`mode`) | `addToQueue` · `shareNext` · `shareNow` · `customScheduled` |
| `SchedulingType` | `automatic` (Buffer publishes) · `notification` (Buffer reminds you) |
| `PostStatus` | `draft` · `needs_approval` · `scheduled` · `sending` · `sent` · `error` |
| `PostSortableKey` | `dueAt` · `createdAt` **only** |
| `SortDirection` | `asc` · `desc` |
| `QueuePosition` | `top` · `bottom` |
| `Service` | `linkedin` `twitter` `facebook` `instagram` `tiktok` `threads` `youtube` `pinterest` `mastodon` `bluesky` `googlebusiness` `startPage` |
| `PostMetricType` | `impressions` `reach` `reactions` `likes` `comments` `shares` `reposts` `quotes` `clicks` `saves` `follows` `views` `viewers` `totalTimeWatched` `engagementRate` `postCount` |
## Gotchas that cost real time
- **Errors come back as HTTP 200.** Check `errors[]` and `__typename`, not the status code.
- **Do not request `totalCount` on `posts`** — API-key auth gets `FORBIDDEN` and the whole
query returns `data: null`.
- The edit mutation is **`editPost`**, not `updatePost`.
- `deletePost` returns **`DeletePostSuccess`**, not `PostActionSuccess`. A blanket
`... on PostActionSuccess` fragment silently matches nothing.
- `schedulingType` is *not* the queue mode. `automatic` vs `notification` only. The queue
mode is `mode`.
- `mode: customScheduled` requires `dueAt` (ISO 8601 UTC). `mode: shareNow` publishes
immediately with no undo.
- `assets` URLs are fetched **server-side** — they must return raw bytes, not an HTML page.
- `metadata.<service>.linkAttachment` and a non-empty `assets` array are mutually exclusive.
- LinkedIn `linkAttachment` only accepts `{ url }`; there is no title/description override.
- There is **no `deleteIdea` mutation** — ideas created via the API must be removed in the UI.
- `movePostInQueue` only accepts posts whose `shareMode` is `addToQueue`/`shareNext`. Drafts
and `customScheduled` posts give
`VoidMutationError: Only queued posts can be moved within the queue`.
## Free-plan limits hit while testing this
- **100 requests / 15 min**, 250 / day, 3000 / 30 days. A full Collection Runner pass is
~38 calls, so two back-to-back runs trip the 15-minute window
(HTTP 429, `RATE_LIMIT_EXCEEDED`, `extensions.window: "15m"`, plus `Retry-After`).
Every response carries `ratelimit` / `ratelimit-policy` headers.
- **Insights are capped at the last 31 days.** A wider `aggregatedPostMetrics` window
returns `BAD_USER_INPUT`.
- **LinkedIn `firstComment` is paid-only**`InvalidInputError` on Free.
- **`needsApproval: true`** is rejected unless the channel has an approval posting policy.
- Daily posting limit on the connected channel is 50/day (`dailyPostingLimits`).
## Error codes
`extensions.code` on top-level `errors[]`: `UNAUTHORIZED` · `FORBIDDEN` · `NOT_FOUND` ·
`BAD_USER_INPUT` · `GRAPHQL_VALIDATION_FAILED` · `RATE_LIMIT_EXCEEDED` · `UNEXPECTED`.
Mutation union error members: `InvalidInputError` · `LimitReachedError` · `NotFoundError` ·
`UnauthorizedError` · `RestProxyError` · `UnexpectedError` — all implement the
`MutationError` interface, so `... on MutationError { message }` catches every one,
including ones Buffer adds later.
## Verification
Every request in the collection was executed against the live API on 2026-08-05 using the
key in `backend/.env`: **38/38 pass.**
Two of those (**Share Now**, **Create Idea**) were validated document-only — sent with a
deliberately invalid id so the server still parses and validates the GraphQL but cannot
execute it — because one publishes to the real LinkedIn account and the other creates
something the API has no mutation to delete. **Create Post · Needs Approval** returns
`InvalidInputError` on this account: the query is correct, the channel just has no approval
policy.
Every post created during verification was deleted; the account is back to the same three
posts it had beforehand, and the pre-existing scheduled job ad still holds its original
`dueAt` slot.

View File

@ -0,0 +1,3 @@
# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and hits a
# different listener (WSL/Docker on :8000) instead of the Windows uvicorn on 127.0.0.1.
VITE_API_BASE=http://127.0.0.1:8000

7
frontend/.env.production Normal file
View File

@ -0,0 +1,7 @@
# The API origin for a production build.
#
# This previously pointed at http://localhost:8000, which meant a production
# bundle called the *user's own machine*. Empty means same-origin requests, which
# works behind a reverse proxy that fronts both the bundle and the API. Set the
# real API origin here if the two are served from different hosts.
VITE_API_BASE=

31
frontend/index.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover lets the layout reach under the iOS notch/home bar;
the safe-area insets in styles.css keep content clear of them.
No maximum-scale/user-scalable — pinch-zoom must stay available. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<!-- Per-route titles are set at runtime by useRouteMeta. -->
<title>TalentFlow · Applicant Tracking System</title>
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric-
compatible stand-in for Neue Montreal, which is a licensed face.
If Neue Montreal is installed locally it wins via the CSS stack. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2987
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "hr-ats-portal",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:lan": "vite --host",
"build": "vite build",
"preview": "vite preview",
"smoke": "node smoke.test.mjs",
"test:token": "node token.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.5.0",
"esbuild": "^0.28.1",
"jsdom": "^30.0.1",
"vite": "^6.3.5"
}
}

131
frontend/smoke.test.mjs Normal file
View File

@ -0,0 +1,131 @@
/**
* Render smoke test mounts all 27 routes (23 app + 4 auth) into jsdom and
* fails on any thrown error, console.error, or empty render.
*
* npm run smoke
*
* Bundled with esbuild (not Vite's SSR loader) because several dependencies
* ship CJS and esbuild's interop handles that cleanly. The render itself lives
* in src/__smoke__/entry.jsx so it exercises the real component tree.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
import { JSDOM } from 'jsdom'
// ---------------------------------------------------------------- environment
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: 'http://localhost:5173/',
pretendToBeVisual: true,
})
globalThis.window = dom.window
globalThis.document = dom.window.document
// Node 24 defines `navigator` as a getter-only global.
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
globalThis.HTMLElement = dom.window.HTMLElement
globalThis.Element = dom.window.Element
globalThis.Node = dom.window.Node
globalThis.getComputedStyle = dom.window.getComputedStyle
globalThis.localStorage = dom.window.localStorage
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
globalThis.cancelAnimationFrame = clearTimeout
globalThis.IS_REACT_ACT_ENVIRONMENT = true
class RO { observe() {} unobserve() {} disconnect() {} }
class MO { observe() {} disconnect() {} takeRecords() { return [] } }
globalThis.ResizeObserver = RO
globalThis.MutationObserver = MO
dom.window.ResizeObserver = RO
dom.window.MutationObserver = MO
dom.window.matchMedia = () => ({
matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
})
// jsdom has no canvas backend; the retained chart engine only needs a context object.
dom.window.HTMLCanvasElement.prototype.getContext = () =>
new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) })
// A signed-in session holding all 104 permissions, so no route is gated away.
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users']
const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
dom.window.localStorage.setItem('tf-auth', JSON.stringify({
access_token: 'test', refresh_token: 'test', expires_in: 1800,
expires_at: Date.now() + 1800_000,
data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions },
}))
// The real-API screens must not hit the network here.
globalThis.fetch = async () => ({
ok: true, status: 200, statusText: 'OK',
text: async () => JSON.stringify({ data: [], status_code: 200 }),
})
// ---------------------------------------------------------------- bundle
const outDir = mkdtempSync(join(tmpdir(), 'tf-smoke-'))
const outFile = join(outDir, 'entry.mjs')
await esbuild.build({
entryPoints: ['src/__smoke__/entry.jsx'],
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
jsx: 'automatic',
loader: { '.js': 'jsx', '.jsx': 'jsx' },
logLevel: 'error',
define: {
'process.env.NODE_ENV': '"development"',
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
},
})
// ---------------------------------------------------------------- run
const errors = []
const origError = console.error
console.error = (...args) => {
const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ')
if (msg.includes('React Router Future Flag')) return // advisory, not a defect
errors.push(msg)
}
let failed = 0
try {
const mod = await import(pathToFileURL(outFile).href)
mod.boot()
for (const path of mod.ALL_ROUTES) {
errors.length = 0
const container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
try {
const text = (await mod.renderRoute(path, container)).trim()
if (errors.length) {
console.log(`FAIL ${path}\n ${errors[0].split('\n').slice(0, 3).join(' | ').slice(0, 260)}`)
failed++
} else if (text.length < 5) {
console.log(`FAIL ${path} (rendered empty)`)
failed++
} else {
console.log(`ok ${path} (${text.length} chars)`)
}
} catch (err) {
console.log(`FAIL ${path}\n ${String(err.message).split('\n')[0].slice(0, 260)}`)
failed++
} finally {
container.remove()
}
}
} finally {
console.error = origError
rmSync(outDir, { recursive: true, force: true })
}
console.log(failed ? `\n${failed}/27 routes FAILED` : `\nAll 27 routes rendered clean`)
process.exit(failed ? 1 : 0)

91
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,91 @@
import { lazy } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import AuthProvider from './auth/AuthProvider'
import RequireAuth from './auth/RequireAuth'
import AppLayout from './app/AppLayout'
import LegacyHashRedirect from './app/LegacyHashRedirect'
import { ROUTES } from './app/routes'
import Login from './pages/Login'
import Signup from './pages/Signup'
import ForgotPassword from './pages/ForgotPassword'
import ConfirmEmail from './pages/ConfirmEmail'
// Route-level code splitting: putting 23 screens in one bundle would make the
// first paint pay for every screen a user never opens.
const SCREENS = {
dashboard: lazy(() => import('./screens/Dashboard')),
inbox: lazy(() => import('./screens/Inbox')),
jobs: lazy(() => import('./screens/Jobs')),
candidates: lazy(() => import('./screens/Candidates')),
talentpool: lazy(() => import('./screens/TalentPool')),
pipeline: lazy(() => import('./screens/Pipeline')),
import: lazy(() => import('./screens/CvImport')),
jobboard: lazy(() => import('./screens/JobBoard')),
recruiterhub: lazy(() => import('./screens/RecruiterHub')),
tasks: lazy(() => import('./screens/Tasks')),
aiassistant: lazy(() => import('./screens/AiAssistant')),
interviews: lazy(() => import('./screens/Interviews')),
assessments: lazy(() => import('./screens/Assessments')),
offers: lazy(() => import('./screens/Offers')),
managers: lazy(() => import('./screens/Managers')),
calendar: lazy(() => import('./screens/Calendar')),
reports: lazy(() => import('./screens/Reports')),
analytics: lazy(() => import('./screens/Analytics')),
aistudio: lazy(() => import('./screens/AiStudio')),
notifications: lazy(() => import('./screens/Notifications')),
rbac: lazy(() => import('./screens/Rbac')),
settings: lazy(() => import('./screens/Settings')),
help: lazy(() => import('./screens/Help')),
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<LegacyHashRedirect />
<Routes>
{/* Public. These keep the /auth prefix as ROUTE paths so the backend's
CONFIRM_EMAIL_PATH=/auth/confirm-email links resolve unchanged. */}
<Route path="/auth" element={<Navigate to="/auth/login" replace />} />
<Route path="/auth/login" element={<Login />} />
<Route path="/auth/signup" element={<Signup />} />
<Route path="/auth/forgot-password" element={<ForgotPassword />} />
<Route path="/auth/confirm-email" element={<ConfirmEmail />} />
{/* Protected */}
<Route
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
{ROUTES.map((r) => {
const Screen = SCREENS[r.path]
return (
<Route
key={r.path}
path={`/${r.path}`}
element={
r.permission ? (
<RequireAuth permission={r.permission}>
<Screen />
</RequireAuth>
) : (
<Screen />
)
}
/>
)
})}
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
</BrowserRouter>
)
}

View File

@ -0,0 +1,113 @@
/* Test-only entry. Kept inside src/ so every import resolves through Vite's
module graph exactly as it does in the app one React instance, one router
instance, one query client. Not shipped: excluded from the build because
nothing in the app imports it. */
import React from 'react'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '../lib/queryClient'
import { initializeCache } from '../data/seedQueries'
import ThemeProvider, { initTheme } from '../theme/ThemeProvider'
import ToastProvider from '../ui/Toast'
import AuthProvider from '../auth/AuthProvider'
import AppLayout from '../app/AppLayout'
import RequireAuth from '../auth/RequireAuth'
import { ROUTES as TABLE } from '../app/routes'
import Login from '../pages/Login'
import Signup from '../pages/Signup'
import ForgotPassword from '../pages/ForgotPassword'
import ConfirmEmail from '../pages/ConfirmEmail'
import Dashboard from '../screens/Dashboard'
import Inbox from '../screens/Inbox'
import Jobs from '../screens/Jobs'
import Candidates from '../screens/Candidates'
import TalentPool from '../screens/TalentPool'
import Pipeline from '../screens/Pipeline'
import CvImport from '../screens/CvImport'
import JobBoard from '../screens/JobBoard'
import RecruiterHub from '../screens/RecruiterHub'
import Tasks from '../screens/Tasks'
import AiAssistant from '../screens/AiAssistant'
import Interviews from '../screens/Interviews'
import Assessments from '../screens/Assessments'
import Offers from '../screens/Offers'
import Managers from '../screens/Managers'
import Calendar from '../screens/Calendar'
import Reports from '../screens/Reports'
import Analytics from '../screens/Analytics'
import AiStudio from '../screens/AiStudio'
import Notifications from '../screens/Notifications'
import Rbac from '../screens/Rbac'
import Settings from '../screens/Settings'
import Help from '../screens/Help'
const SCREENS = {
dashboard: Dashboard, inbox: Inbox, jobs: Jobs, candidates: Candidates,
talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard,
recruiterhub: RecruiterHub, tasks: Tasks, aiassistant: AiAssistant,
interviews: Interviews, assessments: Assessments, offers: Offers,
managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics,
aistudio: AiStudio, notifications: Notifications, rbac: Rbac,
settings: Settings, help: Help,
}
const PAGES = {
'/auth/login': Login,
'/auth/signup': Signup,
'/auth/forgot-password': ForgotPassword,
'/auth/confirm-email': ConfirmEmail,
}
export const ALL_ROUTES = [
...Object.keys(PAGES),
...TABLE.map((r) => `/${r.path}`),
]
export function boot() {
initTheme()
initializeCache(queryClient)
}
/** Mount one route, wait for effects to settle, return its rendered text. */
export async function renderRoute(path, container) {
const h = React.createElement
const isAuth = path.startsWith('/auth/')
const def = TABLE.find((r) => `/${r.path}` === path)
const Screen = isAuth ? PAGES[path] : SCREENS[def.path]
const inner = isAuth
? h(Route, { path, element: h(Screen) })
: h(
Route,
{ element: h(RequireAuth, null, h(AppLayout)) },
h(Route, { path, element: h(Screen) }),
)
const tree = h(
QueryClientProvider, { client: queryClient },
h(ThemeProvider, null,
h(ToastProvider, null,
h(MemoryRouter, { initialEntries: [path] },
h(AuthProvider, null,
h(Routes, null, inner, h(Route, { path: '*', element: h(Navigate, { to: path, replace: true }) })),
),
),
),
),
)
const root = createRoot(container)
try {
await act(async () => { root.render(tree) })
await act(async () => { await new Promise((r) => setTimeout(r, 40)) })
return container.textContent || ''
} finally {
await act(async () => { root.unmount() })
}
}

View File

@ -0,0 +1,7 @@
/* Test-only entry exposing the token layer to the token test harness. */
export { request, setSessionExpiredHandler } from '../lib/apiClient'
export { refreshSession } from '../lib/refresh'
export {
setSession, getSession, clearSession, getAccessToken, getRefreshToken, isExpiring,
} from '../lib/tokenStore'
export { ApiError, SessionExpiredError } from '../lib/errors'

46
frontend/src/api/auth.js Normal file
View File

@ -0,0 +1,46 @@
/* Public auth endpoints. All are unauthenticated (`auth: false`) attaching a
stale bearer to a login request would be harmless but misleading in the
network log, and the reset flow must never trigger a refresh. */
import { request } from '../lib/apiClient'
const pub = { auth: false }
export function login(email, password) {
return request('/users/login', { ...pub, method: 'POST', body: { email, password } })
}
export function signup(name, email, password) {
return request('/users/signup', { ...pub, method: 'POST', body: { name, email, password } })
}
export function confirmEmail(token) {
return request('/users/confirm-email', { ...pub, method: 'POST', body: { token } })
}
export function resendConfirmEmail(email) {
return request('/users/confirm-email/resend', { ...pub, method: 'POST', body: { email } })
}
export function forgetPassword(email) {
return request('/users/forget-password', { ...pub, method: 'POST', body: { email } })
}
export function verifyForgetCode(email, code) {
return request('/users/forget-password/verify-code', {
...pub,
method: 'POST',
body: { email, code },
})
}
export function setNewPassword(password, resetToken) {
// `token` is the 10-minute reset JWT, carried in its own Authorization header
// and checked by forget_password/permissions.py's HTTPBearer. Passing `token`
// explicitly also suppresses the refresh path, which is what we want: a reset
// token is not a session.
return request('/users/forget-password/new-password', {
method: 'POST',
body: { password },
token: resetToken,
})
}

17
frontend/src/api/inbox.js Normal file
View File

@ -0,0 +1,17 @@
import { request } from '../lib/apiClient'
/**
* Persisted mailbox messages.
*
* NOTE: this endpoint currently has no auth dependency server-side
* (backend/inbox/app.py). We send the bearer anyway, so adding
* Depends(get_current_user) later is a zero-diff change on this side.
*/
export function listMessages() {
return request('/inbox/fetch')
}
/** Triggers the Graph proxy to pull new mail and persist it. */
export function syncMailbox({ token, top, skip } = {}) {
return request('/email/fetch', { params: { token, top, skip } })
}

31
frontend/src/api/roles.js Normal file
View File

@ -0,0 +1,31 @@
import { request } from '../lib/apiClient'
/** Roles with their expanded `bundles` and resolved `effective_permissions`. */
export function listRoles() {
return request('/roles/fetch')
}
export function createRole(body) {
return request('/roles/create', { method: 'POST', body })
}
export function updateRole(recordId, body) {
return request('/roles/update', { method: 'PUT', params: { record_id: recordId }, body })
}
export function deleteRole(recordId) {
return request('/roles/delete', { method: 'DELETE', params: { record_id: recordId } })
}
/** Permission bundles (41 seeded), each resolving to a set of tag names. */
export function listPermissions() {
return request('/permissions/fetch')
}
export function createPermission(body) {
return request('/permissions/create', { method: 'POST', body })
}
export function updatePermission(recordId, body) {
return request('/permissions/update', { method: 'PUT', params: { record_id: recordId }, body })
}
/** The 104-tag catalog: 13 modules x 8 actions. */
export function listPermissionTags() {
return request('/permission-tags/fetch')
}

34
frontend/src/api/users.js Normal file
View File

@ -0,0 +1,34 @@
import { request } from '../lib/apiClient'
/** The ONLY endpoint that returns `permissions`. Login and refresh do not. */
export function me() {
return request('/users/me')
}
export function list({ record_id, search, top, skip } = {}) {
return request('/users/fetch', { params: { record_id, search, top, skip } })
}
export function create(body) {
return request('/users/create', { method: 'POST', body })
}
export function update(recordId, body) {
return request('/users/update', { method: 'PUT', params: { record_id: recordId }, body })
}
export function assignRole(recordId, roleId) {
return request('/users/assign-role', {
method: 'PUT',
params: { record_id: recordId },
body: { role_id: roleId },
})
}
export function removeRole(recordId) {
return request('/users/remove-role', { method: 'PUT', params: { record_id: recordId } })
}
export function remove(recordId) {
return request('/users/delete', { method: 'DELETE', params: { record_id: recordId } })
}

View File

@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom'
import Chat from './ai/Chat'
import Icon from '../ui/icons'
/** The slide-over dock — the same chat as the full page, in compact mode. */
export default function AiDock({ open, onClose }) {
const navigate = useNavigate()
return (
<div className={`ai-dock${open ? ' open' : ''}`} id="aiDock">
<div className="ai-dock-inner">
{open && (
<>
<div className="card-head" style={{ borderRadius: 0 }}>
<div>
<h3><Icon name="sparkles" /> AI Assistant</h3>
</div>
<div className="flex items-center gap-8">
<button
className="btn btn-ghost btn-sm"
onClick={() => {
onClose()
navigate('/aiassistant')
}}
>
Expand
</button>
<button className="modal-close" onClick={onClose} aria-label="Close">
<Icon name="x" />
</button>
</div>
</div>
<div style={{ flex: 1, padding: 16, overflow: 'hidden', display: 'flex' }}>
<Chat compact />
</div>
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,75 @@
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import Sidebar from './Sidebar'
import Topbar from './Topbar'
import AiDock from './AiDock'
import { ROUTE_BY_PATH } from './routes'
import { useBadges, useHotkeys, useNavOpen, useRouteMeta, useSidebarCollapsed } from './useShell'
import Icon from '../ui/icons'
import Spinner from '../components/Spinner'
export default function AppLayout() {
const location = useLocation()
const contentRef = useRef(null)
const [navOpen, setNavOpen] = useNavOpen()
const [collapsed, toggleCollapsed] = useSidebarCollapsed()
const [dockOpen, setDockOpen] = useState(false)
const badges = useBadges()
const routeKey = location.pathname.split('/')[1] || 'dashboard'
const route = ROUTE_BY_PATH[routeKey]
useRouteMeta(route)
const onEscape = useCallback(() => {
setNavOpen(false)
setDockOpen(false)
}, [setNavOpen])
const searchRef = useHotkeys({ onEscape })
// Navigating closes the drawer and the dock, and resets scroll the three
// things Router.render did at the end of every route change (js/app.js:44-51).
useEffect(() => {
setNavOpen(false)
setDockOpen(false)
if (contentRef.current) contentRef.current.scrollTop = 0
}, [location.pathname, setNavOpen])
return (
<div id="app">
<Sidebar
collapsed={collapsed}
mobileOpen={navOpen}
onToggleCollapse={toggleCollapsed}
badges={badges}
/>
<div className="main-wrap">
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} />
<main className="content" id="main-content" ref={contentRef}>
<Suspense fallback={<div className="route-loading"><Spinner label="Loading" /></div>}>
<Outlet />
</Suspense>
</main>
</div>
<button
className="ai-fab"
onClick={() => setDockOpen(true)}
title="AI Recruiter Assistant"
aria-label="Open AI Assistant"
>
<Icon name="sparkles" />
</button>
<AiDock open={dockOpen} onClose={() => setDockOpen(false)} />
<div
className={`scrim${navOpen ? ' open' : ''}`}
onClick={() => setNavOpen(false)}
aria-hidden="true"
/>
</div>
)
}

View File

@ -0,0 +1,100 @@
/* Global search App.search from js/app.js:171-191. Same sources and the same
4/4/3 caps. The inline onclick="App.searchGo(...)" strings become navigate()
calls, and the setTimeout(cb, 120) hack that waited for the old router to
swap innerHTML is gone: the target screen reads `state.open` instead. */
import { useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { seedQuery } from '../data/seedQueries'
import { Avatar, Icon } from '../ui/primitives'
export default function GlobalSearch({ inputRef }) {
const navigate = useNavigate()
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const boxRef = useRef(null)
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const results = useMemo(() => {
const term = q.trim().toLowerCase()
if (!term) return null
return {
jobs: jobs.filter((j) => (j.title + j.id + j.department).toLowerCase().includes(term)).slice(0, 4),
candidates: candidates.filter((c) => (c.name + c.email + c.jobTitle).toLowerCase().includes(term)).slice(0, 4),
managers: managers.filter((m) => m.name.toLowerCase().includes(term)).slice(0, 3),
}
}, [q, jobs, candidates, managers])
function go(path, state) {
setQ('')
setOpen(false)
navigate(path, { state })
}
const empty = results && !results.jobs.length && !results.candidates.length && !results.managers.length
return (
<div className="topbar-search" onClick={(e) => e.stopPropagation()}>
<Icon name="search" />
<input
type="text"
ref={inputRef}
value={q}
placeholder="Search jobs, candidates, managers…"
autoComplete="off"
onChange={(e) => {
setQ(e.target.value)
setOpen(Boolean(e.target.value.trim()))
}}
onFocus={() => setOpen(Boolean(q.trim()))}
/>
<div className={`search-results${open && results ? ' open' : ''}`} ref={boxRef}>
{results && (
<>
{results.jobs.length > 0 && <div className="search-group-label">Jobs</div>}
{results.jobs.map((j) => (
<div key={j.id} className="search-item" onClick={() => go('/jobs', { openJob: j.id })}>
<span className="kpi-icn i-indigo" style={{ width: 32, height: 32, borderRadius: 8 }}>
<Icon name="briefcase" />
</span>
<div>
<div className="si-title">{j.title}</div>
<div className="si-sub">{j.id} · {j.department}</div>
</div>
</div>
))}
{results.candidates.length > 0 && <div className="search-group-label">Candidates</div>}
{results.candidates.map((c) => (
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="si-title">{c.name}</div>
<div className="si-sub">{c.jobTitle}</div>
</div>
</div>
))}
{results.managers.length > 0 && <div className="search-group-label">Hiring Managers</div>}
{results.managers.map((m) => (
<div key={m.id} className="search-item" onClick={() => go('/managers', { openManager: m.id })}>
<Avatar name={m.name} initials={m.initials} color={m.color} />
<div>
<div className="si-title">{m.name}</div>
<div className="si-sub">{m.title}</div>
</div>
</div>
))}
{empty && <div className="search-empty">No results for &ldquo;{q}&rdquo;</div>}
</>
)}
</div>
<kbd className="search-kbd">K</kbd>
</div>
)
}

View File

@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ROUTE_BY_PATH } from './routes'
/**
* The prototype addressed screens by `location.hash` (#dashboard, #candidates).
* Anyone with a bookmark and the old post-login redirect target
* `/index.html#dashboard` must not dead-end after the cutover.
*
* Runs once. StrictMode's double-invoke is harmless because the navigation is
* `replace` and the second run finds no hash left to act on.
*/
export default function LegacyHashRedirect() {
const navigate = useNavigate()
useEffect(() => {
const route = (window.location.hash || '').slice(1)
if (route && ROUTE_BY_PATH[route]) {
navigate(`/${route}`, { replace: true })
}
}, [navigate])
return null
}

View File

@ -0,0 +1,83 @@
import { NavLink } from 'react-router-dom'
import { NAV_GROUPS, ROUTES } from './routes'
import { useAuth } from '../auth/AuthContext'
import Icon from '../ui/icons'
import BrandMark from '../components/BrandMark'
export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) {
const { can } = useAuth()
// A group heading renders only if something under it survived the permission
// filter otherwise a low-privilege user sees orphaned section labels.
const visible = ROUTES.filter((r) => can(r.permission))
return (
<aside
className={`sidebar${collapsed ? ' collapsed' : ''}${mobileOpen ? ' mobile-open' : ''}`}
id="sidebar"
>
<div className="sidebar-brand">
<div className="brand-logo">
<BrandMark />
</div>
<div className="brand-text">
<span className="brand-name">TalentFlow</span>
<span className="brand-sub">Utopia Brands · ATS</span>
</div>
<button
className="sidebar-collapse-btn"
onClick={onToggleCollapse}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<Icon name="chevron-left" />
</button>
</div>
<nav className="sidebar-nav">
{NAV_GROUPS.map((group) => {
const items = visible.filter((r) => r.group === group)
if (!items.length) return null
return (
<div key={group}>
<div className="nav-section-label">{group}</div>
{items.map((r) => {
const count = r.badge ? badges?.[r.badge] : null
return (
<NavLink
key={r.path}
to={`/${r.path}`}
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<Icon name={r.icon} />
<span>{r.title}</span>
{r.tag && <span className="nav-badge nav-badge-ai">{r.tag}</span>}
{count ? (
<span
className={`nav-badge${r.badge === 'inbox' || r.badge === 'notifications' ? ' nav-badge-alert' : ''}`}
>
{count}
</span>
) : null}
</NavLink>
)
})}
</div>
)
})}
</nav>
<div className="sidebar-footer">
<div className="usage-card">
<div className="usage-top">
<span>Seats used</span>
<span>14 / 20</span>
</div>
<div className="usage-bar">
<div className="usage-fill" style={{ width: '70%' }} />
</div>
</div>
</div>
</aside>
)
}

150
frontend/src/app/Topbar.jsx Normal file
View File

@ -0,0 +1,150 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Dropdown, { DropdownGroup } from '../ui/Dropdown'
import GlobalSearch from './GlobalSearch'
import { Avatar, Icon } from '../ui/primitives'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { useToast } from '../ui/Toast'
import { useTheme } from '../theme/ThemeProvider'
import { useAuth } from '../auth/AuthContext'
function initialsFromName(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '?'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
export default function Topbar({ onOpenNav, searchRef }) {
const { theme, toggleTheme } = useTheme()
const { user, signOut } = useAuth()
const { toast } = useToast()
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
const { data: messages = [] } = useQuery(seedQuery('messages'))
const updateNotifications = useSeedMutation('notifications')
// App.hydrateProfile's DOM sweep is gone the session is read directly.
const name = user?.name || 'Guest'
const email = user?.email || ''
const role = user?.role_name || user?.role || 'Member'
function markAllRead() {
updateNotifications((ns) => ns.map((n) => ({ ...n, unread: false })))
toast('All notifications marked as read', 'success')
}
return (
<header className="topbar">
<button className="icon-btn menu-toggle" onClick={onOpenNav} aria-label="Toggle menu">
<Icon name="menu" />
</button>
<GlobalSearch inputRef={searchRef} />
<div className="topbar-actions">
<button
className="icon-btn"
onClick={toggleTheme}
aria-pressed={theme === 'dark'}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
<span className="icon-sun"><Icon name="sun" /></span>
<span className="icon-moon"><Icon name="moon" /></span>
</button>
<DropdownGroup>
<Dropdown
panelClassName="dropdown-menu-wide"
trigger={({ toggle }) => (
<button className="icon-btn" onClick={toggle} title="Messages" aria-label="Messages">
<Icon name="message" />
<span className="dot dot-blue" />
</button>
)}
>
<div className="dropdown-head">Messages</div>
<div className="dd-scroll">
{messages.map((m) => (
<div key={m.id ?? m.name} className={`notif-row${m.unread ? ' unread' : ''}`}>
<Avatar name={m.name} initials={m.initials} color={m.color} />
<div className="notif-body">
<div className="notif-title">{m.name}</div>
<div className="notif-text">{m.text}</div>
<div className="notif-time">{m.time} ago</div>
</div>
</div>
))}
</div>
<div className="dropdown-foot">
<Link to="/notifications">Open inbox</Link>
</div>
</Dropdown>
<Dropdown
panelClassName="dropdown-menu-wide"
trigger={({ toggle }) => (
<button className="icon-btn" onClick={toggle} title="Notifications" aria-label="Notifications">
<Icon name="bell" />
<span className="dot dot-red" />
</button>
)}
>
<div className="dropdown-head">
Notifications
<button className="link-btn" onClick={markAllRead}>Mark all read</button>
</div>
<div className="dd-scroll">
{notifications.slice(0, 6).map((n) => (
<div key={n.id ?? n.title} className={`notif-row${n.unread ? ' unread' : ''}`}>
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
<div className="notif-body">
<div className="notif-title">{n.title}</div>
<div className="notif-text">{n.text}</div>
<div className="notif-time">{n.time}</div>
</div>
</div>
))}
</div>
<div className="dropdown-foot">
<Link to="/notifications">View all</Link>
</div>
</Dropdown>
<div className="topbar-divider" />
<Dropdown
trigger={({ toggle }) => (
<button className="profile-btn" onClick={toggle}>
<span className="avatar avatar-grad">{initialsFromName(name)}</span>
<span className="profile-meta">
<span className="profile-name">{name}</span>
<span className="profile-role">{role}</span>
</span>
<Icon name="chevron-down" className="chev" />
</button>
)}
>
<div className="dropdown-profile">
<span className="avatar avatar-grad avatar-lg">{initialsFromName(name)}</span>
<div>
<div className="dp-name">{name}</div>
<div className="dp-email">{email}</div>
</div>
</div>
<div className="dropdown-divider" />
<Link className="dropdown-link" to="/settings"><Icon name="user" />My Profile</Link>
<Link className="dropdown-link" to="/settings"><Icon name="settings" />Settings</Link>
<Link className="dropdown-link" to="/help"><Icon name="help" />Help Center</Link>
<div className="dropdown-divider" />
<button className="dropdown-link danger" onClick={signOut}>
<Icon name="logout" />Sign out
</button>
</Dropdown>
</DropdownGroup>
</div>
</header>
)
}

View File

@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Icon from '../../ui/icons'
import { reply } from './replies'
import { seedQuery } from '../../data/seedQueries'
import { aiPrompts } from '../../data/seed'
/** Shared by the full AI Assistant page and the slide-over dock. */
export default function Chat({ compact = false, resetKey = 0 }) {
const [messages, setMessages] = useState([])
const [value, setValue] = useState('')
const inputRef = useRef(null)
const scrollRef = useRef(null)
const timers = useRef([])
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
useEffect(() => setMessages([]), [resetKey])
useEffect(() => {
const list = timers.current
return () => list.forEach(clearTimeout)
}, [])
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}, [messages])
const send = useCallback(
(text) => {
const body = (text ?? value).trim()
if (!body) return
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
setMessages((m) => [...m, { id: `u-${id}`, role: 'you', text: body }, { id: `a-${id}`, role: 'ai', typing: true }])
setValue('')
if (inputRef.current) inputRef.current.style.height = 'auto'
// The prototype's artificial 850-1350ms latency, kept so the typing
// indicator is visible rather than flashing.
const t = setTimeout(() => {
setMessages((m) =>
m.map((msg) =>
msg.id === `a-${id}`
? { ...msg, typing: false, node: reply(body, { candidates, recruiters }) }
: msg,
),
)
}, 850 + Math.random() * 500)
timers.current.push(t)
},
[value, candidates, recruiters],
)
const started = messages.length > 0
return (
<div className="chat-wrap" style={compact ? { height: '100%' } : undefined}>
<div className="chat-scroll" ref={scrollRef}>
{!started ? (
<>
<div className="ai-hero">
<div className="ai-logo"><Icon name="sparkles" /></div>
<h2 style={{ fontSize: compact ? 18 : 22, marginBottom: 6 }}>AI Recruiter Assistant</h2>
<p className="text-muted">Ask anything about your candidates, jobs, and pipeline</p>
</div>
<div
style={{
display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center',
maxWidth: 720, margin: '0 auto 10px',
}}
>
{aiPrompts.slice(0, compact ? 6 : 12).map((p) => (
<button key={p.text} className="prompt-chip" onClick={() => send(p.prompt)}>
<Icon name={p.icon} /> {p.text}
</button>
))}
</div>
</>
) : (
messages.map((m) => (
<div className="chat-msg" key={m.id}>
<div className={`chat-av ${m.role === 'you' ? 'user' : 'ai'}`}>
<Icon name={m.role === 'you' ? 'users' : 'sparkles'} />
</div>
<div className="chat-bubble">
<div className="chat-role">{m.role === 'you' ? 'You' : 'AI Assistant'}</div>
{m.typing ? (
<div className="chat-typing"><span /><span /><span /></div>
) : (
<div className="chat-text">{m.text ?? m.node}</div>
)}
</div>
</div>
))
)}
</div>
<div style={{ paddingTop: 12 }}>
<div className="chat-input-bar">
<textarea
ref={inputRef}
rows={1}
value={value}
placeholder="Message AI Assistant…"
onChange={(e) => {
setValue(e.target.value)
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 140)}px`
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
/>
<button className="chat-send" onClick={() => send()} aria-label="Send">
<Icon name="arrow-right" />
</button>
</div>
<p className="text-muted text-sm" style={{ textAlign: 'center', marginTop: 8 }}>
UI preview · responses are simulated. <Icon name="lock" /> API-ready for backend integration.
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,185 @@
/* ============================================================
replies.jsx the keyword-matched canned responses from js/aiassistant.js.
These were HTML strings assembled with innerHTML. They are JSX now, which is
the whole point: the prototype's chat was also the one place anyone had
thought about escaping, and they did it partially (`text.replace(/</g,'&lt;')`
on the input only not &, " or '). JSX escapes everything, everywhere.
Still a stub. Wire a real endpoint into Chat's `send` to make it live.
============================================================ */
export function reply(prompt, { candidates, recruiters }) {
const p = prompt.toLowerCase()
if (p.includes('rank')) {
const top = [...candidates].sort((a, b) => b.aiScore - a.aiScore).slice(0, 5)
return (
<>
<p>Here are the top-ranked candidates by ATS match score:</p>
<ul>
{top.map((c, i) => (
<li key={c.id}>
<b>{i + 1}. {c.name}</b> {c.aiScore}% match · {c.jobTitle} · {c.recommendation}
</li>
))}
</ul>
<p className="text-muted">
This is a UI preview. Connect an LLM endpoint to generate live rankings from
resume + JD embeddings.
</p>
</>
)
}
if (p.includes('compare')) {
const [a, b] = candidates.slice(0, 2)
return (
<>
<p>Comparing <b>{a.name}</b> vs <b>{b.name}</b>:</p>
<ul>
<li><b>Experience:</b> {a.experience}y vs {b.experience}y</li>
<li><b>ATS Score:</b> {a.aiScore}% vs {b.aiScore}%</li>
<li><b>Recommendation:</b> {a.recommendation} vs {b.recommendation}</li>
</ul>
<p><b>Suggested:</b> {a.aiScore >= b.aiScore ? a.name : b.name} appears stronger on core criteria.</p>
</>
)
}
if (p.includes('job description') || p.includes('jd')) {
return (
<>
<p><b>Senior Product Designer</b></p>
<p>
Were looking for a Senior Product Designer to craft intuitive, delightful
experiences across our platform. Youll own end-to-end design, from research to
polished UI, and partner closely with product and engineering.
</p>
<p><b>Responsibilities:</b> lead design for key initiatives, run user research, build and maintain design systems, mentor peers.</p>
<p><b>Requirements:</b> 5+ years product design, strong portfolio, fluency in Figma, systems thinking.</p>
</>
)
}
if (p.includes('interview question')) {
return (
<>
<p>Here are role-specific interview questions:</p>
<ul>
<li>Walk me through how youd design a system to handle 1M concurrent users.</li>
<li>Describe a technically challenging project and the tradeoffs you made.</li>
<li>How do you approach debugging a production incident under time pressure?</li>
<li>Tell me about a time you disagreed with a teammate on an approach.</li>
</ul>
</>
)
}
if (p.includes('summar')) {
const c = candidates[0]
return (
<>
<p><b>Resume summary {c.name}</b></p>
<p>
{c.experience} years of experience, currently {c.currentTitle} at {c.currentCompany}.
Strong in {c.skills.slice(0, 3).join(', ')}. ATS match {c.aiScore}% for {c.jobTitle}. {c.recommendation}.
</p>
</>
)
}
if (p.includes('email')) {
return (
<>
<p><b>Subject:</b> Interview Invitation Next Steps</p>
<p>Hi [Candidate],</p>
<p>
Thank you for applying. We were impressed by your background and would love to
invite you to an interview. Please share your availability for this week.
</p>
<p>Best regards,<br />Talent Team</p>
</>
)
}
if (p.includes('offer letter')) {
return (
<>
<p><b>Offer Letter</b></p>
<p>
Dear [Candidate], We are pleased to offer you the position of Product Manager at a
base salary of $160,000, plus equity and benefits. This offer is contingent on
standard background checks.
</p>
<p>Were excited about the possibility of you joining the team.</p>
</>
)
}
if (p.includes('skill gap')) {
return (
<>
<p><b>Skill Gap Analysis Engineering pipeline</b></p>
<ul>
<li><span className="skill-pill skill-missing">Kubernetes</span> under-represented (only 22% of pipeline)</li>
<li><span className="skill-pill skill-missing">System Design</span> gap at senior level</li>
<li><span className="skill-pill skill-matched">React</span> well covered</li>
</ul>
<p>Consider sourcing candidates with cloud-native infra experience.</p>
</>
)
}
if (p.includes('pipeline')) {
return (
<>
<p><b>Pipeline health analysis</b></p>
<ul>
<li>{candidates.length} active candidates across 6 stages</li>
<li>Conversion Applied Interview: ~28%</li>
<li>Bottleneck detected at <b>Assessment</b> stage (longest dwell time)</li>
<li>Offer acceptance trending at 82%</li>
</ul>
<p>Recommendation: accelerate assessment turnaround to improve velocity.</p>
</>
)
}
if (p.includes('productivity') || p.includes('recruiter')) {
const top = [...recruiters].sort((a, b) => b.hires - a.hires)[0]
return (
<>
<p><b>Team productivity this month</b></p>
<ul>
<li>Top performer: {top?.name}</li>
<li>Avg time-to-hire: 27 days (3 days faster than last month)</li>
<li>Interview completion rate: 91%</li>
</ul>
</>
)
}
if (p.includes('recommend') || p.includes('suggest')) {
const c = [...candidates].sort((a, b) => b.aiScore - a.aiScore)[0]
return (
<p>
<b>Top recommendation:</b> {c.name} ({c.aiScore}% match) for {c.jobTitle}. Strong on{' '}
{c.matchedSkills.slice(0, 2).join(' & ')}. Id prioritise scheduling a screen this week.
</p>
)
}
return (
<>
<p>
I can help with ranking candidates, comparing profiles, drafting JDs, interview
questions, emails, offer letters, skill-gap and pipeline analysis, and more.
</p>
<p className="text-muted">
This is a fully-designed interface. Wire an AI endpoint (Claude / OpenAI) into the
chats <code>send</code> handler to make responses live.
</p>
</>
)
}

View File

@ -0,0 +1,54 @@
/* ============================================================
routes.js the 23-route information architecture.
This is the one artefact ADR 0013 says to preserve outright: the module
breakdown, nav grouping and screen inventory are a validated UX artefact
independent of the fake data behind them. Titles are verbatim from
js/app.js:7-16 and the group order is verbatim from index.html's sidebar.
`permission` gates the nav item and the route guard. See auth/permissions.js:
this is cosmetic only /users/*, /roles/* and /permissions/* enforce
server-side. Routes with no matching backend module carry null and are open
to any signed-in user.
============================================================ */
export const NAV_GROUPS = ['Workspace', 'Recruiting', 'Hiring', 'Insights', 'System']
export const ROUTES = [
// --- Workspace ---
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
// --- Recruiting ---
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
{ path: 'jobboard', title: 'Job Board', icon: 'layers', group: 'Recruiting', permission: 'job_board.view' },
{ path: 'recruiterhub', title: 'Recruiter Hub', icon: 'check-circle', group: 'Recruiting', permission: 'analytics.view' },
{ path: 'tasks', title: 'Tasks', icon: 'check-square', group: 'Recruiting', permission: null, badge: 'tasks' },
{ path: 'aiassistant', title: 'AI Assistant', icon: 'sparkles', group: 'Recruiting', permission: null, tag: 'AI' },
// --- Hiring ---
{ path: 'interviews', title: 'Interviews', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: null },
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
// --- Insights ---
{ path: 'reports', title: 'Reports', icon: 'reports', group: 'Insights', permission: 'reports.view' },
{ path: 'analytics', title: 'Analytics', icon: 'analytics', group: 'Insights', permission: 'analytics.view' },
{ path: 'aistudio', title: 'AI Studio', icon: 'zap', group: 'Insights', permission: null },
{ path: 'notifications', title: 'Notifications', icon: 'bell', group: 'Insights', permission: null, badge: 'notifications' },
// --- System ---
{ path: 'rbac', title: 'Access Control', icon: 'shield', group: 'System', permission: 'rbac_users.view' },
{ path: 'settings', title: 'Settings', icon: 'settings', group: 'System', permission: 'settings.view' },
{ path: 'help', title: 'Help', icon: 'help', group: 'System', permission: null },
]
export const ROUTE_BY_PATH = Object.fromEntries(ROUTES.map((r) => [r.path, r]))
export const DEFAULT_ROUTE = 'dashboard'

View File

@ -0,0 +1,95 @@
/* Shell hooks — the behaviours that lived loose in js/app.js's init(). */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { seedQuery } from '../data/seedQueries'
const SIDEBAR_KEY = 'tf-sidebar'
/** document.title and <html data-view> — CSS keys off data-view (js/app.js:38). */
export function useRouteMeta(route) {
useEffect(() => {
if (!route) return undefined
document.documentElement.setAttribute('data-view', route.path)
document.title = `TalentFlow · ${route.title}`
return () => document.documentElement.removeAttribute('data-view')
}, [route])
}
/**
* Mobile nav drawer. All four class toggles are load-bearing for the frozen CSS:
* `nav-open` on <html> is what the rules key off, because the AI FAB sits before
* .scrim in the DOM so no sibling selector can reach it.
*/
export function useNavOpen() {
const [navOpen, setNavOpen] = useState(false)
useEffect(() => {
document.documentElement.classList.toggle('nav-open', navOpen)
document.body.style.overflow = navOpen ? 'hidden' : ''
return () => {
document.documentElement.classList.remove('nav-open')
}
}, [navOpen])
return [navOpen, setNavOpen]
}
export function useSidebarCollapsed() {
// The prototype didn't persist this; a collapsed sidebar springing back open
// on every navigation reads as a bug.
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem(SIDEBAR_KEY) === '1'
} catch {
return false
}
})
const toggle = useCallback(() => {
setCollapsed((c) => {
try {
localStorage.setItem(SIDEBAR_KEY, c ? '0' : '1')
} catch {
/* ignore */
}
return !c
})
}, [])
return [collapsed, toggle]
}
/** Cmd/Ctrl+K focuses search; Escape closes the drawer and the dock (js/app.js:297-300). */
export function useHotkeys({ onEscape }) {
const searchRef = useRef(null)
useEffect(() => {
const onKeyDown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
searchRef.current?.focus()
}
if (e.key === 'Escape') onEscape?.()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onEscape])
return searchRef
}
/**
* The four sidebar badge counts. App.updateBadges() was an imperative DOM write
* that every mutating call site had to remember to call; these are derived, so
* completing a task updates the badge with no call site involved at all.
*/
export function useBadges() {
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
return {
jobs: jobs.filter((j) => j.status === 'Open').length,
notifications: notifications.filter((n) => n.unread).length,
tasks: tasks.filter((t) => !t.done).length,
inbox: inbox.filter((i) => i.unread).length,
}
}

View File

@ -0,0 +1,15 @@
import { createContext, useContext } from 'react'
export const AuthContext = createContext(null)
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
return ctx
}
/** `{ can, permissions }` — see auth/permissions.js on what this actually enforces. */
export function usePermission() {
const { can, permissions } = useAuth()
return { can, permissions }
}

View File

@ -0,0 +1,94 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AuthContext } from './AuthContext'
import { makeCan } from './permissions'
import { qk } from '../lib/queryKeys'
import { setSessionExpiredHandler } from '../lib/apiClient'
import { subscribe, getSession, setSession, mergeUser, clearSession } from '../lib/tokenStore'
import * as authApi from '../api/auth'
import * as usersApi from '../api/users'
const emptySnapshot = () => null
export default function AuthProvider({ children }) {
const session = useSyncExternalStore(subscribe, getSession, emptySnapshot)
const navigate = useNavigate()
const qc = useQueryClient()
// apiClient calls this when a refresh fails outright. Registered here so the
// HTTP layer never has to know about the router.
useEffect(() => {
setSessionExpiredHandler(() => {
clearSession()
qc.clear()
navigate('/auth/login?expired=1', { replace: true })
})
return () => setSessionExpiredHandler(() => {})
}, [navigate, qc])
// Permission bootstrap. This is a query rather than a useEffect fetch on
// purpose: TanStack Query dedupes, so React 19 StrictMode's double mount
// issues ONE request instead of two.
const me = useQuery({
queryKey: qk.auth.me(),
queryFn: () => usersApi.me().then((r) => r.data),
enabled: Boolean(session?.access_token),
staleTime: 5 * 60_000,
retry: false,
})
// Fold `permissions` into the stored session so a page reload has them before
// /users/me resolves otherwise the nav flickers on every refresh.
useEffect(() => {
if (me.data) mergeUser(me.data)
}, [me.data])
const signIn = useCallback(
async (email, password) => {
const res = await authApi.login(email, password)
setSession(res)
// Refetch /users/me for the new user; the old one's permissions must not leak.
await qc.invalidateQueries({ queryKey: qk.auth.me() })
return res
},
[qc],
)
const signOut = useCallback(() => {
// Client-side only: the backend has no logout endpoint, no denylist and no
// jti tracking, so the refresh token stays valid until its 7-day exp.
clearSession()
qc.clear()
navigate('/auth/login', { replace: true })
}, [navigate, qc])
const user = me.data ?? session?.data ?? null
const permissions = me.data?.permissions ?? session?.data?.permissions ?? null
const status = !session?.access_token
? 'anonymous'
: me.isError
? 'error'
: me.data || permissions
? 'authenticated'
: 'loading'
const value = useMemo(
() => ({
session,
user,
permissions,
status,
can: makeCan(permissions),
isAuthenticated: status === 'authenticated',
signIn,
signOut,
setSession,
}),
[session, user, permissions, status, signIn, signOut],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@ -0,0 +1,40 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
import Spinner from '../components/Spinner'
/**
* Route guard.
*
* Blocking on `loading` is deliberate: rendering the shell before /users/me
* resolves would paint the full 23-item nav and then remove items a moment
* later, which reads as a bug rather than as security.
*/
export default function RequireAuth({ children, permission }) {
const { status, can } = useAuth()
const location = useLocation()
if (status === 'anonymous') {
return <Navigate to="/auth/login" replace state={{ from: location }} />
}
if (status === 'error') {
return <Navigate to="/auth/login?expired=1" replace />
}
if (status === 'loading') {
return (
<div className="route-loading">
<Spinner label="Loading your workspace" />
</div>
)
}
if (permission && !can(permission)) return <Forbidden />
return children
}
export function Forbidden() {
return (
<div className="empty-state">
<h3>You dont have access to this page</h3>
<p>Ask an administrator to grant your role the required permission.</p>
</div>
)
}

View File

@ -0,0 +1,35 @@
/* ============================================================
permissions.js the 104-tag vocabulary, mirrored from the backend.
IMPORTANT WHAT THIS DOES AND DOES NOT DO
------------------------------------------
Everything here is COSMETIC: it hides nav items and blocks routes in the UI.
Real enforcement is `require_permission` on the server, and today that only
guards /users/*, /roles/* and /permissions/*. The other 20 screens are seed
data with no server behind them, and /inbox/fetch has no auth dependency at
all. A ticked box in the RBAC matrix is not an access control.
Source of truth: backend/users/permissions.py PermissionTag, which asserts at
startup that the enum equals the full modules x actions cross-product.
============================================================ */
export const MODULES = [
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users',
]
export const ACTIONS = [
'view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure',
]
/** All 104 `module.action` tags. */
export const ALL_TAGS = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
/**
* Build a permission predicate. A null/undefined tag is always allowed routes
* like Tasks and Help have no backend module and are open to any signed-in user.
*/
export function makeCan(permissions) {
const set = new Set(permissions ?? [])
return (tag) => !tag || set.has(tag)
}

View File

@ -0,0 +1,9 @@
export default function Alert({ type = 'danger', children }) {
if (!children) return null
const cls = type === 'success' ? 'alert alert-success' : 'alert alert-danger'
return (
<div className={cls} role="alert">
{children}
</div>
)
}

View File

@ -0,0 +1,67 @@
import { useState } from 'react'
import BrandMark from './BrandMark'
import ThemeToggle from './ThemeToggle'
export default function AuthLayout({ title, subtitle, children, foot }) {
return (
<div className="auth-shell">
<aside className="auth-aside" aria-hidden="false">
<BrandMark size="lg" />
<div className="auth-aside-copy">
<h1>Hiring, streamlined.</h1>
<p>
TalentFlow keeps Utopia Brands recruiting in one place roles, pipeline,
and people.
</p>
</div>
<p className="auth-aside-foot">Utopia Brands · ATS</p>
</aside>
<div className="auth-panel">
<div className="auth-panel-top">
<div className="auth-panel-brand">
<BrandMark />
</div>
<ThemeToggle />
</div>
<div className="auth-card card">
{title ? <h2 className="page-title">{title}</h2> : null}
{subtitle ? <p className="auth-sub">{subtitle}</p> : null}
{children}
</div>
{foot ? <div className="auth-foot">{foot}</div> : null}
</div>
</div>
)
}
export function useFormState(initial = {}) {
const [values, setValues] = useState(initial)
const [errors, setErrors] = useState({})
const [alert, setAlert] = useState(null)
const [busy, setBusy] = useState(false)
function setField(name, value) {
setValues((prev) => ({ ...prev, [name]: value }))
setErrors((prev) => {
if (!prev[name]) return prev
const next = { ...prev }
delete next[name]
return next
})
}
return {
values,
setValues,
setField,
errors,
setErrors,
alert,
setAlert,
busy,
setBusy,
}
}

View File

@ -0,0 +1,13 @@
export default function BrandMark({ size = 'md', showName = true }) {
const logoClass = size === 'lg' ? 'brand-logo brand-logo-lg' : 'brand-logo'
return (
<div className="auth-brand">
<div className={logoClass}>
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
<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" />
</svg>
</div>
{showName ? <span className="brand-name">TalentFlow</span> : null}
</div>
)
}

View File

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
function formatSeconds(total) {
const s = Math.max(0, Math.floor(total))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
/**
* Countdown seeded from a server duration (seconds).
* Pass `expiresIn` from the API never invent a client-side default window.
*/
export default function Countdown({ expiresIn, startedAt, onExpire, label = 'Code expires in' }) {
const [remaining, setRemaining] = useState(() => {
if (expiresIn == null || !Number.isFinite(Number(expiresIn))) return null
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
return Math.max(0, Number(expiresIn) - elapsed)
})
useEffect(() => {
if (expiresIn == null || !Number.isFinite(Number(expiresIn))) {
setRemaining(null)
return undefined
}
const tick = () => {
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
const left = Math.max(0, Number(expiresIn) - elapsed)
setRemaining(left)
if (left <= 0 && onExpire) onExpire()
}
tick()
const id = setInterval(tick, 250)
return () => clearInterval(id)
}, [expiresIn, startedAt, onExpire])
if (remaining == null) return null
return (
<p className="countdown" aria-live="polite">
{remaining > 0 ? (
<>
{label} <strong>{formatSeconds(remaining)}</strong>
</>
) : (
<span>Code expired</span>
)}
</p>
)
}
export function useResendGate(resendAfter, startedAt) {
const [remaining, setRemaining] = useState(() => {
if (resendAfter == null || !Number.isFinite(Number(resendAfter))) return 0
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
return Math.max(0, Number(resendAfter) - elapsed)
})
useEffect(() => {
if (resendAfter == null || !Number.isFinite(Number(resendAfter))) {
setRemaining(0)
return undefined
}
const tick = () => {
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
setRemaining(Math.max(0, Number(resendAfter) - elapsed))
}
tick()
const id = setInterval(tick, 250)
return () => clearInterval(id)
}, [resendAfter, startedAt])
return remaining
}

View File

@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react'
const LENGTH = 6
export default function OtpInput({ value = '', onChange, disabled = false, error = false }) {
const refs = useRef([])
const digits = Array.from({ length: LENGTH }, (_, i) => value[i] || '')
useEffect(() => {
refs.current = refs.current.slice(0, LENGTH)
}, [])
function commit(nextDigits) {
onChange(nextDigits.join('').slice(0, LENGTH))
}
function setAt(index, char) {
const next = digits.slice()
next[index] = char
commit(next)
}
function handleChange(index, e) {
const raw = e.target.value.replace(/\D/g, '')
if (!raw) {
setAt(index, '')
return
}
if (raw.length > 1) {
const chars = raw.slice(0, LENGTH - index).split('')
const next = digits.slice()
chars.forEach((c, i) => {
next[index + i] = c
})
commit(next)
const focusIdx = Math.min(index + chars.length, LENGTH - 1)
refs.current[focusIdx]?.focus()
return
}
setAt(index, raw)
if (index < LENGTH - 1) refs.current[index + 1]?.focus()
}
function handleKeyDown(index, e) {
if (e.key === 'Backspace' && !digits[index] && index > 0) {
e.preventDefault()
setAt(index - 1, '')
refs.current[index - 1]?.focus()
}
if (e.key === 'ArrowLeft' && index > 0) {
e.preventDefault()
refs.current[index - 1]?.focus()
}
if (e.key === 'ArrowRight' && index < LENGTH - 1) {
e.preventDefault()
refs.current[index + 1]?.focus()
}
}
function handlePaste(e) {
e.preventDefault()
const pasted = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, LENGTH)
if (!pasted) return
const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] || '')
commit(next)
refs.current[Math.min(pasted.length, LENGTH) - 1]?.focus()
}
return (
<div className="otp-row" role="group" aria-label="One-time code">
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el
}}
className={`otp-input${error ? ' err' : ''}`}
type="text"
inputMode="numeric"
autoComplete={i === 0 ? 'one-time-code' : 'off'}
maxLength={1}
value={digit}
disabled={disabled}
aria-label={`Digit ${i + 1}`}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
/>
))}
</div>
)
}

View File

@ -0,0 +1,56 @@
import { useId, useState } from 'react'
export default function PasswordField({
id,
label = 'Password',
value,
onChange,
error,
autoComplete = 'current-password',
placeholder = '••••••••',
required = true,
disabled = false,
}) {
const autoId = useId()
const fieldId = id || autoId
const [visible, setVisible] = useState(false)
return (
<div className="form-field">
<label htmlFor={fieldId}>{label}{required ? <span className="req"> *</span> : null}</label>
<div className="pw-wrap">
<input
id={fieldId}
type={visible ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete={autoComplete}
placeholder={placeholder}
className={error ? 'err' : ''}
required={required}
disabled={disabled}
/>
<button
type="button"
className="pw-toggle"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? 'Hide password' : 'Show password'}
tabIndex={-1}
>
{visible ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
<span className={`field-error${error ? ' show' : ''}`}>{error || ''}</span>
</div>
)
}

View File

@ -0,0 +1,7 @@
export default function Spinner({ label = 'Loading' }) {
return (
<span className="spinner" role="status" aria-label={label}>
<span className="spinner-dot" />
</span>
)
}

View File

@ -0,0 +1,35 @@
import { useTheme } from '../theme/ThemeProvider'
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
const onClick = toggleTheme
const dark = theme === 'dark'
return (
<button
type="button"
className="auth-theme-toggle"
onClick={onClick}
aria-pressed={dark ? 'true' : 'false'}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
)
}

View File

@ -1,11 +1,20 @@
/* ============================================================
data.js Realistic dummy dataset + generators
Exposes global `DB`
============================================================ */
(function () {
'use strict';
seed.js Realistic dummy dataset + generators
Ported from the prototype's js/data.js. The IIFE became an ES module and
`window.DB` became the default export; the LCG and every generator are
unchanged, so the dataset is byte-identical to the prototype's.
// ---------- seeded pseudo-random for stable data ----------
Read this as a *display-requirements* artefact, not a data model see
docs/architecture/01-repository-assessment.md §2.2. Screens whose backend
endpoints exist read the API instead; the rest resolve from here.
============================================================ */
// The prototype pinned "today" to 2026-07-09 in ~8 places across five files so
// the generated relative dates stayed stable. Exported from one place now, so
// switching the app to real time is a one-line change.
export const TODAY = new Date('2026-07-09T09:00:00');
// ---------- seeded pseudo-random for stable data ----------
let seed = 88123;
function rand() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
function pick(arr) { return arr[Math.floor(rand() * arr.length)]; }
@ -50,7 +59,7 @@
function initials(name) { return name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase(); }
function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); }
function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); }
function daysAgo(n) { const d = new Date('2026-07-09T09:00:00'); d.setDate(d.getDate() - n); return d; }
function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; }
function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }
function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
@ -182,19 +191,24 @@
}
// ---------- Activity feed ----------
// The prototype stored each row as an HTML string with the candidate name
// interpolated into <b> tags, then injected it with innerHTML. That is one of
// the 38 XSS sinks, and it cannot be rendered without dangerouslySetInnerHTML.
// Rows are structured now: a `parts` array of plain strings and {b} spans that
// the view renders as JSX. Same words on screen, no markup in the data.
const activityTemplates = [
{ icon: 'user-plus', color: 'i-green', text: (n, j) => `<b>${n}</b> applied for <b>${j}</b>` },
{ icon: 'calendar', color: 'i-blue', text: (n, j) => `Interview scheduled with <b>${n}</b> for <b>${j}</b>` },
{ icon: 'check', color: 'i-teal', text: (n, j) => `<b>${n}</b> moved to <b>Offer</b> stage` },
{ icon: 'file', color: 'i-purple', text: (n, j) => `Offer sent to <b>${n}</b> for <b>${j}</b>` },
{ icon: 'star', color: 'i-amber', text: (n, j) => `<b>${n}</b> completed an assessment` },
{ icon: 'x', color: 'i-red', text: (n, j) => `<b>${n}</b> was rejected for <b>${j}</b>` }
{ icon: 'user-plus', color: 'i-green', parts: (n, j) => [{ b: n }, ' applied for ', { b: j }] },
{ icon: 'calendar', color: 'i-blue', parts: (n, j) => ['Interview scheduled with ', { b: n }, ' for ', { b: j }] },
{ icon: 'check', color: 'i-teal', parts: (n) => [{ b: n }, ' moved to ', { b: 'Offer' }, ' stage'] },
{ icon: 'file', color: 'i-purple', parts: (n, j) => ['Offer sent to ', { b: n }, ' for ', { b: j }] },
{ icon: 'star', color: 'i-amber', parts: (n) => [{ b: n }, ' completed an assessment'] },
{ icon: 'x', color: 'i-red', parts: (n, j) => [{ b: n }, ' was rejected for ', { b: j }] }
];
const activity = [];
for (let i = 0; i < 18; i++) {
const t = pick(activityTemplates);
const cand = pick(candidates);
activity.push({ icon: t.icon, color: t.color, html: t.text(cand.name, cand.jobTitle), time: int(1, 300), candidateId: cand.id });
activity.push({ icon: t.icon, color: t.color, parts: t.parts(cand.name, cand.jobTitle), time: int(1, 300), candidateId: cand.id });
}
activity.sort((a, b) => a.time - b.time);
function relTime(mins) {
@ -234,7 +248,7 @@
};
// ---------- KPIs ----------
const today = new Date('2026-07-09');
const today = new Date(TODAY);
const kpis = {
openJobs: jobs.filter(j => j.status === 'Open').length,
closedJobs: jobs.filter(j => j.status === 'Closed').length,
@ -488,25 +502,39 @@
const recentlyViewed = [];
const favorites = { candidates: [], jobs: [] };
// ---------- Expose ----------
window.DB = {
departments, businessUnits, locations, empTypes, grades, jobStatuses, educationLevels, stages, sources, skillsPool, benefitsPool,
recruiters, managers, jobs, candidates, interviews, assessments, offers,
activity, notifications, messages, analytics, kpis, roles, users,
interviewTypes, meetingTypes, companies,
// enterprise
sourceMeta, inboxSources, processingStatuses, resumeStatuses, inbox, emails,
publishPlatforms, publishings, tasks, savedSearches, evalTemplates,
rbacModules, permTypes, rbacRoles, aiModules, aiPrompts,
recentlyViewed, favorites,
// helpers
fmtDate, fmtShort, relTime, initials, avatarColor, int, pick, atsRecommendationClass,
money: n => '$' + n.toLocaleString('en-US'),
moneyK: n => '$' + Math.round(n / 1000) + 'k',
getJob: id => jobs.find(j => j.id === id),
getCandidate: id => candidates.find(c => c.id === id),
getManager: id => managers.find(m => m.id === id),
getRecruiter: id => recruiters.find(r => r.id === id),
getRecruiterByName: n => recruiters.find(r => r.name === n)
};
})();
// ---------- Formatters ----------
// Money keeps the prototype's hardcoded '$'. Postings span six jurisdictions and
// there is no currency field in this dataset — that is a data-model fix for the
// backend (01-repository-assessment.md §2.2 "Money"), not a migration change.
const money = n => '$' + n.toLocaleString('en-US');
const moneyK = n => '$' + Math.round(n / 1000) + 'k';
// ---------- Lookups ----------
// The prototype read these off the DB global. They now take their list as an
// argument so a screen can pass either the seed array or a cached API list.
const byId = (list, id) => list.find(x => x.id === id);
const getJob = id => byId(jobs, id);
const getCandidate = id => byId(candidates, id);
const getManager = id => byId(managers, id);
const getRecruiter = id => byId(recruiters, id);
const getRecruiterByName = n => recruiters.find(r => r.name === n);
// ---------- Exports ----------
// Named exports so screens import only what they use, and `avatarColor`/`initials`
// can reach the UI primitives without dragging the whole dataset in (the prototype's
// UI.avatar() read them off the DB global, coupling every primitive to seed data).
export {
// reference lists — never mutated, imported directly rather than through the cache
departments, businessUnits, locations, empTypes, grades, jobStatuses, educationLevels,
stages, sources, skillsPool, benefitsPool, interviewTypes, meetingTypes, companies,
sourceMeta, inboxSources, processingStatuses, resumeStatuses,
rbacModules, permTypes, savedSearches, evalTemplates, aiModules, aiPrompts,
// mutable buckets — these go through the query cache (see data/seedQueries.js)
recruiters, managers, jobs, candidates, interviews, assessments, offers,
activity, notifications, messages, analytics, kpis, roles, users,
inbox, emails, publishPlatforms, publishings, tasks, rbacRoles,
recentlyViewed, favorites,
// helpers
fmtDate, fmtShort, relTime, initials, avatarColor, int, pick, atsRecommendationClass,
money, moneyK, byId, getJob, getCandidate, getManager, getRecruiter, getRecruiterByName,
};

View File

@ -0,0 +1,99 @@
/* ============================================================
seedQueries.js seed data served through the query cache.
Two jobs:
1. Make swapping a screen to a real endpoint a ONE-LINE change. A screen does
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
and the day GET /jobs/fetch exists that becomes
const { data: jobs = [] } = useQuery({ queryKey: qk.jobs.list(f),
queryFn: () => jobsApi.list(f) })
with the component body, the JSX and the table config untouched.
2. Give the seed a mutation model. The prototype mutated the DB global in
place and called Router.reload(); here the CACHE is the store, and every
mutation is a setQueryData that re-renders exactly the subscribed screens.
============================================================ */
import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { qk } from '../lib/queryKeys'
import * as seed from './seed'
/**
* gcTime: Infinity is load-bearing, not a micro-optimisation. These buckets are
* mutable app state, not server state if a mutated bucket were garbage
* collected after sitting off-screen it would silently revert to the pristine
* seed on the next mount, which looks like data loss and is miserable to debug.
*/
export function seedQuery(bucket) {
return {
queryKey: qk.seed[bucket](),
queryFn: async () => seed[bucket],
staleTime: Infinity,
gcTime: Infinity,
retry: false,
}
}
/** Buckets pre-populated at boot because the SHELL reads them before any route mounts. */
export const SHELL_BUCKETS = ['jobs', 'notifications', 'messages', 'tasks', 'inbox']
/** Everything the cache owns. The reference lists are imported directly instead. */
export const SEED_BUCKETS = [
...SHELL_BUCKETS,
'candidates', 'interviews', 'assessments', 'offers', 'activity', 'emails',
'publishings', 'rbacRoles', 'recruiters', 'managers', 'users',
]
/**
* Seed the shell's buckets before first render so the sidebar badges and the
* topbar dropdowns paint with real counts instead of popping in a frame later.
* This is the "cache initialized" step, called once from main.jsx.
*/
export function initializeCache(queryClient) {
for (const bucket of SHELL_BUCKETS) {
queryClient.setQueryData(qk.seed[bucket](), seed[bucket])
}
// Runtime buckets the prototype kept on DB. `favorites` and `recentlyViewed`
// restore from localStorage — a "recently viewed" list that empties on every
// refresh is worse than not having one.
queryClient.setQueryData(qk.seed.favorites(), readPersisted('tf-favorites', seed.favorites))
queryClient.setQueryData(qk.seed.recentlyViewed(), readPersisted('tf-recent', []))
}
function readPersisted(key, fallback) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : fallback
} catch {
return fallback
}
}
export function persist(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* ignore */
}
}
/**
* Mutate a seed bucket. Returns `update(fn)` where fn maps the old value to the
* new one the same shape as a React setState updater.
*
* const updateCandidates = useSeedMutation('candidates')
* updateCandidates(cs => cs.map(c => c.id === id ? { ...c, stage } : c))
*/
export function useSeedMutation(bucket) {
const qc = useQueryClient()
return useCallback(
(updater) => {
const key = qk.seed[bucket]()
qc.setQueryData(key, (old) => updater(old ?? seed[bucket]))
return qc.getQueryData(key)
},
[qc, bucket],
)
}

View File

@ -0,0 +1,113 @@
/* ============================================================
apiClient.js the one HTTP entry point.
Attaches the bearer token, renews proactively inside the skew window, and
retries exactly once on a 401. Never loops: if the retry also 401s, the
session is over and the expired handler fires.
============================================================ */
import { ApiError, parseDetail, SessionExpiredError } from './errors'
import { getAccessToken, isExpiring } from './tokenStore'
import { refreshSession } from './refresh'
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
// AuthProvider registers this so the module never has to import the router.
let onSessionExpired = () => {}
export function setSessionExpiredHandler(fn) {
onSessionExpired = fn
}
function buildUrl(path, params) {
const url = new URL(`${API_BASE}${path}`, window.location.origin)
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v)
}
}
return url.toString()
}
/**
* @param {object} opts
* @param {boolean} [opts.auth=true] attach the session bearer token
* @param {string} [opts.token] an explicit token (the password-reset flow);
* suppresses all refresh behaviour
*/
export async function request(
path,
{ method = 'GET', body, params, auth = true, token, signal } = {},
) {
// PROACTIVE renewal. Coalesced by single-flight, so a screen firing six
// queries at once on an expiring token still triggers exactly one refresh.
if (auth && !token && isExpiring()) {
try {
await refreshSession()
} catch {
// Fall through — the 401 path below makes the final call. This keeps a
// transient network blip from logging the user out.
}
}
const send = async () => {
const headers = { Accept: 'application/json' }
if (body != null) headers['Content-Type'] = 'application/json'
const bearer = token ?? (auth ? getAccessToken() : null)
if (bearer) headers.Authorization = `Bearer ${bearer}`
return fetch(buildUrl(path, params), {
method,
headers,
signal,
body: body != null ? JSON.stringify(body) : undefined,
})
}
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)
}
// REACTIVE renewal — exactly one retry.
if (res.status === 401 && auth && !token) {
try {
await refreshSession()
} catch (err) {
if (err instanceof SessionExpiredError) onSessionExpired()
throw err
}
res = await send()
if (res.status === 401) {
// A fresh access token still 401s: the user was deactivated or soft-deleted
// server-side (get_current_user rejects both). Signing them out is correct.
onSessionExpired()
throw new ApiError('Session expired', 401, null)
}
}
let data = null
const text = await res.text()
if (text) {
try {
data = JSON.parse(text)
} catch {
data = null
}
}
if (!res.ok) {
throw new ApiError(
parseDetail(data?.detail) || res.statusText || 'Request failed',
res.status,
data,
)
}
return data
}
export const get = (path, params, opts) => request(path, { ...opts, params })
export const post = (path, body, opts) => request(path, { ...opts, method: 'POST', body })
export const put = (path, body, opts) => request(path, { ...opts, method: 'PUT', body })
export const del = (path, opts) => request(path, { ...opts, method: 'DELETE' })

View File

@ -2,11 +2,14 @@
charts.js Lightweight Canvas chart engine (no libraries)
Exposes global `Charts` with: line, bar, groupedBar, doughnut,
area, horizontalBar, sparkline
============================================================ */
(function () {
'use strict';
function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
Retained verbatim from the prototype per ADR 0013 the only edits are the
IIFE wrapper becoming an ES module and `window.Charts` becoming a default
export. The engine still reads --c1..--c8 and --border/--text-3/--bg-elev
live from CSS, so it re-themes itself with no React involvement.
============================================================ */
function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
/* Series colours live in CSS (--c1..--c8) so light and dark each get a
set tuned to their surface. Read live rather than cached: App.setTheme
@ -336,12 +339,10 @@
canvas.onmouseleave = hideTip;
}
window.Charts = { line, bar, groupedBar, doughnut, horizontalBar, sparkline, legend, token: css };
// Live getter: each read reflects the active theme's --c1..--c8.
Object.defineProperty(window.Charts, 'PALETTE', { get: palette, enumerable: true });
// `legend` is gone: it returned an HTML string, which is now the
// <ChartLegend/> component in src/ui/Chart.jsx.
const Charts = { line, bar, groupedBar, doughnut, horizontalBar, sparkline, token: css };
// Live getter: each read reflects the active theme's --c1..--c8.
Object.defineProperty(Charts, 'PALETTE', { get: palette, enumerable: true });
function legend(items) {
return `<div class="chart-legend">${items.map(it =>
`<span class="legend-item"><span class="legend-dot" style="background:${it.color}"></span>${it.label}</span>`).join('')}</div>`;
}
})();
export default Charts;

View File

@ -0,0 +1,69 @@
/* ============================================================
errors.js the API error type and FastAPI `detail` unwrapping.
Lifted verbatim from the original src/api.js. FastAPI returns errors as
{detail: ...} where `detail` is a string for HTTPException and an array of
{loc, msg, type} objects for request-validation failures parseDetail
already handles both shapes, so it moves unchanged.
============================================================ */
export class ApiError extends Error {
constructor(message, status, body) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
/** Raised when the refresh token itself is rejected — the session is over. */
export class SessionExpiredError extends Error {
constructor(message = 'Your session has expired. Please sign in again.') {
super(message)
this.name = 'SessionExpiredError'
}
}
export function parseDetail(detail) {
if (detail == null || detail === '') return null
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (typeof item === 'string') return item
if (item && typeof item === 'object') {
return item.msg || item.message || JSON.stringify(item)
}
return String(item)
})
.filter(Boolean)
.join('; ')
}
if (typeof detail === 'object') {
return detail.msg || detail.message || JSON.stringify(detail)
}
return String(detail)
}
export function friendlyAuthError(err, fallback = 'Something went wrong. Please try again.') {
if (!(err instanceof ApiError)) {
return err?.message || fallback
}
const fromServer = parseDetail(err.body?.detail)
switch (err.status) {
case 429:
return fromServer || 'Too many attempts. Please wait a moment and try again.'
case 404:
return fromServer || 'No account found for that email.'
case 400:
return fromServer || 'Please check your details and try again.'
case 401:
return fromServer || 'Incorrect email or password.'
case 409:
return fromServer || 'An account with that email already exists.'
case 502:
return fromServer || 'Email service is temporarily unavailable. Please try again later.'
default:
return fromServer || err.message || fallback
}
}

View File

@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import { ApiError, SessionExpiredError } from './errors'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// A recruiter re-entering a screen within the minute gets cache, no spinner.
staleTime: 60_000,
gcTime: 15 * 60_000,
// This is an all-day tool. The prototype had no refetch-on-focus, and
// restriping every table on alt-tab would be a visible behaviour change.
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: (count, err) => {
if (err instanceof SessionExpiredError) return false
// 401 is already handled by apiClient's refresh-and-retry; re-running
// the query would just repeat work. 403/404/422 will never succeed.
if (err instanceof ApiError && err.status >= 400 && err.status < 500) return false
return count < 2
},
retryDelay: (i) => Math.min(1000 * 2 ** i, 8000),
},
mutations: { retry: 0 },
},
})

View File

@ -0,0 +1,48 @@
/* Query key convention: [domain, scope, ...params], always an array, params
always last as a single object so `invalidateQueries({queryKey:['users']})`
catches every scope under a domain. */
export const qk = {
auth: { me: () => ['auth', 'me'] },
// --- real backend endpoints ---
users: {
all: () => ['users'],
list: (p = {}) => ['users', 'list', p],
},
roles: {
all: () => ['roles'],
list: () => ['roles', 'list'],
permissions: () => ['roles', 'permissions'],
tags: () => ['roles', 'permission-tags'],
},
mailbox: {
all: () => ['mailbox'],
messages: () => ['mailbox', 'messages'],
},
// --- seed-backed buckets ---
// These are not "server state" — the cache IS the store for them, so every
// mutation is a setQueryData. See data/seedQueries.js.
seed: {
all: () => ['seed'],
jobs: () => ['seed', 'jobs'],
candidates: () => ['seed', 'candidates'],
interviews: () => ['seed', 'interviews'],
assessments: () => ['seed', 'assessments'],
offers: () => ['seed', 'offers'],
tasks: () => ['seed', 'tasks'],
notifications: () => ['seed', 'notifications'],
messages: () => ['seed', 'messages'],
activity: () => ['seed', 'activity'],
inbox: () => ['seed', 'inbox'],
emails: () => ['seed', 'emails'],
publishings: () => ['seed', 'publishings'],
rbacRoles: () => ['seed', 'rbacRoles'],
recruiters: () => ['seed', 'recruiters'],
managers: () => ['seed', 'managers'],
users: () => ['seed', 'users'],
favorites: () => ['seed', 'favorites'],
recentlyViewed: () => ['seed', 'recentlyViewed'],
},
}

View File

@ -0,0 +1,88 @@
/* ============================================================
refresh.js single-flight token refresh.
WHY SINGLE-FLIGHT IS MANDATORY, NOT AN OPTIMISATION
---------------------------------------------------
POST /users/refresh calls serialize_token(create_access_token(user),
create_refresh_token(user), user) it mints a NEW refresh token every time
and there is no reuse detection or jti tracking on the server, so the old one
stays valid until its 7-day exp.
Two concurrent refreshes therefore BOTH succeed and return two different
valid pairs. Whichever setSession() lands second wins; requests already in
flight carry a token from the losing pair. The result is intermittent 401s
that look random and are close to undiagnosable from logs.
So: one promise per tab, and a Web Locks mutex across tabs.
============================================================ */
import { SessionExpiredError } from './errors'
import {
getSession,
getRefreshToken,
setSession,
clearSession,
reloadFromStorage,
} from './tokenStore'
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
let inFlight = null
/**
* Refresh the session. Concurrent callers all await the SAME promise, so at most
* one POST /users/refresh is outstanding per tab at any moment.
*/
export function refreshSession() {
if (inFlight) return inFlight
inFlight = run().finally(() => {
inFlight = null
})
return inFlight
}
async function post(refreshToken) {
let res
try {
res = await fetch(`${API_BASE}/users/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
// The backend takes the refresh token in the JSON BODY — not a cookie,
// not an Authorization header (backend/users/app.py: TokenRefresh).
body: JSON.stringify({ refresh_token: refreshToken }),
})
} catch {
// Network failure is not an expired session — don't destroy a good session
// because the wifi dropped. Surface it and let the caller retry later.
throw new Error('Unable to reach the server while renewing your session.')
}
if (!res.ok) {
// 401 "Invalid or expired refresh token", or the user was deactivated.
clearSession()
throw new SessionExpiredError()
}
// Rotates BOTH tokens; setSession preserves `permissions` from /users/me.
return setSession(await res.json())
}
async function run() {
reloadFromStorage()
const token = getRefreshToken()
if (!token) throw new SessionExpiredError()
// Cross-tab single flight. Without it, five open tabs hitting an expired token
// means five rotations of the same refresh token: four pairs are orphaned and
// four tabs end up holding a stale access token.
if (typeof navigator !== 'undefined' && navigator.locks) {
return navigator.locks.request('tf-auth-refresh', async () => {
reloadFromStorage()
const current = getRefreshToken()
// Another tab rotated while we waited for the lock — reuse its result
// instead of spending our now-superseded token.
if (current && current !== token) return getSession()
return post(current ?? token)
})
}
return post(token)
}

View File

@ -0,0 +1,121 @@
/* ============================================================
tokenStore.js the session record, its expiry math, and cross-tab sync.
A module-level singleton rather than React state, because apiClient must read
the access token from plain async code that has no hooks available. React
binds to it through useSyncExternalStore (see auth/AuthProvider.jsx), which
works because every write replaces `cached` wholesale the snapshot identity
only changes when the session actually changes.
Storage key stays `tf-auth` so anyone already signed in stays signed in.
============================================================ */
const KEY = 'tf-auth'
// Renew this far ahead of expiry. Covers clock drift between browser and server
// and, more importantly, means a request never has to eat a guaranteed 401 first.
export const CLOCK_SKEW_MS = 60_000
function readRaw() {
try {
return JSON.parse(localStorage.getItem(KEY)) || null
} catch {
return null
}
}
let cached = readRaw()
const subscribers = new Set()
function emit() {
subscribers.forEach((fn) => fn())
}
function persist() {
try {
localStorage.setItem(KEY, JSON.stringify(cached))
} catch {
/* quota or private mode — the in-memory session still works for this tab */
}
}
export function getSession() {
return cached
}
export function getAccessToken() {
return cached?.access_token ?? null
}
export function getRefreshToken() {
return cached?.refresh_token ?? null
}
export function subscribe(fn) {
subscribers.add(fn)
return () => subscribers.delete(fn)
}
/** Re-read from storage. Another tab may have rotated the pair since we last looked. */
export function reloadFromStorage() {
cached = readRaw()
return cached
}
export function isExpiring(skew = CLOCK_SKEW_MS) {
if (!cached?.access_token) return false
// Sessions written before this field existed can't be reasoned about; let the
// reactive 401 path handle those rather than refreshing on every request.
if (!cached.expires_at) return false
return Date.now() >= cached.expires_at - skew
}
/**
* Store a login/signup/refresh response.
*
* The `data` MERGE is load-bearing. /users/login and /users/refresh return a
* user record WITHOUT `permissions` only GET /users/me resolves those
* (backend/users/serializers.py: serialize_user's with_permissions defaults to
* False). A naive assignment would therefore wipe the permission list on the
* first background refresh, and every permission-gated nav item would vanish
* mid-session.
*/
export function setSession(res) {
cached = {
access_token: res.access_token,
refresh_token: res.refresh_token,
expires_in: res.expires_in,
expires_at: Date.now() + (res.expires_in ?? 1800) * 1000,
data: { ...(cached?.data ?? {}), ...(res.data ?? {}) },
}
persist()
emit()
return cached
}
/** Merge the GET /users/me payload — this is what puts `permissions` on the session. */
export function mergeUser(user) {
if (!cached || !user) return cached
cached = { ...cached, data: { ...cached.data, ...user } }
persist()
emit()
return cached
}
export function clearSession() {
cached = null
try {
localStorage.removeItem(KEY)
} catch {
/* ignore */
}
emit()
}
// Cross-tab: sign-out and token rotation both propagate. `e.key === null` is a
// storage.clear() from another tab.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== KEY && e.key !== null) return
cached = readRaw()
emit()
})
}

35
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,35 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// The frozen design contract (ADR 0013 §1): 1,269 lines, 93 tokens, dual
// themes, WCAG 2.1 AA verified across 23 routes. Content-frozen new CSS may
// only use existing var(--) tokens.
import './styles/styles.css'
import './styles/auth.css'
import App from './App'
import ThemeProvider, { initTheme } from './theme/ThemeProvider'
import ToastProvider from './ui/Toast'
import { queryClient } from './lib/queryClient'
import { initializeCache } from './data/seedQueries'
// Before render: theme first so there is no light-mode flash, then the cache so
// the shell's badges and dropdowns paint with real counts on the first frame
// instead of popping in once a query resolves.
initTheme()
initializeCache(queryClient)
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<App />
</ToastProvider>
</ThemeProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</StrictMode>,
)

View File

@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import AuthLayout from '../components/AuthLayout'
import Alert from '../components/Alert'
import Spinner from '../components/Spinner'
import { confirmEmail, resendConfirmEmail } from '../api/auth'
import { friendlyAuthError } from '../lib/errors'
const titles = {
verifying: 'Confirming your email',
success: 'Email confirmed',
error: 'Confirmation failed',
}
const subtitles = {
verifying: 'Hold on while we activate your account.',
success: 'Your TalentFlow account is active.',
error: 'That link did not work. Request a new one below.',
}
export default function ConfirmEmail() {
const [params] = useSearchParams()
const token = params.get('token') || ''
const [state, setState] = useState('verifying')
const [alert, setAlert] = useState(null)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState('')
const [busy, setBusy] = useState(false)
const [resent, setResent] = useState(false)
// StrictMode runs mount effects twice in dev; the ref survives the remount so
// the POST fires once. The server is idempotent either way.
const firedRef = useRef(false)
const runConfirm = useCallback(async () => {
if (!token) {
setState('error')
setAlert({ type: 'danger', message: 'This confirmation link is missing its token.' })
return
}
try {
const res = await confirmEmail(token)
const data = res?.data || {}
if (data.email) setEmail(data.email)
setState('success')
setAlert({
type: 'success',
message: data.already_confirmed
? 'Your email was already confirmed. You can sign in.'
: 'Your email is confirmed. You can sign in now.',
})
} catch (err) {
setState('error')
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not confirm your email.') })
}
}, [token])
useEffect(() => {
if (firedRef.current) return
firedRef.current = true
runConfirm()
}, [runConfirm])
async function onResend(e) {
e.preventDefault()
if (!email.trim()) {
setEmailError('Email is required')
return
}
setEmailError('')
setAlert(null)
setBusy(true)
try {
await resendConfirmEmail(email.trim())
setResent(true)
setAlert({ type: 'success', message: 'We sent a new confirmation link to your email.' })
} catch (err) {
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not send a new link.') })
} finally {
setBusy(false)
}
}
return (
<AuthLayout
title={titles[state]}
subtitle={subtitles[state]}
foot={
<>
Back to{' '}
<Link className="link-btn" to="/auth/login">
Sign in
</Link>
</>
}
>
<Alert type={alert?.type}>{alert?.message}</Alert>
{state === 'verifying' && (
<p className="countdown" aria-live="polite">
<Spinner label="Confirming your email" /> Confirming
</p>
)}
{state === 'success' && (
<Link className="btn btn-primary btn-block" to="/auth/login" style={{ textDecoration: 'none' }}>
Sign in
</Link>
)}
{state === 'error' && !resent && (
<form onSubmit={onResend} noValidate>
<div className="form-field">
<label htmlFor="confirm-email">
Email<span className="req"> *</span>
</label>
<input
id="confirm-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setEmailError('')
}}
className={emailError ? 'err' : ''}
placeholder="you@utopiabrands.com"
disabled={busy}
required
/>
<span className={`field-error${emailError ? ' show' : ''}`}>{emailError || ''}</span>
</div>
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
{busy ? <Spinner label="Sending link" /> : null}
{busy ? 'Sending…' : 'Send a new link'}
</button>
</form>
)}
</AuthLayout>
)
}

View File

@ -0,0 +1,282 @@
import { useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import AuthLayout from '../components/AuthLayout'
import PasswordField from '../components/PasswordField'
import Alert from '../components/Alert'
import OtpInput from '../components/OtpInput'
import Countdown, { useResendGate } from '../components/Countdown'
import Spinner from '../components/Spinner'
import { forgetPassword, verifyForgetCode, setNewPassword } from '../api/auth'
import { friendlyAuthError } from '../lib/errors'
function pickTiming(payload) {
const src = payload?.data && typeof payload.data === 'object' ? payload.data : payload || {}
const expiresIn = src.expires_in
const resendAfter = src.resend_after
return {
expiresIn: expiresIn != null && Number.isFinite(Number(expiresIn)) ? Number(expiresIn) : null,
resendAfter:
resendAfter != null && Number.isFinite(Number(resendAfter)) ? Number(resendAfter) : null,
}
}
function pickResetToken(payload) {
if (!payload) return null
if (payload.reset_token) return payload.reset_token
if (payload.access_token) return payload.access_token
if (payload.data?.reset_token) return payload.data.reset_token
if (payload.data?.access_token) return payload.data.access_token
return null
}
export default function ForgotPassword() {
const [step, setStep] = useState(1)
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [errors, setErrors] = useState({})
const [alert, setAlert] = useState(null)
const [busy, setBusy] = useState(false)
const [expiresIn, setExpiresIn] = useState(null)
const [resendAfter, setResendAfter] = useState(null)
const [startedAt, setStartedAt] = useState(null)
const [resetToken, setResetToken] = useState(null)
const [expired, setExpired] = useState(false)
const resendLeft = useResendGate(resendAfter, startedAt)
const onExpire = useCallback(() => setExpired(true), [])
function applyChallengeTiming(payload) {
const { expiresIn: exp, resendAfter: ra } = pickTiming(payload)
setExpiresIn(exp)
setResendAfter(ra)
setStartedAt(Date.now())
setExpired(false)
}
async function requestCode(e) {
e?.preventDefault()
const nextErrors = {}
if (!email.trim()) nextErrors.email = 'Email is required'
setErrors(nextErrors)
if (Object.keys(nextErrors).length) return
setAlert(null)
setBusy(true)
try {
const res = await forgetPassword(email.trim())
applyChallengeTiming(res)
setCode('')
setStep(2)
setAlert({
type: 'success',
message: res?.detail || res?.message || 'We sent a 6-digit code to your email.',
})
} catch (err) {
setAlert({ type: 'danger', message: friendlyAuthError(err) })
} finally {
setBusy(false)
}
}
async function verifyCode(e) {
e.preventDefault()
const nextErrors = {}
if (!/^\d{6}$/.test(code)) nextErrors.code = 'Enter the 6-digit code'
setErrors(nextErrors)
if (Object.keys(nextErrors).length) return
if (expired) {
setAlert({ type: 'danger', message: 'That code has expired. Request a new one.' })
return
}
setAlert(null)
setBusy(true)
try {
const res = await verifyForgetCode(email.trim(), code)
const token = pickResetToken(res)
if (!token) {
setAlert({ type: 'danger', message: 'Verification succeeded but no reset token was returned.' })
return
}
setResetToken(token)
setStep(3)
setAlert(null)
} catch (err) {
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Invalid or expired code.') })
} finally {
setBusy(false)
}
}
async function submitPassword(e) {
e.preventDefault()
const nextErrors = {}
if (!password) nextErrors.password = 'Password is required'
else if (password.length < 8) nextErrors.password = 'Use at least 8 characters'
if (confirm !== password) nextErrors.confirm = 'Passwords do not match'
setErrors(nextErrors)
if (Object.keys(nextErrors).length) return
setAlert(null)
setBusy(true)
try {
await setNewPassword(password, resetToken)
setAlert({
type: 'success',
message: 'Password updated. You can sign in with your new password.',
})
setStep(4)
} catch (err) {
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not update password.') })
} finally {
setBusy(false)
}
}
const titles = {
1: 'Forgot password',
2: 'Enter code',
3: 'New password',
4: 'Password updated',
}
const subtitles = {
1: 'Well email you a one-time code to reset access.',
2: `Enter the 6-digit code sent to ${email}.`,
3: 'Choose a new password for your account.',
4: 'Youre all set.',
}
return (
<AuthLayout
title={titles[step]}
subtitle={subtitles[step]}
foot={
step === 4 ? (
<Link className="link-btn" to="/auth/login">
Back to sign in
</Link>
) : (
<>
Remembered it?{' '}
<Link className="link-btn" to="/auth/login">
Sign in
</Link>
</>
)
}
>
<Alert type={alert?.type}>{alert?.message}</Alert>
{step === 1 && (
<form onSubmit={requestCode} noValidate>
<div className="form-field">
<label htmlFor="fp-email">
Email<span className="req"> *</span>
</label>
<input
id="fp-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setErrors((prev) => {
if (!prev.email) return prev
const n = { ...prev }
delete n.email
return n
})
}}
className={errors.email ? 'err' : ''}
placeholder="you@utopiabrands.com"
disabled={busy}
required
/>
<span className={`field-error${errors.email ? ' show' : ''}`}>{errors.email || ''}</span>
</div>
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
{busy ? <Spinner label="Sending code" /> : null}
{busy ? 'Sending…' : 'Send code'}
</button>
</form>
)}
{step === 2 && (
<form onSubmit={verifyCode} noValidate>
<OtpInput
value={code}
onChange={(v) => {
setCode(v)
setErrors((prev) => {
if (!prev.code) return prev
const n = { ...prev }
delete n.code
return n
})
}}
disabled={busy}
error={Boolean(errors.code)}
/>
<span className={`field-error${errors.code ? ' show' : ''}`}>{errors.code || ''}</span>
<Countdown expiresIn={expiresIn} startedAt={startedAt} onExpire={onExpire} />
<button type="submit" className="btn btn-primary btn-block" disabled={busy || expired}>
{busy ? <Spinner label="Verifying" /> : null}
{busy ? 'Verifying…' : 'Verify code'}
</button>
<div className="auth-resend">
{resendLeft > 0 ? (
<span className="countdown">Resend available in {Math.ceil(resendLeft)}s</span>
) : (
<button
type="button"
className="link-btn"
disabled={busy}
onClick={() => requestCode()}
>
Resend code
</button>
)}
</div>
</form>
)}
{step === 3 && (
<form onSubmit={submitPassword} noValidate>
<PasswordField
id="fp-password"
label="New password"
autoComplete="new-password"
value={password}
onChange={setPassword}
error={errors.password}
disabled={busy}
/>
<PasswordField
id="fp-confirm"
label="Confirm password"
autoComplete="new-password"
value={confirm}
onChange={setConfirm}
error={errors.confirm}
disabled={busy}
/>
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
{busy ? <Spinner label="Saving password" /> : null}
{busy ? 'Saving…' : 'Update password'}
</button>
</form>
)}
{step === 4 && (
<Link className="btn btn-primary btn-block" to="/auth/login" style={{ textDecoration: 'none' }}>
Sign in
</Link>
)}
</AuthLayout>
)
}

View File

@ -0,0 +1,100 @@
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import AuthLayout, { useFormState } from '../components/AuthLayout'
import PasswordField from '../components/PasswordField'
import Alert from '../components/Alert'
import Spinner from '../components/Spinner'
import { friendlyAuthError } from '../lib/errors'
import { useAuth } from '../auth/AuthContext'
export default function Login() {
const form = useFormState({ email: '', password: '' })
const { signIn } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [params] = useSearchParams()
// Where the guard bounced us from, so a deep link survives the login round-trip.
const from = location.state?.from?.pathname || '/dashboard'
const expired = params.get('expired') === '1'
async function onSubmit(e) {
e.preventDefault()
const errors = {}
if (!form.values.email.trim()) errors.email = 'Email is required'
if (!form.values.password) errors.password = 'Password is required'
form.setErrors(errors)
if (Object.keys(errors).length) return
form.setAlert(null)
form.setBusy(true)
try {
await signIn(form.values.email.trim(), form.values.password)
// In-SPA now: the old full page load out to /index.html#dashboard is gone.
navigate(from, { replace: true })
} catch (err) {
form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') })
form.setBusy(false)
}
}
return (
<AuthLayout
title="Sign in"
subtitle="Welcome back to TalentFlow."
foot={
<>
New here?{' '}
<Link className="link-btn" to="/auth/signup">
Create an account
</Link>
</>
}
>
<form onSubmit={onSubmit} noValidate>
<Alert type={form.alert?.type}>{form.alert?.message}</Alert>
{!form.alert && expired ? (
<Alert type="danger">Your session expired. Please sign in again.</Alert>
) : null}
<div className="form-field">
<label htmlFor="login-email">
Email<span className="req"> *</span>
</label>
<input
id="login-email"
type="email"
autoComplete="email"
value={form.values.email}
onChange={(e) => form.setField('email', e.target.value)}
className={form.errors.email ? 'err' : ''}
placeholder="you@utopiabrands.com"
disabled={form.busy}
required
/>
<span className={`field-error${form.errors.email ? ' show' : ''}`}>
{form.errors.email || ''}
</span>
</div>
<PasswordField
id="login-password"
value={form.values.password}
onChange={(v) => form.setField('password', v)}
error={form.errors.password}
disabled={form.busy}
/>
<div className="auth-row-end">
<Link className="link-btn" to="/auth/forgot-password">
Forgot password?
</Link>
</div>
<button type="submit" className="btn btn-primary btn-block" disabled={form.busy}>
{form.busy ? <Spinner label="Signing in" /> : null}
{form.busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</AuthLayout>
)
}

View File

@ -0,0 +1,209 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import AuthLayout, { useFormState } from '../components/AuthLayout'
import PasswordField from '../components/PasswordField'
import Alert from '../components/Alert'
import Spinner from '../components/Spinner'
import { useResendGate } from '../components/Countdown'
import { signup, resendConfirmEmail } from '../api/auth'
import { friendlyAuthError, ApiError } from '../lib/errors'
import { setSession } from '../lib/tokenStore'
export default function Signup() {
const form = useFormState({ name: '', email: '', password: '', confirm: '' })
const navigate = useNavigate()
const [sent, setSent] = useState(false)
const [resendAfter, setResendAfter] = useState(null)
const [startedAt, setStartedAt] = useState(null)
const resendLeft = useResendGate(resendAfter, startedAt)
async function onResend() {
form.setAlert(null)
form.setBusy(true)
try {
const res = await resendConfirmEmail(form.values.email.trim())
const data = res?.data || {}
setResendAfter(Number.isFinite(Number(data.resend_after)) ? Number(data.resend_after) : null)
setStartedAt(Date.now())
form.setAlert({ type: 'success', message: 'We sent a new confirmation link.' })
} catch (err) {
form.setAlert({
type: 'danger',
message: friendlyAuthError(err, 'Could not resend the link.'),
})
} finally {
form.setBusy(false)
}
}
async function onSubmit(e) {
e.preventDefault()
const errors = {}
if (!form.values.name.trim()) errors.name = 'Name is required'
if (!form.values.email.trim()) errors.email = 'Email is required'
if (!form.values.password) errors.password = 'Password is required'
else if (form.values.password.length < 8) errors.password = 'Use at least 8 characters'
if (form.values.confirm !== form.values.password) errors.confirm = 'Passwords do not match'
form.setErrors(errors)
if (Object.keys(errors).length) return
form.setAlert(null)
form.setBusy(true)
try {
const res = await signup(
form.values.name.trim(),
form.values.email.trim(),
form.values.password,
)
// The account starts inactive; only the mailed link activates it, so the
// tokens in this response are unusable until then.
if (!res?.data?.is_active) {
setSent(true)
form.setAlert({
type: 'success',
message: `We sent a confirmation link to ${res?.data?.email || form.values.email.trim()}.`,
})
form.setBusy(false)
return
}
setSession(res)
navigate('/dashboard', { replace: true })
} catch (err) {
// 502 means the account was created but the email failed offer a resend
// rather than a dead end.
if (err instanceof ApiError && err.status === 502) {
setSent(true)
form.setAlert({
type: 'danger',
message: 'Your account was created but we could not send the email. Try again below.',
})
form.setBusy(false)
return
}
form.setAlert({
type: 'danger',
message: friendlyAuthError(err, 'Could not create your account.'),
})
form.setBusy(false)
}
}
if (sent) {
return (
<AuthLayout
title="Check your email"
subtitle="Open the link we sent to activate your account."
foot={
<>
Already confirmed?{' '}
<Link className="link-btn" to="/auth/login">
Sign in
</Link>
</>
}
>
<Alert type={form.alert?.type}>{form.alert?.message}</Alert>
{resendLeft > 0 ? (
<p className="countdown" aria-live="polite">
Resend available in {Math.ceil(resendLeft)}s
</p>
) : (
<button
type="button"
className="btn btn-primary btn-block"
disabled={form.busy}
onClick={onResend}
>
{form.busy ? <Spinner label="Sending link" /> : null}
{form.busy ? 'Sending…' : 'Resend confirmation email'}
</button>
)}
</AuthLayout>
)
}
return (
<AuthLayout
title="Create account"
subtitle="Join TalentFlow for Utopia Brands hiring."
foot={
<>
Already have an account?{' '}
<Link className="link-btn" to="/auth/login">
Sign in
</Link>
</>
}
>
<form onSubmit={onSubmit} noValidate>
<Alert type={form.alert?.type}>{form.alert?.message}</Alert>
<div className="form-field">
<label htmlFor="signup-name">
Full name<span className="req"> *</span>
</label>
<input
id="signup-name"
type="text"
autoComplete="name"
value={form.values.name}
onChange={(e) => form.setField('name', e.target.value)}
className={form.errors.name ? 'err' : ''}
placeholder="Asfand Ahmed"
disabled={form.busy}
required
/>
<span className={`field-error${form.errors.name ? ' show' : ''}`}>
{form.errors.name || ''}
</span>
</div>
<div className="form-field">
<label htmlFor="signup-email">
Email<span className="req"> *</span>
</label>
<input
id="signup-email"
type="email"
autoComplete="email"
value={form.values.email}
onChange={(e) => form.setField('email', e.target.value)}
className={form.errors.email ? 'err' : ''}
placeholder="you@utopiabrands.com"
disabled={form.busy}
required
/>
<span className={`field-error${form.errors.email ? ' show' : ''}`}>
{form.errors.email || ''}
</span>
</div>
<PasswordField
id="signup-password"
label="Password"
autoComplete="new-password"
value={form.values.password}
onChange={(v) => form.setField('password', v)}
error={form.errors.password}
disabled={form.busy}
/>
<PasswordField
id="signup-confirm"
label="Confirm password"
autoComplete="new-password"
value={form.values.confirm}
onChange={(v) => form.setField('confirm', v)}
error={form.errors.confirm}
disabled={form.busy}
/>
<button type="submit" className="btn btn-primary btn-block" disabled={form.busy}>
{form.busy ? <Spinner label="Creating account" /> : null}
{form.busy ? 'Creating…' : 'Create account'}
</button>
</form>
</AuthLayout>
)
}

View File

@ -0,0 +1,32 @@
import { useState } from 'react'
import Chat from '../app/ai/Chat'
import { Icon } from '../ui/primitives'
export default function AiAssistant() {
// Bumping the key resets the transcript the old AI.newChat().
const [resetKey, setResetKey] = useState(0)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">AI Assistant</h1>
<p className="page-sub">Your recruiting copilot powered by AI (interface preview)</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending">
<span className="pulse" />Model endpoint · Not connected
</span>
<button className="btn btn-secondary" onClick={() => setResetKey((k) => k + 1)}>
<Icon name="plus" /> New Chat
</button>
</div>
</div>
<div className="card">
<div className="card-body">
<Chat resetKey={resetKey} />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,104 @@
import { useState } from 'react'
import Modal from '../ui/Modal'
import { Badge, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { aiModules } from '../data/seed'
export default function AiStudio() {
const { toast } = useToast()
const [detail, setDetail] = useState(null)
const betaCount = aiModules.filter((m) => m.status === 'Beta').length
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">AI Studio</h1>
<p className="page-sub">
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>
</div>
</div>
<div className="card brand-hero mb-18">
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap' }}>
<div className="ai-logo" style={{ margin: 0, width: 56, height: 56 }}><Icon name="sparkles" /></div>
<div style={{ flex: 1, minWidth: 220 }}>
<h2 style={{ fontSize: 19, marginBottom: 4 }}>Everything is API-ready</h2>
<p style={{ opacity: 0.88 }}>
Each module below ships with a complete, production-grade interface. Connect your model
endpoint to activate them no UI work required.
</p>
</div>
<button className="btn btn-on-brand" onClick={() => toast('Integration guide opened', 'info')}>
<Icon name="external" /> Integration Guide
</button>
</div>
</div>
<div className="grid g-3">
{aiModules.map((m) => (
<div key={m.name} className="card" style={{ cursor: 'pointer' }} onClick={() => setDetail(m)}>
<div className="card-body">
<div className="flex items-center" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<span className={`kpi-icn ${m.cls}`} style={{ width: 46, height: 46, borderRadius: 13 }}>
<Icon name={m.icon} />
</span>
<Badge className={m.status === 'Beta' ? 'b-indigo' : 'b-gray'}>{m.status}</Badge>
</div>
<div className="lr-title" style={{ fontSize: 15 }}>{m.name}</div>
<div className="lr-sub" style={{ marginTop: 5, lineHeight: 1.5 }}>{m.desc}</div>
<div style={{ marginTop: 14, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>
{m.status === 'Beta' ? 'Try it' : 'Join waitlist'} <Icon name="arrow-right" />
</div>
</div>
</div>
))}
</div>
{detail && (
<Modal
title={detail.name}
subtitle={`${detail.status} · AI Module`}
size="modal-lg"
onClose={() => setDetail(null)}
footer={<button className="btn btn-secondary" onClick={() => setDetail(null)}>Close</button>}
>
<div className="flex items-center gap-16" style={{ marginBottom: 18 }}>
<span className={`kpi-icn ${detail.cls}`} style={{ width: 56, height: 56, borderRadius: 16 }}>
<Icon name={detail.icon} />
</span>
<div>
<div className="fw-600" style={{ fontSize: 16 }}>{detail.name}</div>
<div className="text-muted">{detail.desc}</div>
</div>
</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>API Contract (preview)</div>
<pre className="resume-thumb" style={{ maxHeight: 'none' }}>
{`POST /api/ai/${detail.name.toLowerCase().replace(/ /g, '-')}
{
"context": { "jobId": "JOB-1001", "candidateIds": [...] },
"options": { "model": "claude-opus", "stream": true }
}
200 OK
{
"result": { ... },
"usage": { "tokens": 1240 }
}`}
</pre>
</div>
</div>
<p className="text-muted text-sm" style={{ marginTop: 14 }}>
<Icon name="lock" /> This features UI is complete. Backend wiring is the only remaining step.
</p>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,171 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { analytics as a } from '../data/seed'
export default function Analytics() {
const { toast } = useToast()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const trend = useMemo(
() => ({
labels: a.hiringTrend.labels,
area: true,
datasets: [
{ label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] },
{ label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] },
],
}),
[],
)
const apps = useMemo(() => ({ labels: a.hiringTrend.labels, data: a.hiringTrend.applications }), [])
const source = useMemo(
() => ({
labels: a.sources.map((s) => s.source),
data: a.sources.map((s) => s.count),
centerValue: candidates.length,
centerLabel: 'Total',
}),
[candidates.length],
)
const offer = useMemo(() => {
const { accepted, pending, declined } = a.offerAcceptance
return {
labels: ['Accepted', 'Pending', 'Declined'],
data: [accepted, pending, declined],
colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')],
centerValue: `${Math.round((accepted / (accepted + declined || 1)) * 100)}%`,
centerLabel: 'Accept rate',
}
}, [])
const pipeline = useMemo(
() => ({
labels: a.pipeline.map((p) => p.stage),
data: a.pipeline.map((p) => p.count),
colors: Charts.PALETTE,
}),
[],
)
const dept = useMemo(
() => ({ labels: a.departments.map((d) => d.dept), data: a.departments.map((d) => d.apps) }),
[],
)
const rec = useMemo(() => {
const top = [...recruiters].sort((x, y) => y.hires - x.hires).slice(0, 8)
return { labels: top.map((r) => r.name), data: top.map((r) => r.hires) }
}, [recruiters])
const tth = useMemo(
() => ({
labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,
datasets: [{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] }],
}),
[],
)
const ttf = useMemo(
() => ({
labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,
datasets: [{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] }],
}),
[],
)
const trendLegend = useMemo(
() => [
{ label: 'Applications', color: Charts.PALETTE[4] },
{ label: 'Hires', color: Charts.PALETTE[0] },
],
[],
)
const sourceLegend = useMemo(
() => a.sources.map((s, i) => ({ label: s.source, color: Charts.PALETTE[i % Charts.PALETTE.length] })),
[],
)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Analytics</h1>
<p className="page-sub">Deep-dive metrics across your recruitment funnel</p>
</div>
<div className="page-head-actions">
<div className="pill-tabs">
<span className="pill-tab">Week</span>
<span className="pill-tab active">Month</span>
<span className="pill-tab">Quarter</span>
</div>
<button className="btn btn-secondary" onClick={() => toast('Analytics exported', 'success')}>
<Icon name="download" /> Export
</button>
</div>
</div>
<div className="grid g-2 mb-18">
<div className="card">
<div className="card-head"><div><h3>Hiring Trend</h3><span className="ch-sub">Hires vs applications</span></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="line" data={trend} height={260} /></div>
<ChartLegend items={trendLegend} />
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Applications Received</h3><span className="ch-sub">Monthly volume</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={apps} height={260} /></div></div>
</div>
</div>
<div className="grid g-3 mb-18">
<div className="card">
<div className="card-head"><div><h3>Source Breakdown</h3></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="doughnut" data={source} height={220} /></div>
<ChartLegend items={sourceLegend} />
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Offer Acceptance</h3></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="doughnut" data={offer} height={220} /></div>
<div className="chart-legend">
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--success)' }} />Accepted</span>
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--warning)' }} />Pending</span>
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--danger)' }} />Declined</span>
</div>
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Pipeline Distribution</h3></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={pipeline} height={260} /></div></div>
</div>
</div>
<div className="grid g-2 mb-18">
<div className="card">
<div className="card-head"><div><h3>Applications by Department</h3><span className="ch-sub">Volume per team</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={dept} height={300} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Recruiter Performance</h3><span className="ch-sub">Hires by recruiter (top 8)</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={rec} height={300} /></div></div>
</div>
</div>
<div className="grid g-2">
<div className="card">
<div className="card-head"><div><h3>Time to Hire</h3><span className="ch-sub">Days, monthly average</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={tth} height={240} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Time to Fill</h3><span className="ch-sub">Days, monthly average</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={ttf} height={240} /></div></div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,248 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Avatar, Badge, Icon, KpiCard, ProgressBar, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { candidates as allCandidates, fmtDate, fmtShort, int } from '../data/seed'
const SECTIONS = ['Problem Solving', 'Code Quality', 'Communication', 'Time Management']
export default function Assessments() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: assessments = [] } = useQuery(seedQuery('assessments'))
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
const [assigning, setAssigning] = useState(false)
const stats = useMemo(() => {
const scored = assessments.filter((a) => a.score)
return {
total: assessments.length,
completed: assessments.filter((a) => a.status === 'Completed').length,
pending: assessments.filter((a) => ['Pending', 'In Progress'].includes(a.status)).length,
avg: Math.round(scored.reduce((s, a) => s + a.score, 0) / (scored.length || 1)),
}
}, [assessments])
const types = useMemo(() => [...new Set(assessments.map((a) => a.type))], [assessments])
const rows = useMemo(
() =>
assessments.filter((a) => {
if (status && a.status !== status) return false
if (type && a.type !== type) return false
if (q && !(a.candidate + a.jobTitle + a.type).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
[assessments, q, status, type],
)
// Section scores were generated inline at render in the prototype, so they
// reshuffled on every repaint. Derived per assessment id and memoised here.
const sectionScores = useMemo(
() => (viewing ? SECTIONS.map((s) => ({ label: s, score: int(60, 98) })) : []),
[viewing],
)
const columns = [
{
key: 'candidate', label: 'Candidate', sortable: true,
render: (a) => (
<div className="user-cell">
<Avatar name={a.candidate} initials={a.initials} color={a.color} />
<div>
<div className="cell-primary">{a.candidate}</div>
<div className="cell-sub">{a.jobTitle}</div>
</div>
</div>
),
},
{
key: 'type', label: 'Assessment', sortable: true,
render: (a) => (
<>
<div className="cell-primary text-sm">{a.type}</div>
<div className="cell-sub">{a.duration}</div>
</>
),
},
{ key: 'assigned', label: 'Assigned', sortable: true, sortValue: (a) => a.assigned.getTime(), render: (a) => <span className="text-muted">{fmtShort(a.assigned)}</span> },
{ key: 'due', label: 'Due', sortable: true, sortValue: (a) => a.due.getTime(), render: (a) => <span className="text-muted">{fmtShort(a.due)}</span> },
{ key: 'score', label: 'Score', sortable: true, align: 'center', render: (a) => (a.score !== null ? <ScoreChip score={a.score} /> : <span className="text-muted"></span>) },
{ key: 'status', label: 'Status', sortable: true, render: (a) => <Badge>{a.status}</Badge> },
{
key: '_a', label: 'Actions', align: 'right',
render: (a) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(a)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Remind" onClick={() => toast(`Reminder sent to ${a.candidate}`, 'info')}><Icon name="mail" /></button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Assessments</h1>
<p className="page-sub">Coding tests, take-homes, and evaluations</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setAssigning(true)}>
<Icon name="plus" /> Assign Assessment
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Total Assigned" value={stats.total} icon="file" tone="i-indigo" />
<KpiCard label="Completed" value={stats.completed} icon="check-circle" tone="i-green" />
<KpiCard label="In Progress / Pending" value={stats.pending} icon="clock" tone="i-amber" />
<KpiCard label="Average Score" value={`${stats.avg}%`} icon="target" tone="i-teal" />
</div>
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or assessment…" />
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{['Completed', 'In Progress', 'Pending', 'Expired'].map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
{types.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
{viewing && (
<Modal
title="Assessment Result"
subtitle={viewing.id}
onClose={() => setViewing(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setViewing(null)}>Close</button>
<button
className="btn btn-primary"
onClick={() => {
const id = viewing.candidateId
setViewing(null)
navigate('/candidates', { state: { openCandidate: id } })
}}
>
View Candidate
</button>
</>
}
>
<div className="flex items-center gap-12 mb-18">
<Avatar name={viewing.candidate} initials={viewing.initials} color={viewing.color} className="avatar-lg" />
<div>
<div className="ph-name" style={{ fontSize: 17 }}>{viewing.candidate}</div>
<div className="ph-role">{viewing.type} · {viewing.jobTitle}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{viewing.status}</Badge></div>
</div>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Type</div><div className="iv">{viewing.type}</div></div>
<div className="info-item"><div className="il">Duration</div><div className="iv">{viewing.duration}</div></div>
<div className="info-item"><div className="il">Assigned</div><div className="iv">{fmtDate(viewing.assigned)}</div></div>
<div className="info-item"><div className="il">Due</div><div className="iv">{fmtDate(viewing.due)}</div></div>
</div>
{viewing.score !== null ? (
<>
<div className="divider" />
<div style={{ textAlign: 'center', padding: '10px 0' }}>
<div
style={{
fontSize: 44, fontWeight: 800, letterSpacing: -1,
color: viewing.score >= 70 ? 'var(--success)' : 'var(--warning)',
}}
>
{viewing.score}%
</div>
<div className="text-muted">Overall Score</div>
</div>
<div className="mb-18"><ProgressBar pct={viewing.score} /></div>
<div className="form-section-title" style={{ marginTop: 0 }}>Section Breakdown</div>
{sectionScores.map((s) => (
<div className="flex items-center gap-12" style={{ marginBottom: 10 }} key={s.label}>
<span style={{ width: 130, fontSize: 13 }}>{s.label}</span>
<div style={{ flex: 1 }}><ProgressBar pct={s.score} /></div>
<b style={{ width: 40, textAlign: 'right' }}>{s.score}%</b>
</div>
))}
</>
) : (
<div className="empty-state">
<Icon name="clock" />
<h3>Assessment not completed</h3>
<p>Results will appear once the candidate submits.</p>
</div>
)}
</Modal>
)}
{assigning && (
<Modal
title="Assign Assessment"
subtitle="Send an evaluation to a candidate"
onClose={() => setAssigning(false)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setAssigning(false)}>Cancel</button>
<button
className="btn btn-primary"
onClick={() => {
setAssigning(false)
toast('Assessment assigned & invite sent', 'success')
}}
>
<Icon name="send" /> Assign
</button>
</>
}
>
<form>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate</label>
<select>{allCandidates.slice(0, 40).map((c) => <option key={c.id}>{c.name}</option>)}</select>
</div>
<div className="form-field">
<label>Assessment Type</label>
<select>{types.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field">
<label>Time Limit</label>
<select><option>45 min</option><option>60 min</option><option>90 min</option><option>3 days</option></select>
</div>
<div className="form-field col-span-2">
<label>Due Date</label>
<input type="date" />
</div>
</div>
</form>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,149 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Avatar, Icon } from '../ui/primitives'
import { seedQuery } from '../data/seedQueries'
import { TODAY } from '../data/seed'
const EVENT_COLORS = {
'Phone Screen': 'b-blue', Technical: 'b-indigo', 'System Design': 'b-purple',
'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green',
'Final Round': 'b-red',
}
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
/** The prototype's hand-built month grid, unchanged in behaviour. */
function buildCells(year, month) {
const startDow = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const prevDays = new Date(year, month, 0).getDate()
const cells = []
for (let i = startDow - 1; i >= 0; i--) cells.push({ day: prevDays - i, other: true })
for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, other: false, date: new Date(year, month, d) })
while (cells.length % 7 !== 0 || cells.length < 42) {
cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true })
}
return cells.slice(0, 42)
}
export default function Calendar() {
const navigate = useNavigate()
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const [{ year, month }, setView] = useState({ year: TODAY.getFullYear(), month: TODAY.getMonth() })
const cells = useMemo(() => buildCells(year, month), [year, month])
const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
const todayKey = TODAY.toDateString()
const todayIvs = interviews.filter((iv) => iv.when.toDateString() === todayKey)
const step = (delta) =>
setView(({ year: y, month: m }) => {
const next = m + delta
if (next < 0) return { year: y - 1, month: 11 }
if (next > 11) return { year: y + 1, month: 0 }
return { year: y, month: next }
})
const openCandidate = (id) => navigate('/candidates', { state: { openCandidate: id } })
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Calendar</h1>
<p className="page-sub">Interview schedule at a glance</p>
</div>
<div className="page-head-actions">
<div className="flex items-center gap-8">
<button className="btn btn-icon btn-secondary" onClick={() => step(-1)} aria-label="Previous month">
<Icon name="chevron-left" />
</button>
<span className="fw-600" style={{ minWidth: 140, textAlign: 'center' }}>{monthName}</span>
<button className="btn btn-icon btn-secondary" onClick={() => step(1)} aria-label="Next month">
<Icon name="chevron-right" />
</button>
</div>
<button
className="btn btn-primary"
onClick={() => navigate('/interviews', { state: { openSchedule: true } })}
>
<Icon name="plus" /> Schedule
</button>
</div>
</div>
<div className="grid g-2-1">
<div className="card">
<div className="card-body">
<div className="cal-grid">
{DOW.map((d) => <div className="cal-dow" key={d}>{d}</div>)}
{cells.map((c, i) => {
const dayEvents = !c.other && c.date
? interviews.filter((iv) => iv.when.toDateString() === c.date.toDateString())
: []
const isToday = !c.other && c.date && c.date.toDateString() === todayKey
return (
<div className={`cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}`} key={i}>
<div className="cal-date">{c.day}</div>
{dayEvents.slice(0, 3).map((iv) => (
<div
key={iv.id}
className={`cal-event ${EVENT_COLORS[iv.type] || 'b-blue'}`}
title={`${iv.candidate} · ${iv.type}`}
onClick={() => openCandidate(iv.candidateId)}
>
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
</div>
))}
{dayEvents.length > 3 && (
<div className="cal-event b-gray">+{dayEvents.length - 3} more</div>
)}
</div>
)
})}
</div>
</div>
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head">
<div>
<h3>Today</h3>
<span className="ch-sub">
{TODAY.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
<div className="card-body">
<div className="list-tight">
{todayIvs.length === 0 ? (
<p className="text-muted">No interviews today</p>
) : (
todayIvs.map((iv) => (
<div
key={iv.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => openCandidate(iv.candidateId)}
>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,277 @@
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
single largest block in js/candidates.js and deserves its own file. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { companies, fmtDate, moneyK, pick } from '../data/seed'
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
const { toast } = useToast()
const [tab, setTab] = useState('Overview')
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
// The prototype called DB.pick() inline while rendering, so the "previous
// employer" changed every repaint. Fixed per candidate.
const priorCompany = useMemo(() => pick(companies), [])
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
return (
<Modal
title="Candidate Profile"
subtitle={c.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button
className={`btn btn-ghost star-btn${c.favorite ? ' on' : ''}`}
style={{ marginRight: 'auto' }}
onClick={() => onToggleFav(c)}
>
<Icon name="star" /> {c.favorite ? 'Favorited' : 'Favorite'}
</button>
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
<button className="btn btn-secondary" onClick={() => toast('Email drafted', 'info')}>
<Icon name="mail" /> Message
</button>
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
<Icon name="check" /> Advance Stage
</button>
</>
}
>
<div className="profile-hero">
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{c.name}</div>
<div className="ph-role">{c.currentTitle} at {c.currentCompany}</div>
<div className="ph-tags">
<Badge>{c.stage}</Badge> <Badge className="b-gray">{c.source}</Badge>
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
</div>
<div style={{ marginTop: 22 }}>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{c.email}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{c.phone}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{c.location}</div></div>
<div className="info-item"><div className="il">Applied For</div><div className="iv">{c.jobTitle}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience} years</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{c.education}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{c.source}</div></div>
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{c.recruiter}</div></div>
<div className="info-item"><div className="il">Applied On</div><div className="iv">{fmtDate(c.applied)}</div></div>
<div className="info-item"><div className="il">Expected Salary</div><div className="iv">{moneyK(c.salary)}</div></div>
<div className="info-item"><div className="il">Rating</div><div className="iv"> {c.rating} / 5.0</div></div>
</div>
<div style={LABEL}>Skills</div>
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</>
)}
{tab === 'Resume' && (
<>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<h3 style={{ marginBottom: 4 }}>{c.name}</h3>
<p className="text-muted">{c.currentTitle} · {c.location}</p>
<div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Summary</div>
<p className="text-muted">
Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience
across {c.department.toLowerCase()}. Passionate about building high-quality products
and collaborating with cross-functional teams.
</p>
<div className="form-section-title">Experience</div>
<div className="info-item">
<div className="iv">{c.currentTitle} {c.currentCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2021 Present</div>
</div>
<div className="info-item" style={{ marginTop: 10 }}>
<div className="iv">Associate {priorCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2018 2021</div>
</div>
<div className="form-section-title">Education</div>
<div className="iv">{c.education}</div>
</div>
</div>
<button className="btn btn-secondary" style={{ marginTop: 14 }} onClick={() => toast('Downloading resume.pdf', 'info')}>
<Icon name="download" /> Download PDF
</button>
</>
)}
{tab === 'Timeline' && (
<div className="timeline">
{[
{ icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` },
{ icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` },
{ icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` },
{ icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' },
{ icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' },
].map((e) => (
<div className="tl-item" key={e.title}>
<div className="tl-dot"><Icon name={e.icon} /></div>
<div className="tl-title">{e.title}</div>
<div className="tl-meta">{e.meta}</div>
<div className="tl-desc">{e.desc}</div>
</div>
))}
</div>
)}
{tab === 'Interview' && (
candidateInterviews.length ? (
<div className="list-tight">
{candidateInterviews.map((iv) => (
<div className="list-row" key={iv.id}>
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="calendar" />
</span>
<div className="lr-main">
<div className="lr-title">{iv.type}</div>
<div className="lr-sub">{fmtDate(iv.when)} · {iv.meeting}</div>
</div>
<div className="lr-right"><Badge>{iv.status}</Badge></div>
</div>
))}
</div>
) : (
<EmptyState icon="calendar" title="No interviews scheduled">
Schedule an interview to get started.
</EmptyState>
)
)}
{tab === 'Notes' && (
<>
<div className="form-field">
<label>Add a note</label>
<textarea placeholder="Write a private note about this candidate…" />
</div>
<button className="btn btn-primary btn-sm" style={{ margin: '10px 0 18px' }} onClick={() => toast('Note saved', 'success')}>
<Icon name="plus" /> Add Note
</button>
<div className="list-tight">
<div className="list-row">
<Avatar name={c.recruiter} />
<div className="lr-main">
<div className="lr-title">{c.recruiter}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Strong communication skills, great culture fit. Recommend advancing.
</div>
<div className="lr-sub">2 days ago</div>
</div>
</div>
<div className="list-row">
<Avatar name="Asfand Ahmed" initials="AA" />
<div className="lr-main">
<div className="lr-title">Asfand Ahmed</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Reviewed portfolio impressive work. Schedule technical round.
</div>
<div className="lr-sub">4 days ago</div>
</div>
</div>
</div>
</>
)}
{tab === 'Activity' && (
<div className="list-tight">
{[
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
{ icon: 'mail', tone: 'i-blue', text: 'Email sent: Interview invitation', when: '1 day ago' },
{ icon: 'star', tone: 'i-amber', text: `Assessment score updated to ${c.aiScore}%`, when: '2 days ago' },
{ icon: 'user-plus', tone: 'i-purple', text: `Applied for ${c.jobTitle}`, when: fmtDate(c.applied) },
].map((a) => (
<div className="list-row" key={a.text}>
<span className={`kpi-icn ${a.tone}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.text}</div>
<div className="lr-sub">{a.when}</div>
</div>
</div>
))}
</div>
)}
{tab === 'Documents' && (
<div className="list-tight">
{[
{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' },
{ n: 'Portfolio.pdf', s: '4.2 MB' }, { n: 'References.docx', s: '48 KB' },
].map((d) => (
<div className="list-row" key={d.n}>
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="file" />
</span>
<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')}>
<Icon name="download" />
</button>
</div>
))}
</div>
)}
{tab === 'Feedback' && (
<>
<div className="list-tight">
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
const r = recruiters[i]
if (!r) return null
const notes = [
'Excellent technical depth and clear communication.',
'Good problem solving, would benefit from more system design exposure.',
'Solid candidate, positive team energy.',
]
return (
<div className="list-row" key={score}>
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{notes[i]}</div>
</div>
<div className="lr-right"><Badge>{score}</Badge></div>
</div>
)
})}
</div>
<button className="btn btn-primary btn-sm" style={{ marginTop: 14 }} onClick={() => toast('Scorecard form opened', 'info')}>
<Icon name="plus" /> Submit Scorecard
</button>
</>
)}
</div>
</Modal>
)
}

View File

@ -0,0 +1,660 @@
/* ============================================================
Candidates the largest screen in the app: a 14-facet filter panel, a
composite relevance sort, a multi-select bulk bar, favourites, a
recently-viewed strip, the ATS-match modal and the 8-tab profile
(CandidateProfile.jsx).
Uses the headless `useDataTable` rather than <DataTable/>, because the
selection column needs to render against a Set this component owns.
============================================================ */
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon, ProgressBar, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import CandidateProfile from './CandidateProfile'
import { qk } from '../lib/queryKeys'
import { persist, seedQuery, useSeedMutation } from '../data/seedQueries'
import {
atsRecommendationClass, avatarColor, departments, educationLevels, getJob,
initials as initialsOf, int, locations, skillsPool, sources, stages, TODAY,
} from '../data/seed'
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const EXP_BUCKETS = ['0-2', '3-5', '6-9', '10+']
const ATS_BANDS = ['85+', '70-84', '<70']
const INTERVIEW_STATES = ['Not Scheduled', 'Scheduled', 'Completed']
const NOTICE = ['Immediate', '2 weeks', '1 month', '2 months', '3 months']
const AVAILABILITY = ['Immediate', '2 weeks', '1 month', 'Passive']
const EMPTY_FILTERS = {
job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '',
manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '',
}
export default function Candidates() {
const { toast } = useToast()
const qc = useQueryClient()
const location = useLocation()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recentlyViewed = [] } = useQuery({
queryKey: qk.seed.recentlyViewed(),
queryFn: async () => [],
staleTime: Infinity,
gcTime: Infinity,
})
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('relevance')
const [selected, setSelected] = useState(() => new Set())
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const [bulkAssigning, setBulkAssigning] = useState(false)
/** ATS + matched-skill ratio + recency. Verbatim from js/candidates.js:14-20. */
const relevance = useCallback((c) => {
const req = (getJob(c.jobId) || {}).skills || []
const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5
const recency = 1 - Math.min(1, (TODAY - c.applied) / (90 * 864e5))
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
}, [])
const openProfile = useCallback(
(c) => {
setProfileFor(c)
qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => {
const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12)
persist('tf-recent', next)
return next
})
},
[qc],
)
// Deep links from global search, dashboard, pipeline, calendar, interviews
useEffect(() => {
const st = location.state
if (!st) return
if (st.openAdd) setAdding(true)
if (st.openCandidate) {
const c = candidates.find((x) => x.id === st.openCandidate)
if (c) openProfile(c)
}
}, [location.state, candidates, openProfile])
const jobTitles = useMemo(() => [...new Set(candidates.map((c) => c.jobTitle))], [candidates])
const rows = useMemo(() => {
const f = filters
let list = candidates.filter((c) => {
if (f.job && c.jobTitle !== f.job) return false
if (f.skill && !c.skills.includes(f.skill)) return false
if (f.dept && c.department !== f.dept) return false
if (f.location && c.location !== f.location) return false
if (f.exp === '0-2' && c.experience > 2) return false
if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false
if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false
if (f.exp === '10+' && c.experience < 10) return false
if (f.edu && c.education !== f.edu) return false
if (f.recruiter && c.recruiter !== f.recruiter) return false
if (f.manager) {
const job = getJob(c.jobId)
if (!job || job.manager !== f.manager) return false
}
if (f.source && c.source !== f.source) return false
if (f.ats === '85+' && c.aiScore < 85) return false
if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && c.aiScore >= 70) return false
if (f.stage && c.stage !== f.stage) return false
if (f.interview && c.interviewStatus !== f.interview) return false
if (f.notice && c.noticePeriod !== f.notice) return false
if (f.availability && c.availability !== f.availability) return false
if (q) {
const term = q.toLowerCase()
const hay = (c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase()
if (!hay.includes(term)) return false
}
return true
})
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
else if (sortMode === 'ats') list = [...list].sort((a, b) => b.aiScore - a.aiScore)
else if (sortMode === 'recent') list = [...list].sort((a, b) => b.applied - a.applied)
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
return list
}, [candidates, filters, q, sortMode, relevance])
const columns = useMemo(
() => [
{ key: '_sel', label: '' },
{ key: 'name', label: 'Candidate', sortable: true },
{ key: 'jobTitle', label: 'Applied Job', sortable: true },
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
{ key: 'stage', label: 'Stage', sortable: true },
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
{ key: 'availability', label: 'Availability' },
{ key: '_a', label: 'Actions', align: 'right' },
],
[relevance],
)
const t = useDataTable({ columns, rows, pageSize: 10 })
function toggleSelect(id) {
setSelected((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleFav(c) {
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
}
function advance(c) {
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) {
toast(`${c.name} cannot be advanced further`, 'warning')
return
}
const stage = STAGE_ORDER[i + 1]
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
toast(`${c.name} moved to ${stage}`, 'success')
}
function bulk(action) {
const ids = [...selected]
if (!ids.length) return
if (action === 'email') {
toast(`Bulk email drafted to ${ids.length} candidates`, 'success')
setSelected(new Set())
return
}
if (action === 'assign') {
setBulkAssigning(true)
return
}
if (action === 'advance') {
updateCandidates((cs) =>
cs.map((c) => {
if (!selected.has(c.id)) return c
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) return c
const stage = STAGE_ORDER[i + 1]
return { ...c, stage, status: stage }
}),
)
toast(`${ids.length} candidates advanced`, 'success')
}
if (action === 'reject') {
updateCandidates((cs) =>
cs.map((c) => (selected.has(c.id) ? { ...c, stage: 'Rejected', status: 'Rejected' } : c)),
)
toast(`${ids.length} candidates rejected`, 'warning')
}
setSelected(new Set())
}
const recentChips = recentlyViewed
.slice(0, 6)
.map((id) => candidates.find((c) => c.id === id))
.filter(Boolean)
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Candidates</h1>
<p className="page-sub">
{rows.length} candidate{rows.length === 1 ? '' : 's'} · ranked by AI relevance
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Search saved', 'success')}>
<Icon name="bookmark" /> Save Search
</button>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate
</button>
</div>
</div>
{recentChips.length > 0 && (
<div className="flex items-center gap-8" style={{ marginBottom: 14, flexWrap: 'wrap' }}>
<span className="text-muted text-sm fw-600">Recently viewed:</span>
{recentChips.map((c) => (
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
<Avatar name={c.name} initials={c.initials} color={c.color} /> {c.name.split(' ')[0]}
</button>
))}
</div>
)}
{selected.size > 0 && (
<div className="bulk-bar" style={{ display: 'flex' }}>
<span className="checkbox on"><Icon name="check" /></span>
<span className="fw-600">{selected.size} selected</span>
<div style={{ flex: 1 }} />
<button className="btn btn-sm" onClick={() => bulk('email')}><Icon name="mail" /> Bulk Email</button>
<button className="btn btn-sm" onClick={() => bulk('assign')}><Icon name="users" /> Assign</button>
<button className="btn btn-sm" onClick={() => bulk('advance')}><Icon name="check" /> Advance</button>
<button className="btn btn-sm" onClick={() => bulk('reject')}><Icon name="x" /> Reject</button>
<button className="btn btn-sm" onClick={() => setSelected(new Set())}><Icon name="x" /> Clear</button>
</div>
)}
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
</div>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters
</button>
<div className="spacer" />
<label className="text-muted text-sm">Sort:</label>
<select className="select" value={sortMode} onChange={(e) => setSortMode(e.target.value)}>
<option value="relevance">AI Relevance</option>
<option value="ats">ATS Score</option>
<option value="recent">Most Recent</option>
<option value="name">Name AZ</option>
</select>
</div>
{showFilters && (
<div
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobTitles} />
<Facet label="Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillsPool} />
<Facet label="Department" value={filters.dept} onChange={(v) => setFilter('dept', v)} any="Any Dept" options={departments} />
<Facet label="Location" value={filters.location} onChange={(v) => setFilter('location', v)} any="Any Location" options={locations} />
<Facet label="Experience" value={filters.exp} onChange={(v) => setFilter('exp', v)} any="Any Exp" options={EXP_BUCKETS} />
<Facet label="Education" value={filters.edu} onChange={(v) => setFilter('edu', v)} any="Any" options={educationLevels} />
<Facet label="Recruiter" value={filters.recruiter} onChange={(v) => setFilter('recruiter', v)} any="Any Recruiter" options={recruiters.map((r) => r.name)} />
<Facet label="Hiring Manager" value={filters.manager} onChange={(v) => setFilter('manager', v)} any="Any Manager" options={managers.map((m) => m.name)} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={sources} />
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
<Facet label="Pipeline Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any Stage" options={stages} />
<Facet label="Interview Status" value={filters.interview} onChange={(v) => setFilter('interview', v)} any="Any" options={INTERVIEW_STATES} />
<Facet label="Notice Period" value={filters.notice} onChange={(v) => setFilter('notice', v)} any="Any" options={NOTICE} />
<Facet label="Availability" value={filters.availability} onChange={(v) => setFilter('availability', v)} any="Any" options={AVAILABILITY} />
</div>
)}
</div>
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<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>
{t.pageRows.length === 0 ? (
<tr><td colSpan={columns.length}><EmptyState /></td></tr>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<span
className={`checkbox ${selected.has(c.id) ? 'on' : ''}`}
onClick={() => toggleSelect(c.id)}
role="checkbox"
aria-checked={selected.has(c.id)}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelect(c.id) } }}
>
<Icon name="check" />
</span>
</td>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="cell-primary">
{c.name}{' '}
{c.favorite && (
<span className="star-btn on" style={{ display: 'inline' }}><Icon name="star" /></span>
)}
</div>
<div className="cell-sub">{c.currentTitle} · {c.location}</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{c.jobTitle}</div>
<div className="cell-sub">{c.department}</div>
</td>
<td style={{ textAlign: 'center' }}><b>{c.experience}</b>y</td>
<td style={{ textAlign: 'center' }}>
<span className={`badge ${atsRecommendationClass(c.recommendation)} badge-plain`}>
{relevance(c)}%
</span>
</td>
<td><Badge>{c.stage}</Badge></td>
<td style={{ textAlign: 'center' }}>
<span style={{ cursor: 'pointer' }} onClick={() => setAtsFor(c)}>
<ScoreChip score={c.aiScore} />
</span>
</td>
<td>
<span className="text-sm">{c.availability}</span>
<div className="cell-sub">{c.noticePeriod} notice</div>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className={`act-btn star-btn ${c.favorite ? 'on' : ''}`} data-tip="Favorite" onClick={() => toggleFav(c)}>
<Icon name="star" />
</button>
<button className="act-btn" data-tip="ATS Match" onClick={() => setAtsFor(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Advance" onClick={() => advance(c)}><Icon name="check" /></button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination {...t} />
</div>
</div>
{atsFor && <AtsMatch candidate={atsFor} onClose={() => setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />}
{profileFor && (
<CandidateProfile
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
/>
)}
{bulkAssigning && (
<BulkAssign
count={selected.size}
recruiters={recruiters}
onClose={() => setBulkAssigning(false)}
onSave={(name) => {
updateCandidates((cs) => cs.map((c) => (selected.has(c.id) ? { ...c, recruiter: name } : c)))
setBulkAssigning(false)
setSelected(new Set())
toast('Recruiter assigned to selected candidates', 'success')
}}
/>
)}
{adding && (
<AddCandidate
jobs={jobs}
count={candidates.length}
onClose={() => setAdding(false)}
onSave={(c) => {
updateCandidates((cs) => [c, ...cs])
setAdding(false)
toast('Candidate added to pipeline', 'success')
}}
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
/>
)}
</div>
)
}
function Facet({ label, value, onChange, any, options }) {
return (
<div className="form-field">
<label>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{any}</option>
{options.map((o) => <option key={o}>{o}</option>)}
</select>
</div>
)
}
function AtsMatch({ candidate: c, onClose, onProfile }) {
const sub = c.subScores
const recCls = c.recommendation === 'Strong Match' ? 'recc-strong'
: c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'
const Row = ({ label, val }) => (
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<span style={{ width: 110, fontSize: 13 }}>{label}</span>
<div style={{ flex: 1 }}><ProgressBar pct={val} /></div>
<b style={{ width: 42, textAlign: 'right' }}>{val}%</b>
</div>
)
return (
<Modal
title="ATS Match Analysis"
subtitle={`${c.id} · ${c.jobTitle}`}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-primary" onClick={() => onProfile(c)}>View Full Profile</button>
</>
}
>
<div className={`recc-banner ${recCls}`}>
<span className="recc-icn">
<Icon name={c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
</span>
<div style={{ flex: 1 }}>
<div className="fw-600" style={{ fontSize: 15 }}>{c.recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {c.jobTitle}</div>
</div>
</div>
<div className="grid g-2" style={{ alignItems: 'center', marginBottom: 20 }}>
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ '--pct': c.aiScore, '--c': ringColor }}>
<div className="ats-val">
<div className="ats-num">{c.aiScore}</div>
<div className="ats-lbl">ATS MATCH</div>
</div>
</div>
</div>
<div>
<Row label="Skills" val={sub.skills} />
<Row label="Experience" val={sub.experience} />
<Row label="Education" val={sub.education} />
<Row label="Keywords" val={sub.keywords} />
<Row label="Location" val={sub.location} />
<Row label="Salary" val={sub.salary} />
</div>
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({c.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({c.missingSkills.length})
</div>
<div className="k-tags">
{c.missingSkills.length
? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
<div className="divider" />
<p className="text-muted text-sm">
<Icon name="sparkles" /> Score computed from JD keywords, resume parsing, experience,
education, location and salary alignment. Connect an AI model to refine with semantic matching.
</p>
</Modal>
)
}
function BulkAssign({ count, recruiters, onClose, onSave }) {
const [name, setName] = useState(recruiters[0]?.name ?? '')
return (
<Modal
title="Bulk Assign Recruiter"
subtitle={`${count} candidates`}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Assign to</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
</Modal>
)
}
function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
const open = jobs.filter((j) => j.status === 'Open')
const form = useFormState({
name: '', email: '', phone: '', job: open[0]?.title ?? '',
experience: '3', company: '', source: sources[0], stage: stages[0],
})
function submit() {
const v = form.values
const errors = {}
if (!v.name.trim()) errors.name = 'Required'
if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = 'Valid email required'
form.setErrors(errors)
if (Object.keys(errors).length) {
onInvalid()
return
}
const job = jobs.find((j) => j.title === v.job) || jobs[0]
const score = int(55, 95)
onSave({
id: `CAN-${5001 + count}`,
name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name),
email: v.email, phone: v.phone || '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: Number(v.experience) || 1, currentCompany: v.company || '—',
currentTitle: job.title, location: job.location,
stage: v.stage, status: v.stage, aiScore: score, source: v.source,
recruiter: job.recruiter, recruiterId: job.recruiterId,
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: 120000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match',
subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
})
}
const field = (n) => ({ value: form.values[n], onChange: (e) => form.setField(n, e.target.value) })
return (
<Modal
title="Add Candidate"
subtitle="Manually add a candidate to the pipeline"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Add Candidate</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field">
<label>Full Name <span className="req">*</span></label>
<input {...field('name')} className={form.errors.name ? 'err' : ''} placeholder="Jane Doe" />
<FieldError>{form.errors.name}</FieldError>
</div>
<div className="form-field">
<label>Email <span className="req">*</span></label>
<input type="email" {...field('email')} className={form.errors.email ? 'err' : ''} placeholder="jane@email.com" />
<FieldError>{form.errors.email}</FieldError>
</div>
<div className="form-field"><label>Phone</label><input {...field('phone')} placeholder="+1 (555) 000-0000" /></div>
<div className="form-field">
<label>Applied Job <span className="req">*</span></label>
<select {...field('job')}>{open.map((j) => <option key={j.id}>{j.title}</option>)}</select>
</div>
<div className="form-field"><label>Experience (years)</label><input type="number" {...field('experience')} /></div>
<div className="form-field"><label>Current Company</label><input {...field('company')} placeholder="Acme Inc." /></div>
<div className="form-field">
<label>Source</label>
<select {...field('source')}>{sources.map((s) => <option key={s}>{s}</option>)}</select>
</div>
<div className="form-field">
<label>Stage</label>
<select {...field('stage')}>{stages.map((s) => <option key={s}>{s}</option>)}</select>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,333 @@
/* ============================================================
CV Import the UX shape is right; the mechanics are still simulated.
The prototype's dropzone read only `e.dataTransfer.files.length` and threw
the files away, then invented a queue with setInterval-driven progress. That
is preserved deliberately: there is no upload endpoint, no object storage and
no parser behind this yet, so pretending otherwise would be worse than the
honest "processed locally in this demo" label the screen already carries.
============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
avatarColor, companies, initials as initialsOf, int, locations, pick, TODAY,
} from '../data/seed'
const FIRST = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']
const LAST = ['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']
const STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' },
{ i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' },
{ i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' },
{ i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' },
{ i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' },
]
export default function CvImport() {
const { toast } = useToast()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const updateCandidates = useSeedMutation('candidates')
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const [duplicateFor, setDuplicateFor] = useState(null)
const timers = useRef(new Set())
useEffect(() => {
const set = timers.current
return () => {
set.forEach((t) => { clearInterval(t); clearTimeout(t) })
set.clear()
}
}, [])
const advance = useCallback((id) => {
const tick = setInterval(() => {
setQueue((q) =>
q.map((item) => {
if (item.id !== id || item.status !== 'Uploading') return item
const progress = Math.min(100, item.progress + int(12, 30))
if (progress >= 100) {
clearInterval(tick)
timers.current.delete(tick)
const done = setTimeout(() => {
setQueue((q2) =>
q2.map((x) => (x.id === id ? { ...x, status: 'Ready', atsScore: int(52, 96) } : x)),
)
timers.current.delete(done)
}, 700 + int(0, 500))
timers.current.add(done)
return { ...item, progress: 100, status: 'Parsing' }
}
return { ...item, progress }
}),
)
}, 220)
timers.current.add(tick)
}, [])
const simulate = useCallback(
(count, isZip) => {
const n = isZip ? 8 : count
const open = jobs.filter((j) => j.status === 'Open')
const items = []
for (let k = 0; k < n; k++) {
const name = `${pick(FIRST)} ${pick(LAST)}`
items.push({
id: `UP-${Math.random().toString(36).slice(2, 8)}`,
name,
file: `${name.split(' ')[0]}_Resume.${pick(['pdf', 'docx', 'doc'])}`,
size: `${int(120, 620)} KB`,
progress: 0,
status: 'Uploading',
atsScore: null,
job: pick(open.length ? open : jobs),
duplicate: Math.random() < 0.18,
imported: false,
})
}
setQueue((q) => [...q, ...items])
items.forEach((i) => advance(i.id))
toast(isZip ? 'ZIP extracted — 8 resumes queued' : `${n} file(s) uploaded`, 'info')
},
[jobs, advance, toast],
)
const doImport = useCallback(
(id) => {
const item = queue.find((x) => x.id === id)
if (!item || item.imported) return
const job = item.job
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: item.name,
initials: initialsOf(item.name),
color: avatarColor(item.name),
email: `${item.name.toLowerCase().replace(/ /g, '.')}@email.com`,
phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 12), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: item.atsScore, source: 'Manual CV Upload',
recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
// Kept verbatim from the prototype, including the literal constants
// this breakdown is fabricated and is flagged as the most misleading
// artefact in the repo (01-repository-assessment.md §2.2).
subScores: { skills: item.atsScore, experience: 80, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
...cs,
])
setQueue((q) => q.map((x) => (x.id === id ? { ...x, imported: true } : x)))
toast(`${item.name} imported → ${job.title}`, 'success')
},
[queue, updateCandidates, toast],
)
function importOne(item) {
if (item.duplicate) setDuplicateFor(item)
else doImport(item.id)
}
function importAll() {
const ready = queue.filter((i) => i.status === 'Ready' && !i.imported && !i.duplicate)
if (!ready.length) {
toast('No files ready to import', 'warning')
return
}
ready.forEach((i) => doImport(i.id))
toast(`${ready.length} candidates imported`, 'success')
}
const importedCount = queue.filter((i) => i.imported).length
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">CV Import</h1>
<p className="page-sub">Upload resumes we parse, score, match, and dedupe automatically</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />AI Resume Parser · Ready</span>
</div>
</div>
<div className="grid g-2-1">
<div>
<div className="card mb-18">
<div className="card-body">
<div
className={`dropzone${dragging ? ' drag' : ''}`}
onClick={() => simulate(int(2, 4))}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
simulate(e.dataTransfer.files.length || int(2, 4))
}}
>
<div className="dz-icn"><Icon name="upload" /></div>
<h3>Drag &amp; drop resumes here</h3>
<p className="text-muted" style={{ marginBottom: 16 }}>
or click to browse PDF, DOC, DOCX and ZIP supported · up to 20 files
</p>
<button
className="btn btn-primary"
onClick={(e) => { e.stopPropagation(); simulate(int(2, 4)) }}
>
<Icon name="upload" /> Browse Files
</button>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 16 }}>
{['PDF', 'DOC', 'DOCX', 'ZIP'].map((t) => (
<span className="badge b-gray badge-plain" key={t}>{t}</span>
))}
</div>
</div>
<div className="flex items-center gap-8" style={{ marginTop: 16, flexWrap: 'wrap' }}>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(3)}>
<Icon name="sparkles" /> Simulate 3 files
</button>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(1, true)}>
<Icon name="layers" /> Simulate ZIP (8 CVs)
</button>
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>
Files are processed locally in this demo
</span>
</div>
</div>
</div>
{queue.length > 0 && (
<div className="card">
<div className="card-head">
<div>
<h3>Processing Queue</h3>
<span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {importedCount} imported
</span>
</div>
<button className="btn btn-primary btn-sm" onClick={importAll}>
<Icon name="check" /> Import All
</button>
</div>
<div className="card-body">
{queue.map((i) => (
<div className="upload-row" key={i.id}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-8">
<span className="fw-600 text-sm">{i.name}</span>
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 7px', fontSize: 10 }}>
DUPLICATE
</span>
)}
</div>
<div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Uploading' || i.status === 'Parsing' ? (
<div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: `${i.progress}%` }} />
</div>
) : (
<div className="cell-sub" style={{ marginTop: 4 }}>
Best match: <b>{i.job?.title}</b>
</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' ? (
<ScoreChip score={i.atsScore} />
) : (
<Badge className={i.status === 'Parsing' ? 'b-amber' : 'b-blue'}>
{i.status}{i.status === 'Uploading' ? ` ${i.progress}%` : ''}
</Badge>
)}
</div>
<div style={{ flexShrink: 0 }}>
{i.imported ? (
<Badge className="b-green">Imported</Badge>
) : i.status === 'Ready' ? (
<button className="btn btn-primary btn-sm" onClick={() => importOne(i)}>Import</button>
) : (
<button className="act-btn" disabled><Icon name="clock" /></button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<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="timeline">
{STEPS.map((s) => (
<div className="tl-item" key={s.t}>
<div className="tl-dot"><Icon name={s.i} /></div>
<div className="tl-title">{s.t}</div>
<div className="tl-desc">{s.d}</div>
</div>
))}
</div>
</div>
</div>
</div>
{duplicateFor && (
<Modal
title="Duplicate Detected"
subtitle={duplicateFor.name}
onClose={() => setDuplicateFor(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDuplicateFor(null)}>Cancel</button>
<button
className="btn btn-secondary"
onClick={() => { setDuplicateFor(null); toast('Merged into existing profile', 'success') }}
>
Merge
</button>
<button
className="btn btn-primary"
onClick={() => { const id = duplicateFor.id; setDuplicateFor(null); doImport(id) }}
>
Import Anyway
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-amber" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="users" />
</span>
<div>
<p className="fw-600" style={{ fontSize: 15 }}>A similar candidate already exists</p>
<p className="text-muted" style={{ marginTop: 4 }}>
{duplicateFor.name} matches an existing profile (95% similarity on name + email).
Importing will create a duplicate.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,257 @@
import { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts'
import { Avatar, Icon, KpiCard, ScoreChip } from '../ui/primitives'
import { seedQuery } from '../data/seedQueries'
import { analytics, fmtShort, kpis, money, relTime } from '../data/seed'
export default function Dashboard() {
const navigate = useNavigate()
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: activity = [] } = useQuery(seedQuery('activity'))
const k = kpis
// Chart payloads must be referentially stable, or <Chart/> re-runs its effect
// and re-animates on every parent render.
const trendData = useMemo(
() => ({
labels: analytics.hiringTrend.labels,
area: true,
datasets: [
{ label: 'Applications', data: analytics.hiringTrend.applications, color: Charts.PALETTE[4] },
{ label: 'Hires', data: analytics.hiringTrend.hires, color: Charts.PALETTE[0] },
],
}),
[],
)
const pipelineData = useMemo(
() => ({
labels: analytics.pipeline.map((p) => p.stage),
data: analytics.pipeline.map((p) => p.count),
colors: Charts.PALETTE,
}),
[],
)
const sourceData = useMemo(
() => ({
labels: analytics.sources.map((s) => s.source),
data: analytics.sources.map((s) => s.count),
}),
[],
)
const legend = useMemo(
() => [
{ label: 'Applications', color: Charts.PALETTE[4] },
{ label: 'Hires', color: Charts.PALETTE[0] },
],
[],
)
const upcoming = interviews.filter((iv) => iv.status === 'Scheduled').slice(0, 5)
const recentApps = [...candidates].sort((a, b) => b.applied - a.applied).slice(0, 5)
const topRecruiters = [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5)
const row1 = [
{ label: 'Open Jobs', value: k.openJobs, icon: 'briefcase', tone: 'i-indigo', trend: '+8%', dir: 'up', foot: 'vs last month' },
{ label: 'Total Candidates', value: k.totalCandidates, icon: 'users', tone: 'i-blue', trend: '+12%', dir: 'up', foot: 'active in pipeline' },
{ label: 'Interviews Today', value: k.interviewsToday, icon: 'calendar', tone: 'i-purple', trend: '3 upcoming', dir: 'flat', foot: 'next at 2:00 PM' },
{ label: 'Offers Accepted', value: k.offersAccepted, icon: 'check-circle', tone: 'i-green', trend: '+5%', dir: 'up', foot: `of ${k.offersSent} sent` },
]
const row2 = [
{ label: 'Time to Hire', value: `${k.timeToHire} days`, icon: 'clock', tone: 'i-teal', trend: '-3 days', dir: 'up', foot: 'faster than target' },
{ label: 'Time to Fill', value: `${k.timeToFill} days`, icon: 'target', tone: 'i-amber', trend: '-2 days', dir: 'up', foot: '41 day average' },
{ label: 'Cost per Hire', value: money(k.costPerHire), icon: 'dollar', tone: 'i-red', trend: '+4%', dir: 'down', foot: 'above budget' },
{ label: 'Closed Jobs', value: k.closedJobs + k.hires, icon: 'award', tone: 'i-purple', trend: '+15%', dir: 'up', foot: 'this quarter' },
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Good morning, Asfand 👋</h1>
<p className="page-sub">
Heres whats happening with your hiring today Thursday, July 9, 2026
</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export
</Link>
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job
</Link>
</div>
</div>
<div className="grid g-kpi">
{row1.map((c) => <KpiCard key={c.label} {...c} />)}
</div>
<div className="grid g-kpi mt-18">
{row2.map((c) => <KpiCard key={c.label} {...c} />)}
</div>
<div className="grid g-2-1 mt-18">
<div className="card">
<div className="card-head">
<div>
<h3>Hiring Trend</h3>
<span className="ch-sub">Hires vs applications over the last 7 months</span>
</div>
<div className="pill-tabs">
<span className="pill-tab active">7M</span>
<span className="pill-tab">1Y</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="line" data={trendData} height={280} />
</div>
<ChartLegend items={legend} />
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Candidate Pipeline</h3>
<span className="ch-sub">Active by stage</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="horizontalBar" data={pipelineData} height={280} />
</div>
</div>
</div>
</div>
<div className="grid g-2-1 mt-18">
<div className="card">
<div className="card-head">
<div>
<h3>Upcoming Interviews</h3>
<span className="ch-sub">Next scheduled sessions</span>
</div>
<Link className="btn btn-ghost btn-sm" to="/interviews">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
{upcoming.length === 0 ? (
<div className="empty-state" style={{ padding: 30 }}>No upcoming interviews</div>
) : (
upcoming.map((iv) => (
<div
key={iv.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/interviews')}
>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type} · {iv.jobTitle}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">{fmtShort(iv.when)}</div>
<div className="lr-sub">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Source Analytics</h3>
<span className="ch-sub">Where candidates come from</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="bar" data={sourceData} height={240} />
</div>
</div>
</div>
</div>
<div className="grid g-3 mt-18">
<div className="card">
<div className="card-head">
<div><h3>Recent Applications</h3></div>
<Link className="btn btn-ghost btn-sm" to="/candidates">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
{recentApps.map((c) => (
<div
key={c.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
>
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div className="lr-main">
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.jobTitle}</div>
</div>
<div className="lr-right"><ScoreChip score={c.aiScore} /></div>
</div>
))}
</div>
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Recruiter Performance</h3></div></div>
<div className="card-body">
<div className="list-tight">
{topRecruiters.map((r) => (
<div key={r.id} className="list-row">
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub">{r.openReqs} open reqs · {r.avgTimeToHire}d avg</div>
</div>
<div className="lr-right">
<div className="fw-600">{r.hires}</div>
<div className="lr-sub">hires</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Recent Activity</h3></div></div>
<div className="card-body" style={{ maxHeight: 360, overflowY: 'auto' }}>
<div className="list-tight">
{activity.slice(0, 8).map((a, i) => (
<div className="list-row" key={`${a.candidateId}-${i}`}>
<span className={`kpi-icn ${a.color}`} style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>
{a.parts.map((p, j) => (typeof p === 'string' ? p : <b key={j}>{p.b}</b>))}
</div>
<div className="lr-sub">{relTime(a.time)}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,582 @@
/* ============================================================
Recruitment Inbox six seed-backed tabs plus the Email tab, which is the
app's oldest real network call (GET /inbox/fetch, previously the only fetch
in the entire prototype).
The email body used to be interpolated raw into markup at js/inbox.js:292
the single widest XSS sink in the repository, and the one that mattered most
because inbound mail is attacker-supplied by definition. It renders as text
now, which is the structural fix.
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import {
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
initials as initialsOf, int, locations, pick, relTime, skillsPool, TODAY,
} from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
const NOW = new Date('2026-07-09T20:00')
function resumeText(i) {
return `${i.name.toUpperCase()}
${i.email} · ${i.phone}
${'—'.repeat(30)}
PROFESSIONAL SUMMARY
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
EXPERIENCE
${pick(companies)} Senior role (2021Present)
${pick(companies)} Associate (20182021)
EDUCATION
Bachelor's Degree, Computer Science
SKILLS
${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
}
function SourceChip({ item }) {
// The dot carries the partner's brand colour; the label uses theme text
// 11px labels in the partner colour failed AA in both themes.
return (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
<span className="source-dot" />
{item.source}
</span>
)
}
export default function Inbox() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
const updateCandidates = useSeedMutation('candidates')
const [tab, setTab] = useState('All Applications')
const [selectedId, setSelectedId] = useState(null)
const [q, setQ] = useState('')
const [previewing, setPreviewing] = useState(null)
const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null)
const emailsQuery = useQuery({
queryKey: qk.mailbox.messages(),
queryFn: async () => {
const res = await inboxApi.listMessages()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({
id: String(row.id),
from: row.sender_name || row.fromEmail || 'Unknown',
fromEmail: row.fromEmail || '',
subject: row.subject || '',
body: row.body || '',
when: row.when ? new Date(row.when) : new Date(),
unread: Boolean(row.unread),
attachment: row.attachment_name || 'Resume.pdf',
attachmentSize: '—',
atsScore: 70,
imported: false,
}))
},
enabled: tab === 'Email',
})
const counts = useMemo(
() => ({
'All Applications': inbox.length,
Unread: inbox.filter((i) => i.processing === 'Unread').length,
Imported: inbox.filter((i) => i.processing === 'Imported').length,
Processed: inbox.filter((i) => i.processing === 'Processed').length,
Rejected: inbox.filter((i) => i.processing === 'Rejected').length,
Duplicates: inbox.filter((i) => i.duplicate).length,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}),
[inbox, emailsQuery.data],
)
const list = useMemo(() => {
let l = inbox
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed')
else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
return l
}, [inbox, tab, q])
const selected = inbox.find((i) => i.id === selectedId)
function select(id) {
setSelectedId(id)
updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i)))
}
function makeCandidate(item, job, cs) {
return {
id: `CAN-${5001 + cs.length}`,
name: item.name, initials: item.initials, color: item.color,
email: item.email, phone: item.phone,
jobId: job.id, jobTitle: job.title, department: job.department,
experience: item.experience, currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: item.atsScore, source: item.source, recruiter: item.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
subScores: { skills: item.atsScore, experience: item.atsScore, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
}
}
function importItem(item) {
const job = getJob(item.jobId) || jobs[0]
updateCandidates((cs) => [makeCandidate(item, job, cs), ...cs])
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Imported', unread: false } : i)))
toast(`${item.name} imported → Applied stage of ${job.title}`, 'success')
}
function parseResume(item) {
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsing' } : i)))
toast('Parsing resume with AI…', 'info')
setTimeout(() => {
updateInbox((items) =>
items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsed', atsScore: int(60, 96) } : i)),
)
toast('Resume parsed — profile fields extracted', 'success')
}, 1100)
}
function moveToPipeline(item) {
if (item.processing !== 'Imported' && item.processing !== 'Processed') importItem(item)
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Processed' } : i)))
toast(`${item.name} moved to pipeline`, 'success')
setTimeout(() => navigate('/pipeline'), 700)
}
function reject(item) {
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Rejected', unread: false } : i)))
toast(`${item.name} rejected`, 'warning')
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Recruitment Inbox</h1>
<p className="page-sub">Every candidate, every source one unified queue</p>
</div>
<div className="page-head-actions">
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
<button
className="btn btn-secondary"
onClick={() => {
toast('Syncing all sources…', 'info')
setTimeout(() => toast('Inbox synced', 'success'), 900)
}}
>
<Icon name="refresh" /> Sync
</button>
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs
</button>
</div>
</div>
<div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
/>
</div>
{tab === 'Email' ? (
<EmailTab query={emailsQuery} jobs={jobs} updateCandidates={updateCandidates} toast={toast} />
) : (
<div className="split">
<div className="split-list">
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search applications…" />
</div>
</div>
<div>
{list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
) : (
list.map((i) => (
<div
key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)}
>
<Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main">
<div className="ii-name">
{i.name}{' '}
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
)}
</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div>
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
</div>
</div>
))
)}
</div>
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Select an application">
Choose an item from the list to view details and take action.
</EmptyState>
</div>
) : (
<ApplicationDetail
item={selected}
onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)}
onParse={() => parseResume(selected)}
onAssign={() => setAssigning(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
/>
)}
</div>
</div>
)}
</div>
{previewing && (
<Modal
title={previewing.attachment}
subtitle={`Resume preview · ${previewing.name}`}
size="modal-lg"
onClose={() => setPreviewing(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setPreviewing(null)}>Close</button>
<button
className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
>
<Icon name="user-plus" /> Import Candidate
</button>
</>
}
>
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>{resumeText(previewing)}</pre>
</Modal>
)}
{assigning && (
<AssignRecruiter
item={assigning}
recruiters={recruiters}
onClose={() => setAssigning(null)}
onSave={(name) => {
updateInbox((items) => items.map((i) => (i.id === assigning.id ? { ...i, recruiter: name } : i)))
setAssigning(null)
toast(`Recruiter assigned to ${assigning.name}`, 'success')
}}
/>
)}
{noting && (
<Modal
title="Add Note"
subtitle={noting.name}
onClose={() => setNoting(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setNoting(null)}>Cancel</button>
<button className="btn btn-primary" onClick={() => { setNoting(null); toast('Note added', 'success') }}>
<Icon name="check" /> Save Note
</button>
</>
}
>
<div className="form-field">
<label>Note</label>
<textarea placeholder="Add a note about this application…" />
</div>
</Modal>
)}
</div>
)
}
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
</div>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{i.email}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{i.phone}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{i.experience} years</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{i.recruiter}</div></div>
<div className="info-item"><div className="il">Received</div><div className="iv">{fmtDate(i.received)}</div></div>
<div className="info-item">
<div className="il">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
</div>
</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div className="fw-600"><Icon name="paperclip" /> {i.attachment}</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
<pre className="resume-thumb">{resumeText(i)}</pre>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button>
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
<Icon name="x" /> Reject
</button>
</div>
</div>
)
}
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
const [name, setName] = useState(item.recruiter)
const current = recruiters.find((r) => r.name === item.recruiter)
return (
<Modal
title="Assign Recruiter"
subtitle={item.name}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
</p>
</Modal>
)
}
/** The live tab: real fetch, real loading state, real error state. */
function EmailTab({ query, jobs, updateCandidates, toast }) {
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(null)
const [imported, setImported] = useState(() => new Set())
const emails = query.data ?? []
const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length
async function sync() {
toast('Fetching from Outlook…', 'info')
const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() })
if (query.isError) toast('Sync failed', 'error')
else toast('Mailbox synced', 'success')
return res
}
function importEmail(e) {
const job = jobs[0]
if (!job) return
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: e.from, initials: initialsOf(e.from), color: avatarColor(e.from),
email: e.fromEmail, phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: 'Potential Match',
subScores: { skills: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
...cs,
])
setImported((s) => new Set(s).add(e.id))
toast(`${e.from} imported from Outlook → ${job.title}`, 'success')
}
const isImported = (e) => imported.has(e.id)
return (
<>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="integration-status"><span className="pulse" />Outlook · Microsoft Graph API</span>
<span className="text-muted text-sm">
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
</span>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={sync}>
<Icon name="refresh" /> Sync Mailbox
</button>
</div>
<div className="split">
<div className="split-list">
{query.isPending && <EmptyState icon="mail" title="Loading…">Fetching mailbox from the server.</EmptyState>}
{query.isError && (
<EmptyState icon="mail" title="Couldnt load mailbox">
{friendlyAuthError(query.error, 'Request failed')}
</EmptyState>
)}
{query.isSuccess && emails.length === 0 && (
<EmptyState icon="mail" title="Nothing here">No emails in the mailbox.</EmptyState>
)}
{query.isSuccess && emails.map((e) => (
<div
key={e.id}
className={`inbox-item${e.unread && selectedId !== e.id ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => setSelectedId(e.id)}
>
<Avatar name={e.from} />
<div className="ii-main">
<div className="ii-name">{e.from}</div>
<div className="ii-pos">{e.subject}</div>
<div className="ii-meta">
<span className="source-chip" style={{ '--chip': '#0078d4' }}>
<Icon name="mail" />Outlook
</span>
{isImported(e) && <Badge className="b-green">Imported</Badge>}
</div>
</div>
<div className="ii-time">{fmtShort(e.when)}</div>
</div>
))}
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="mail" title="Select an email">
Preview email body and resume attachments here.
</EmptyState>
</div>
) : (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-12" style={{ marginBottom: 6 }}>
<h2 style={{ fontSize: 18, flex: 1 }}>{selected.subject}</h2>
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
</div>
<div className="flex items-center gap-12" style={{ marginBottom: 20 }}>
<Avatar name={selected.from} />
<div>
<div className="fw-600">{selected.from}</div>
<div className="cell-sub">{selected.fromEmail} · {fmtDate(selected.when)}</div>
</div>
</div>
{/* Rendered as TEXT. This is js/inbox.js:292, the widest XSS sink
in the prototype, and inbound mail is attacker-supplied. */}
<div className="email-preview" style={{ marginBottom: 18, whiteSpace: 'pre-wrap' }}>
{selected.body}
</div>
<div className="attach-card" style={{ marginBottom: 18 }}>
<span className="attach-icn"><Icon name="file" /></span>
<div style={{ flex: 1 }}>
<div className="fw-600">{selected.attachment}</div>
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
</div>
<div className="flex items-center gap-8">
<ScoreChip score={selected.atsScore} />
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
<Icon name="eye" /> Preview
</button>
</div>
</div>
<div className="flex gap-8">
{isImported(selected) ? (
<button className="btn btn-secondary" disabled><Icon name="check" /> Already Imported</button>
) : (
<button className="btn btn-primary" onClick={() => importEmail(selected)}>
<Icon name="user-plus" /> Import Candidate
</button>
)}
<button className="btn btn-secondary" onClick={() => toast('Reply drafted', 'info')}>
<Icon name="mail" /> Reply
</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={() => toast('Email archived', 'info')}>
<Icon name="trash" /> Archive
</button>
</div>
</div>
)}
</div>
</div>
</>
)
}

View File

@ -0,0 +1,383 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, AvatarStack, Badge, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import {
candidates as allCandidates, evalTemplates, fmtShort, interviewTypes, meetingTypes,
} from '../data/seed'
export default function Interviews() {
const { toast } = useToast()
const navigate = useNavigate()
const location = useLocation()
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [feedbackFor, setFeedbackFor] = useState(null)
const [scheduling, setScheduling] = useState(false)
useEffect(() => {
if (location.state?.openSchedule) setScheduling(true)
}, [location.state])
const stats = useMemo(
() => ({
scheduled: interviews.filter((i) => i.status === 'Scheduled').length,
completed: interviews.filter((i) => i.status === 'Completed').length,
today: 5,
cancelled: interviews.filter((i) => ['Cancelled', 'No Show'].includes(i.status)).length,
}),
[interviews],
)
const rows = useMemo(
() =>
interviews.filter((iv) => {
if (status && iv.status !== status) return false
if (type && iv.type !== type) return false
if (q && !(iv.candidate + iv.jobTitle + iv.interviewers.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
[interviews, q, status, type],
)
const upcoming = interviews.filter((iv) => iv.status === 'Scheduled').slice(0, 4)
const columns = [
{
key: 'candidate', label: 'Candidate', sortable: true,
render: (iv) => (
<div className="user-cell">
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div>
<div className="cell-primary">{iv.candidate}</div>
<div className="cell-sub">{iv.jobTitle}</div>
</div>
</div>
),
},
{ key: 'type', label: 'Round', sortable: true, render: (iv) => <Badge className="b-indigo">{iv.type}</Badge> },
{
key: 'when', label: 'Date & Time', sortable: true, sortValue: (iv) => iv.when.getTime(),
render: (iv) => (
<>
<div className="text-sm fw-600">{fmtShort(iv.when)}</div>
<div className="cell-sub">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · {iv.duration}m
</div>
</>
),
},
{
key: 'meeting', label: 'Type',
render: (iv) => (
<span className="flex items-center gap-8">
<Icon name={iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map'} />
{iv.meeting}
</span>
),
},
{ key: 'interviewers', label: 'Interviewers', render: (iv) => <AvatarStack names={iv.interviewers} /> },
{ key: 'status', label: 'Status', sortable: true, render: (iv) => <Badge>{iv.status}</Badge> },
{ key: 'feedback', label: 'Feedback', render: (iv) => (iv.feedback ? <Badge>{iv.feedback}</Badge> : <span className="text-muted"></span>) },
{
key: '_a', label: 'Actions', align: 'right',
render: (iv) => (
<div className="row-actions">
<button
className="act-btn" data-tip="View candidate"
onClick={() => navigate('/candidates', { state: { openCandidate: iv.candidateId } })}
>
<Icon name="eye" />
</button>
<button className="act-btn" data-tip="Feedback" onClick={() => setFeedbackFor(iv)}>
<Icon name="star" />
</button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Interviews</h1>
<p className="page-sub">Manage and track all interview activity</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/calendar"><Icon name="calendar" /> Calendar View</Link>
<button className="btn btn-primary" onClick={() => setScheduling(true)}>
<Icon name="plus" /> Schedule Interview
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Scheduled" value={stats.scheduled} icon="calendar" tone="i-blue" />
<KpiCard label="Completed" value={stats.completed} icon="check-circle" tone="i-green" />
<KpiCard label="Today" value={stats.today} icon="clock" tone="i-purple" />
<KpiCard label="Cancelled / No-show" value={stats.cancelled} icon="x-circle" tone="i-red" />
</div>
<div className="grid g-2-1">
<div className="card">
<div className="card-head"><div><h3>All Interviews</h3></div></div>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or interviewer…" />
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{['Scheduled', 'Completed', 'Cancelled', 'No Show'].map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Rounds</option>
{interviewTypes.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head"><div><h3>Up Next</h3><span className="ch-sub">Scheduled sessions</span></div></div>
<div className="card-body">
<div className="list-tight">
{upcoming.map((iv) => (
<div className="list-row" key={iv.id}>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type} · {iv.meeting}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">{fmtShort(iv.when)}</div>
<div className="lr-sub">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{feedbackFor && (
<Scorecard
interview={feedbackFor}
jobs={jobs}
onClose={() => setFeedbackFor(null)}
onSubmit={() => { setFeedbackFor(null); toast('Scorecard submitted', 'success') }}
toast={toast}
/>
)}
{scheduling && (
<ScheduleForm
people={[...recruiters, ...managers]}
onClose={() => setScheduling(false)}
onSubmit={() => { setScheduling(false); toast('Interview scheduled & invite sent', 'success') }}
/>
)}
</div>
)
}
/** Star rating — replaces the imperative Interviews._bindStars() DOM toggling. */
function Stars({ value, onChange }) {
return (
<div className="rating-stars">
{[1, 2, 3, 4, 5].map((n) => (
<span
key={n}
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => onChange(n)}
role="radio"
aria-checked={n === value}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(n) } }}
>
<Icon name="star" />
</span>
))}
</div>
)
}
function CriteriaList({ criteria, ratings, setRating }) {
return criteria.map((c) => (
<div className="setting-row" style={{ padding: '12px 0' }} key={c}>
<div className="setting-info"><h4>{c}</h4></div>
<Stars value={ratings[c] ?? 0} onChange={(v) => setRating(c, v)} />
</div>
))
}
function Scorecard({ interview: iv, jobs, onClose, onSubmit, toast }) {
const job = jobs.find((j) => j.title === iv.jobTitle)
const dept = job ? job.department : 'All'
const initial = evalTemplates.find((t) => t.dept === dept) || evalTemplates.find((t) => t.dept === 'All')
const [tab, setTab] = useState('form')
const [templateName, setTemplateName] = useState(initial.name)
const [ratings, setRatings] = useState({})
const [recommendation, setRecommendation] = useState('Hire')
const template = evalTemplates.find((t) => t.name === templateName) ?? initial
const setRating = (crit, val) => setRatings((r) => ({ ...r, [crit]: val }))
return (
<Modal
title="Interview Evaluation"
subtitle={`${iv.id} · ${iv.type}`}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={onSubmit}><Icon name="check" /> Submit Scorecard</button>
</>
}
>
<div className="flex items-center gap-12 mb-18">
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div>
<div className="ph-role">{iv.type} · {iv.jobTitle}</div>
</div>
<Badge>{iv.status}</Badge>
</div>
<div style={{ marginBottom: 18 }}>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'form', label: 'Dynamic Form' },
{ key: 'upload', label: 'Upload Sheet' },
{ key: 'both', label: 'Both' },
]}
/>
</div>
{tab === 'form' && (
<div className="tab-pane active">
<div className="form-field" style={{ marginBottom: 8 }}>
<label>Evaluation Template</label>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)}>
{evalTemplates.map((t) => <option key={t.name}>{t.name}</option>)}
</select>
</div>
<CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} />
<div className="form-field" style={{ marginTop: 8 }}>
<label>Comments</label>
<textarea placeholder="Strengths, concerns, and areas explored…" />
</div>
<div className="form-field" style={{ marginTop: 14 }}>
<label>Overall Recommendation</label>
<div className="seg" style={{ marginTop: 4 }}>
{['Hire', 'Hold', 'Reject'].map((r) => (
<button
type="button" key={r}
className={r === recommendation ? 'active' : ''}
onClick={() => setRecommendation(r)}
>
{r}
</button>
))}
</div>
</div>
</div>
)}
{tab === 'upload' && (
<div className="tab-pane active">
<div className="dropzone" style={{ padding: 32 }} onClick={() => toast('File picker (demo)', 'info')}>
<div className="dz-icn"><Icon name="upload" /></div>
<h3 style={{ fontSize: 15 }}>Upload evaluation sheet</h3>
<p className="text-muted">PDF, DOC, or DOCX · scanned scorecards supported</p>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 12 }}>
{['PDF', 'DOC', 'DOCX'].map((t) => <span className="badge b-gray badge-plain" key={t}>{t}</span>)}
</div>
</div>
</div>
)}
{tab === 'both' && (
<div className="tab-pane active">
<p className="text-muted" style={{ marginBottom: 14 }}>
Capture structured ratings <b>and</b> attach a signed sheet both are stored on the scorecard.
</p>
<CriteriaList criteria={template.criteria.slice(0, 3)} ratings={ratings} setRating={setRating} />
<div className="upload-row" style={{ marginTop: 12 }}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1 }}>
<div className="fw-600 text-sm">Interviewer_Scorecard.pdf</div>
<div className="cell-sub">Attached · 214 KB</div>
</div>
<Badge className="b-green">Uploaded</Badge>
</div>
</div>
)}
</Modal>
)
}
function ScheduleForm({ people, onClose, onSubmit }) {
return (
<Modal
title="Schedule Interview"
subtitle="Set up a new interview session"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={onSubmit}><Icon name="calendar" /> Schedule</button>
</>
}
>
<form>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate <span className="req">*</span></label>
<select>{allCandidates.slice(0, 40).map((c) => <option key={c.id}>{c.name}</option>)}</select>
</div>
<div className="form-field">
<label>Interview Round</label>
<select>{interviewTypes.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field">
<label>Meeting Type</label>
<select>{meetingTypes.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field"><label>Date</label><input type="date" /></div>
<div className="form-field"><label>Time</label><input type="time" defaultValue="14:00" /></div>
<div className="form-field">
<label>Duration</label>
<select defaultValue="60 min"><option>30 min</option><option>45 min</option><option>60 min</option><option>90 min</option></select>
</div>
<div className="form-field">
<label>Interviewer</label>
<select>{people.map((p) => <option key={p.id}>{p.name}</option>)}</select>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,344 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Badge, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { int, publishPlatforms, TODAY } from '../data/seed'
const STEPS = ['Select Job', 'Approval', 'Platforms', 'Publish']
export default function JobBoard() {
const { toast } = useToast()
const location = useLocation()
const { data: publishings = [] } = useQuery(seedQuery('publishings'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const updatePublishings = useSeedMutation('publishings')
const [publishing, setPublishing] = useState(null) // { jobId } | null
useEffect(() => {
if (location.state?.publishJob) setPublishing({ jobId: location.state.publishJob })
}, [location.state])
const totals = useMemo(
() =>
publishings.reduce(
(a, p) => ({ views: a.views + p.views, clicks: a.clicks + p.clicks, apps: a.apps + p.apps }),
{ views: 0, clicks: 0, apps: 0 },
),
[publishings],
)
const conv = totals.views ? ((totals.apps / totals.views) * 100).toFixed(1) : '0'
const platRows = useMemo(() => {
const agg = {}
for (const p of publishings) {
if (!agg[p.platform]) agg[p.platform] = { views: 0, clicks: 0, apps: 0, jobs: 0 }
agg[p.platform].views += p.views
agg[p.platform].clicks += p.clicks
agg[p.platform].apps += p.apps
agg[p.platform].jobs += 1
}
return Object.entries(agg).sort((a, b) => b[1].apps - a[1].apps)
}, [publishings])
const chartData = useMemo(
() => ({ labels: platRows.map((p) => p[0]), data: platRows.map((p) => p[1].apps) }),
[platRows],
)
const columns = [
{
key: 'jobTitle', label: 'Job', sortable: true,
render: (p) => (<><div className="cell-primary">{p.jobTitle}</div><div className="cell-sub">{p.jobId}</div></>),
},
{
key: 'platform', label: 'Platform', sortable: true,
render: (p) => {
const pl = publishPlatforms.find((x) => x.name === p.platform) || {}
return (
<span className="flex items-center gap-8">
<span className="platform-logo" style={{ width: 26, height: 26, background: pl.color || '#888' }}>
<Icon name={pl.icon || 'briefcase'} />
</span>
{p.platform}
</span>
)
},
},
{
key: 'status', label: 'Status', sortable: true,
render: (p) => (
<Badge className={p.status === 'Live' ? 'b-green' : p.status === 'Paused' ? 'b-amber' : 'b-blue'}>
{p.status}
</Badge>
),
},
{ key: 'views', label: 'Views', sortable: true, align: 'right', render: (p) => p.views.toLocaleString() },
{ key: 'clicks', label: 'Clicks', sortable: true, align: 'right', render: (p) => p.clicks.toLocaleString() },
{ key: 'apps', label: 'Applications', sortable: true, align: 'right', render: (p) => <b>{p.apps}</b> },
{
key: '_conv', label: 'Conversion', sortable: true, sortValue: (p) => (p.views ? p.apps / p.views : 0),
render: (p) => (
<span className="badge b-indigo badge-plain">
{p.views ? ((p.apps / p.views) * 100).toFixed(1) : '0.0'}%
</span>
),
},
{
key: '_a', label: '', align: 'right',
render: (p) => (
<button className="act-btn" data-tip="Manage" onClick={() => toast(`Managing ${p.platform} posting`, 'info')}>
<Icon name="external" />
</button>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Job Board</h1>
<p className="page-sub">Publish requisitions across channels and track performance</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/analytics"><Icon name="trending-up" /> Analytics</Link>
<button className="btn btn-primary" onClick={() => setPublishing({})}>
<Icon name="send" /> Publish a Job
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Total Views" value={totals.views.toLocaleString()} icon="eye" tone="i-blue" foot="across all platforms" />
<KpiCard
label="Total Clicks" value={totals.clicks.toLocaleString()} icon="target" tone="i-purple"
foot={`${totals.views ? ((totals.clicks / totals.views) * 100).toFixed(1) : '0.0'}% CTR`}
/>
<KpiCard label="Applications" value={totals.apps.toLocaleString()} icon="users" tone="i-green" foot="from job boards" />
<KpiCard label="Conversion Rate" value={`${conv}%`} icon="trending-up" tone="i-teal" foot="view → application" />
</div>
<div className="grid g-2-1 mb-18">
<div className="card">
<div className="card-head"><div><h3>Platform Performance</h3><span className="ch-sub">Applications by channel</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={chartData} height={300} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Connected Platforms</h3></div></div>
<div className="card-body">
<div className="list-tight">
{publishPlatforms.map((p) => (
<div className="list-row" key={p.name}>
<span className="platform-logo" style={{ background: p.color }}><Icon name={p.icon} /></span>
<div className="lr-main">
<div className="lr-title">{p.name}</div>
<div className="lr-sub">{p.cost === 'Free' ? 'Free posting' : `Paid · ${p.cost}`}</div>
</div>
{p.connected ? (
<Badge className="b-green">Connected</Badge>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => toast(`Connecting ${p.name}`, 'info')}>
Connect
</button>
)}
</div>
))}
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Active Postings</h3>
<span className="ch-sub">{publishings.length} live postings across {platRows.length} platforms</span>
</div>
<button className="btn btn-secondary btn-sm" onClick={() => toast('Performance report exported', 'success')}>
<Icon name="download" /> Export
</button>
</div>
<DataTable columns={columns} rows={publishings} pageSize={8} />
</div>
{publishing && (
<PublishFlow
jobs={jobs}
initialJobId={publishing.jobId}
onClose={() => setPublishing(null)}
onPublish={(job, platforms) => {
updatePublishings((ps) => [
...platforms.map((p) => ({
jobId: job.id, jobTitle: job.title, platform: p, status: 'Live',
views: int(0, 30), clicks: 0, apps: 0, published: new Date(TODAY),
})),
...ps,
])
}}
toast={toast}
/>
)}
</div>
)
}
/** The app's only multi-step form. State lives here rather than on a global. */
function PublishFlow({ jobs, initialJobId, onClose, onPublish, toast }) {
const publishable = jobs.filter((j) => j.status !== 'Draft')
const openJobs = jobs.filter((j) => j.status === 'Open')
const [step, setStep] = useState(1)
const [jobId, setJobId] = useState(initialJobId || openJobs[0]?.id || publishable[0]?.id)
const [platforms, setPlatforms] = useState(['Career Portal'])
const job = jobs.find((j) => j.id === jobId)
function next() {
if (step === 3) {
if (!platforms.length) {
toast('Select at least one platform', 'warning')
return
}
onPublish(job, platforms)
}
setStep((s) => s + 1)
}
function togglePlatform(name) {
setPlatforms((ps) => (ps.includes(name) ? ps.filter((p) => p !== name) : [...ps, name]))
}
return (
<Modal
title="Publish Job"
subtitle="Distribute this requisition to job boards"
size="modal-lg"
onClose={onClose}
footer={
step === 4 ? (
<button className="btn btn-primary" onClick={onClose}><Icon name="check" /> Done</button>
) : (
<>
<button className="btn btn-secondary" onClick={() => (step === 1 ? onClose() : setStep((s) => s - 1))}>
{step === 1 ? 'Cancel' : 'Back'}
</button>
<button className="btn btn-primary" onClick={next}>
{step === 3 ? <><Icon name="send" /> Publish</> : 'Continue'}
</button>
</>
)
}
>
<div className="stepper">
{STEPS.map((s, i) => {
const n = i + 1
const cls = n < step ? 'done' : n === step ? 'active' : ''
return (
<div style={{ display: 'contents' }} key={s}>
<div className={`step ${cls}`}>
<div className="step-num">{n < step ? '✓' : n}</div>
<div className="step-label">{s}</div>
</div>
{i < STEPS.length - 1 && <div className={`step-line ${n < step ? 'done' : ''}`} />}
</div>
)
})}
</div>
{step === 1 && (
<>
<div className="form-field">
<label>Select requisition to publish</label>
<select value={jobId} onChange={(e) => setJobId(e.target.value)}>
{publishable.map((j) => <option key={j.id} value={j.id}>{j.title} · {j.id}</option>)}
</select>
</div>
{job && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginTop: 16 }}>
<div className="card-body">
<div className="flex items-center gap-12">
<span className="kpi-icn i-indigo" style={{ width: 44, height: 44, borderRadius: 12 }}>
<Icon name="briefcase" />
</span>
<div>
<div className="fw-600">{job.title}</div>
<div className="cell-sub">{job.department} · {job.location} · {job.type}</div>
</div>
</div>
</div>
</div>
)}
</>
)}
{step === 2 && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
<span className="kpi-icn i-green" style={{ width: 44, height: 44, borderRadius: 12 }}>
<Icon name="check-circle" />
</span>
<div>
<div className="fw-600">Approval granted</div>
<div className="cell-sub">Approved by Department Head · Budget confirmed</div>
</div>
</div>
{['Hiring Manager sign-off', 'Finance budget approval', 'Compliance review'].map((label, i) => (
<div className="setting-row" style={{ padding: '10px 0', ...(i === 2 ? { border: 'none' } : {}) }} key={label}>
<div className="setting-info"><h4>{label}</h4></div>
<Badge className="b-green">Approved</Badge>
</div>
))}
</div>
</div>
)}
{step === 3 && (
<>
<p className="text-muted" style={{ marginBottom: 14 }}>
Select the platforms to publish this role to
</p>
<div className="grid g-2">
{publishPlatforms.map((p) => (
<div
key={p.name}
className={`platform-card${platforms.includes(p.name) ? ' selected' : ''}${!p.connected ? ' disabled' : ''}`}
style={!p.connected ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
onClick={() => togglePlatform(p.name)}
>
<span className="platform-logo" style={{ background: p.color }}><Icon name={p.icon} /></span>
<div style={{ flex: 1 }}>
<div className="fw-600">{p.name}</div>
<div className="cell-sub">{p.cost === 'Free' ? 'Free' : `Paid · ${p.cost}`}</div>
</div>
<span className="platform-check"><Icon name="check" /></span>
</div>
))}
</div>
</>
)}
{step === 4 && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div className="kpi-icn i-green" style={{ width: 64, height: 64, borderRadius: 18, margin: '0 auto 16px' }}>
<Icon name="check-circle" />
</div>
<h2 style={{ fontSize: 20, marginBottom: 6 }}>Published Successfully</h2>
<p className="text-muted" style={{ marginBottom: 20 }}>
{job?.title} is now live on {platforms.length} platform{platforms.length > 1 ? 's' : ''}
</p>
<div className="flex gap-8" style={{ justifyContent: 'center', flexWrap: 'wrap' }}>
{platforms.map((p) => <Badge className="b-green" key={p}>{p}</Badge>)}
</div>
</div>
)}
</Modal>
)
}

View File

@ -0,0 +1,520 @@
/* ============================================================
Jobs the reference CRUD pattern for the app: filtered DataTable plus
view / reassign / create-edit / delete modals. The other CRUD screens follow
this shape.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Avatar, Badge, FieldError, Icon, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
businessUnits, departments, educationLevels, empTypes, fmtDate, fmtShort,
getRecruiterByName, grades, jobStatuses, locations, moneyK, TODAY,
} from '../data/seed'
export default function Jobs() {
const { toast } = useToast()
const navigate = useNavigate()
const location = useLocation()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateJobs = useSeedMutation('jobs')
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
const [editing, setEditing] = useState(undefined) // undefined = closed, null = create
const [reassigning, setReassigning] = useState(null)
const [deleting, setDeleting] = useState(null)
// Deep-link intents from global search, the dashboard and the manager portal.
useEffect(() => {
const st = location.state
if (!st) return
if (st.openCreate) setEditing(null)
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
}, [location.state, jobs])
const rows = useMemo(
() =>
jobs.filter((j) => {
if (dept && j.department !== dept) return false
if (status && j.status !== status) return false
if (type && j.type !== type) return false
if (q) {
const term = q.toLowerCase()
const hay = (j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase()
if (!hay.includes(term)) return false
}
return true
}),
[jobs, q, dept, status, type],
)
const openCount = jobs.filter((j) => j.status === 'Open').length
const columns = [
{ key: 'id', label: 'Job ID', sortable: true, render: (j) => <span className="cell-mono">{j.id}</span> },
{
key: 'title', label: 'Job Title', sortable: true,
render: (j) => (
<>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">{j.businessUnit} · {j.grade}</div>
</>
),
},
{ key: 'department', label: 'Department', sortable: true },
{
key: 'manager', label: 'Hiring Manager', sortable: true,
render: (j) => (
<div className="user-cell"><Avatar name={j.manager} /><span>{j.manager}</span></div>
),
},
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location}</span> },
{ key: 'type', label: 'Type', render: (j) => <Badge className="b-gray">{j.type}</Badge> },
{ key: 'applications', label: 'Apps', sortable: true, align: 'center', render: (j) => <b>{j.applications}</b> },
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
{ key: 'created', label: 'Created', sortable: true, sortValue: (j) => j.created.getTime(), render: (j) => <span className="text-muted">{fmtShort(j.created)}</span> },
{
key: '_a', label: 'Actions', align: 'right',
render: (j) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button>
<button className="act-btn danger" data-tip="Delete" onClick={() => setDeleting(j)}><Icon name="trash" /></button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Jobs</h1>
<p className="page-sub">{jobs.length} requisitions · {openCount} currently open</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Jobs exported to CSV', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => setEditing(null)}>
<Icon name="plus" /> Create Job
</button>
</div>
</div>
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search jobs, IDs, managers…" />
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{jobStatuses.map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
{empTypes.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
{viewing && (
<JobDetail
job={viewing}
onClose={() => setViewing(null)}
onEdit={() => { const j = viewing; setViewing(null); setEditing(j) }}
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onReassign={() => { const j = viewing; setViewing(null); setReassigning(j) }}
/>
)}
{editing !== undefined && (
<JobForm
job={editing}
managers={managers}
recruiters={recruiters}
count={jobs.length}
onClose={() => setEditing(undefined)}
onSave={(next, isEdit) => {
updateJobs((js) => (isEdit ? js.map((j) => (j.id === next.id ? next : j)) : [next, ...js]))
setEditing(undefined)
toast(isEdit ? 'Job updated successfully' : 'Job created successfully', 'success')
}}
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
/>
)}
{reassigning && (
<Reassign
job={reassigning}
recruiters={recruiters}
onClose={() => setReassigning(null)}
onSave={(name) => {
updateJobs((js) => js.map((j) => (j.id === reassigning.id ? { ...j, recruiter: name } : j)))
setReassigning(null)
toast('Recruiter reassigned', 'success')
}}
/>
)}
{deleting && (
<Modal
title="Confirm Deletion"
onClose={() => setDeleting(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDeleting(null)}>Cancel</button>
<button
className="btn btn-danger"
onClick={() => {
updateJobs((js) => js.filter((j) => j.id !== deleting.id))
setDeleting(null)
toast('Job deleted', 'success')
}}
>
<Icon name="trash" /> Delete Job
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-red" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="trash" />
</span>
<div>
<p style={{ fontWeight: 600, fontSize: 15 }}>Delete {deleting.title}?</p>
<p className="text-muted" style={{ marginTop: 4 }}>
This will permanently remove requisition {deleting.id} and its {deleting.applications} applications.
This action cannot be undone.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}
const SECTION_LABEL = {
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
textTransform: 'uppercase', marginBottom: 6,
}
function JobDetail({ job: j, onClose, onEdit, onPublish, onReassign }) {
const r = getRecruiterByName(j.recruiter)
const loadCls = r ? (r.workload > 80 ? 'b-red' : r.workload > 60 ? 'b-amber' : 'b-green') : ''
return (
<Modal
title="Job Details"
subtitle={j.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
<button className="btn btn-primary" onClick={onEdit}><Icon name="edit" /> Edit Job</button>
</>
}
>
<div className="flex items-center gap-16 mb-18">
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
<Icon name="briefcase" />
</span>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
<div className="text-muted">{j.id} · {j.department} · {j.businessUnit}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{j.status}</Badge></div>
</div>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.manager}</div></div>
<div className="info-item">
<div className="il">Assigned Recruiter</div>
<div className="iv flex items-center gap-8">
{j.recruiter}
{r && <span className={`badge ${loadCls} badge-plain`} style={{ fontSize: 10 }}>{r.workload}% load</span>}
<button className="link-btn" onClick={onReassign}>Reassign</button>
</div>
</div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location}</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">Grade</div><div className="iv">{j.grade}</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 Range</div><div className="iv">{moneyK(j.salaryMin)} {moneyK(j.salaryMax)}</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">Education</div><div className="iv">{j.education}</div></div>
<div className="info-item"><div className="il">Deadline</div><div className="iv">{fmtDate(j.deadline)}</div></div>
</div>
<div className="divider" />
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Key Responsibilities</div>
<ul style={{ paddingLeft: 18, color: 'var(--text-2)' }}>
{j.responsibilities.map((x) => <li key={x}>{x}</li>)}
</ul>
</div>
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
<div>
<div style={SECTION_LABEL}>Benefits</div>
<div className="k-tags">{j.benefits.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
<div className="divider" />
<div className="flex items-center gap-12">
<span className="text-muted text-sm">Hiring progress</span>
<div style={{ flex: 1 }}><ProgressBar pct={j.progress} /></div>
<span className="fw-600">{j.progress}%</span>
</div>
</Modal>
)
}
function Reassign({ job, recruiters, onClose, onSave }) {
const [name, setName] = useState(job.recruiter)
return (
<Modal
title="Reassign Recruiter"
subtitle={job.title}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Reassign</button>
</>
}
>
<div className="form-field">
<label>Assigned Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => (
<option key={r.id} value={r.name}>{r.name} {r.workload}% load</option>
))}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Workload is recalculated automatically across the recruiters assigned requisitions.
</p>
</Modal>
)
}
function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid }) {
const isEdit = Boolean(job)
const form = useFormState({
title: job?.title ?? '',
department: job?.department ?? departments[0],
businessUnit: job?.businessUnit ?? businessUnits[0],
grade: job?.grade ?? grades[0],
type: job?.type ?? empTypes[0],
manager: job?.manager ?? managers[0]?.name ?? '',
recruiter: job?.recruiter ?? recruiters[0]?.name ?? '',
salaryMin: job?.salaryMin ?? '',
salaryMax: job?.salaryMax ?? '',
experience: job?.experience ?? '',
education: job?.education ?? educationLevels[0],
location: job?.location ?? locations[0],
vacancies: job?.vacancies ?? 1,
description: job?.description ?? '',
responsibilities: job ? job.responsibilities.join('\n') : '',
skills: job ? job.skills.join(', ') : '',
benefits: job ? job.benefits.join(', ') : '',
deadline: '',
status: job?.status ?? 'Open',
})
function submit() {
const v = form.values
const errors = {}
if (!v.title.trim()) errors.title = 'Job title is required'
if (!v.description.trim()) errors.description = 'Description is required'
if (!v.salaryMin || Number(v.salaryMin) <= 0) errors.salaryMin = 'Enter a valid amount'
form.setErrors(errors)
if (Object.keys(errors).length) {
onInvalid()
return
}
const skills = v.skills.split(',').map((s) => s.trim()).filter(Boolean)
const benefits = v.benefits.split(',').map((s) => s.trim()).filter(Boolean)
const responsibilities = v.responsibilities.split('\n').map((s) => s.trim()).filter(Boolean)
const salaryMin = Number(v.salaryMin)
const salaryMax = Number(v.salaryMax) || salaryMin + 20000
if (isEdit) {
onSave(
{
...job,
title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade,
type: v.type, manager: v.manager, recruiter: v.recruiter, salaryMin, salaryMax,
experience: v.experience, education: v.education, location: v.location,
vacancies: Number(v.vacancies) || 1, description: v.description, responsibilities,
skills: skills.length ? skills : job.skills,
benefits: benefits.length ? benefits : job.benefits,
status: v.status,
},
true,
)
return
}
onSave(
{
id: `JOB-${1001 + count}`,
title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade,
manager: v.manager, managerId: '', recruiter: v.recruiter, recruiterId: '',
location: v.location, type: v.type, vacancies: Number(v.vacancies) || 1,
applications: 0, status: v.status, created: new Date(TODAY),
deadline: v.deadline ? new Date(v.deadline) : new Date('2026-08-09'),
salaryMin, salaryMax,
experience: v.experience || '3+ years', education: v.education,
skills, benefits, description: v.description,
responsibilities: responsibilities.length ? responsibilities : ['Own key projects'],
progress: 0,
},
false,
)
}
const field = (name) => ({
value: form.values[name],
onChange: (e) => form.setField(name, e.target.value),
})
return (
<Modal
title={isEdit ? 'Edit Job' : 'Create New Job'}
subtitle={isEdit ? job.id : 'Fill in the details to post a requisition'}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}>
<Icon name="check" /> {isEdit ? 'Save Changes' : 'Create Job'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Job Title <span className="req">*</span></label>
<input {...field('title')} className={form.errors.title ? 'err' : ''} placeholder="e.g. Senior Product Designer" />
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Department <span className="req">*</span></label>
<select {...field('department')}>{departments.map((d) => <option key={d}>{d}</option>)}</select>
</div>
<div className="form-field">
<label>Business Unit</label>
<select {...field('businessUnit')}>{businessUnits.map((b) => <option key={b}>{b}</option>)}</select>
</div>
<div className="form-field">
<label>Grade</label>
<select {...field('grade')}>{grades.map((g) => <option key={g}>{g}</option>)}</select>
</div>
<div className="form-field">
<label>Employment Type</label>
<select {...field('type')}>{empTypes.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field">
<label>Hiring Manager <span className="req">*</span></label>
<select {...field('manager')}>{managers.map((m) => <option key={m.id}>{m.name}</option>)}</select>
</div>
<div className="form-field">
<label>Recruiter <span className="req">*</span></label>
<select {...field('recruiter')}>{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}</select>
</div>
<div className="form-field">
<label>Salary Min ($) <span className="req">*</span></label>
<input type="number" {...field('salaryMin')} className={form.errors.salaryMin ? 'err' : ''} placeholder="90000" />
<FieldError>{form.errors.salaryMin}</FieldError>
</div>
<div className="form-field">
<label>Salary Max ($)</label>
<input type="number" {...field('salaryMax')} placeholder="130000" />
</div>
<div className="form-field">
<label>Experience</label>
<input {...field('experience')} placeholder="5+ years" />
</div>
<div className="form-field">
<label>Education</label>
<select {...field('education')}>{educationLevels.map((e) => <option key={e}>{e}</option>)}</select>
</div>
<div className="form-field">
<label>Location <span className="req">*</span></label>
<select {...field('location')}>{locations.map((l) => <option key={l}>{l}</option>)}</select>
</div>
<div className="form-field">
<label>Vacancies</label>
<input type="number" min="1" {...field('vacancies')} />
</div>
<div className="form-field col-span-2">
<label>Job Description <span className="req">*</span></label>
<textarea {...field('description')} className={form.errors.description ? 'err' : ''} placeholder="Describe the role…" />
<FieldError>{form.errors.description}</FieldError>
</div>
<div className="form-field col-span-2">
<label>Responsibilities</label>
<textarea {...field('responsibilities')} placeholder="One per line…" />
</div>
<div className="form-field col-span-2">
<label>Required Skills</label>
<input {...field('skills')} placeholder="React, TypeScript, System Design" />
</div>
<div className="form-field col-span-2">
<label>Benefits</label>
<input {...field('benefits')} placeholder="Equity, 401(k), Unlimited PTO" />
</div>
<div className="form-field">
<label>Deadline</label>
<input type="date" {...field('deadline')} />
</div>
<div className="form-field">
<label>Status</label>
<select {...field('status')}>{jobStatuses.map((s) => <option key={s}>{s}</option>)}</select>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,163 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Avatar, Badge, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
export default function Managers() {
const { toast } = useToast()
const navigate = useNavigate()
const location = useLocation()
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const [detail, setDetail] = useState(null)
// Global search navigates here with the manager to open replaces the old
// App.searchGo(route, cb) + setTimeout(cb, 120) hack.
useEffect(() => {
const id = location.state?.openManager
if (id) setDetail(managers.find((m) => m.id === id) ?? null)
}, [location.state, managers])
const totalReqs = managers.reduce((s, m) => s + m.openReqs, 0)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Hiring Managers</h1>
<p className="page-sub">{managers.length} managers · {totalReqs} active requisitions</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => toast('Invite manager', 'info')}>
<Icon name="plus" /> Add Manager
</button>
</div>
</div>
<div className="grid g-3">
{managers.map((m) => (
<div className="card" key={m.id}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
<Avatar name={m.name} initials={m.initials} color={m.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="lr-title">{m.name}</div>
<div className="lr-sub">{m.title}</div>
</div>
</div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</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 className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<span className="cell-sub"><Icon name="mail" /> {m.email.split('@')[0]}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
</div>
</div>
</div>
))}
</div>
{detail && (
<ManagerDetail
manager={detail}
jobs={jobs.filter((j) => j.manager === detail.name)}
onClose={() => setDetail(null)}
navigate={navigate}
toast={toast}
/>
)}
</div>
)
}
function ManagerDetail({ manager: m, jobs, onClose, navigate, toast }) {
const go = (path, state) => {
onClose()
navigate(path, { state })
}
return (
<Modal
title="Hiring Manager"
subtitle={m.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-primary" onClick={() => toast('Message sent', 'success')}>
<Icon name="mail" /> Message
</button>
</>
}
>
<div className="profile-hero" style={{ marginBottom: 18 }}>
<Avatar name={m.name} initials={m.initials} color={m.color} className="avatar-lg" />
<div>
<div className="ph-name">{m.name}</div>
<div className="ph-role">{m.title}</div>
<div className="ph-tags">
<Badge className="b-indigo">{m.department}</Badge>
<span className="badge b-gray badge-plain">{m.teamSize} reports</span>
</div>
</div>
</div>
<div className="grid g-3" style={{ marginBottom: 18 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{jobs.length}</span><span className="stat-mini-lbl">Total Jobs</span></div>
<div className="stat-mini">
<span className="stat-mini-val">{jobs.reduce((s, j) => s + j.applications, 0)}</span>
<span className="stat-mini-lbl">Applications</span>
</div>
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>Hiring Manager Portal</div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 16 }}>
<button className="btn btn-secondary" onClick={() => go('/jobs', { openCreate: true })}>
<Icon name="plus" /> Raise Requisition
</button>
<button className="btn btn-secondary" onClick={() => go('/candidates')}>
<Icon name="users" /> Review Candidates
</button>
<button className="btn btn-secondary" onClick={() => go('/interviews', { openSchedule: true })}>
<Icon name="calendar" /> Schedule Interview
</button>
<button className="btn btn-secondary" onClick={() => go('/offers')}>
<Icon name="check-circle" /> Approve Offers
</button>
</div>
<div className="form-section-title">Requisitions</div>
<div className="list-tight">
{jobs.length === 0 ? (
<p className="text-muted">No requisitions</p>
) : (
jobs.map((j) => (
<div
key={j.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => go('/jobs', { openJob: j.id })}
>
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name="briefcase" />
</span>
<div className="lr-main">
<div className="lr-title">{j.title}</div>
<div className="lr-sub">{j.applications} applications</div>
</div>
<Badge>{j.status}</Badge>
</div>
))
)}
</div>
</Modal>
)
}

View File

@ -0,0 +1,57 @@
import { useQuery } from '@tanstack/react-query'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
export default function Notifications() {
const { toast } = useToast()
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
const update = useSeedMutation('notifications')
// Marking one read used to be `this.classList.remove('unread')` a DOM edit
// the badge count never saw. Writing to the cache keeps the sidebar in sync.
const markOne = (i) => update((ns) => ns.map((n, j) => (j === i ? { ...n, unread: false } : n)))
const markAll = () => {
update((ns) => ns.map((n) => ({ ...n, unread: false })))
toast('All notifications marked as read', 'success')
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Notifications</h1>
<p className="page-sub">Stay on top of hiring activity</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={markAll}><Icon name="check" /> Mark all read</button>
<button className="btn btn-ghost" onClick={() => toast('Notification settings', 'info')}>
<Icon name="more" />
</button>
</div>
</div>
<div className="card">
<div className="list-tight" style={{ padding: 0 }}>
{notifications.map((n, i) => (
<div
key={n.id ?? `${n.title}-${i}`}
className={`notif-row${n.unread ? ' unread' : ''}`}
onClick={() => markOne(i)}
>
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
<div className="notif-body">
<div className="notif-title">{n.title}</div>
<div className="notif-text">{n.text}</div>
<div className="notif-time">{n.time}</div>
</div>
{n.unread && (
<span className="dot dot-blue" style={{ position: 'static', border: 'none', alignSelf: 'center' }} />
)}
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,259 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Avatar, Badge, FieldError, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { useFormState } from '../components/AuthLayout'
import { fmtDate, fmtShort, money, TODAY } from '../data/seed'
export default function Offers() {
const { toast } = useToast()
const { data: offers = [] } = useQuery(seedQuery('offers'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const updateOffers = useSeedMutation('offers')
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [viewing, setViewing] = useState(null)
const [creating, setCreating] = useState(false)
const stats = useMemo(() => {
const decided = offers.filter((o) => ['Accepted', 'Declined'].includes(o.status)).length
return {
sent: offers.filter((o) => o.status !== 'Draft').length,
accepted: offers.filter((o) => o.status === 'Accepted').length,
pending: offers.filter((o) => ['Sent', 'Negotiating'].includes(o.status)).length,
rate: Math.round((offers.filter((o) => o.status === 'Accepted').length / (decided || 1)) * 100),
}
}, [offers])
const rows = useMemo(
() =>
offers.filter((o) => {
if (status && o.status !== status) return false
if (q && !(o.candidate + o.jobTitle + o.recruiter).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
[offers, q, status],
)
const columns = [
{
key: 'candidate', label: 'Candidate', sortable: true,
render: (o) => (
<div className="user-cell">
<Avatar name={o.candidate} initials={o.initials} color={o.color} />
<div>
<div className="cell-primary">{o.candidate}</div>
<div className="cell-sub">{o.jobTitle}</div>
</div>
</div>
),
},
{ key: 'department', label: 'Department', sortable: true },
{ key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: (o) => <b>{money(o.base)}</b> },
{ key: 'equity', label: 'Equity', render: (o) => <span className="text-muted">{o.equity}</span> },
{ key: 'bonus', label: 'Bonus', align: 'center', render: (o) => <span className="text-muted">{o.bonus}</span> },
{ key: 'sent', label: 'Sent', sortable: true, sortValue: (o) => o.sent.getTime(), render: (o) => <span className="text-muted">{fmtShort(o.sent)}</span> },
{ key: 'status', label: 'Status', sortable: true, render: (o) => <Badge>{o.status}</Badge> },
{
key: '_a', label: 'Actions', align: 'right',
render: (o) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(o)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Resend" onClick={() => toast(`Offer resent to ${o.candidate}`, 'info')}><Icon name="send" /></button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Offers</h1>
<p className="page-sub">Track offer letters and acceptance</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> Create Offer
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Offers Sent" value={stats.sent} icon="send" tone="i-indigo" />
<KpiCard label="Accepted" value={stats.accepted} icon="check-circle" tone="i-green" />
<KpiCard label="Awaiting Response" value={stats.pending} icon="clock" tone="i-amber" />
<KpiCard label="Acceptance Rate" value={`${stats.rate}%`} icon="trending-up" tone="i-teal" />
</div>
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or role…" />
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map((s) => <option key={s}>{s}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
{viewing && <OfferDetail offer={viewing} onClose={() => setViewing(null)} toast={toast} />}
{creating && (
<CreateOffer
candidates={candidates}
onClose={() => setCreating(false)}
onSave={(offer) => {
updateOffers((os) => [offer, ...os])
setCreating(false)
toast('Offer sent successfully', 'success')
}}
toast={toast}
/>
)}
</div>
)
}
function OfferDetail({ offer: o, onClose, toast }) {
const total = o.base + Math.round((o.base * parseInt(o.bonus, 10)) / 100)
return (
<Modal
title="Offer Details"
subtitle={o.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-secondary" onClick={() => toast('Offer PDF downloaded', 'info')}>
<Icon name="download" /> Download
</button>
<button className="btn btn-primary" onClick={() => { onClose(); toast('Offer resent', 'success') }}>
<Icon name="send" /> Resend Offer
</button>
</>
}
>
<div className="flex items-center gap-12 mb-18">
<Avatar name={o.candidate} initials={o.initials} color={o.color} className="avatar-lg" />
<div>
<div className="ph-name" style={{ fontSize: 17 }}>{o.candidate}</div>
<div className="ph-role">{o.jobTitle} · {o.department}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{o.status}</Badge></div>
</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
<div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>Compensation Package</div>
<div className="info-grid">
<div className="info-item"><div className="il">Base Salary</div><div className="iv" style={{ fontSize: 18 }}>{money(o.base)}</div></div>
<div className="info-item"><div className="il">Annual Bonus</div><div className="iv" style={{ fontSize: 18 }}>{o.bonus}</div></div>
<div className="info-item"><div className="il">Equity</div><div className="iv" style={{ fontSize: 18 }}>{o.equity}</div></div>
<div className="info-item"><div className="il">Est. Total Cash</div><div className="iv" style={{ fontSize: 18, color: 'var(--success)' }}>{money(total)}</div></div>
</div>
</div>
</div>
<div className="info-grid">
<div className="info-item"><div className="il">Sent On</div><div className="iv">{fmtDate(o.sent)}</div></div>
<div className="info-item"><div className="il">Expires</div><div className="iv">{fmtDate(o.expires)}</div></div>
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{o.recruiter}</div></div>
<div className="info-item"><div className="il">Offer ID</div><div className="iv mono">{o.id}</div></div>
</div>
</Modal>
)
}
function CreateOffer({ candidates, onClose, onSave, toast }) {
const eligible = candidates.filter((c) => ['Interview', 'Offer'].includes(c.stage))
const form = useFormState({
candidate: eligible[0]?.name ?? '',
base: '', bonus: '10', equity: '', expires: '', notes: '',
})
function submit() {
if (!form.values.base || Number(form.values.base) <= 0) {
form.setErrors({ base: 'Required' })
toast('Enter a base salary', 'error')
return
}
const cand = candidates.find((c) => c.name === form.values.candidate) || candidates[0]
onSave({
id: `OFR-${9001 + candidates.length}`,
candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color,
jobTitle: cand.jobTitle, department: cand.department, status: 'Sent',
base: Number(form.values.base),
equity: form.values.equity || '10k RSU',
bonus: `${form.values.bonus || 10}%`,
sent: new Date(TODAY),
expires: form.values.expires ? new Date(form.values.expires) : new Date('2026-07-23'),
recruiter: cand.recruiter,
})
}
return (
<Modal
title="Create Offer"
subtitle="Generate and send an offer letter"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="send" /> Send Offer</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate <span className="req">*</span></label>
<select value={form.values.candidate} onChange={(e) => form.setField('candidate', e.target.value)}>
{eligible.map((c) => <option key={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-field">
<label>Base Salary ($) <span className="req">*</span></label>
<input
type="number" placeholder="140000"
className={form.errors.base ? 'err' : ''}
value={form.values.base}
onChange={(e) => form.setField('base', e.target.value)}
/>
<FieldError>{form.errors.base}</FieldError>
</div>
<div className="form-field">
<label>Annual Bonus (%)</label>
<input type="number" value={form.values.bonus} onChange={(e) => form.setField('bonus', e.target.value)} />
</div>
<div className="form-field">
<label>Equity (RSU)</label>
<input placeholder="20k RSU" value={form.values.equity} onChange={(e) => form.setField('equity', e.target.value)} />
</div>
<div className="form-field">
<label>Expiration Date</label>
<input type="date" value={form.values.expires} onChange={(e) => form.setField('expires', e.target.value)} />
</div>
<div className="form-field col-span-2">
<label>Notes</label>
<textarea
placeholder="Additional details for the offer…"
value={form.values.notes}
onChange={(e) => form.setField('notes', e.target.value)}
/>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,143 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Avatar, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [
{ name: 'Applied', color: 'var(--stage-1)' },
{ name: 'Screening', color: 'var(--stage-2)' },
{ name: 'Assessment', color: 'var(--stage-3)' },
{ name: 'Interview', color: 'var(--stage-4)' },
{ name: 'Offer', color: 'var(--stage-5)' },
{ name: 'Hired', color: 'var(--stage-6)' },
{ name: 'Rejected', color: 'var(--stage-7)' },
]
/**
* Native HTML5 drag-and-drop, kept rather than swapped for a library. React
* supports draggable/onDragStart/onDragOver/onDrop as props, the frozen CSS
* already styles `.dragging` and `.drag-over`, and the prototype has no touch
* drag either so adopting @dnd-kit would be a feature addition smuggled into
* a 1:1 port. If touch kanban is wanted it is a scoped follow-up confined to
* this file.
*/
export default function Pipeline() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const updateCandidates = useSeedMutation('candidates')
const [jobId, setJobId] = useState('')
const [draggingId, setDraggingId] = useState(null)
const [overStage, setOverStage] = useState(null)
const list = useMemo(
() => (jobId ? candidates.filter((c) => c.jobId === jobId) : candidates),
[candidates, jobId],
)
const byStage = useMemo(() => {
const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []]))
for (const c of list) if (map[c.stage]) map[c.stage].push(c)
return map
}, [list])
function onDrop(stage) {
setOverStage(null)
const id = draggingId
setDraggingId(null)
if (!id) return
const cand = candidates.find((c) => c.id === id)
if (!cand || cand.stage === stage) return
// Mutating the cache re-renders every screen reading candidates, so the
// move is visible on Candidates and Talent Pool too.
updateCandidates((cs) => cs.map((c) => (c.id === id ? { ...c, stage, status: stage } : c)))
toast(`${cand.name} moved to ${stage}`, 'success')
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Pipeline</h1>
<p className="page-sub">Drag candidates between stages to update their status</p>
</div>
<div className="page-head-actions">
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.filter((j) => j.status === 'Open').map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => navigate('/candidates', { state: { openAdd: true } })}
>
<Icon name="plus" /> Add Candidate
</button>
</div>
</div>
<div className="kanban">
{KANBAN_STAGES.map((st) => {
const cards = byStage[st.name] ?? []
return (
<div className="kanban-col" key={st.name}>
<div className="kanban-col-head">
<span className="k-dot" style={{ background: st.color }} />
<h4>{st.name}</h4>
<span className="k-count">{cards.length}</span>
</div>
<div
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}
onDragOver={(e) => { e.preventDefault(); setOverStage(st.name) }}
onDragLeave={() => setOverStage((s) => (s === st.name ? null : s))}
onDrop={(e) => { e.preventDefault(); onDrop(st.name) }}
>
{cards.map((c) => (
<div
key={c.id}
className={`k-card${draggingId === c.id ? ' dragging' : ''}`}
draggable
onDragStart={(e) => {
setDraggingId(c.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', c.id)
}}
onDragEnd={() => { setDraggingId(null); setOverStage(null) }}
onClick={() => {
// Don't open the profile on the click that ends a drag.
if (draggingId) return
navigate('/candidates', { state: { openCandidate: c.id } })
}}
>
<div className="k-card-top">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="kc-name">{c.name}</div>
<div className="kc-role">{c.currentTitle}</div>
</div>
</div>
<div className="kc-role">{c.jobTitle}</div>
<div className="k-tags">
{c.skills.slice(0, 3).map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
<div className="k-card-meta">
<span className="cell-sub">{c.currentCompany}</span>
<ScoreChip score={c.aiScore} />
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,258 @@
/* ============================================================
Access Control one of the three screens with a real backend.
The prototype's 13 modules x 8 permission types map EXACTLY onto the
backend's 104-tag vocabulary (same modules, same actions, same order), so the
matrix can render real server truth instead of an invented boolean grid.
HONESTY NOTE: the prototype's "Save Changes" fired a success toast and saved
nothing, and its matrix gated nothing (01-repository-assessment.md §2.4). The
backend grants permissions through *bundles* (`roles.permissions` is a list of
bundle ids), not per-tag, so an arbitrary tag set is not expressible through
`PUT /roles/update`. Rather than reproduce a lying save button, the matrix
shows resolved `effective_permissions` read-only and says where they come
from. Creating a role is a real POST.
============================================================ */
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as rolesApi from '../api/roles'
import { permTypes, rbacModules } from '../data/seed'
// Prototype label -> backend module slug. Order matches, so this is positional.
const MODULE_SLUGS = [
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users',
]
const ACTION_SLUGS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
const ROLE_COLORS = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']
export default function Rbac() {
const { toast } = useToast()
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(null)
const [creating, setCreating] = useState(false)
const rolesQuery = useQuery({
queryKey: qk.roles.list(),
queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
})
const createRole = useMutation({
mutationFn: (body) => rolesApi.createRole(body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.roles.all() })
setCreating(false)
toast('Role created', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not create the role.'), 'error'),
})
const roles = rolesQuery.data ?? []
const role = roles.find((r) => r.id === selectedId) ?? roles[0]
// effective_permissions is a flat list of "module.action" tags.
const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role])
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Access Control</h1>
<p className="page-sub">
Enterprise RBAC roles, permission bundles and the 104-tag vocabulary, live from the server
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> New Role
</button>
</div>
</div>
{rolesQuery.isPending && (
<div className="card"><div className="card-body"><EmptyState icon="clock" title="Loading roles">Fetching from the server</EmptyState></div></div>
)}
{rolesQuery.isError && (
<div className="card">
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(rolesQuery.error, 'The server did not return the role list.')}
{' '}This screen needs the <code>rbac_users.view</code> permission.
</EmptyState>
</div>
</div>
)}
{rolesQuery.isSuccess && (
<div className="rbac-layout">
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-body" style={{ padding: 12 }}>
<div className="nav-section-label" style={{ padding: '6px 8px' }}>Roles</div>
<div className="role-list">
{roles.map((r, i) => (
<div
key={r.id}
className={`role-item${r.id === role?.id ? ' active' : ''}`}
onClick={() => setSelectedId(r.id)}
>
<span className="role-badge" style={{ background: ROLE_COLORS[i % ROLE_COLORS.length] }}>
<Icon name="shield" />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{r.role_name}</div>
<div className="cell-sub">
{(r.effective_permissions?.length ?? 0)} permissions
{r.is_system ? ' · system' : ''}
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="card">
{role && (
<>
<div className="card-head">
<div className="flex items-center gap-12">
<span className="role-badge" style={{ background: ROLE_COLORS[roles.indexOf(role) % ROLE_COLORS.length] }}>
<Icon name="shield" />
</span>
<div>
<h3>{role.role_name}</h3>
<span className="ch-sub">{role.description || 'No description'}</span>
</div>
</div>
<div className="flex items-center gap-8">
{role.is_system && <Badge className="b-gray">System role</Badge>}
<span className="badge b-gray badge-plain">
{role.effective_permissions?.length ?? 0} / 104
</span>
</div>
</div>
<div className="card-body">
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
<Icon name="lock" /> These are the roles <b>resolved</b> permissions. The server grants
them through permission bundles
{role.bundles?.length ? ` (${role.bundles.map((b) => b.name ?? b).join(', ')})` : ''},
so individual cells are not directly editable here.
</p>
<div className="table-wrap">
<table className="rbac-matrix">
<thead>
<tr>
<th>Module</th>
{permTypes.map((p) => <th key={p}>{p}</th>)}
</tr>
</thead>
<tbody>
{rbacModules.map((label, mi) => (
<tr key={label}>
<td>{label}</td>
{ACTION_SLUGS.map((action, ai) => {
const on = granted.has(`${MODULE_SLUGS[mi]}.${action}`)
return (
<td key={action}>
<span
className={`perm-check${on ? ' on' : ''}`}
title={`${MODULE_SLUGS[mi]}.${action}`}
aria-label={`${label} ${permTypes[ai]}: ${on ? 'granted' : 'not granted'}`}
>
<Icon name="check" />
</span>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</div>
)}
{creating && (
<CreateRole
busy={createRole.isPending}
onClose={() => setCreating(false)}
onSave={(body) => createRole.mutate(body)}
/>
)}
</div>
)
}
function CreateRole({ busy, onClose, onSave }) {
const form = useFormState({ role_name: '', description: '' })
function submit() {
if (!form.values.role_name.trim()) {
form.setErrors({ role_name: 'Required' })
return
}
onSave({
role_name: form.values.role_name.trim(),
description: form.values.description.trim() || 'Custom role',
permissions: [],
is_active: true,
})
}
return (
<Modal
title="Create Role"
subtitle="Define a new access role"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy}>
<Icon name="check" /> {busy ? 'Creating…' : 'Create Role'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Role Name <span className="req">*</span></label>
<input
placeholder="e.g. Regional Recruiter"
className={form.errors.role_name ? 'err' : ''}
value={form.values.role_name}
onChange={(e) => form.setField('role_name', e.target.value)}
/>
<FieldError>{form.errors.role_name}</FieldError>
</div>
<div className="form-field col-span-2">
<label>Description</label>
<input
placeholder="What can this role do?"
value={form.values.description}
onChange={(e) => form.setField('description', e.target.value)}
/>
</div>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
The role starts with no permission bundles. Assign bundles server-side to grant it access.
</p>
</form>
</Modal>
)
}

View File

@ -0,0 +1,181 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import Charts from '../lib/charts'
import { Avatar, Badge, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { analytics, int } from '../data/seed'
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const WEEKS = ['W1', 'W2', 'W3', 'W4', 'W5']
const STAGES = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const MAX_HEAT = 5
const heatColor = (v) => (v === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + (v / MAX_HEAT) * 0.8})`)
export default function RecruiterHub() {
const { toast } = useToast()
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const [recId, setRecId] = useState(null)
const r = recruiters.find((x) => x.id === recId) ?? recruiters[0]
const trendData = useMemo(
() =>
r
? {
labels: analytics.hiringTrend.labels,
area: true,
datasets: [{ label: 'Hires', data: r.monthlyTrend, color: Charts.PALETTE[0] }],
}
: null,
[r],
)
// The prototype re-rolled these counts on every render via DB.int(). Keyed to
// the recruiter so they're stable while you look at one.
const pipelineData = useMemo(
() => ({ labels: STAGES, data: STAGES.map(() => int(2, 14)), colors: Charts.PALETTE }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[r?.id],
)
const board = useMemo(() => [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 8), [recruiters])
if (!r) return null
const slaCls = r.sla === 'On Track' ? 'b-green' : r.sla === 'At Risk' ? 'b-amber' : 'b-red'
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Recruiter Hub</h1>
<p className="page-sub">Personalized performance dashboard &amp; workload</p>
</div>
<div className="page-head-actions">
<select className="select" value={r.id} onChange={(e) => setRecId(e.target.value)}>
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
<button className="btn btn-secondary" onClick={() => toast('Report exported', 'success')}>
<Icon name="download" /> Export
</button>
</div>
</div>
<div className="card brand-hero mb-18">
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
<Avatar name={r.name} initials={r.initials} color="rgba(255,255,255,.18)" className="avatar-lg" />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{r.name}</div>
<div style={{ opacity: 0.85 }}>{r.department} Recruiter · {r.rating} rating</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800 }}>{r.workload}%</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Workload</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800 }}>{r.efficiency}%</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Efficiency</div>
</div>
<div style={{ textAlign: 'center' }}><Badge className={slaCls}>{r.sla}</Badge></div>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Open Positions" value={r.openPositions} icon="briefcase" tone="i-indigo" foot="active reqs" />
<KpiCard label="Closed Positions" value={r.closedPositions} icon="check-circle" tone="i-green" foot="this year" />
<KpiCard label="Avg Time to Hire" value={`${r.avgTimeToHire}d`} icon="clock" tone="i-teal" foot="target 30d" />
<KpiCard label="Avg Time to Fill" value={`${r.avgTimeToFill}d`} icon="target" tone="i-amber" foot="req → offer" />
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Interviews Today" value={r.interviewsToday} icon="calendar" tone="i-purple" />
<KpiCard label="Offers Pending" value={r.offersPending} icon="file" tone="i-blue" />
<KpiCard label="Awaiting Approval" value={r.jobsAwaitingApproval} icon="clock" tone="i-amber" />
<KpiCard label="Jobs Overdue" value={r.jobsOverdue} icon="alert" tone="i-red" />
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Conversion Rate" value={`${r.conversionRate}%`} icon="trending-up" tone="i-green" foot="applicant → hire" />
<KpiCard label="Interview Completion" value={`${r.interviewCompletion}%`} icon="check-square" tone="i-teal" />
<KpiCard label="Avg Response Time" value={`${r.avgResponseTime}h`} icon="zap" tone="i-purple" foot="to candidates" />
<KpiCard label="TAT Performance" value={`${r.tat}%`} icon="award" tone="i-indigo" foot="turnaround" />
</div>
<div className="grid g-2-1 mb-18">
<div className="card">
<div className="card-head"><div><h3>Monthly Hiring Trend</h3><span className="ch-sub">Hires per month</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={trendData} height={260} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Workload Heatmap</h3><span className="ch-sub">Interview load</span></div></div>
<div className="card-body">
<div className="heatmap">
<div className="hm-label" />
{WEEKS.map((w) => <div className="hm-label" style={{ justifyContent: 'center' }} key={w}>{w}</div>)}
{DAYS.map((d, di) => (
<div style={{ display: 'contents' }} key={d}>
<div className="hm-label">{d}</div>
{r.heatmap[di].map((v, wi) => (
<div
className="hm-cell"
key={`${d}-${wi}`}
style={{ background: heatColor(v) }}
data-tip={`${v} interviews`}
/>
))}
</div>
))}
</div>
<div className="hm-legend">
Less
{[0, 1, 2, 3, 5].map((v) => (
<span className="hm-box" key={v} style={{ background: heatColor(v) }} />
))}
More
</div>
</div>
</div>
</div>
<div className="grid g-2">
<div className="card">
<div className="card-head"><div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Top performers by hires</span></div></div>
<div className="card-body">
{board.map((rec, i) => (
<div
className="leader-row"
key={rec.id}
style={
rec.id === r.id
? { background: 'var(--primary-soft)', borderRadius: 10, paddingLeft: 8, paddingRight: 8 }
: undefined
}
>
<span className={`leader-rank ${i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''}`}>
{i + 1}
</span>
<Avatar name={rec.name} initials={rec.initials} color={rec.color} />
<div className="lr-main">
<div className="lr-title">{rec.name}</div>
<div className="lr-sub">{rec.efficiency}% efficiency · {rec.avgTimeToHire}d avg</div>
</div>
<div className="lr-right">
<div className="fw-600">{rec.hires}</div>
<div className="lr-sub">hires</div>
</div>
</div>
))}
</div>
</div>
<div className="card">
<div className="card-head">
<div><h3>Candidate Pipeline</h3><span className="ch-sub">This recruiters active candidates</span></div>
</div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={pipelineData} height={260} /></div></div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,173 @@
import { useMemo } from 'react'
import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts'
import DataTable from '../ui/DataTable'
import { Icon, KpiCard, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { analytics as a, int } from '../data/seed'
const FUNNEL = [
{ stage: 'Applied', v: 100 }, { stage: 'Screened', v: 62 }, { stage: 'Assessed', v: 41 },
{ stage: 'Interviewed', v: 28 }, { stage: 'Offered', v: 14 }, { stage: 'Hired', v: 9 },
]
const REPORT_TYPES = [
{ name: 'Hiring Funnel Report', desc: 'Conversion rates across each pipeline stage', icn: 'filter', cls: 'i-indigo' },
{ name: 'Source Effectiveness', desc: 'ROI and quality by sourcing channel', icn: 'target', cls: 'i-teal' },
{ name: 'Diversity & Inclusion', desc: 'Demographic breakdown of the pipeline', icn: 'users', cls: 'i-purple' },
{ name: 'Recruiter Scorecard', desc: 'Individual performance metrics', icn: 'award', cls: 'i-amber' },
{ name: 'Offer Analysis', desc: 'Acceptance rates and compensation trends', icn: 'file', cls: 'i-green' },
{ name: 'Interview Analytics', desc: 'Interviewer load and feedback quality', icn: 'calendar', cls: 'i-blue' },
]
export default function Reports() {
const { toast } = useToast()
// The prototype generated hires/ttf inline at render time via DB.int(), so
// they changed on every re-render. Computed once here instead.
const deptRows = useMemo(
() =>
a.departments.map((d) => {
const rate = Math.round((d.open ? d.apps / (d.open * 40) : 0.5) * 100)
return {
id: d.dept, dept: d.dept, open: d.open, apps: d.apps,
hires: int(1, 8), ttf: int(28, 52), rate: Math.min(rate, 98),
}
}),
[],
)
const funnelData = useMemo(
() => ({
labels: FUNNEL.map((f) => f.stage),
data: FUNNEL.map((f) => f.v),
colors: Charts.PALETTE,
yFmt: (v) => `${v}%`,
}),
[],
)
const timeData = useMemo(
() => ({
labels: a.hiringTrend.labels,
datasets: [
{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] },
{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] },
],
yFmt: (v) => `${v}d`,
}),
[],
)
const timeLegend = useMemo(
() => [
{ label: 'Time to Hire', color: Charts.PALETTE[0] },
{ label: 'Time to Fill', color: Charts.PALETTE[2] },
],
[],
)
const cards = [
{ label: 'Total Hires (YTD)', value: a.hiringTrend.hires.reduce((s, v) => s + v, 0), icon: 'award', tone: 'i-green', foot: '+18% vs last year' },
{ label: 'Total Applications', value: a.hiringTrend.applications.reduce((s, v) => s + v, 0).toLocaleString(), icon: 'users', tone: 'i-blue', foot: 'across all channels' },
{ label: 'Avg. Time to Hire', value: '27 days', icon: 'clock', tone: 'i-teal', foot: '3 days faster' },
{ label: 'Avg. Cost per Hire', value: '$4,280', icon: 'dollar', tone: 'i-amber', foot: 'within budget' },
]
const columns = [
{ key: 'dept', label: 'Department', sortable: true, render: (r) => <span className="cell-primary">{r.dept}</span> },
{ key: 'open', label: 'Open Roles', sortable: true, align: 'center' },
{ key: 'apps', label: 'Applications', sortable: true, align: 'center', render: (r) => <b>{r.apps}</b> },
{ key: 'hires', label: 'Hires', sortable: true, align: 'center' },
{ key: 'ttf', label: 'Time to Fill', sortable: true, align: 'center', render: (r) => `${r.ttf} days` },
{
key: 'rate',
label: 'Fill Rate',
sortable: true,
render: (r) => (
<div className="flex items-center gap-8">
<div style={{ flex: 1 }}><ProgressBar pct={r.rate} /></div>
<b style={{ width: 38, textAlign: 'right' }}>{r.rate}%</b>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Reports</h1>
<p className="page-sub">Recruitment metrics and downloadable insights</p>
</div>
<div className="page-head-actions">
<select className="select" defaultValue="Last 7 months">
<option>Last 7 months</option>
<option>This quarter</option>
<option>This year</option>
</select>
<button className="btn btn-primary" onClick={() => toast('Full report exported to PDF', 'success')}>
<Icon name="download" /> Export Report
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
{cards.map((c) => <KpiCard key={c.label} {...c} />)}
</div>
<div className="grid g-2 mb-18">
<div className="card">
<div className="card-head">
<div><h3>Hiring Funnel</h3><span className="ch-sub">Stage-by-stage conversion</span></div>
<button className="btn btn-ghost btn-sm" onClick={() => toast('Chart exported', 'info')}>
<Icon name="download" />
</button>
</div>
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={funnelData} height={280} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Time to Hire vs Fill</h3><span className="ch-sub">Monthly trend (days)</span></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="groupedBar" data={timeData} height={280} /></div>
<ChartLegend items={timeLegend} />
</div>
</div>
</div>
<div className="card mb-18">
<div className="card-head">
<div><h3>Department Performance</h3><span className="ch-sub">Hiring breakdown by team</span></div>
<button className="btn btn-secondary btn-sm" onClick={() => toast('Table exported to CSV', 'success')}>
<Icon name="download" /> CSV
</button>
</div>
<DataTable columns={columns} rows={deptRows} pageSize={10} />
</div>
<div className="card">
<div className="card-head"><div><h3>Report Library</h3><span className="ch-sub">Generate a detailed report</span></div></div>
<div className="card-body">
<div className="grid g-3">
{REPORT_TYPES.map((r) => (
<div
key={r.name}
className="card"
style={{ boxShadow: 'none', background: 'var(--bg-sunken)', cursor: 'pointer' }}
onClick={() => toast(`Generating: ${r.name}`, 'info')}
>
<div className="card-body">
<span className={`kpi-icn ${r.cls}`} style={{ marginBottom: 12 }}><Icon name={r.icn} /></span>
<div className="lr-title">{r.name}</div>
<div className="lr-sub" style={{ marginTop: 4 }}>{r.desc}</div>
<div style={{ marginTop: 12, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>
Generate <Icon name="chevron-right" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}

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