feat(P03): complete placement-reporting phase (REQ-11-003)
Phase 3 complete: the D-107 overview (distinct-learner counting toward 1,000), the per-employer placement view, placement.ts wire pins, the engine-client placements section, the /employer/applications page + the overview mock-block conversion. Verify four-layers PASS. Ships as v0.10.3. ---ci--- phase: 3 milestone: v0.11 status: complete requirements: covered: [REQ-11-003] partial: [] ---/ci---
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"stage": "complete",
|
||||
"phase": 3,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.11",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-10-01T07:30:00Z"
|
||||
"updated_at": "2026-10-01T09:00:00Z"
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ from ..placements.base import (
|
||||
ApplicationTransitionResponse,
|
||||
EmployerApplicationsResponse,
|
||||
EmployerApplicationSummary,
|
||||
EmployerPlacementsResponse,
|
||||
HireTransitionRequest,
|
||||
PlacementRead,
|
||||
)
|
||||
@@ -946,6 +947,29 @@ claims_router.add_api_route(
|
||||
)
|
||||
|
||||
|
||||
# -- own placements (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107/D-109) ---------------
|
||||
|
||||
|
||||
@claims_router.get("/placements", response_model=EmployerPlacementsResponse)
|
||||
async def list_own_placements(
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
],
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> EmployerPlacementsResponse:
|
||||
"""The claimed entity's OWN placements (active + revoked — the
|
||||
full history, D-109 surface 2's data source): created_at then id;
|
||||
honest empty state at zero placements. G-311: 401 session → 403
|
||||
claim (never data before the gates)."""
|
||||
_session, account = auth
|
||||
employer_id = _require_claimed(account)
|
||||
rows = placements.list_placements_by_employer(employer_id)
|
||||
return EmployerPlacementsResponse(
|
||||
placements=[_placement_read(row) for row in rows],
|
||||
total=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# -- self-serve billing (P5, Task 5-1-01 — REQ-10-005, R-59/G-310) ------------------
|
||||
|
||||
|
||||
|
||||
@@ -23,9 +23,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from ..config import Settings
|
||||
from ..marketplace.store import MarketplaceStore
|
||||
from ..placements.base import (
|
||||
GUIDING_GOAL,
|
||||
AdminTransitionRequest,
|
||||
ApplicationTransitionResponse,
|
||||
HireTransitionRequest,
|
||||
PlacementOverview,
|
||||
PlacementRead,
|
||||
)
|
||||
from ..placements.store import PlacementRow, PlacementStore
|
||||
@@ -185,3 +187,44 @@ async def admin_rehire_placement(
|
||||
) from exc
|
||||
raise
|
||||
return _placement_read(rehired)
|
||||
|
||||
|
||||
# -- the reporting read (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107) ---------------
|
||||
|
||||
|
||||
@router.get("/overview", response_model=PlacementOverview)
|
||||
async def placement_overview(
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> PlacementOverview:
|
||||
"""The D-107 computation — counts from ROWS at query time (never
|
||||
denormalized counters, G-302/G-306): the guiding metric is DISTINCT
|
||||
learners with ≥1 active placement, all-time cumulative toward
|
||||
GUIDING_GOAL (a learner placed twice counts ONCE — the outcome's
|
||||
noun is learners); revoked excluded from every count (D-102);
|
||||
cohort month = the calendar month of start_date (YYYY-MM); orderings
|
||||
pinned (R-73: by_employer count desc then employer_id;
|
||||
by_cohort_month month ascending)."""
|
||||
rows = placements.list_placements(status="active")
|
||||
learner_ids = {row.learner_id for row in rows}
|
||||
by_employer: dict[str, int] = {}
|
||||
by_month: dict[str, int] = {}
|
||||
for row in rows:
|
||||
by_employer[row.employer_id] = by_employer.get(row.employer_id, 0) + 1
|
||||
month = row.start_date[:7] # ISO YYYY-MM (the calendar slice)
|
||||
by_month[month] = by_month.get(month, 0) + 1
|
||||
return PlacementOverview(
|
||||
total_active_placements=len(rows),
|
||||
distinct_learners_placed=len(learner_ids),
|
||||
guiding_goal=GUIDING_GOAL,
|
||||
by_employer=[
|
||||
{"employer_id": employer_id, "count": count}
|
||||
for employer_id, count in sorted(
|
||||
by_employer.items(), key=lambda kv: (-kv[1], kv[0])
|
||||
)
|
||||
],
|
||||
by_cohort_month=[
|
||||
{"month": month, "count": count}
|
||||
for month, count in sorted(by_month.items())
|
||||
],
|
||||
)
|
||||
|
||||
@@ -239,3 +239,36 @@ class AdminTransitionRequest(BaseModel):
|
||||
to: ApplicationStatus
|
||||
role_title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
start_date: date | None = None
|
||||
|
||||
|
||||
#: D-107: the guiding outcome's north star (PROJECT.md line 7 — the
|
||||
#: noun is LEARNERS, not placements).
|
||||
GUIDING_GOAL = 1000
|
||||
|
||||
|
||||
class PlacementOverview(BaseModel):
|
||||
"""GET /v1/placements/admin/overview response (D-107): counts
|
||||
computed from rows at query time — NEVER denormalized counters
|
||||
(G-302/G-306). The guiding metric is DISTINCT learners with ≥1
|
||||
active placement (a learner placed twice counts ONCE); revoked
|
||||
excluded from every count; cohort month = the calendar month of
|
||||
start_date (YYYY-MM, employment-start month — not hired month)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
total_active_placements: int = 0
|
||||
distinct_learners_placed: int = 0
|
||||
guiding_goal: int = GUIDING_GOAL
|
||||
by_employer: list[dict[str, Any]] = Field(default_factory=list)
|
||||
by_cohort_month: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployerPlacementsResponse(BaseModel):
|
||||
"""GET /v1/employers/placements response (D-109 surface 2's data
|
||||
source): the claimed entity's OWN placements (active + revoked —
|
||||
the employer sees their full history), created_at then id."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
placements: list[PlacementRead] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
@@ -302,6 +302,14 @@ class PlacementStore(Protocol):
|
||||
IntegrityError (the caller's honest 409)."""
|
||||
...
|
||||
|
||||
def list_placements_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[PlacementRow]:
|
||||
"""v0.11 P3 (D-109): the claimed entity's OWN placements
|
||||
(active + revoked — the employer sees their full history),
|
||||
created_at then id (the deterministic order)."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
...
|
||||
|
||||
@@ -672,5 +680,20 @@ class SQLitePlacementStore:
|
||||
placement._normalize()
|
||||
return placement
|
||||
|
||||
def list_placements_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[PlacementRow]:
|
||||
"""v0.11 P3 (D-109): the claimed entity's OWN placements
|
||||
(active + revoked — the full history), created_at then id."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(PlacementRow)
|
||||
.where(PlacementRow.employer_id == employer_id)
|
||||
.order_by(col(PlacementRow.created_at), PlacementRow.id)
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def close(self) -> None:
|
||||
self._engine.dispose()
|
||||
|
||||
@@ -195,9 +195,11 @@ def test_revoke_then_rehire_three_event_ledger(scaffold) -> None:
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "revoked"
|
||||
|
||||
# the revoked placement is out of the active set but in history
|
||||
# the revoked placement is out of the active set (P3's overview now
|
||||
# exists — the D-102/D-107 exclusion arm)
|
||||
resp = client.get("/v1/placements/admin/overview", headers=_admin())
|
||||
assert resp.status_code == 404 # P3 ships the overview — 404 until then
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total_active_placements"] == 0
|
||||
|
||||
# re-hire: the FRESH active row (R-80) + decided_at = the re-hire
|
||||
# event's stamp (G-326 — asserted at the store layer in test_store)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Reporting tests (D-107, REQ-11-003) — the distinct-learner rule,
|
||||
cohort bucketing, revoke exclusion, the per-employer view's gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Employer, Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
from ai_service.placements.store import SQLitePlacementStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name=f"Labs {employer_id}",
|
||||
)
|
||||
)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:10]}",
|
||||
source="admin",
|
||||
external_id=f"t-{uuid.uuid4().hex[:10]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _hire_direct(
|
||||
placements: SQLitePlacementStore,
|
||||
marketplace: SQLiteMarketplaceStore,
|
||||
posting_id: str,
|
||||
learner_id: str,
|
||||
role_title: str = "Engineer",
|
||||
start_date: str = "2026-11-01",
|
||||
) -> str:
|
||||
"""Direct store-level hire (the route arms are P2-tested; here we
|
||||
exercise the REPORTING computation over placement rows)."""
|
||||
application, _ = marketplace.insert_application(posting_id, learner_id)
|
||||
_, placement = placements.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="operator",
|
||||
actor_ref="operator",
|
||||
role_title=role_title,
|
||||
start_date=__import__("datetime").date.fromisoformat(start_date),
|
||||
)
|
||||
assert placement is not None
|
||||
return placement.id
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
placements = entered.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
|
||||
yield {
|
||||
"client": entered,
|
||||
"placements": placements,
|
||||
"marketplace": marketplace_store,
|
||||
}
|
||||
|
||||
|
||||
def _overview(client: TestClient) -> dict:
|
||||
resp = client.get(
|
||||
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def test_empty_overview_honest_zeroes(scaffold) -> None:
|
||||
body = _overview(scaffold["client"])
|
||||
assert body["total_active_placements"] == 0
|
||||
assert body["distinct_learners_placed"] == 0
|
||||
assert body["guiding_goal"] == 1000
|
||||
assert body["by_employer"] == []
|
||||
assert body["by_cohort_month"] == []
|
||||
|
||||
|
||||
def test_distinct_learner_rule(scaffold) -> None:
|
||||
"""D-107: a learner placed TWICE counts ONCE toward the guiding
|
||||
goal (the outcome's noun is learners); both placements count in
|
||||
total_active_placements."""
|
||||
s = scaffold
|
||||
posting_a = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b = _seed_posting(s["marketplace"], "emp-b")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner")
|
||||
body = _overview(s["client"])
|
||||
assert body["total_active_placements"] == 2
|
||||
assert body["distinct_learners_placed"] == 1 # placed twice, counted once
|
||||
assert body["by_employer"] == [
|
||||
{"employer_id": "emp-a", "count": 1},
|
||||
{"employer_id": "emp-b", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_cohort_month_bucketing(scaffold) -> None:
|
||||
"""D-107/D-103: cohort month = the calendar month of start_date —
|
||||
two starts in one month share a bucket; a future-dated start
|
||||
lands in its own month."""
|
||||
s = scaffold
|
||||
posting_a = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_c = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner",
|
||||
start_date="2026-11-01")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner-2",
|
||||
start_date="2026-11-20")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_c, "api-learner",
|
||||
start_date="2027-02-05")
|
||||
body = _overview(s["client"])
|
||||
assert body["by_cohort_month"] == [
|
||||
{"month": "2026-11", "count": 2},
|
||||
{"month": "2027-02", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_revoke_excluded_from_counts(scaffold) -> None:
|
||||
"""D-102/D-107: revoked placements are gone from every count; a
|
||||
learner whose ONLY placement was revoked is back to 0."""
|
||||
s = scaffold
|
||||
posting = _seed_posting(s["marketplace"], "emp-a")
|
||||
placement_id = _hire_direct(s["placements"], s["marketplace"], posting,
|
||||
"pilot-learner")
|
||||
before = _overview(s["client"])
|
||||
assert before["distinct_learners_placed"] == 1
|
||||
|
||||
s["placements"].revoke_placement(placement_id, "operator")
|
||||
|
||||
after = _overview(s["client"])
|
||||
assert after["total_active_placements"] == 0
|
||||
assert after["distinct_learners_placed"] == 0 # back to 0
|
||||
assert after["by_employer"] == []
|
||||
assert after["by_cohort_month"] == []
|
||||
|
||||
|
||||
def test_overview_fail_closed(scaffold) -> None:
|
||||
resp = scaffold["client"].get("/v1/placements/admin/overview")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_per_employer_view_own_rows(scaffold) -> None:
|
||||
"""The employer placement view: session-gated, own claimed entity
|
||||
only; honest empty state (403 + claim-CTA unclaimed; 401 no token)."""
|
||||
s = scaffold
|
||||
posting = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting, "pilot-learner")
|
||||
|
||||
# 401 without a session
|
||||
resp = s["client"].get("/v1/employers/placements")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# 403 + claim-CTA when unclaimed
|
||||
resp = s["client"].post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "viewer@example.com", "password": "hunter2hunter2"},
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
resp = s["client"].get(
|
||||
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "no_claimed_employer" in str(resp.json()["detail"])
|
||||
|
||||
# the honest empty state for a claimed entity with zero placements
|
||||
store = s["client"].app.state.marketplace_store
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id="emp-b",
|
||||
source="seed-corpus",
|
||||
external_id="emp-b",
|
||||
name="Labs emp-b",
|
||||
)
|
||||
)
|
||||
resp = s["client"].post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-b"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
claim_id = resp.json()["id"]
|
||||
s["client"].post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
)
|
||||
resp = s["client"].get(
|
||||
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
assert resp.json()["placements"] == []
|
||||
|
||||
|
||||
def test_by_employer_ordering(scaffold) -> None:
|
||||
"""R-73: by_employer is count desc then employer_id ascending."""
|
||||
s = scaffold
|
||||
posting_a1 = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b1 = _seed_posting(s["marketplace"], "emp-b")
|
||||
posting_b2 = _seed_posting(s["marketplace"], "emp-b")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a1, "pilot-learner")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b1, "pilot-learner-2")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b2, "api-learner")
|
||||
body = _overview(s["client"])
|
||||
assert body["by_employer"] == [
|
||||
{"employer_id": "emp-b", "count": 2},
|
||||
{"employer_id": "emp-a", "count": 1},
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { EmployerApplicationsClient } from '../../../../components/employer/employer-applications-client';
|
||||
|
||||
/**
|
||||
* The hiring pipeline (v0.11, REQ-11-002/003, D-109 surface 1): own
|
||||
* applications with the seven D-099 status badges, per-target
|
||||
* transition CTAs, and the hire form {role_title, start_date}
|
||||
* (employer-attested, D-103). Session-gated shell with honest
|
||||
* loading/error/empty/claim states (the A-406 board-client
|
||||
* discipline). learner_id IS visible here — the G-319 supersession
|
||||
* scopes it to the session's own claimed-entity postings (D-105).
|
||||
*/
|
||||
export default function ApplicationsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Hiring Pipeline</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Hiring Pipeline
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
Review applicants on your postings and drive them through the hiring
|
||||
lifecycle — in review, interview, offer, reject, or hire. A hire
|
||||
records the placement with the role title and start date you attest.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EmployerApplicationsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
EmployerApplicationSummary,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
listOwnEmployerApplications,
|
||||
employerMarkInReview,
|
||||
employerMarkInterview,
|
||||
employerMarkOffer,
|
||||
employerRejectApplication,
|
||||
employerHireApplication,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* The hiring-pipeline client (v0.11, REQ-11-002/003, D-109 surface
|
||||
* 1): the session employer's OWN applications (every status, per-
|
||||
* status facets) with per-target transition CTAs — the matrix stays
|
||||
* OUT of the request bodies (D-105: each call names its target).
|
||||
* The hire form carries the employer-attested {role_title,
|
||||
* start_date} (D-103/G-324). Withdraw is NEVER offered here (D-099:
|
||||
* employers reject; only the learner or an operator can withdraw).
|
||||
*
|
||||
* Honest states (A-406): loading/error/empty/claim, per-action
|
||||
* pending + error; a 403 cross-entity or 409 matrix arm renders from
|
||||
* EngineError.status — NO new error classes (D-110). learner_id IS
|
||||
* visible (the G-319 supersession, own applicants only).
|
||||
*/
|
||||
|
||||
const STATUS_LABELS: Record<ApplicationStatus, string> = {
|
||||
submitted: 'Submitted',
|
||||
in_review: 'In Review',
|
||||
interview: 'Interview',
|
||||
offer: 'Offer',
|
||||
hired: 'Hired',
|
||||
rejected: 'Rejected',
|
||||
withdrawn: 'Withdrawn',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<ApplicationStatus, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
submitted: 'default',
|
||||
in_review: 'info',
|
||||
interview: 'info',
|
||||
offer: 'warning',
|
||||
hired: 'success',
|
||||
rejected: 'error',
|
||||
withdrawn: 'default',
|
||||
};
|
||||
|
||||
/** The next legal targets per the D-099 matrix (forward-only with
|
||||
* skip-forward — the CTA set matches the store's transition set). */
|
||||
const NEXT_TARGETS: Record<string, Array<{ key: string; label: string }>> = {
|
||||
submitted: [
|
||||
{ key: 'in-review', label: 'In Review' },
|
||||
{ key: 'interview', label: 'Interview' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
in_review: [
|
||||
{ key: 'interview', label: 'Interview' },
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
interview: [
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
offer: [
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
};
|
||||
|
||||
export function EmployerApplicationsClient() {
|
||||
const [applications, setApplications] = useState<EmployerApplicationSummary[] | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// per-application action state: the honest pending/error pair
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
// the hire form: which application id is being hired + its fields
|
||||
const [hireFor, setHireFor] = useState<string | null>(null);
|
||||
const [roleTitle, setRoleTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const body = await listOwnEmployerApplications(controller.signal);
|
||||
if (!controller.signal.aborted) setApplications(body.applications);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true); // honest logged-out state — never a retry loop
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true); // the honest claim-CTA state
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load applications.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
async function runTransition(
|
||||
application: EmployerApplicationSummary,
|
||||
target: 'in-review' | 'interview' | 'offer' | 'reject',
|
||||
): Promise<void> {
|
||||
setActionError(null);
|
||||
setPending(application.id);
|
||||
try {
|
||||
const dispatch = {
|
||||
'in-review': employerMarkInReview,
|
||||
interview: employerMarkInterview,
|
||||
offer: employerMarkOffer,
|
||||
reject: employerRejectApplication,
|
||||
}[target];
|
||||
const body = await dispatch(application.id);
|
||||
if (body.application_id === application.id) {
|
||||
setApplications((prev) =>
|
||||
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true);
|
||||
} else if (err instanceof EngineError) {
|
||||
// the 403 cross-entity + 409 matrix arms — the honest status
|
||||
setActionError(`${application.id}: ${err.message}`);
|
||||
} else {
|
||||
setActionError(err instanceof Error ? err.message : 'The transition failed.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitHire(application: EmployerApplicationSummary): Promise<void> {
|
||||
setActionError(null);
|
||||
setPending(application.id);
|
||||
try {
|
||||
const body = await employerHireApplication(application.id, {
|
||||
role_title: roleTitle,
|
||||
start_date: startDate,
|
||||
});
|
||||
setApplications((prev) =>
|
||||
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
|
||||
);
|
||||
setHireFor(null);
|
||||
setRoleTitle('');
|
||||
setStartDate('');
|
||||
} catch (err) {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true);
|
||||
} else {
|
||||
// 422 (missing/empty fields, G-324) + 409 (matrix) render here
|
||||
setActionError(err instanceof Error ? err.message : 'The hire failed.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
Loading your hiring pipeline…
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loggedOut) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
You are signed out — your session is missing or expired.
|
||||
</p>
|
||||
<a href="/employer/login">
|
||||
<Button variant="primary" size="md">Sign in</Button>
|
||||
</a>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (claimPrompt) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
No employer entity is claimed on this account yet.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
|
||||
Claim your company's entity first — the hiring pipeline lists
|
||||
applicants on your claimed entity's postings.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">{error}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
The hiring pipeline is unavailable — please try again shortly.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!applications || applications.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
No applications yet — applicants on your published postings appear
|
||||
here as they apply.
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{actionError && (
|
||||
<Card>
|
||||
<CardBody className="py-3 text-sm text-red-700 dark:text-red-300">{actionError}</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{applications.map((application) => {
|
||||
const targets = NEXT_TARGETS[application.status] ?? [];
|
||||
return (
|
||||
<Card key={application.id}>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_BADGE[application.status]}>
|
||||
{STATUS_LABELS[application.status]}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{application.learner_id}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
applied {application.created_at ? application.created_at.slice(0, 10) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{application.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hireFor === application.id ? (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||
Record the placement you attest (visible to the learner and
|
||||
the audit export):
|
||||
</p>
|
||||
<Input
|
||||
label="Role title"
|
||||
value={roleTitle}
|
||||
onChange={(e) => setRoleTitle(e.target.value)}
|
||||
placeholder="AI Orchestration Engineer"
|
||||
maxLength={200}
|
||||
/>
|
||||
<Input
|
||||
label="Employment start date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={pending === application.id || roleTitle === '' || startDate === ''}
|
||||
onClick={() => {
|
||||
void submitHire(application);
|
||||
}}
|
||||
>
|
||||
{pending === application.id ? 'Recording…' : 'Confirm hire'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setHireFor(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{targets.map((target) => (
|
||||
<Button
|
||||
key={target.key}
|
||||
variant={target.key === 'reject' ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={pending === application.id}
|
||||
onClick={() => {
|
||||
if (target.key === 'hire') {
|
||||
setHireFor(application.id);
|
||||
setActionError(null);
|
||||
} else {
|
||||
void runTransition(
|
||||
application,
|
||||
target.key as 'in-review' | 'interview' | 'offer' | 'reject',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pending === application.id ? '…' : target.label}
|
||||
</Button>
|
||||
))}
|
||||
{/* D-099: withdraw is NEVER offered to employers */}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,16 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Briefcase, Users, TrendingUp, Target, ArrowRight } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardBody, Avatar } from '@nextcraft/ui';
|
||||
import type { EmployerProfile, EmployerMe, OwnPostingsResponse } from '@nextcraft/types';
|
||||
import type {
|
||||
EmployerProfile,
|
||||
EmployerMe,
|
||||
OwnPostingsResponse,
|
||||
EmployerPlacementsResponse,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
getEmployerMe,
|
||||
listOwnEmployerPostings,
|
||||
listOwnEmployerPlacements,
|
||||
getEmployer,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
@@ -33,6 +39,7 @@ export function EmployerOverviewClient() {
|
||||
const [me, setMe] = useState<EmployerMe | null>(null);
|
||||
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
|
||||
const [profile, setProfile] = useState<EmployerProfile | null>(null);
|
||||
const [placements, setPlacements] = useState<EmployerPlacementsResponse | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -45,12 +52,18 @@ export function EmployerOverviewClient() {
|
||||
const meBody = await getEmployerMe(controller.signal);
|
||||
setMe(meBody);
|
||||
if (meBody.claimed_employer_id) {
|
||||
const [own, prof] = await Promise.all([
|
||||
const [own, prof, placed] = await Promise.all([
|
||||
listOwnEmployerPostings(controller.signal),
|
||||
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
|
||||
// v0.11 P3 (D-109 surface 2): REAL placements — the mock
|
||||
// metric block is REPLACED (D-092: never leave a convertible
|
||||
// surface on mock). A placements fetch failure is honest —
|
||||
// the rest of the dashboard still renders.
|
||||
listOwnEmployerPlacements(controller.signal).catch(() => null),
|
||||
]);
|
||||
setPostings(own);
|
||||
setProfile(prof);
|
||||
setPlacements(placed);
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -160,6 +173,10 @@ export function EmployerOverviewClient() {
|
||||
const activePostings = facets?.published ?? 0;
|
||||
const totalApplicants =
|
||||
postings?.postings.reduce((sum, p) => sum + p.applicant_count, 0) ?? 0;
|
||||
// v0.11 P3 (D-107): the active-placement count from the rows at
|
||||
// query time — the honest 0 when none exist.
|
||||
const activePlacements =
|
||||
placements?.placements.filter((p) => p.status === 'active').length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -187,13 +204,20 @@ export function EmployerOverviewClient() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Metric cards — postings/applicants REAL (GET /v1/employers/
|
||||
postings); talent-match/placement stay mock-labeled (D-092). */}
|
||||
{/* Metric cards — postings/applicants/placements REAL (GET
|
||||
/v1/employers/postings + /placements, v0.11 P3);
|
||||
talent-match stays mock-labeled (D-092 — no backend computes
|
||||
it). */}
|
||||
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<MetricCard label="Active Postings" value={activePostings} icon={Briefcase} tone="primary" />
|
||||
<MetricCard label="Total Applicants" value={totalApplicants} icon={Users} tone="accent" />
|
||||
<MetricCard
|
||||
label="Placements Recorded"
|
||||
value={activePlacements}
|
||||
icon={TrendingUp}
|
||||
tone="amber"
|
||||
/>
|
||||
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
|
||||
<MockMetricCard label="Placement Rate" value="—" icon={TrendingUp} tone="amber" />
|
||||
</section>
|
||||
|
||||
{/* Middle row — per-posting applicant table + claimed-entity profile */}
|
||||
@@ -286,6 +310,66 @@ export function EmployerOverviewClient() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* v0.11 P3 (D-109 surface 2, REQ-11-003): REAL placements — the
|
||||
hired applications with role + start date. The mock
|
||||
placement block is REPLACED (D-092); talent-match analytics
|
||||
stay mock-labeled (no backend computes them). */}
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Placements
|
||||
</h2>
|
||||
<Link
|
||||
href="/employer/applications"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
Manage hiring pipeline <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
{placements && placements.placements.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="p-3 font-medium">Role</th>
|
||||
<th className="p-3 font-medium">Learner</th>
|
||||
<th className="p-3 font-medium">Start Date</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{placements.placements.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
|
||||
>
|
||||
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">
|
||||
{p.role_title}
|
||||
</td>
|
||||
<td className="p-3 text-slate-700 dark:text-slate-300">{p.learner_id}</td>
|
||||
<td className="p-3 text-slate-700 dark:text-slate-300">{p.start_date}</td>
|
||||
<td className="p-3">
|
||||
{p.status === 'active' ? (
|
||||
<Badge variant="success">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="default">Revoked</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<CardBody className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
No placements recorded yet — hires you record in the hiring
|
||||
pipeline appear here.
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Mock-labeled analytics (D-092: no backend computes these). */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
@@ -364,12 +448,14 @@ function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'close
|
||||
}
|
||||
|
||||
/** D-092 honest mock label for the analytics section — the charts ride
|
||||
* nowhere until a backend computes them; the copy says so. */
|
||||
* nowhere until a backend computes them; the copy says so. v0.11:
|
||||
* placement COUNTS are now real (the section above) — only the
|
||||
* talent-match analytics remain mock. */
|
||||
function MockAnalyticsNotice() {
|
||||
return (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Talent-match and placement analytics are sample data at pilot — no backend
|
||||
computes them yet. Real posting and applicant counts appear above.
|
||||
Talent-match analytics are sample data at pilot — no backend computes them yet.
|
||||
Real posting, applicant, and placement counts appear above.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ import type {
|
||||
AdminClaimsResponse,
|
||||
ClaimDecisionResponse,
|
||||
AdminAuditResponse,
|
||||
EmployerApplicationsResponse,
|
||||
ApplicationTransitionResponse,
|
||||
HireTransitionRequest,
|
||||
EmployerPlacementsResponse,
|
||||
} from '@nextcraft/types';
|
||||
|
||||
import { engineBaseUrl } from './engine-base-url';
|
||||
@@ -1171,3 +1175,96 @@ export async function completeOwnEmployerMockCheckout(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- placements (v0.11 P3, REQ-11-002/003, D-110) ----------------------------------
|
||||
|
||||
/** GET /v1/employers/applications — the session employer's OWN hiring
|
||||
* pipeline (every status, per-status facets). Expired session →
|
||||
* SessionRequiredError; unclaimed → ClaimRequiredError (the G-311
|
||||
* gates surface as the honest state classes, never a re-fetch). */
|
||||
export async function listOwnEmployerApplications(
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmployerApplicationsResponse> {
|
||||
return jsonFetch<EmployerApplicationsResponse>('/v1/employers/applications', {
|
||||
headers: employerHeaders(),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** The shared per-target transition transport (D-105: the matrix stays
|
||||
* OUT of the request bodies — each call names its target). 401 →
|
||||
* SessionRequiredError; 403-claim → ClaimRequiredError; 403
|
||||
* cross-entity + 409 matrix → EngineError with the honest status
|
||||
* (the surfaces render the state from err.status — NO new error
|
||||
* classes, D-110). */
|
||||
async function employerTransition(
|
||||
applicationId: string,
|
||||
target: 'in-review' | 'interview' | 'offer' | 'reject' | 'hire',
|
||||
body?: HireTransitionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return jsonFetch<ApplicationTransitionResponse>(
|
||||
`/v1/employers/applications/${encodeURIComponent(applicationId)}/${target}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: employerHeaders(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** POST .../in-review — submitted → in_review. */
|
||||
export function employerMarkInReview(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'in-review', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../interview. */
|
||||
export function employerMarkInterview(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'interview', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../offer. */
|
||||
export function employerMarkOffer(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'offer', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../reject — terminal. */
|
||||
export function employerRejectApplication(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'reject', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../hire — terminal; carries the employer-attested
|
||||
* {role_title, start_date} (D-103/G-324). The response carries the
|
||||
* created placement record. */
|
||||
export function employerHireApplication(
|
||||
applicationId: string,
|
||||
body: HireTransitionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'hire', body, signal);
|
||||
}
|
||||
|
||||
/** GET /v1/employers/placements — the claimed entity's OWN placements
|
||||
* (active + revoked, the full history; the overview's placements
|
||||
* section data source, D-109 surface 2). */
|
||||
export async function listOwnEmployerPlacements(
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmployerPlacementsResponse> {
|
||||
return jsonFetch<EmployerPlacementsResponse>('/v1/employers/placements', {
|
||||
headers: employerHeaders(),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,8 +65,10 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
|
||||
assert.ok(/import \{ candidates \} from '@nextcraft\/mock-data'/.test(talent), "talent search keeps the candidates mock");
|
||||
assert.ok(/import \{ candidates, competencyStacks \} from '@nextcraft\/mock-data'/.test(talentDetail), "candidate profile keeps the candidates mock");
|
||||
|
||||
// The overview's talent-match/placement metrics stay MOCK-LABELED —
|
||||
// the honest label rides the card copy (never presented as real).
|
||||
// The overview's talent-match metrics stay MOCK-LABELED — the
|
||||
// honest label rides the card copy (never presented as real).
|
||||
// v0.11 P3: placement counts are now REAL (the D-092 conversion —
|
||||
// the convertible surface left mock); only talent-match stays mock.
|
||||
const overviewClient = readFileSync(
|
||||
web("components/employer/employer-overview-client.tsx"),
|
||||
"utf8",
|
||||
@@ -77,10 +79,14 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
|
||||
assert.ok(/no backend\s+computes them yet/.test(overviewClient), "analytics notice is honest about the mock");
|
||||
|
||||
// The REAL arms: active-postings + applicant counts from the real
|
||||
// route; the claimed-entity profile via the public marketplace fetch.
|
||||
// route; the claimed-entity profile via the public marketplace fetch;
|
||||
// v0.11: the placements section is REAL (listOwnEmployerPlacements).
|
||||
assert.ok(/listOwnEmployerPostings/.test(overviewClient), "overview fetches own postings");
|
||||
assert.ok(/applicant_count/.test(overviewClient), "overview renders per-posting applicant counts");
|
||||
assert.ok(/getEmployer\(/.test(overviewClient), "overview fetches the claimed-entity profile (public route)");
|
||||
assert.ok(/listOwnEmployerPlacements/.test(overviewClient), "overview fetches REAL placements (v0.11)");
|
||||
assert.ok(/Placements Recorded/.test(overviewClient), "the real placement metric renders");
|
||||
assert.ok(!/MockMetricCard label="Placement Rate"/.test(overviewClient), "the placement mock card is GONE (converted)");
|
||||
});
|
||||
|
||||
test("pin: the session-gated clients carry every honest-state branch (MH-3b, G-312 source pins)", async () => {
|
||||
|
||||
@@ -774,3 +774,95 @@ test("pricing pin: purchasable CTAs route to /employer/login; the enterprise Con
|
||||
);
|
||||
assert.ok(/mock provider \(honestly labeled/.test(client), "the pilot mock path is labeled");
|
||||
});
|
||||
|
||||
// v0.11 P3 (D-110): the placement wire pins — placement.ts mirrors
|
||||
// placements/base.py field-for-field (the G-108 discipline; the
|
||||
// employer.ts block's template). Every change happens in both places.
|
||||
|
||||
test("wire pin: Placement mirrors placements/base.py PlacementRead field-for-field (dual-schema sync)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const pySrc = readFileSync(
|
||||
web("../../apps/ai-service/ai_service/placements/base.py"),
|
||||
"utf8",
|
||||
);
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface Placement {"),
|
||||
typesSrc.indexOf("export interface PlacementEvent"),
|
||||
);
|
||||
const fields = [
|
||||
"id:", "learner_id:", "employer_id:", "posting_id:",
|
||||
"application_id:", "role_title:", "start_date:", "status:",
|
||||
"created_at:", "decided_at:",
|
||||
];
|
||||
for (const f of fields) {
|
||||
assert.ok(block.includes(f), `Placement missing base.py field ${f}`);
|
||||
}
|
||||
// start_date is a DATE on both sides (D-103) — never a datetime
|
||||
assert.ok(
|
||||
/start_date: date/.test(pySrc),
|
||||
"base.py start_date is a pydantic date",
|
||||
);
|
||||
assert.ok(
|
||||
/start_date: string;/.test(block),
|
||||
"placement.ts start_date is the ISO date string",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: ApplicationStatus union mirrors the D-099 matrix (status-union regex pin)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export type ApplicationStatus"),
|
||||
typesSrc.indexOf("export type PlacementStatus"),
|
||||
);
|
||||
// the seven D-099 statuses, in matrix order
|
||||
const statuses = [
|
||||
"'submitted'", "'in_review'", "'interview'", "'offer'",
|
||||
"'hired'", "'rejected'", "'withdrawn'",
|
||||
];
|
||||
for (const status of statuses) {
|
||||
assert.ok(block.includes(status), `ApplicationStatus missing ${status}`);
|
||||
}
|
||||
assert.ok(
|
||||
!/"submitted"/.test(block),
|
||||
"snake_case on the wire (no kebab 'in-review')",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: PlacementOverview mirrors the D-107 computation shape", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface PlacementOverview"),
|
||||
);
|
||||
const fields = [
|
||||
"total_active_placements:", "distinct_learners_placed:",
|
||||
"guiding_goal:", "by_employer:", "by_cohort_month:",
|
||||
];
|
||||
for (const f of fields) {
|
||||
assert.ok(block.includes(f), `PlacementOverview missing ${f}`);
|
||||
}
|
||||
assert.ok(
|
||||
/export const GUIDING_GOAL = 1000;/.test(typesSrc),
|
||||
"the north star is 1,000 learners",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: EmployerApplicationSummary carries learner_id per the G-319 supersession (D-105)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface EmployerApplicationSummary"),
|
||||
typesSrc.indexOf("export interface EmployerApplicationsResponse"),
|
||||
);
|
||||
assert.ok(/learner_id: string;/.test(block), "learner_id rides the summary");
|
||||
// the own-postings LIST stays counts-only: OwnPostingSummary (the
|
||||
// v0.10 contract) still carries ONLY applicant_count — never a
|
||||
// learner_id field
|
||||
const employerTypes = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
||||
const ownBlock = employerTypes.slice(
|
||||
employerTypes.indexOf("export interface OwnPostingSummary"),
|
||||
employerTypes.indexOf("export interface OwnPostingsFacets"),
|
||||
);
|
||||
assert.ok(
|
||||
!/learner_id/.test(ownBlock),
|
||||
"the G-319 pin split: OwnPostingSummary stays counts-only",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './employer';
|
||||
export * from './defense';
|
||||
export * from './grading';
|
||||
export * from './marketplace';
|
||||
export * from './placement';
|
||||
export * from './telemetry';
|
||||
export * from './user';
|
||||
export * from './ui';
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Nextcraft — Placement Types
|
||||
*
|
||||
* Placement-tracking wire shapes (v0.11, REQ-11-001..005): the
|
||||
* hiring-pipeline surface served by /v1/employers/applications/*,
|
||||
* /v1/marketplace/applications/*, and /v1/placements/admin/*.
|
||||
*
|
||||
* Dual-schema-sync mirror of
|
||||
* `apps/ai-service/ai_service/placements/base.py` (the G-108
|
||||
* discipline, D-110): field-for-field parity, snake_case as emitted
|
||||
* by the API — every change happens in both places, pinned by
|
||||
* `apps/web/tests/pins.test.ts`.
|
||||
*
|
||||
* D-099: the application status set is forward-only with
|
||||
* skip-forward; hired/rejected/withdrawn are TERMINAL.
|
||||
*
|
||||
* G-319 supersession (D-105): learner_id IS visible on
|
||||
* application-scoped reads for the session's own claimed-entity
|
||||
* postings — the own-postings LIST stays counts-only.
|
||||
*/
|
||||
|
||||
/** D-099: the hiring lifecycle (snake_case, the billing `past_due`
|
||||
* precedent). hired/rejected/withdrawn are terminal — no outgoing
|
||||
* edges. */
|
||||
export type ApplicationStatus =
|
||||
| 'submitted'
|
||||
| 'in_review'
|
||||
| 'interview'
|
||||
| 'offer'
|
||||
| 'hired'
|
||||
| 'rejected'
|
||||
| 'withdrawn';
|
||||
|
||||
/** D-102: the placement lifecycle — created active; the ONLY
|
||||
* transition is the operator correction active → revoked. */
|
||||
export type PlacementStatus = 'active' | 'revoked';
|
||||
|
||||
/** A placement record (base.py PlacementRead). employer/learner/
|
||||
* posting are ID REFERENCES resolved from source rows at read/export
|
||||
* (G-302) — never trusted denormalized copies. start_date is the
|
||||
* employer-attested employment start DATE (D-103), never a
|
||||
* datetime. */
|
||||
export interface Placement {
|
||||
id: string;
|
||||
learner_id: string;
|
||||
employer_id: string;
|
||||
posting_id: string;
|
||||
application_id: string;
|
||||
role_title: string;
|
||||
start_date: string;
|
||||
status: PlacementStatus;
|
||||
created_at: string;
|
||||
decided_at: string;
|
||||
}
|
||||
|
||||
/** One ledger event (base.py PlacementEventRead): the append-only
|
||||
* hash-chained trail — application-stage events carry
|
||||
* application_id (placement_id null); the placement lane carries
|
||||
* placement_id set. */
|
||||
export interface PlacementEvent {
|
||||
id: number;
|
||||
application_id: string | null;
|
||||
placement_id: string | null;
|
||||
event_type: string;
|
||||
actor_type: string;
|
||||
actor_ref: string;
|
||||
payload: Record<string, unknown>;
|
||||
prev_hash: string;
|
||||
hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** An employer-facing application row (base.py
|
||||
* EmployerApplicationSummary, D-105/G-319): learner_id visible on
|
||||
* application-scoped reads ONLY (the own-postings list stays
|
||||
* counts-only — the pin split). */
|
||||
export interface EmployerApplicationSummary {
|
||||
id: string;
|
||||
posting_id: string;
|
||||
learner_id: string;
|
||||
status: ApplicationStatus;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** GET /v1/employers/applications response (base.py
|
||||
* EmployerApplicationsResponse): own-pipeline applications (every
|
||||
* status) + per-status facet counts (every status key ALWAYS
|
||||
* present — a zero-count status carries its honest 0). */
|
||||
export interface EmployerApplicationsResponse {
|
||||
applications: EmployerApplicationSummary[];
|
||||
facets: Record<ApplicationStatus, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** The transition result (base.py ApplicationTransitionResponse):
|
||||
* the application's new status + the placement record when the
|
||||
* transition created one (hire). */
|
||||
export interface ApplicationTransitionResponse {
|
||||
application_id: string;
|
||||
status: ApplicationStatus;
|
||||
placement: Placement | null;
|
||||
}
|
||||
|
||||
/** POST .../hire body (base.py HireTransitionRequest, D-103/G-324):
|
||||
* role_title is employer-attested free text (1..200 — the house
|
||||
* bounds); start_date is the employment start DATE (YYYY-MM-DD). */
|
||||
export interface HireTransitionRequest {
|
||||
role_title: string;
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
/** GET /v1/employers/placements response (base.py
|
||||
* EmployerPlacementsResponse): the claimed entity's OWN placements
|
||||
* (active + revoked — the full history), created_at then id. */
|
||||
export interface EmployerPlacementsResponse {
|
||||
placements: Placement[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** D-107: the guiding outcome's north star (PROJECT.md line 7 — the
|
||||
* noun is LEARNERS, not placements). */
|
||||
export const GUIDING_GOAL = 1000;
|
||||
|
||||
/** GET /v1/placements/admin/overview response (base.py
|
||||
* PlacementOverview, D-107): counts computed from rows at query
|
||||
* time — never denormalized counters. The guiding metric is
|
||||
* DISTINCT learners with ≥1 active placement (a learner placed
|
||||
* twice counts ONCE); revoked excluded from every count; cohort
|
||||
* month = the calendar month of start_date (YYYY-MM). */
|
||||
export interface PlacementOverview {
|
||||
total_active_placements: number;
|
||||
distinct_learners_placed: number;
|
||||
guiding_goal: number;
|
||||
by_employer: Array<{ employer_id: string; count: number }>;
|
||||
by_cohort_month: Array<{ month: string; count: number }>;
|
||||
}
|
||||
Reference in New Issue
Block a user