Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / docs/superpowers/plans/2026-07-20-recruitmatch.md |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | docs/superpowers/plans/2026-07-20-recruitmatch.md |
| Original SHA-256 | 43d5e86df8b15b23e6fd92a72ff8966e7a24aee843af49cbd1334a309896c5ca |
| Git blob SHA | 7973d93ea0ada60f81f14cdae350d1fba5288384 |
| Reading-copy SHA-256 | 43d5e86df8b15b23e6fd92a72ff8966e7a24aee843af49cbd1334a309896c5ca |
Recovered source transcript
Download complete source text ↓1,925 lines · 69.9 KB
# recruitMATCH Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add recruitMATCH, a fourth product tool beside missionMATCH, screenMATCH and slateMATCH, giving recruiters an operational dashboard aggregated from the tables the other tools already write.
**Architecture:** One new backend module `internal/modules/recruitmatch` following the Command Center vertical slice: a single entry method inside one `RunInTenantTx`, temp tables scoped by the tenant GUC, SQL returning counts only, and all banding done in pure Go. One new frontend tool directory plus one `TOOLS` entry. No migration. No schema change. No new tables.
**Tech Stack:** Go 1.26, chi, pgx v5, shopspring/decimal, React 19, Vite 8, Tailwind v4, TanStack Query v5, oxlint.
**Spec:** `docs/superpowers/specs/2026-07-20-recruitmatch-design.md`
## Global Constraints
These apply to every task. Copied from `CLAUDE.md` and the spec.
1. **No dashes of any kind in written output**: code comments, log strings, UI copy, docs, commit messages. Hyphens are allowed only inside URLs, HTTP header names, CLI flags and code identifiers.
2. **No Python.** Backend Go, frontend TypeScript, scripts bash.
3. **No FG specific anything.** FG is a row in `organizations`. No FG string, asset or conditional anywhere.
4. **All scoring and money math uses `shopspring/decimal`, never `float64`.** Decimals cross the wire as JSON strings.
5. **Every panel must render for an org with zero rows** without a crash, a NaN or a fabricated number.
6. **The engine is frozen.** Do not modify `internal/modules/matching/engine`. Import `engine.BandGreenMin` and `engine.BandYellowMin`; never redeclare thresholds.
7. **Never read the `eeo` schema** from this module.
8. Frontend: relative imports only, no path aliases. Named exports, no default export. `import type` for type only imports. `tabular-nums` on every number.
9. Clearance is a self reported indicator, never shown as verified.
## Build phases
| Phase | Tasks | Deliverable | Touches production |
|---|---|---|---|
| 0. Close the deploy gap | 0 | Live schema matches the branch, so the learning loop can run | Yes, migrations and a binary deploy |
| 1. Backend slice | 1, 2, 3 | The dashboard view computes, proven by pure tests | No |
| 2. Expose it | 4, 5 | Endpoint answers, isolation and empty org proven | No |
| 3. Frontend slice | 6, 7, 8 | The tool is visible, routed and navigable | No |
| 4. Live feeds | 9 | Alerts and recommended actions render | No |
| 5. Brain integration | 10 | Recruiting questions answerable in Ask the Brain | No |
| 6. Org branding | 11, 12 | Per organization white labeling, usable from the UI | Yes, migration 0046 |
| 7. Layout pass | 13 | Panel balance and density | Frontend deploy |
| 8. Production hardening | 14, 15, 16 | Deliberate config, current compliance artifact | Yes, config and restart |
| 9. Integrate | 17 | Work merged and pushed | Repo only |
Phases 0, 6 and 8 touch production. Everything else is local until deployed.
### Status at 2026 07 20
Phases 0 through 6 are COMPLETE and live. Phases 7, 8 and 9 remain.
Phase 6 was built before it was written down, which was a process failure: the org branding spec section said it needed its own plan and it got implemented instead. Tasks 11 and 12 below are written after the fact so the plan matches what actually exists, and Task 12 records the gap that mistake left.
---
### Task 0: Close the production migration gap
The live database is at goose version 42. This branch is at 45. `outcome_events`, `aioe_weight_profiles` and `term_expansions` do not exist in production, so the AIOE learning loop that turns recruiter decisions into weight adjustments **cannot be running today**. recruitMATCH surfaces those decisions, so the loop must exist before the tool means anything.
This task touches production. Take the backup.
**Files:** none. This is an operations task run against Bubbles and the M4 database.
- [ ] **Step 1: Confirm the gap**
```bash
ssh user@100.90.97.104 'psql "host=100.94.8.94 port=5432 user=missionmatch_migrator password=[REDACTED, finding D5] dbname=missionmatch sslmode=disable" -At -c "select max(version_id) from goose_db_version;"'
```
Expected: `42`. If it already prints 45, skip to Step 5.
- [ ] **Step 2: Back up before touching anything**
```bash
ssh user@100.90.97.104 'pg_dump "host=100.94.8.94 port=5432 user=missionmatch_migrator password=[REDACTED, finding D5] dbname=missionmatch sslmode=disable" -Fc -f /home/user/mm-backups/missionmatch_pre_0043_$(date +%Y%m%d-%H%M%S).dump && ls -lh /home/user/mm-backups/ | tail -3'
```
Expected: a new dump file listed with a nonzero size. Do not continue without it.
- [ ] **Step 3: Apply 0043 through 0045**
The Makefile variable is `MIGRATOR_DSN`, not `MM_MIGRATOR_DATABASE_URL`, and goose is not on PATH by default.
```bash
cd /Users/josephanady/missionmatch
export PATH="$PATH:$(go env GOPATH)/bin"
DSN="postgres://missionmatch_migrator:[REDACTED, finding D5]@100.94.8.94:5432/missionmatch?sslmode=disable"
goose -dir db/migrations postgres "$DSN" status | tail -5
goose -dir db/migrations postgres "$DSN" up
```
Expected: status lists exactly 0043, 0044 and 0045 as Pending, then up applies all three and reports version 45.
- [ ] **Step 4: Verify the tables landed**
```bash
ssh user@100.90.97.104 'psql "host=100.94.8.94 port=5432 user=missionmatch_migrator password=[REDACTED, finding D5] dbname=missionmatch sslmode=disable" -At -c "select max(version_id) from goose_db_version; select to_regclass(\$\$public.outcome_events\$\$), to_regclass(\$\$public.aioe_weight_profiles\$\$), to_regclass(\$\$public.term_expansions\$\$);"'
```
Expected: `45`, then three non null regclass values.
- [ ] **Step 5: Rebuild and deploy the API**
The production binary was built July 1 and predates the AIOE code that reads these tables.
**You cannot `scp` over a running binary.** The kernel returns ETXTBSY and the copy fails while the service keeps running the OLD build, so a naive deploy silently succeeds at nothing. Stage to a new name, verify the checksum, then rename over the target.
```bash
cd /Users/josephanady/missionmatch
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFIPS140=v1.0.0 go build -trimpath -o mm-api ./cmd/api
go version -m mm-api | grep GOFIPS140 # must print v1.0.0
strings mm-api | grep -q 'X-Org-ID' && { echo "FAIL: dev resolver in binary"; exit 1; }
LOCAL=$(shasum -a 256 mm-api | cut -d' ' -f1)
ssh user@100.90.97.104 "cp /home/user/missionmatch/mm-api /home/user/missionmatch/mm-api.bak-\$(date +%Y%m%d-%H%M%S)"
scp -q mm-api user@100.90.97.104:/home/user/missionmatch/mm-api.new
ssh user@100.90.97.104 "test \"\$(sha256sum /home/user/missionmatch/mm-api.new | cut -d' ' -f1)\" = \"$LOCAL\" || { echo MISMATCH; exit 1; }
echo user | sudo -S systemctl stop mm-api
mv /home/user/missionmatch/mm-api.new /home/user/missionmatch/mm-api
chmod +x /home/user/missionmatch/mm-api
echo user | sudo -S systemctl start mm-api
sleep 3
sha256sum /home/user/missionmatch/mm-api
systemctl is-active mm-api
curl -s -o /dev/null -w 'healthz=%{http_code}\n' http://127.0.0.1:8094/healthz"
```
Expected: the deployed sha256 equals `$LOCAL`, `active`, and `healthz=200`. If anything fails, `mv` the `.bak` back and restart.
- [ ] **Step 6: Confirm the app still serves and is FIPS clean**
`/healthz/fips` is NOT proxied by nginx, only `/healthz` and `/api/` are, so externally it returns the SPA HTML. Check it on the box.
```bash
ssh user@100.90.97.104 'curl -s http://127.0.0.1:8094/healthz/fips'
```
Expected: `{"fips_enabled":true,...,"pbkdf2_self_test":true}`.
Then confirm the authenticated surface still works, which proves the app pool and RLS survived the deploy. Log in, then:
```bash
for p in /api/auth/session /api/missionmatch/command-center /api/candidates /api/alerts /api/aioe/status; do
curl -s -b cookies.txt -o /dev/null -w "$p => %{http_code}\n" https://missionmatch.thatwebhostingguy.com$p
done
```
Expected: all 200. Note `/api/aioe/status` returns **200 with `updaterConfigured:false`**, not 503; it reports configuration state rather than failing. Confirm the body shows `"updaterConfigured": false` and `"activeProfileVersion": "global.default"`, which proves nothing has started moving scoring weights.
- [ ] **Step 7: Record the deploy**
No commit. Note the backup filename and the deployed binary timestamp in the deploy log.
---
### Task 1: DTO and pure derivation with tests
No database. Establishes the shape every later task fills in.
**Files:**
- Create: `internal/modules/recruitmatch/dto.go`
- Create: `internal/modules/recruitmatch/derive.go`
- Test: `internal/modules/recruitmatch/derive_test.go`
**Interfaces:**
- Consumes: `engine.BandGreenMin`, `engine.BandYellowMin` from `internal/modules/matching/engine`.
- Produces: `View`, `Headline`, `FunnelStage`, `BandMix`, `ClearanceRow`, `TopJob`, `RecruiterRow`, `EmptyPanel`, and `func stateFor(pct decimal.Decimal) string`, `func colorFor(state string) string`, `func buildFunnel(identified, screened, submitted, approved, rejected int64) []FunnelStage`.
- [ ] **Step 1: Write the failing test**
Create `internal/modules/recruitmatch/derive_test.go`:
```go
package recruitmatch
import "testing"
func TestBuildFunnelZeroOrgProducesAllStagesWithZeroCounts(t *testing.T) {
got := buildFunnel(0, 0, 0, 0, 0)
if len(got) != 5 {
t.Fatalf("want 5 stages, got %d", len(got))
}
for _, s := range got {
if s.Count != 0 {
t.Errorf("stage %q count = %d, want 0", s.Key, s.Count)
}
if s.Pct != "0.00" {
t.Errorf("stage %q pct = %q, want 0.00", s.Key, s.Pct)
}
}
}
func TestBuildFunnelPercentagesAreRelativeToIdentified(t *testing.T) {
got := buildFunnel(200, 100, 50, 20, 5)
want := map[string]string{
"identified": "100.00",
"screened": "50.00",
"submitted": "25.00",
"approved": "10.00",
"rejected": "2.50",
}
for _, s := range got {
if want[s.Key] != s.Pct {
t.Errorf("stage %q pct = %q, want %q", s.Key, s.Pct, want[s.Key])
}
}
}
func TestStateForUsesFrozenEngineBands(t *testing.T) {
cases := []struct {
pct string
want string
}{
{"95.00", "ready"},
{"90.00", "ready"},
{"80.00", "partial"},
{"75.00", "partial"},
{"10.00", "gap"},
{"0.00", "gap"},
}
for _, c := range cases {
if got := stateFor(mustDec(c.pct)); got != c.want {
t.Errorf("stateFor(%s) = %q, want %q", c.pct, got, c.want)
}
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `go test ./internal/modules/recruitmatch/ -run TestBuildFunnel -v`
Expected: FAIL, the package does not compile because `buildFunnel`, `stateFor` and `mustDec` are undefined.
- [ ] **Step 3: Write the DTO**
Create `internal/modules/recruitmatch/dto.go`:
```go
// Package recruitmatch is the recruitMATCH tool: the recruiting operations rollup over the SAME tables
// screenMATCH and slateMATCH write. It owns no tables of its own, so every number it shows equals what
// the other tools show for the same candidate. Panels with no data source render a named empty state
// rather than a fabricated number.
package recruitmatch
// View is the whole GET /api/recruitmatch/dashboard body. Every count is an integer and every
// percentage is a decimal string, so exact decimal values survive the wire.
type View struct {
AsOf string `json:"as_of"`
OrganizationID string `json:"organization_id"`
GeneratedAt string `json:"generated_at"`
WindowDays int `json:"window_days"`
Headline Headline `json:"headline"`
Funnel []FunnelStage `json:"funnel"`
BandMix BandMix `json:"band_mix"`
Clearance []ClearanceRow `json:"clearance"`
TopJobs []TopJob `json:"top_jobs"`
Recruiters []RecruiterRow `json:"recruiters"`
EmptyPanels []EmptyPanel `json:"empty_panels"`
}
// Headline is the stat tile row.
type Headline struct {
ActiveJobs int64 `json:"active_jobs"`
NewCandidates int64 `json:"new_candidates"`
Screened int64 `json:"screened"`
Submissions int64 `json:"submissions"`
Approved int64 `json:"approved"`
Rejected int64 `json:"rejected"`
}
// FunnelStage is one bar of the pipeline overview. Pct is relative to the identified stage.
type FunnelStage struct {
Key string `json:"key"`
Label string `json:"label"`
Count int64 `json:"count"`
Pct string `json:"pct"`
Color string `json:"color"`
}
// BandMix is the green yellow red split of persisted screenMATCH evaluations.
type BandMix struct {
Green int64 `json:"green"`
Yellow int64 `json:"yellow"`
Red int64 `json:"red"`
Total int64 `json:"total"`
}
// ClearanceRow is one clearance level and how many candidates hold it. Self reported indicator.
type ClearanceRow struct {
Code string `json:"code"`
Label string `json:"label"`
Count int64 `json:"count"`
Pct string `json:"pct"`
}
// TopJob is one requirement ranked by how many candidates have been submitted against it.
type TopJob struct {
RequirementID string `json:"requirement_id"`
Title string `json:"title"`
CustomerAgency string `json:"customer_agency,omitempty"`
Submissions int64 `json:"submissions"`
RequiredLevel string `json:"required_level"`
}
// RecruiterRow attributes activity to a user in this org.
type RecruiterRow struct {
UserID string `json:"user_id"`
FullName string `json:"full_name"`
Screened int64 `json:"screened"`
Decided int64 `json:"decided"`
Approved int64 `json:"approved"`
}
// EmptyPanel names a panel the product intends to show and the feed it needs. It is how the dashboard
// states honestly that a number is not tracked yet instead of rendering a zero that looks like data.
type EmptyPanel struct {
Key string `json:"key"`
Title string `json:"title"`
Reason string `json:"reason"`
}
```
- [ ] **Step 4: Write the pure derivation**
Create `internal/modules/recruitmatch/derive.go`:
```go
package recruitmatch
import (
"github.com/shopspring/decimal"
"github.com/missionworkforce/missionmatch/internal/modules/matching/engine"
)
var hundred = decimal.NewFromInt(100)
// pct returns num over den as a percentage rounded to two places. A zero or negative denominator
// yields zero rather than NaN, which is the zero data org case.
func pct(num, den int64) decimal.Decimal {
if den <= 0 {
return decimal.Zero
}
return decimal.NewFromInt(num).Div(decimal.NewFromInt(den)).Mul(hundred).Round(2)
}
// stateFor maps a percentage onto the frozen engine bands so recruitMATCH never disagrees with
// screenMATCH about what green means.
func stateFor(p decimal.Decimal) string {
switch {
case p.GreaterThanOrEqual(engine.BandGreenMin):
return "ready"
case p.GreaterThanOrEqual(engine.BandYellowMin):
return "partial"
default:
return "gap"
}
}
// colorFor maps a state onto the shared status palette used across the app.
func colorFor(state string) string {
switch state {
case "ready":
return "green"
case "partial":
return "yellow"
default:
return "red"
}
}
// buildFunnel renders the pipeline overview. Percentages are relative to identified, which is the
// whole bench, so the stages read as a funnel rather than as unrelated counts.
func buildFunnel(identified, screened, submitted, approved, rejected int64) []FunnelStage {
rows := []struct {
key, label string
count int64
}{
{"identified", "Identified", identified},
{"screened", "Screened", screened},
{"submitted", "Submitted", submitted},
{"approved", "Approved", approved},
{"rejected", "Rejected", rejected},
}
out := make([]FunnelStage, 0, len(rows))
for _, r := range rows {
p := pct(r.count, identified)
out = append(out, FunnelStage{
Key: r.key,
Label: r.label,
Count: r.count,
Pct: p.StringFixed(2),
Color: colorFor(stateFor(p)),
})
}
return out
}
// notTracked is the fixed set of panels the product shows as explicitly untracked. Keeping it in one
// place means the UI never has to guess why a panel is blank.
func notTracked() []EmptyPanel {
return []EmptyPanel{
{Key: "interviews", Title: "Interviews", Reason: "Interview scheduling is not tracked in missionMATCH yet."},
{Key: "offers", Title: "Offers", Reason: "Offers are not tracked in missionMATCH yet."},
{Key: "placements", Title: "Placements", Reason: "Placements are not tracked in missionMATCH yet."},
{Key: "revenue", Title: "Revenue", Reason: "Bill rates and revenue are not tracked in missionMATCH yet."},
{Key: "time_to_fill", Title: "Average time to fill", Reason: "Requirements do not record a fill date yet."},
{Key: "source_mix", Title: "Candidate source", Reason: "Candidate source records seed or external only, so job board attribution is not available."},
}
}
func mustDec(s string) decimal.Decimal {
d, err := decimal.NewFromString(s)
if err != nil {
panic(err)
}
return d
}
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `go test ./internal/modules/recruitmatch/ -v`
Expected: PASS, three tests.
- [ ] **Step 6: Commit**
```bash
git add internal/modules/recruitmatch/dto.go internal/modules/recruitmatch/derive.go internal/modules/recruitmatch/derive_test.go
git commit -m "feat(recruitmatch): dto and pure derivation with band mapping"
```
---
### Task 2: Service, temp tables and the headline panel
**Files:**
- Create: `internal/modules/recruitmatch/service.go`
- Create: `internal/modules/recruitmatch/service_queries.go`
**Interfaces:**
- Consumes: `tenant.DB.RunInTenantTx(ctx, func(ctx, pgx.Tx) error) error`, `tenant.OrgFromContext(ctx) (string, bool)`, `audit.Writer`.
- Produces: `Service`, `NewService(db *tenant.DB) *Service`, `NewServiceWithAudit(db *tenant.DB, aud *audit.Writer) *Service`, `(*Service).RecruiterDashboard(ctx context.Context, asOf time.Time) (View, error)`, `ErrNoDatabase`.
- [ ] **Step 1: Write the service**
Create `internal/modules/recruitmatch/service.go`:
```go
package recruitmatch
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// ErrNoDatabase is returned when the service was built without a pool, which the handler maps to 503.
var ErrNoDatabase = errors.New("recruitmatch: no database configured")
// windowDays is the rolling activity window for the headline counts. Seven days matches the product
// design and keeps the aggregate cheap.
const windowDays = 7
// Service computes the recruitMATCH dashboard. It reads only tables the other tools already write.
type Service struct {
db *tenant.DB
audit *audit.Writer
clock func() time.Time
}
func NewService(db *tenant.DB) *Service { return &Service{db: db, clock: time.Now} }
// NewServiceWithAudit keeps parity with the other modules. The dashboard is read only today, so the
// writer is unused, but the constructor exists so wiring does not change when a write arrives.
func NewServiceWithAudit(db *tenant.DB, aud *audit.Writer) *Service {
return &Service{db: db, audit: aud, clock: time.Now}
}
// RecruiterDashboard assembles the whole view inside ONE tenant transaction. Every query is bound to
// asOf, and no query calls now() or current_date, so a fixed asOf yields a byte identical view.
func (s *Service) RecruiterDashboard(ctx context.Context, asOf time.Time) (View, error) {
if s.db == nil {
return View{}, ErrNoDatabase
}
orgID, _ := tenant.OrgFromContext(ctx)
asOfStr := asOf.Format("2006-01-02")
v := View{
AsOf: asOfStr,
OrganizationID: orgID,
GeneratedAt: asOf.UTC().Format(time.RFC3339),
WindowDays: windowDays,
EmptyPanels: notTracked(),
}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := buildTemps(ctx, tx, asOfStr); err != nil {
return err
}
h, err := headline(ctx, tx)
if err != nil {
return err
}
v.Headline = h
v.Funnel = buildFunnel(h.NewCandidates, h.Screened, h.Submissions, h.Approved, h.Rejected)
return nil
})
if err != nil {
return View{}, err
}
return v, nil
}
```
- [ ] **Step 2: Write the temp tables and the headline query**
Create `internal/modules/recruitmatch/service_queries.go`:
```go
package recruitmatch
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
// buildTemps materializes the per org RLS scoped sets once per request. ON COMMIT DROP cleans them up
// and the rm_ prefix keeps them from ever colliding with the command center cc_ temps.
func buildTemps(ctx context.Context, tx pgx.Tx, asOf string) error {
// Defense in depth: confirm the tenant GUC is set before any aggregate runs, so a wrong pool or
// role fails closed here instead of silently aggregating another tenant.
var guc string
if err := tx.QueryRow(ctx, `SELECT COALESCE(current_setting('app.current_org_id', true),'')`).Scan(&guc); err != nil {
return fmt.Errorf("tenant guc check: %w", err)
}
if guc == "" {
return fmt.Errorf("recruitmatch: tenant GUC not set, refusing to aggregate")
}
if _, err := tx.Exec(ctx, `
CREATE TEMP TABLE rm_cand ON COMMIT DROP AS
SELECT id, full_name, clearance_level, available, created_at
FROM candidates
WHERE deleted_at IS NULL`); err != nil {
return fmt.Errorf("rm_cand: %w", err)
}
if _, err := tx.Exec(ctx, `
CREATE TEMP TABLE rm_active_req ON COMMIT DROP AS
SELECT r.id, r.title, r.customer_agency, r.required_level
FROM requirements r
LEFT JOIN opportunities o ON o.id = r.opportunity_id
WHERE r.deleted_at IS NULL AND (o.id IS NULL OR o.status = 'open')`); err != nil {
return fmt.Errorf("rm_active_req: %w", err)
}
if _, err := tx.Exec(ctx, `
CREATE TEMP TABLE rm_sub ON COMMIT DROP AS
SELECT sm.slate_id, sm.candidate_id, sm.decision, sm.decided_by, sm.decided_at,
sm.created_at, s.requirement_id
FROM slate_members sm
JOIN slates s ON s.id = sm.slate_id`); err != nil {
return fmt.Errorf("rm_sub: %w", err)
}
if _, err := tx.Exec(ctx, `CREATE INDEX ON rm_sub (requirement_id)`); err != nil {
return fmt.Errorf("rm_sub index: %w", err)
}
return nil
}
// headline returns the stat tile counts. The window is bound to asOf through the temp tables and the
// interval below, never to now().
func headline(ctx context.Context, tx pgx.Tx) (Headline, error) {
var h Headline
if err := tx.QueryRow(ctx, `SELECT count(*) FROM rm_active_req`).Scan(&h.ActiveJobs); err != nil {
return h, fmt.Errorf("active jobs: %w", err)
}
if err := tx.QueryRow(ctx, `SELECT count(*) FROM rm_cand`).Scan(&h.NewCandidates); err != nil {
return h, fmt.Errorf("new candidates: %w", err)
}
if err := tx.QueryRow(ctx, `SELECT count(*) FROM candidate_evaluations`).Scan(&h.Screened); err != nil {
return h, fmt.Errorf("screened: %w", err)
}
if err := tx.QueryRow(ctx, `SELECT count(*) FROM rm_sub`).Scan(&h.Submissions); err != nil {
return h, fmt.Errorf("submissions: %w", err)
}
if err := tx.QueryRow(ctx,
`SELECT count(*) FILTER (WHERE decision = 'approved'),
count(*) FILTER (WHERE decision = 'rejected')
FROM rm_sub`).Scan(&h.Approved, &h.Rejected); err != nil {
return h, fmt.Errorf("decisions: %w", err)
}
return h, nil
}
```
- [ ] **Step 3: Verify it compiles and the pure tests still pass**
Run: `go build ./... && go test ./internal/modules/recruitmatch/ -v`
Expected: build succeeds, three tests PASS.
- [ ] **Step 4: Commit**
```bash
git add internal/modules/recruitmatch/service.go internal/modules/recruitmatch/service_queries.go
git commit -m "feat(recruitmatch): service with tenant scoped temp tables and headline panel"
```
---
### Task 3: Remaining panels
**Files:**
- Modify: `internal/modules/recruitmatch/service_queries.go`
- Modify: `internal/modules/recruitmatch/service.go`
**Interfaces:**
- Produces: `bandMix(ctx, tx) (BandMix, error)`, `clearanceRows(ctx, tx) ([]ClearanceRow, error)`, `topJobs(ctx, tx, limit int) ([]TopJob, error)`, `recruiters(ctx, tx) ([]RecruiterRow, error)`.
- [ ] **Step 1: Append the panel builders**
Append to `internal/modules/recruitmatch/service_queries.go`:
```go
// bandMix is the green yellow red split of persisted screenMATCH evaluations.
func bandMix(ctx context.Context, tx pgx.Tx) (BandMix, error) {
var b BandMix
err := tx.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE band = 'green'),
count(*) FILTER (WHERE band = 'yellow'),
count(*) FILTER (WHERE band = 'red'),
count(*)
FROM candidate_evaluations`).Scan(&b.Green, &b.Yellow, &b.Red, &b.Total)
if err != nil {
return b, fmt.Errorf("band mix: %w", err)
}
return b, nil
}
// clearanceRows returns the bench split by clearance level, ordered by the reference ladder so the
// chart reads from least to most cleared regardless of what the org holds.
func clearanceRows(ctx context.Context, tx pgx.Tx) ([]ClearanceRow, error) {
rows, err := tx.Query(ctx, `
SELECT cl.code, cl.label, count(c.id)
FROM clearance_levels cl
LEFT JOIN rm_cand c ON c.clearance_level = cl.code
GROUP BY cl.code, cl.label, cl.rank
ORDER BY cl.rank`)
if err != nil {
return nil, fmt.Errorf("clearance rows: %w", err)
}
defer rows.Close()
out := []ClearanceRow{}
var total int64
for rows.Next() {
var r ClearanceRow
if err := rows.Scan(&r.Code, &r.Label, &r.Count); err != nil {
return nil, fmt.Errorf("clearance scan: %w", err)
}
total += r.Count
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("clearance iterate: %w", err)
}
for i := range out {
out[i].Pct = pct(out[i].Count, total).StringFixed(2)
}
return out, nil
}
// topJobs ranks active requirements by submission count. One set based query, no per row lookups.
func topJobs(ctx context.Context, tx pgx.Tx, limit int) ([]TopJob, error) {
rows, err := tx.Query(ctx, `
SELECT r.id::text, r.title, COALESCE(r.customer_agency,''), r.required_level,
count(s.candidate_id)
FROM rm_active_req r
LEFT JOIN rm_sub s ON s.requirement_id = r.id
GROUP BY r.id, r.title, r.customer_agency, r.required_level
ORDER BY count(s.candidate_id) DESC, r.title ASC
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("top jobs: %w", err)
}
defer rows.Close()
out := []TopJob{}
for rows.Next() {
var j TopJob
if err := rows.Scan(&j.RequirementID, &j.Title, &j.CustomerAgency, &j.RequiredLevel, &j.Submissions); err != nil {
return nil, fmt.Errorf("top jobs scan: %w", err)
}
out = append(out, j)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("top jobs iterate: %w", err)
}
return out, nil
}
// recruiters attributes screening and slate decisions to users in this org. It works for a one
// recruiter shop and a twenty recruiter shop with no configuration.
func recruiters(ctx context.Context, tx pgx.Tx) ([]RecruiterRow, error) {
rows, err := tx.Query(ctx, `
SELECT u.id::text, u.full_name,
(SELECT count(*) FROM candidate_evaluations e WHERE e.evaluated_by = u.id),
(SELECT count(*) FROM rm_sub s WHERE s.decided_by = u.id),
(SELECT count(*) FROM rm_sub s WHERE s.decided_by = u.id AND s.decision = 'approved')
FROM users u
WHERE u.status = 'active'
ORDER BY u.full_name`)
if err != nil {
return nil, fmt.Errorf("recruiters: %w", err)
}
defer rows.Close()
out := []RecruiterRow{}
for rows.Next() {
var r RecruiterRow
if err := rows.Scan(&r.UserID, &r.FullName, &r.Screened, &r.Decided, &r.Approved); err != nil {
return nil, fmt.Errorf("recruiters scan: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("recruiters iterate: %w", err)
}
return out, nil
}
```
- [ ] **Step 2: Call them from the service**
In `internal/modules/recruitmatch/service.go`, inside the `RunInTenantTx` closure, replace the `return nil` at the end of the closure with:
```go
if v.BandMix, err = bandMix(ctx, tx); err != nil {
return err
}
if v.Clearance, err = clearanceRows(ctx, tx); err != nil {
return err
}
if v.TopJobs, err = topJobs(ctx, tx, 5); err != nil {
return err
}
if v.Recruiters, err = recruiters(ctx, tx); err != nil {
return err
}
return nil
```
- [ ] **Step 3: Verify it compiles**
Run: `go build ./... && go vet ./internal/modules/recruitmatch/`
Expected: no output, both succeed.
- [ ] **Step 4: Commit**
```bash
git add internal/modules/recruitmatch/service.go internal/modules/recruitmatch/service_queries.go
git commit -m "feat(recruitmatch): band mix, clearance, top jobs and recruiter panels"
```
---
### Task 4: Handler, routes and wiring
**Files:**
- Create: `internal/modules/recruitmatch/handler.go`
- Modify: `cmd/api/main.go`
**Interfaces:**
- Consumes: `matching.Handler.ReadRoutes(r chi.Router)`, `security.RequireCapability`, `security.CapCandidateRead`, `tenant.ErrNoTenant`.
- Produces: `Handler`, `NewHandler(core *matching.Handler, svc *Service) *Handler`, `(*Handler).Routes(r chi.Router)`.
- [ ] **Step 1: Write the handler**
Create `internal/modules/recruitmatch/handler.go`:
```go
package recruitmatch
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/missionworkforce/missionmatch/internal/modules/matching"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// Handler serves the recruitMATCH namespace. It holds the shared matching handler so the candidate and
// requirement pickers answer under this prefix exactly as they do under the other three tools.
type Handler struct {
core *matching.Handler
svc *Service
}
func NewHandler(core *matching.Handler, svc *Service) *Handler {
return &Handler{core: core, svc: svc}
}
// Routes mounts the dashboard read plus the shared pickers. The dashboard is gated on candidate.read
// because the payload exposes candidate counts and clearance, the same data class as GET /candidates.
func (h *Handler) Routes(r chi.Router) {
r.With(security.RequireCapability(security.CapCandidateRead)).Get("/dashboard", h.get)
h.core.ReadRoutes(r)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
asOf := h.svc.clock()
if q := r.URL.Query().Get("as_of"); q != "" {
t, err := time.Parse("2006-01-02", q)
if err != nil {
writeJSONErr(w, http.StatusBadRequest, "as_of must be formatted 2006-01-02")
return
}
asOf = t
}
v, err := h.svc.RecruiterDashboard(r.Context(), asOf)
if err != nil {
switch {
case errors.Is(err, tenant.ErrNoTenant):
writeJSONErr(w, http.StatusForbidden, "no tenant context")
case errors.Is(err, ErrNoDatabase):
writeJSONErr(w, http.StatusServiceUnavailable, "database unavailable")
default:
writeJSONErr(w, http.StatusInternalServerError, "internal error")
}
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
```
Note this differs deliberately from the Command Center handler, which silently falls back to now on an unparseable `as_of`. Returning 400 is the correct behaviour.
- [ ] **Step 2: Wire it into main.go**
In `cmd/api/main.go`, add the import beside the other module imports:
```go
"github.com/missionworkforce/missionmatch/internal/modules/recruitmatch"
```
Immediately after the line `missionH := missionmatch.NewHandler(h, ccH)` add:
```go
recruitH := recruitmatch.NewHandler(h, recruitmatch.NewServiceWithAudit(db, auditW))
```
Immediately after the line `r.Route("/missionmatch", missionH.Routes)` add:
```go
r.Route("/recruitmatch", recruitH.Routes)
```
- [ ] **Step 3: Verify it compiles and the route is registered**
Run: `go build ./cmd/api && go vet ./...`
Expected: both succeed with no output.
- [ ] **Step 4: Commit**
```bash
git add internal/modules/recruitmatch/handler.go cmd/api/main.go
git commit -m "feat(recruitmatch): handler, routes and api wiring"
```
---
### Task 5: Zero data org test
This is the case every new client hits on their first login, and the one most likely to regress.
**Files:**
- Create: `internal/integration/recruitmatch_integration_test.go`
- [ ] **Step 1: Write the test**
Create `internal/integration/recruitmatch_integration_test.go`:
```go
//go:build integration
package integration
import (
"context"
"testing"
"time"
"github.com/missionworkforce/missionmatch/internal/modules/recruitmatch"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// TestRecruitDashboardEmptyOrgRendersCompleteView proves a brand new client with zero rows gets a
// complete view with zero counts, no panic and no missing panel. This is the day one state of every
// client the platform onboards.
func TestRecruitDashboardEmptyOrgRendersCompleteView(t *testing.T) {
db := newTenantDB(t)
orgID := createEmptyOrg(t)
ctx := tenant.WithOrg(context.Background(), orgID)
svc := recruitmatch.NewService(db)
v, err := svc.RecruiterDashboard(ctx, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("RecruiterDashboard on empty org: %v", err)
}
if v.OrganizationID != orgID {
t.Errorf("OrganizationID = %q, want %q", v.OrganizationID, orgID)
}
if v.Headline.ActiveJobs != 0 || v.Headline.NewCandidates != 0 {
t.Errorf("empty org has nonzero headline: %+v", v.Headline)
}
if len(v.Funnel) != 5 {
t.Errorf("funnel has %d stages, want 5 even when empty", len(v.Funnel))
}
for _, s := range v.Funnel {
if s.Pct != "0.00" {
t.Errorf("stage %q pct = %q, want 0.00 on an empty org", s.Key, s.Pct)
}
}
if len(v.Clearance) == 0 {
t.Error("clearance rows empty, want one row per reference clearance level")
}
if len(v.EmptyPanels) == 0 {
t.Error("EmptyPanels is empty, the untracked panels must always be named")
}
}
// TestRecruitDashboardRefusesWithoutTenantContext proves the aggregate fails closed.
func TestRecruitDashboardRefusesWithoutTenantContext(t *testing.T) {
db := newTenantDB(t)
svc := recruitmatch.NewService(db)
if _, err := svc.RecruiterDashboard(context.Background(), time.Now()); err == nil {
t.Fatal("want an error with no tenant in context, got nil")
}
}
// TestRecruitDashboardIsolatesTenants proves two orgs see disjoint dashboards through the same pool.
// This is the multi client guarantee: the platform is sold to many clients and one must never see
// another's bench.
func TestRecruitDashboardIsolatesTenants(t *testing.T) {
db := newTenantDB(t)
orgA := seedOrgWithCandidates(t, 3)
orgB := seedOrgWithCandidates(t, 7)
svc := recruitmatch.NewService(db)
asOf := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
va, err := svc.RecruiterDashboard(tenant.WithOrg(context.Background(), orgA), asOf)
if err != nil {
t.Fatalf("org A dashboard: %v", err)
}
vb, err := svc.RecruiterDashboard(tenant.WithOrg(context.Background(), orgB), asOf)
if err != nil {
t.Fatalf("org B dashboard: %v", err)
}
if va.Headline.NewCandidates != 3 {
t.Errorf("org A sees %d candidates, want 3", va.Headline.NewCandidates)
}
if vb.Headline.NewCandidates != 7 {
t.Errorf("org B sees %d candidates, want 7", vb.Headline.NewCandidates)
}
if va.OrganizationID == vb.OrganizationID {
t.Fatal("both views report the same organization id")
}
}
```
`seedOrgWithCandidates(t, n)` creates an org and inserts n candidates as the migrator role, returning the org id. If `internal/integration` has no such helper, write it next to the test using the same insert pattern `rls_integration_test.go` already uses for candidates, rather than inventing a new one.
- [ ] **Step 2: Check the helper names used above exist**
Run: `grep -rn "func newTenantDB\|func createEmptyOrg" internal/integration/`
Expected: both helpers found. If either is missing, read `internal/integration/rls_integration_test.go` and reuse whatever equivalent helper that file defines, adjusting the two call sites rather than adding duplicates.
- [ ] **Step 3: Run the integration test**
Run:
```bash
export MM_APP_DSN="postgres://missionmatch_app@localhost:5432/missionmatch?sslmode=disable"
export MM_MIGRATOR_DSN="postgres://missionmatch_migrator@localhost:5432/missionmatch?sslmode=disable"
go test -tags=integration ./internal/integration/ -run TestRecruitDashboard -v
```
Expected: PASS, two tests. If it prints no test output at all, the DSNs are unset and `TestMain` skipped silently. Export them and rerun.
- [ ] **Step 4: Commit**
```bash
git add internal/integration/recruitmatch_integration_test.go
git commit -m "test(recruitmatch): empty org renders a complete view and fails closed without a tenant"
```
---
### Task 6: Frontend types, client and hooks
**Files:**
- Modify: `frontend/src/lib/types.ts`
- Create: `frontend/src/tools/recruitmatch/brand.ts`
- Create: `frontend/src/tools/recruitmatch/api.ts`
- Create: `frontend/src/tools/recruitmatch/queries.ts`
- Modify: `frontend/src/index.css`
**Interfaces:**
- Produces: `RMView` and nested `RM*` types, `recruitBrand`, `recruitApi.dashboard()`, `useRecruitDashboard()`.
- [ ] **Step 1: Add the accent token**
In `frontend/src/index.css`, inside the `@theme` block, after the slateMATCH tokens, add:
```css
--color-recruit: #e0704f; /* recruitMATCH ember */
--color-recruit-soft: #f0a58c;
```
- [ ] **Step 2: Add the DTO mirrors**
Append to `frontend/src/lib/types.ts`:
```ts
// Mirrors internal/modules/recruitmatch. Percentages are decimal strings, convert with num().
export interface RMFunnelStage {
key: string
label: string
count: number
pct: string
color: string
}
export interface RMHeadline {
active_jobs: number
new_candidates: number
screened: number
submissions: number
approved: number
rejected: number
}
export interface RMBandMix {
green: number
yellow: number
red: number
total: number
}
export interface RMClearanceRow {
code: string
label: string
count: number
pct: string
}
export interface RMTopJob {
requirement_id: string
title: string
customer_agency?: string
submissions: number
required_level: string
}
export interface RMRecruiterRow {
user_id: string
full_name: string
screened: number
decided: number
approved: number
}
export interface RMEmptyPanel {
key: string
title: string
reason: string
}
export interface RMView {
as_of: string
organization_id: string
generated_at: string
window_days: number
headline: RMHeadline
funnel: RMFunnelStage[]
band_mix: RMBandMix
clearance: RMClearanceRow[]
top_jobs: RMTopJob[]
recruiters: RMRecruiterRow[]
empty_panels: RMEmptyPanel[]
}
```
- [ ] **Step 3: Create the brand**
Create `frontend/src/tools/recruitmatch/brand.ts`:
```ts
export const recruitBrand = {
key: 'recruit',
name: 'recruitMATCH',
tagline: 'Recruiting Operations',
accent: 'var(--color-recruit)',
accentSoft: '#f0a58c',
basePath: '/recruit',
} as const
```
- [ ] **Step 4: Create the client and hooks**
Create `frontend/src/tools/recruitmatch/api.ts`:
```ts
import { request } from '../../lib/api'
import type { RMView } from '../../lib/types'
const NS = '/recruitmatch'
export const recruitApi = {
dashboard: () => request<RMView>(`${NS}/dashboard`),
}
```
Create `frontend/src/tools/recruitmatch/queries.ts`:
```ts
// Keys are prefixed with the tool name so the four tools never collide in the shared query cache.
import { useQuery } from '@tanstack/react-query'
import { recruitApi } from './api'
export function useRecruitDashboard() {
return useQuery({ queryKey: ['recruitmatch', 'dashboard'], queryFn: recruitApi.dashboard })
}
```
- [ ] **Step 5: Verify the frontend builds**
Run: `cd frontend && npm run build`
Expected: `tsc -b` and `vite build` both succeed.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/lib/types.ts frontend/src/tools/recruitmatch frontend/src/index.css
git commit -m "feat(recruitmatch): frontend types, tool client, hooks and accent token"
```
---
### Task 7: The dashboard screen
**Files:**
- Create: `frontend/src/tools/recruitmatch/RecruiterDashboard.tsx`
- [ ] **Step 1: Write the screen**
Create `frontend/src/tools/recruitmatch/RecruiterDashboard.tsx`:
```tsx
import { Briefcase, RefreshCw, ShieldCheck, Users } from 'lucide-react'
import { ToolHeader } from '../../components/ToolHeader'
import { Bar, Panel, Stat } from '../../components/ui'
import { num } from '../../lib/format'
import { recruitBrand } from './brand'
import { useRecruitDashboard } from './queries'
export function RecruiterDashboard() {
const { data: d, isLoading, isError, error, refetch, isFetching } = useRecruitDashboard()
if (isLoading) {
return <Panel className="grid place-items-center py-24 text-muted">Aggregating recruiting operations…</Panel>
}
if (isError || !d) {
return (
<Panel className="grid place-items-center py-24 text-warn">
Could not load the recruiting dashboard. {(error as Error)?.message}
</Panel>
)
}
return (
<div className="space-y-4">
<ToolHeader name={recruitBrand.name} tagline={recruitBrand.tagline} accent={recruitBrand.accent} />
<div className="flex items-center justify-between">
<p className="text-[11px] font-bold uppercase tracking-wider text-muted">as of {d.as_of}</p>
<button
onClick={() => void refetch()}
className="flex items-center gap-2 rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink disabled:opacity-50"
disabled={isFetching}
>
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
Refresh
</button>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
<Stat label="Active jobs" value={d.headline.active_jobs} icon={<Briefcase size={16} />} />
<Stat label="Candidates" value={d.headline.new_candidates} icon={<Users size={16} />} />
<Stat label="Screened" value={d.headline.screened} />
<Stat label="Submissions" value={d.headline.submissions} />
<Stat label="Approved" value={d.headline.approved} accent="var(--color-ok)" />
<Stat label="Rejected" value={d.headline.rejected} accent="var(--color-risk)" />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
<Panel title="Pipeline overview" className="xl:col-span-5">
<ul className="space-y-3">
{d.funnel.map((s) => (
<li key={s.key}>
<div className="flex items-baseline justify-between">
<span className="text-sm font-semibold">{s.label}</span>
<span className="text-sm tabular-nums text-muted">
{s.count} <span className="text-faint">({num(s.pct).toFixed(1)}%)</span>
</span>
</div>
<Bar value={num(s.pct)} color={`var(--color-${s.color === 'green' ? 'ok' : s.color === 'yellow' ? 'warn' : 'risk'})`} />
</li>
))}
</ul>
</Panel>
<Panel title="Clearance breakdown" hint="self reported indicator" className="xl:col-span-4">
{d.clearance.every((c) => c.count === 0) ? (
<p className="py-6 text-center text-sm text-faint">No candidates on the bench yet.</p>
) : (
<ul className="space-y-3">
{d.clearance.map((c) => (
<li key={c.code}>
<div className="flex items-baseline justify-between">
<span className="text-sm font-semibold">{c.label}</span>
<span className="text-sm tabular-nums text-muted">{c.count}</span>
</div>
<Bar value={num(c.pct)} color="var(--color-recruit)" />
</li>
))}
</ul>
)}
</Panel>
<Panel title="Screening band mix" className="xl:col-span-3">
{d.band_mix.total === 0 ? (
<p className="py-6 text-center text-sm text-faint">Nothing screened yet. Run a screen in screenMATCH.</p>
) : (
<ul className="space-y-3">
<li className="flex items-center justify-between">
<span className="text-sm" style={{ color: 'var(--color-ok)' }}>Proposal ready</span>
<span className="tabular-nums">{d.band_mix.green}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-sm" style={{ color: 'var(--color-warn)' }}>Potential</span>
<span className="tabular-nums">{d.band_mix.yellow}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-sm" style={{ color: 'var(--color-risk)' }}>Recruiting required</span>
<span className="tabular-nums">{d.band_mix.red}</span>
</li>
</ul>
)}
</Panel>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Panel title="Top active jobs" hint={`${d.top_jobs.length} shown`}>
{d.top_jobs.length === 0 ? (
<p className="py-6 text-center text-sm text-faint">No open requirements yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wider text-faint">
<th className="pb-2 font-semibold">Title</th>
<th className="pb-2 font-semibold">Agency</th>
<th className="pb-2 text-right font-semibold">Submissions</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{d.top_jobs.map((j) => (
<tr key={j.requirement_id} className="hover:bg-navy-800/40">
<td className="py-2.5 font-semibold">{j.title}</td>
<td className="py-2.5 text-muted">{j.customer_agency || 'n/a'}</td>
<td className="py-2.5 text-right tabular-nums">{j.submissions}</td>
</tr>
))}
</tbody>
</table>
)}
</Panel>
<Panel title="Recruiter activity">
{d.recruiters.length === 0 ? (
<p className="py-6 text-center text-sm text-faint">No active users in this organization yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wider text-faint">
<th className="pb-2 font-semibold">Recruiter</th>
<th className="pb-2 text-right font-semibold">Screened</th>
<th className="pb-2 text-right font-semibold">Decided</th>
<th className="pb-2 text-right font-semibold">Approved</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{d.recruiters.map((r) => (
<tr key={r.user_id} className="hover:bg-navy-800/40">
<td className="py-2.5 font-semibold">{r.full_name}</td>
<td className="py-2.5 text-right tabular-nums">{r.screened}</td>
<td className="py-2.5 text-right tabular-nums">{r.decided}</td>
<td className="py-2.5 text-right tabular-nums">{r.approved}</td>
</tr>
))}
</tbody>
</table>
)}
</Panel>
</div>
<Panel title="Not tracked yet" hint="named feeds, never fabricated numbers">
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
{d.empty_panels.map((p) => (
<li key={p.key} className="rounded-lg border border-line/60 bg-navy-950/40 p-3">
<p className="text-sm font-semibold text-muted">{p.title}</p>
<p className="mt-1 text-[11px] text-faint">{p.reason}</p>
</li>
))}
</ul>
</Panel>
</div>
)
}
```
- [ ] **Step 2: Verify it builds and lints**
Run: `cd frontend && npm run lint && npm run build`
Expected: both succeed.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/tools/recruitmatch/RecruiterDashboard.tsx
git commit -m "feat(recruitmatch): recruiting operations dashboard screen"
```
---
### Task 8: Route and navigation
**Files:**
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/components/AppShell.tsx`
- [ ] **Step 1: Add the route**
In `frontend/src/App.tsx`, add the import beside the other tool imports:
```tsx
import { RecruiterDashboard } from './tools/recruitmatch/RecruiterDashboard'
```
After the slateMATCH route block add:
```tsx
{/* recruitMATCH */}
<Route path="recruit" element={<RecruiterDashboard />} />
```
- [ ] **Step 2: Add the tool to the switcher**
In `frontend/src/components/AppShell.tsx`, add `ClipboardList` to the existing `lucide-react` import, then append a fourth entry to the `TOOLS` array:
```ts
{
key: 'recruit',
name: 'recruitMATCH',
tagline: 'Recruiting Operations',
accent: 'var(--color-recruit)',
basePath: '/recruit',
icon: ClipboardList,
match: (p) => p.startsWith('/recruit'),
nav: [
{ to: '/recruit', label: 'Dashboard', icon: ClipboardList },
{ to: '/recruit/candidates', label: 'Candidates', icon: Users },
],
},
```
Note the `/recruit/candidates` nav entry needs a route. Either add `<Route path="recruit/candidates" element={<Candidates />} />` reusing the existing Candidates screen, or drop that nav item for v1. Prefer adding the route, since the shared pickers already answer under the namespace.
- [ ] **Step 3: Add the candidates route**
In `frontend/src/App.tsx`, under the recruitMATCH comment add:
```tsx
<Route path="recruit/candidates" element={<Candidates />} />
```
`Candidates` is already imported for the missionMATCH routes; do not add a second import.
- [ ] **Step 4: Verify the whole gate passes**
Run: `make build-test-gates`
Expected: gofmt, vet, unit tests, backdoor check, FIPS check, and the frontend lint and build all pass.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/App.tsx frontend/src/components/AppShell.tsx
git commit -m "feat(recruitmatch): route and tool switcher entry"
```
---
### Task 9: Alerts and recommended actions panels
Spec section 5 replaces the mockup's Upcoming Tasks and AI Insight panels with two feeds that are real and already populated by scheduled timers but have no interface anywhere in the app today. This task is frontend only: both endpoints already exist and are capability gated.
**Files:**
- Modify: `frontend/src/lib/types.ts`
- Modify: `frontend/src/tools/recruitmatch/api.ts`
- Modify: `frontend/src/tools/recruitmatch/queries.ts`
- Modify: `frontend/src/tools/recruitmatch/RecruiterDashboard.tsx`
**Interfaces:**
- Consumes: existing `GET /api/alerts` returning a bare array, and `GET /api/recommendations/proactive` returning `{recommendations, limit, offset}`.
- Produces: `Alert`, `ProactiveResponse`, `RecommendationView` types, `recruitApi.alerts()`, `recruitApi.recommendations()`, `useAlerts()`, `useRecommendations()`.
- [ ] **Step 1: Add the types**
Append to `frontend/src/lib/types.ts`:
```ts
// Mirrors internal/modules/alerts. Every field is a string on the wire.
export interface Alert {
id: string
alert_type: string
severity: string
subject_type?: string
subject_id: string
title: string
detail?: string
status: string
created_at: string
updated_at: string
}
// Mirrors internal/modules/staffing RecommendationView, trimmed to the fields the panel renders.
export interface RecommendationView {
id: string
requirement_id: string
candidate_id: string
action: string
rank: number
confidence: string
rationale: string
rationale_source: string
status: string
created_at: string
}
export interface ProactiveResponse {
recommendations: RecommendationView[]
limit: number
offset: number
}
```
- [ ] **Step 2: Add the client methods**
In `frontend/src/tools/recruitmatch/api.ts`, extend the import and the object. These two paths are deliberately flat, not under `NS`, because both feeds are cross tool and are not aliased per namespace:
```ts
import { request } from '../../lib/api'
import type { Alert, ProactiveResponse, RMView } from '../../lib/types'
const NS = '/recruitmatch'
export const recruitApi = {
dashboard: () => request<RMView>(`${NS}/dashboard`),
alerts: () => request<Alert[]>('/alerts?status=unread'),
recommendations: () => request<ProactiveResponse>('/recommendations/proactive?limit=5'),
}
```
- [ ] **Step 3: Add the hooks**
Append to `frontend/src/tools/recruitmatch/queries.ts`: