Merge pull request 'Backend_CODEBASE' (#2) from Backend_CODEBASE into main
Reviewed-on: #2pull/3/head^2
commit
45558ceb35
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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=
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -24,4 +27,7 @@ app.add_middleware(
|
|||
|
||||
app.include_router(inbox_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(role_router)
|
||||
app.include_router(role_router)
|
||||
app.include_router(forget_password_router)
|
||||
app.include_router(confirmation_router)
|
||||
app.include_router(candidate_router)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
36
devserver.py
36
devserver.py
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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=
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 “{q}”</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<kbd className="search-kbd">⌘K</kbd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,'<')`
|
||||
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>
|
||||
We’re looking for a Senior Product Designer to craft intuitive, delightful
|
||||
experiences across our platform. You’ll 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 you’d 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>We’re 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(' & ')}. I’d 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
|
||||
chat’s <code>send</code> handler to make responses live.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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 don’t have access to this page</h3>
|
||||
<p>Ask an administrator to grant your role the required permission.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default function Spinner({ label = 'Loading' }) {
|
||||
return (
|
||||
<span className="spinner" role="status" aria-label={label}>
|
||||
<span className="spinner-dot" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
},
|
||||
})
|
||||
|
|
@ -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'],
|
||||
},
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: 'We’ll 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: 'You’re 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 feature’s UI is complete. Backend wiring is the only remaining step.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 A–Z</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
Here’s what’s 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (0–100) evaluates how well a candidate matches the job requirements based on skills, experience, and education. Higher scores indicate stronger matches.' },
|
||||
{ q: 'Can I move candidates between pipeline stages?', a: 'Yes. Open the Pipeline view and simply drag any candidate card between stage columns. The candidate’s status updates automatically.' },
|
||||
{ q: 'How do I schedule an interview?', a: 'Go to Interviews or Calendar and click "Schedule Interview". Select the candidate, round, date, time, and interviewers.' },
|
||||
{ q: 'How do I export reports?', a: 'On the Reports page, use the "Export Report" button for a full PDF, or the CSV buttons on individual tables.' },
|
||||
]
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (2021–Present)
|
||||
• ${pick(companies)} — Associate (2018–2021)
|
||||
|
||||
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="Couldn’t 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 recruiter’s 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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="Couldn’t 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 role’s <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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 & 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 recruiter’s 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue