push the scoring connect
parent
221633be0e
commit
f7778538dd
|
|
@ -0,0 +1,41 @@
|
|||
# Build context for backend/Dockerfile and app/Dockerfile is the repo root, so this
|
||||
# file decides what is even eligible to be copied into those images.
|
||||
|
||||
.git/
|
||||
.gitignore
|
||||
.gitignore.local
|
||||
.claude/
|
||||
.cursor/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Secrets are passed at runtime via compose `env_file` — never baked into a layer.
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
**/__pycache__/
|
||||
**/*.py[cod]
|
||||
**/*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Frontend has its own context (./frontend) and its own .dockerignore; nothing of it
|
||||
# belongs in a Python image, and node_modules would dominate the context transfer.
|
||||
frontend/
|
||||
|
||||
# Candidate CVs live on the bind mount, not inside an image.
|
||||
backend/inbox/decoded_attachments/
|
||||
|
||||
docs/
|
||||
tests/
|
||||
scripts/
|
||||
tools/
|
||||
*.md
|
||||
*.log
|
||||
tmp/
|
||||
temp/
|
||||
77
README.md
77
README.md
|
|
@ -113,6 +113,83 @@ Optional — background inbox sync workers (need Redis):
|
|||
docker compose up redis taskiq-worker taskiq-scheduler
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Every service has its own image and its own container, all in one
|
||||
[docker-compose.yml](docker-compose.yml). **Postgres is in that file but does not run**
|
||||
— it sits behind a compose profile, and the stack talks to the PostgreSQL server
|
||||
already running on the host.
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
| Service | Image | Host port | Built from |
|
||||
|---|---|---|---|
|
||||
| `backend-api` | `hrms-backend:local` | 8000 | [backend/Dockerfile](backend/Dockerfile) |
|
||||
| `taskiq-worker` · `taskiq-scheduler` · `taskiq-cv-worker` · `taskiq-cv-scheduler` | `hrms-backend:local` (same image, different `command`) | — | same |
|
||||
| `ats-engine` | `hrms-ats-engine:local` | 8100 | [app/Dockerfile](app/Dockerfile) |
|
||||
| `frontend` | `hrms-frontend:local` | 5173 | [frontend/Dockerfile](frontend/Dockerfile) |
|
||||
| `redis` | `redis:7-alpine` | 6379 | — |
|
||||
| `postgres` *(profile `postgres` — never starts by default)* | `hrms-postgres:local` | 5433 | [docker/postgres/Dockerfile](docker/postgres/Dockerfile) |
|
||||
|
||||
The backend image builds from the **repo root**, not `./backend`: `job/candidate`
|
||||
imports the scoring engine from `app/`, and `inbox.plugins` pulls that in transitively,
|
||||
so a `./backend` context produces workers that die on `No module named 'app'`.
|
||||
|
||||
### The shared file mount
|
||||
|
||||
`backend/inbox/decoded_attachments/` on the host is bind-mounted into every container
|
||||
that touches a CV — `backend-api`, `taskiq-worker`, `taskiq-cv-worker` — at the
|
||||
identical path `/app/inbox/decoded_attachments`. A PDF written by the API is the same
|
||||
file the worker opens, and absolute paths stored in the database resolve in either
|
||||
direction (`inbox.plugins.resolve_attachment_path` also falls back to
|
||||
basename-under-that-folder for rows written by a host process). Point it elsewhere with
|
||||
`ATTACHMENTS_DIR=/some/host/path`.
|
||||
|
||||
### Talking to the host
|
||||
|
||||
`backend/.env` is written for host processes, so compose overrides the three values a
|
||||
container needs: `DB_HOST=host.docker.internal` (the local Postgres),
|
||||
`EMAIL_URL=http://host.docker.internal:5000` (the email service on the host), and
|
||||
`REDIS_URL=redis://redis:6379/0`. The host Postgres must accept connections from the
|
||||
Docker bridge — `listen_addresses = '*'` plus a `pg_hba.conf` entry for `172.16.0.0/12`.
|
||||
|
||||
> **Stop the host `uvicorn` and `npm run dev` first.** Windows lets a host process bind
|
||||
> `127.0.0.1:8000` while Docker binds `0.0.0.0:8000`, and `localhost` resolves to `::1`
|
||||
> first — so both listen and requests silently reach whichever won. Same for 5173. Use
|
||||
> `BACKEND_PORT` / `FRONTEND_PORT` / `ATS_PORT` if both must run.
|
||||
|
||||
`VITE_API_BASE` is inlined into the bundle at **build** time (default
|
||||
`http://localhost:8000`), so changing the API origin means rebuilding the frontend
|
||||
image, not restarting the container.
|
||||
|
||||
### The Postgres profile
|
||||
|
||||
The image is defined alongside everything else, but the `postgres` profile keeps it out
|
||||
of `docker compose build` and `docker compose up` — bringing it up is always explicit:
|
||||
|
||||
```bash
|
||||
docker compose --profile postgres build postgres
|
||||
docker compose --profile postgres up -d postgres # host port 5433; 5432 is the host server's
|
||||
```
|
||||
|
||||
Pointing the app at it is a second, deliberate step: set `DB_HOST=postgres` (the only
|
||||
value that changes — services reach it on 5432 over the compose network) and recreate
|
||||
the services. Its volume starts empty, so Alembic rebuilds the schema on first boot; it
|
||||
does not share the host server's data.
|
||||
|
||||
### Live-code overlay
|
||||
|
||||
[docker-compose.dev.yml](docker-compose.dev.yml) is not a second stack — it defines no
|
||||
services or images, it only adds source bind mounts and `--reload` to the ones above:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
### 4. First run
|
||||
|
||||
1. Sign up / log in (`/auth/login`) — the user needs a role carrying
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec).
|
||||
# Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /.
|
||||
#
|
||||
# The backend imports this same package as a library; this image is the separate
|
||||
# service form of it, so it can be scaled, restarted or pointed at a different model
|
||||
# independently of the portal API.
|
||||
#
|
||||
# THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there):
|
||||
#
|
||||
# docker build -f app/Dockerfile -t hrms-ats-engine:local .
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/srv
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY app/ ./app/
|
||||
|
||||
# Installs the pinned dependencies from pyproject.toml along with the package. The
|
||||
# copy at /srv/app stays on sys.path ahead of the installed one, so the dev overlay's
|
||||
# source bind mount is what actually executes.
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8100
|
||||
|
||||
# No module-level `app` object exists on purpose (app/main.py), so the factory form
|
||||
# is mandatory here.
|
||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"]
|
||||
|
|
@ -1,13 +1,43 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Backend image. The FastAPI API and all four Taskiq processes (inbox worker and
|
||||
# scheduler, CV worker and scheduler) run from this one image; docker-compose picks
|
||||
# the process with `command:`.
|
||||
#
|
||||
# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend:
|
||||
#
|
||||
# docker build -f backend/Dockerfile -t hrms-backend:local .
|
||||
#
|
||||
# backend/job/candidate imports the bulk-ats scoring engine (`app.core.errors`,
|
||||
# `app.services.pdf`, `app.services.scoring`), which lives in app/ at the repo root
|
||||
# and is pulled in transitively by inbox.plugins -> inbox.tasks. A ./backend context
|
||||
# cannot see it, so the workers would die on import.
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
COPY requirements.txt .
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
# Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf`
|
||||
# resolves under PYTHONPATH=/app. requirements.txt says to `pip install -e ..` for
|
||||
# this in a host environment — copying it in is the container equivalent, and its
|
||||
# dependencies (openai, pypdf, pydantic-settings, python-multipart) are already pinned
|
||||
# above.
|
||||
COPY backend/ /app/
|
||||
COPY app/ /app/app/
|
||||
|
||||
# Runs the Taskiq worker against taskiq_management.broker_setup.
|
||||
# docker-compose overrides this command if needed.
|
||||
CMD ["taskiq", "worker", "taskiq_management.broker_setup:broker", "inbox.tasks", "inbox.sync_tasks", "taskiq_management.tasks"]
|
||||
# Decoded CV attachments are read and written here. docker-compose bind-mounts the
|
||||
# host folder over this path so every container shares one set of files; creating it
|
||||
# in the image keeps an un-mounted container from failing on first write.
|
||||
RUN mkdir -p /app/inbox/decoded_attachments
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# Live-code overlay. Mounts the source folders into the running containers, so what
|
||||
# executes is what is on disk on the host — edit, save, uvicorn reloads.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
#
|
||||
# The CV attachments mount from the base file still applies: compose merges volumes
|
||||
# by target path, and /app/inbox/decoded_attachments is nested under the /app mount,
|
||||
# so the daemon mounts the parent first and the attachments folder on top.
|
||||
#
|
||||
# Two mounts per Python service, not one: /app is the backend tree and /app/app is the
|
||||
# bulk-ats engine that backend/job/candidate imports. Mounting only ./backend over
|
||||
# /app would hide the engine baked into the image and every worker would fail on
|
||||
# import.
|
||||
#
|
||||
# The frontend stays as the built nginx image — a Vite dev server wants node_modules
|
||||
# on the mount, which is slow and fragile across a Windows bind mount. Run
|
||||
# `npm run dev` on the host for the frontend loop.
|
||||
|
||||
services:
|
||||
backend-api:
|
||||
command:
|
||||
["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
||||
ats-engine:
|
||||
command:
|
||||
[
|
||||
"uvicorn",
|
||||
"app.main:create_app",
|
||||
"--factory",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8100",
|
||||
"--reload",
|
||||
]
|
||||
volumes:
|
||||
- ./app:/srv/app
|
||||
|
||||
taskiq-worker:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
||||
taskiq-scheduler:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
||||
taskiq-cv-worker:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
||||
taskiq-cv-scheduler:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
|
@ -1,4 +1,82 @@
|
|||
# HR-ATS-Portal — every service and every image, in one file.
|
||||
#
|
||||
# docker compose build # hrms-backend / hrms-ats-engine / hrms-frontend
|
||||
# docker compose up -d
|
||||
# docker compose ps
|
||||
# docker compose logs -f backend-api
|
||||
#
|
||||
# ── Postgres ──────────────────────────────────────────────────────────────────────
|
||||
# The `postgres` service at the bottom is behind a compose PROFILE, so it is defined
|
||||
# and buildable here but never starts with a plain `docker compose up`. The stack
|
||||
# talks to the PostgreSQL server already running on the host, via
|
||||
# DB_HOST=host.docker.internal (backend/.env says localhost — correct for a host
|
||||
# process, wrong inside a container).
|
||||
#
|
||||
# Host Postgres must accept connections from the Docker bridge: listen_addresses = '*'
|
||||
# in postgresql.conf and a pg_hba.conf line for 172.16.0.0/12 (or the specific subnet).
|
||||
#
|
||||
# To build/run the containerised database instead, see the comments on that service.
|
||||
#
|
||||
# ── Ports ─────────────────────────────────────────────────────────────────────────
|
||||
# Stop a host `uvicorn` (8000) and `npm run dev` (5173) before starting these, or
|
||||
# override with BACKEND_PORT / FRONTEND_PORT / ATS_PORT. Windows lets a host process
|
||||
# bind 127.0.0.1:8000 while Docker binds 0.0.0.0:8000, and `localhost` resolves to ::1
|
||||
# first — both listen, and requests reach whichever won.
|
||||
#
|
||||
# ── Overlay ───────────────────────────────────────────────────────────────────────
|
||||
# docker-compose.dev.yml adds live source mounts and --reload on top of this file. It
|
||||
# defines no services or images of its own; it only overrides the ones here:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
x-backend-build: &backend-build
|
||||
# Root context, not ./backend: backend/job/candidate imports the bulk-ats engine
|
||||
# from app/, which sits outside the backend folder. See backend/Dockerfile.
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
|
||||
x-backend-env: &backend-env
|
||||
PYTHONPATH: /app
|
||||
# backend/.env is written for host processes; these are the values a container needs.
|
||||
#
|
||||
# DB_HOST defaults to the LOCAL Postgres server on the host. Set DB_HOST=postgres in
|
||||
# the shell (or a root .env) to point the whole stack at the container below instead
|
||||
# — that is the only value that has to change, since services reach it over the
|
||||
# compose network on 5432, not the published host port.
|
||||
DB_HOST: ${DB_HOST:-host.docker.internal}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
EMAIL_URL: http://host.docker.internal:5000
|
||||
BACKEND_URL: http://backend-api:8000
|
||||
|
||||
# The one shared folder. Every process that decodes, scores or serves a CV reads and
|
||||
# writes the same host directory, so a file written by the API is the same file the
|
||||
# worker opens. Absolute paths stored in the DB match across services because the
|
||||
# mount target is identical everywhere; inbox.plugins.resolve_attachment_path also
|
||||
# falls back to basename-under-this-directory for rows written by a host process.
|
||||
x-attachments: &attachments
|
||||
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||
|
||||
x-backend-service: &backend-service
|
||||
build: *backend-build
|
||||
image: hrms-backend:local
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment: *backend-env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# required:false — the default stack uses the host's Postgres and never starts
|
||||
# this one, and that must not be an error. When the profile IS active, startup
|
||||
# waits for it to pass pg_isready.
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
# --- Redis (broker + result backend for taskiq) ----------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: hrms-redis
|
||||
|
|
@ -14,11 +92,80 @@ services:
|
|||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
taskiq-worker:
|
||||
# --- portal API ------------------------------------------------------------------
|
||||
backend-api:
|
||||
<<: *backend-service
|
||||
container_name: hrms-backend-api
|
||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
volumes: *attachments
|
||||
healthcheck:
|
||||
# python:slim has no curl and the API exposes no /health route, so this is a
|
||||
# plain TCP check against the uvicorn socket.
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import socket;socket.create_connection(('127.0.0.1',8000),3).close()",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
# --- bulk ATS scoring engine (standalone service form of app/) --------------------
|
||||
ats-engine:
|
||||
build:
|
||||
context: ./backend
|
||||
context: .
|
||||
dockerfile: app/Dockerfile
|
||||
image: hrms-ats-engine:local
|
||||
container_name: hrms-ats-engine
|
||||
env_file:
|
||||
# OPENAI_API_KEY currently lives in backend/.env; a root .env (see .env.example)
|
||||
# overrides it when present, and the stack still comes up when it is not.
|
||||
- ./backend/.env
|
||||
- path: ./.env
|
||||
required: false
|
||||
ports:
|
||||
- "${ATS_PORT:-8100}:8100"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8100/api/v1/health',timeout=3)",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
# --- React portal -----------------------------------------------------------------
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
# Baked into the bundle at build time — change it and rebuild, not restart.
|
||||
VITE_API_BASE: ${VITE_API_BASE:-http://localhost:8000}
|
||||
image: hrms-frontend:local
|
||||
container_name: hrms-frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# --- background processing (same image as backend-api, different command) ---------
|
||||
taskiq-worker:
|
||||
<<: *backend-service
|
||||
container_name: hrms-taskiq-worker
|
||||
working_dir: /app
|
||||
command:
|
||||
[
|
||||
"taskiq",
|
||||
|
|
@ -30,53 +177,29 @@ services:
|
|||
"--workers",
|
||||
"1",
|
||||
]
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
<<: *backend-env
|
||||
TASKIQ_QUEUE_NAME: inbox
|
||||
TASKIQ_WORKER_NAME: worker-01
|
||||
# .env uses localhost for the host-side API; containers must reach the host.
|
||||
DB_HOST: host.docker.internal
|
||||
EMAIL_URL: http://host.docker.internal:5000
|
||||
BACKEND_URL: http://host.docker.internal:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
volumes: *attachments
|
||||
|
||||
taskiq-scheduler:
|
||||
build:
|
||||
context: ./backend
|
||||
<<: *backend-service
|
||||
container_name: hrms-taskiq-scheduler
|
||||
working_dir: /app
|
||||
command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks"]
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
command:
|
||||
[
|
||||
"taskiq",
|
||||
"scheduler",
|
||||
"taskiq_management.broker_setup:scheduler",
|
||||
"inbox.sync_tasks",
|
||||
]
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
<<: *backend-env
|
||||
TASKIQ_QUEUE_NAME: inbox
|
||||
DB_HOST: host.docker.internal
|
||||
EMAIL_URL: http://host.docker.internal:5000
|
||||
BACKEND_URL: http://host.docker.internal:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
taskiq-cv-worker:
|
||||
build:
|
||||
context: ./backend
|
||||
<<: *backend-service
|
||||
container_name: hrms-taskiq-cv-worker
|
||||
working_dir: /app
|
||||
command:
|
||||
[
|
||||
"taskiq",
|
||||
|
|
@ -86,30 +209,15 @@ services:
|
|||
"--workers",
|
||||
"1",
|
||||
]
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
<<: *backend-env
|
||||
TASKIQ_CV_QUEUE_NAME: cv_upload
|
||||
TASKIQ_WORKER_NAME: cv-worker-01
|
||||
DB_HOST: host.docker.internal
|
||||
EMAIL_URL: http://host.docker.internal:5000
|
||||
BACKEND_URL: http://host.docker.internal:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
volumes: *attachments
|
||||
|
||||
taskiq-cv-scheduler:
|
||||
build:
|
||||
context: ./backend
|
||||
<<: *backend-service
|
||||
container_name: hrms-taskiq-cv-scheduler
|
||||
working_dir: /app
|
||||
command:
|
||||
[
|
||||
"taskiq",
|
||||
|
|
@ -117,21 +225,49 @@ services:
|
|||
"taskiq_management.cv_broker_setup:cv_scheduler",
|
||||
"inbox.cv_tasks",
|
||||
]
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
<<: *backend-env
|
||||
TASKIQ_CV_QUEUE_NAME: cv_upload
|
||||
DB_HOST: host.docker.internal
|
||||
EMAIL_URL: http://host.docker.internal:5000
|
||||
BACKEND_URL: http://host.docker.internal:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
|
||||
# The profile is what keeps it out of `docker compose build` and `docker compose up`.
|
||||
# Nothing about the default stack changes by its presence in this file.
|
||||
#
|
||||
# docker compose --profile postgres build postgres # build the image
|
||||
# docker compose --profile postgres up -d postgres # run it, host port 5433
|
||||
#
|
||||
# Pointing the app at it is a separate, deliberate step — set DB_HOST=postgres (see
|
||||
# x-backend-env) and recreate the services. The volume starts empty, so Alembic
|
||||
# rebuilds the schema on first boot; it does not share the host server's data.
|
||||
postgres:
|
||||
profiles: ["postgres"]
|
||||
build:
|
||||
context: ./docker/postgres
|
||||
image: hrms-postgres:local
|
||||
container_name: hrms-postgres
|
||||
environment:
|
||||
# Interpolated from the shell or a root .env, NOT from backend/.env — compose
|
||||
# variable substitution and container environment are different things.
|
||||
# Defaults match backend/.env.example.
|
||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${DB_NAME:-hrms}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
ports:
|
||||
# 5433: the host's own Postgres server owns 5432. Only for host-side tools —
|
||||
# containers reach this one on 5432 over the compose network.
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
postgres-data:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Postgres image — BUILT, BUT NOT USED BY THE DEFAULT STACK.
|
||||
#
|
||||
# Every service in docker-compose.yml points at the Postgres already running on the
|
||||
# host (DB_HOST=host.docker.internal). This image exists so the database can be
|
||||
# containerised on demand — a clean machine, a throwaway test run, a second dev — and
|
||||
# it is kept in its own file (docker-compose.postgres.yml) so bringing it up is always
|
||||
# a deliberate act:
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml build postgres
|
||||
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d postgres
|
||||
#
|
||||
# Context is ./docker/postgres.
|
||||
|
||||
FROM postgres:16-alpine
|
||||
|
||||
# The inbox tables store tz-aware timestamps and the app reads them as UTC
|
||||
# (backend/migrations/versions/20260812_1035-b3f1c2d4e5a6_inbox_timestamps_tz_aware.py).
|
||||
ENV TZ=UTC \
|
||||
PGTZ=UTC
|
||||
|
||||
# Runs once, against an empty data volume only.
|
||||
COPY initdb/ /docker-entrypoint-initdb.d/
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
-- Runs once, on first initialisation of an empty data volume.
|
||||
|
||||
-- Timestamps are stored tz-aware and read back as UTC by the backend.
|
||||
ALTER SYSTEM SET timezone TO 'UTC';
|
||||
ALTER SYSTEM SET log_timezone TO 'UTC';
|
||||
|
||||
-- Known migration gap in the inbox module: Alembic autogeneration creates every
|
||||
-- table on first boot, but not this enum type, and a brand-new database fails
|
||||
-- without it (README "Fresh database — one manual step").
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_application_status') THEN
|
||||
CREATE TYPE candidate_application_status AS ENUM
|
||||
('PROCESS','PENDING','APPROVED','REJECTED','ONHOLD','CLOSED');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
tmp/
|
||||
.env
|
||||
.env.*
|
||||
!.env.development
|
||||
!.env.production
|
||||
*.log
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# React portal: Vite build in node, served by nginx with the SPA history fallback.
|
||||
# Context is ./frontend.
|
||||
#
|
||||
# docker build -t hrms-frontend:local ./frontend
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Vite inlines VITE_* at BUILD time, so the API origin is fixed when the image is
|
||||
# built, not when the container starts — rebuild the image to point it elsewhere.
|
||||
# `.env.production.local` outranks every other env file, so this wins over the empty
|
||||
# VITE_API_BASE in .env.production (which means "same origin, behind a proxy").
|
||||
ARG VITE_API_BASE=http://localhost:8000
|
||||
RUN printf 'VITE_API_BASE=%s\n' "$VITE_API_BASE" > .env.production.local \
|
||||
&& npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
<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>" />
|
||||
<script type="module" crossorigin src="/assets/index-IKokchhk.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D-apCYSF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# One SPA at `/`. `vite dev` and `vite preview` serve index.html for every
|
||||
# unmatched path; vite.config.js notes that a static deploy needs the equivalent
|
||||
# rewrite rule. This is it — without it /auth/confirm-email (a router path, not a
|
||||
# file) 404s when a confirmation email link is opened cold.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Hashed filenames, so they can be cached hard.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# index.html must never be cached, or a redeploy keeps serving the old asset hashes.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
|
||||
}
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
/* The profile modal for candidate rows on /candidates (identity from
|
||||
/candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct
|
||||
from CandidateProfile.jsx, which renders the 8-tab TalentPool modal. */
|
||||
from CandidateProfile.jsx, which renders the 8-tab TalentPool modal.
|
||||
|
||||
SCORING IS A THIRD, INDEPENDENT READ: GET /pipeline/candidate/score/fetch,
|
||||
the candidate's current ats_results row. It has to be, because the detail
|
||||
payload is not a source of scoring at all — serialize_candidate_profile and
|
||||
serialize_manual_candidate_profile both hardcode ai_score, matched_keywords,
|
||||
missing_keywords, summary_critique and scored_at to null/[]
|
||||
(backend/job/candidate/serializers.py:116, 174-185). Reading scoring from it
|
||||
meant every candidate on this screen showed "Not scored yet" however many
|
||||
times the engine had actually scored them. */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
|
@ -12,6 +21,7 @@ import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
|
||||
const TABS = ['Overview', 'Scoring', 'File']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
|
|
@ -41,6 +51,13 @@ function formatExperience(value, unit) {
|
|||
return text
|
||||
}
|
||||
|
||||
/** ats_results.computed_at is an ISO string; fmtDate takes a Date. */
|
||||
function fmtStamp(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
|
||||
}
|
||||
|
||||
function useCandidateDetail(userId) {
|
||||
return useQuery({
|
||||
queryKey: qk.candidates.detail(userId),
|
||||
|
|
@ -49,11 +66,51 @@ function useCandidateDetail(userId) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate's current ats_results row — the only scoring source this modal has.
|
||||
*
|
||||
* Sent WITHOUT job_post_id, for the same reason Talent Pool omits it: a row here
|
||||
* is a candidate USER account with no job context (toCandidateUserView leaves
|
||||
* jobId null), so pinning could only ever hide a score that exists under some
|
||||
* other post. Unpinned, the endpoint answers with the newest current score the
|
||||
* candidate has anywhere.
|
||||
*
|
||||
* Keyed by qk.pipeline.candidateScore, so re-opening the same candidate — or
|
||||
* opening one already viewed in Talent Pool — repaints from cache.
|
||||
*/
|
||||
function useAtsResult(userId) {
|
||||
return useQuery({
|
||||
queryKey: qk.pipeline.candidateScore({ userId: userId ?? null }),
|
||||
queryFn: () => pipelineApi.fetchCandidateScore({ userId }),
|
||||
select: pipelineApi.toAtsScore,
|
||||
enabled: Boolean(userId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* job_post_id -> title, so the score reads as "scored against Senior Backend
|
||||
* Engineer" rather than a uuid. Same query key and row shape as the Candidates
|
||||
* screen's own jobs query, so this is a cache hit rather than a second request.
|
||||
*/
|
||||
function useJobTitles() {
|
||||
return useQuery({
|
||||
queryKey: qk.jobPosts.list(),
|
||||
queryFn: async () => {
|
||||
const res = await candidatesApi.listJobs()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((row) => ({ id: row.id, title: row.title }))
|
||||
},
|
||||
select: (rows) => new Map(rows.map((row) => [String(row.id), row.title])),
|
||||
})
|
||||
}
|
||||
|
||||
export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const isLive = Boolean(c.userId)
|
||||
const detail = useCandidateDetail(c.userId)
|
||||
const live = detail.data ?? null
|
||||
const ats = useAtsResult(c.userId)
|
||||
const atsRow = ats.data ?? null
|
||||
|
||||
const view = useMemo(() => {
|
||||
const currentTitle = stripSentinel(live?.current_title) ?? c.currentTitle ?? null
|
||||
|
|
@ -65,17 +122,20 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
live?.documents?.[0]?.name || c.filename || null
|
||||
const matchSummary = live?.match_summary ?? null
|
||||
const messageId = live?.message_id ?? null
|
||||
const aiScore = live?.ai_score ?? c.aiScore ?? null
|
||||
// ats_results wins over the detail payload's denormalised copy, because it is
|
||||
// the row the copy is made from — and on this screen the copy is always null.
|
||||
const aiScore = atsRow?.overall_score ?? live?.ai_score ?? c.aiScore ?? null
|
||||
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
|
||||
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []
|
||||
const critique = live?.summary_critique ?? c.critique ?? null
|
||||
const errorCode = live?.error_code ?? c.errorCode ?? null
|
||||
const errorMessage = live?.error_message ?? live?.match_error ?? c.errorMessage ?? null
|
||||
const scoredFor = live?.job_title ?? jobTitle ?? null
|
||||
const scored = live
|
||||
const scored = atsRow != null || (live
|
||||
? Boolean(live.scored_at || live.ai_score != null)
|
||||
: c.scoringStatus === 'completed'
|
||||
: c.scoringStatus === 'completed')
|
||||
return {
|
||||
band: atsRow?.band || null,
|
||||
name: c.name,
|
||||
email: c.email,
|
||||
applied: c.applied,
|
||||
|
|
@ -100,7 +160,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
sourceLabel: SOURCE_LABEL[source] ?? source ?? '—',
|
||||
subtitle: filename || c.email || null,
|
||||
}
|
||||
}, [c, live, jobTitle])
|
||||
}, [c, live, atsRow, jobTitle])
|
||||
|
||||
// enabled:false stays pending forever in TanStack v5 — short-circuit when no userId.
|
||||
const guard = !isLive ? null
|
||||
|
|
@ -146,8 +206,9 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
</div>
|
||||
{view.aiScore != null && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<ScoreChip score={view.aiScore} />
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
|
||||
<ScoreChip score={Math.round(view.aiScore)} />
|
||||
{/* The band replaces the static label only when the ATS row answered. */}
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>{view.band || 'AI Match'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -157,7 +218,12 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
</div>
|
||||
|
||||
<div className="tab-pane active">
|
||||
{guard ?? (<>
|
||||
{/* Scoring sits OUTSIDE `guard`: it renders from the ats_results query, so
|
||||
a slow or failed detail fetch must not blank it, and its own loading
|
||||
and error states belong to that query. */}
|
||||
{tab === 'Scoring' && <ScoringTab enabled={isLive} ats={ats} fallbackJobTitle={view.scoredFor} />}
|
||||
|
||||
{tab !== 'Scoring' && (guard ?? (<>
|
||||
{tab === 'Overview' && (
|
||||
<>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
|
|
@ -168,67 +234,101 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
|
||||
<div className="info-item"><div className="il">Added On</div><div className="iv">{view.applied ? fmtDate(view.applied) : '—'}</div></div>
|
||||
</div>
|
||||
{view.scored && (
|
||||
{/* Gated on the list being non-empty, not on `scored`: the detail
|
||||
payload never carries keywords, so keying it to the score would
|
||||
render a heading over a dash for every scored candidate. */}
|
||||
{view.matchedSkills.length > 0 && (
|
||||
<>
|
||||
<div style={LABEL}>Matched Skills</div>
|
||||
<div className="k-tags">
|
||||
{view.matchedSkills.length
|
||||
? view.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
|
||||
: <span className="text-muted">—</span>}
|
||||
{view.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Scoring' && (
|
||||
view.scored ? (
|
||||
<>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
|
||||
<p className="text-muted" style={{ marginBottom: 18 }}>{view.critique ?? '—'}</p>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Matched Skills ({view.matchedSkills.length})
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 16 }}>
|
||||
{view.matchedSkills.length
|
||||
? view.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 ({view.missingSkills.length})
|
||||
</div>
|
||||
<div className="k-tags">
|
||||
{view.missingSkills.length
|
||||
? view.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>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState icon="target" title="Not scored yet">
|
||||
This candidate has not been scored against a job post.
|
||||
</EmptyState>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'File' && (
|
||||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">File Name</div><div className="iv">{view.filename ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
|
||||
{view.messageId && (
|
||||
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{view.messageId}</div></div>
|
||||
)}
|
||||
<div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</div></div>
|
||||
{view.errorCode && (
|
||||
<div className="info-item"><div className="il">Error</div><div className="iv">{view.errorCode}</div></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</>))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ats_results row, and nothing else.
|
||||
*
|
||||
* What that table stores IS the result: overall_score, band, the job post it was
|
||||
* computed against, and when (backend/inbox/models.py::AtsResults). The matched
|
||||
* and missing keywords and the critique live on the `candidates` table, which
|
||||
* this endpoint does not join — so they are absent here rather than rendered as
|
||||
* a heading over a dash.
|
||||
*/
|
||||
function ScoringTab({ enabled, ats, fallbackJobTitle }) {
|
||||
// Before the early returns: hook order cannot depend on query state.
|
||||
const { data: jobTitles } = useJobTitles()
|
||||
const row = ats.data ?? null
|
||||
|
||||
// enabled:false stays pending forever in TanStack v5, so a candidate with no
|
||||
// userId must short-circuit rather than spin.
|
||||
if (!enabled) {
|
||||
return (
|
||||
<EmptyState icon="target" title="Not scored yet">
|
||||
This candidate has no account to look a score up against.
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
if (ats.isPending) {
|
||||
return <EmptyState icon="refresh" title="Loading score…">Fetching the ATS result.</EmptyState>
|
||||
}
|
||||
if (ats.isError) {
|
||||
return (
|
||||
<EmptyState icon="alert" title="Could not load the ATS result">
|
||||
{friendlyAuthError(ats.error, 'Please try again.')}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
if (!row) {
|
||||
return (
|
||||
<EmptyState icon="target" title="Not scored yet">
|
||||
This candidate has not been scored against a job post.
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
// The uuid resolves to a title only once the jobs list is cached; the prop is
|
||||
// the fallback, and it is '—' on this screen when the row carries no job.
|
||||
const against = (row.job_post_id && jobTitles?.get(String(row.job_post_id))) || fallbackJobTitle || '—'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 18, marginBottom: 22 }}>
|
||||
{/* overall_score is a float column; the ring and the label both want an int. */}
|
||||
<ScoreChip score={Math.round(row.overall_score ?? 0)} />
|
||||
<div>
|
||||
<div className="iv" style={{ fontSize: 16, fontWeight: 600 }}>{row.band || 'Scored'}</div>
|
||||
<div className="cell-sub">ATS match score out of 100</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<div className="il">Scored Against</div>
|
||||
<div className="iv">{against}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Scored On</div>
|
||||
<div className="iv">{fmtStamp(row.computed_at) ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue