From e583bcd54d89eddd7e7e0155c8c411b2ab30430b Mon Sep 17 00:00:00 2001 From: Jinny oh Date: Thu, 15 Oct 2026 17:59:14 -0700 Subject: [PATCH 1/5] feat(positions): show the position page around the hiring process Build goal G#10. The position page was a long stack of text and links. It now leads with where the search stands and what to do next: - Header with status badge and key facts as chips - Six-step process bar (JD, success profile, sourcing, matching, outreach, submission) from a new read-only GET .../progress - A rule-based "next" card linking to the page to act on; no AI, nothing runs by itself (brief 1.1) - Candidate numbers, links grouped into four cards, and the JD and intake, details, weights, status and activity folded below Generated with AI assistance Co-Authored-By: Interactor Build --- apps/web/messages/en/positions.json | 77 +++- .../web/src/app/api/permission-matrix.test.ts | 11 + .../positions/[positionId]/progress/route.ts | 28 ++ .../components/positions/position-detail.tsx | 333 ++++++++++++++---- .../positions/position-progress.test.ts | 77 ++++ .../components/positions/position-progress.ts | 76 ++++ .../components/positions/positions.test.tsx | 72 +++- .../src/server/positions/progress.db.test.ts | 171 +++++++++ apps/web/src/server/positions/progress.ts | 101 ++++++ docs/planning/api-contracts.md | 2 + 10 files changed, 867 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts create mode 100644 apps/web/src/components/positions/position-progress.test.ts create mode 100644 apps/web/src/components/positions/position-progress.ts create mode 100644 apps/web/src/server/positions/progress.db.test.ts create mode 100644 apps/web/src/server/positions/progress.ts diff --git a/apps/web/messages/en/positions.json b/apps/web/messages/en/positions.json index 4f683cc..820668c 100644 --- a/apps/web/messages/en/positions.json +++ b/apps/web/messages/en/positions.json @@ -59,7 +59,7 @@ }, "detail": { "edit": "Edit", - "ownerLine": "Owner: {name}", + "clientOwnerLine": "{client} · Owner: {name}", "fields": { "department": "Department", "reportingLine": "Reports to", @@ -93,7 +93,80 @@ "poolLink": "Find in my pool", "benchmarkLink": "Market benchmark", "boardLink": "Candidates in this position", - "historyLink": "What has this client liked before?" + "historyLink": "What has this client liked before?", + "experienceChip": "{value} experience", + "openingsChip": "{count, plural, one {# opening} other {# openings}}", + "process": { + "label": "Hiring process", + "stepTitle": "{n}. {name}", + "stepDetail": "{state} · {detail}", + "steps": { + "jd": "Job description", + "profile": "Success profile", + "sourcing": "Sourcing", + "matching": "Matching", + "outreach": "Outreach", + "submission": "Submission" + }, + "states": { + "done": "Done", + "current": "Now", + "waiting": "Not started" + }, + "counts": { + "sourcing": "{count} in pipeline", + "matching": "{count} analyzed", + "outreach": "{count} contacted", + "submission": "{count} submitted" + }, + "jdStates": { + "none": "Not added", + "in_progress": "Waiting for approval", + "approved": "Approved" + }, + "profileStates": { + "none": "Not written", + "draft": "Draft, not confirmed", + "confirmed": "Confirmed" + } + }, + "next": { + "label": "Next:", + "addJd": "Add the client's job description below.", + "approveJd": "Structure the job description and get the hiring manager's approval.", + "confirmProfile": "Write and confirm the success profile. Matching compares candidates with it.", + "reviewDrafts": "{count, plural, one {# uploaded resume waits} other {# uploaded resumes wait}} for your review.", + "addCandidates": "Add candidates: upload resumes or LinkedIn PDFs, or find them in your pool.", + "analyze": "{count, plural, one {# candidate has} other {# candidates have}} no match analysis yet.", + "outreach": "Contact the candidates you want to approach.", + "board": "Keep the candidates moving on the candidate board.", + "open": "Open" + }, + "numbers": { + "label": "Candidates", + "pipeline": "In pipeline", + "analyzed": "Analyzed", + "interested": "Interested", + "submitted": "Submitted" + }, + "groups": { + "label": "Work on this position", + "candidates": "Candidates", + "candidatesHint": "Review board and resume uploads", + "find": "Find candidates", + "findHint": "Search criteria and your pool", + "prepare": "Prepare the search", + "prepareHint": "JD, intake questions and success profile", + "insights": "Insights", + "insightsHint": "Market benchmark and client history" + }, + "sections": { + "jd": "Job description and intake", + "details": "Position details", + "weights": "Matching weights", + "status": "Change status", + "activity": "Activity" + } }, "errors": { "generic": "Could not save. Check the values and try again.", diff --git a/apps/web/src/app/api/permission-matrix.test.ts b/apps/web/src/app/api/permission-matrix.test.ts index 0274f09..2f5eab6 100644 --- a/apps/web/src/app/api/permission-matrix.test.ts +++ b/apps/web/src/app/api/permission-matrix.test.ts @@ -247,6 +247,9 @@ vi.mock('@/server/candidates/duplicates', () => ({ vi.mock('@/server/candidates/confirm', () => ({ confirmCandidate: async () => ({ candidateId: 'c1', resolution: 'create', mergedIntoId: null }), })); +vi.mock('@/server/positions/progress', () => ({ + getPositionProgress: async () => ({ jd: 'none', successProfile: 'none', pendingDrafts: 0 }), +})); vi.mock('@/server/candidates/place', () => ({ listIntakeDrafts: async () => [], placeConfirmedCandidate: async () => ({ entryId: 'e1', added: true, canAnalyze: true }), @@ -616,6 +619,7 @@ import { GET as pipelineGet } from './v1/agencies/[agencyId]/positions/[position import { POST as batchPost } from './v1/agencies/[agencyId]/positions/[positionId]/analyses/route'; import { GET as reviewGet } from './v1/agencies/[agencyId]/positions/[positionId]/review/route'; import { GET as intakeDraftsGet } from './v1/agencies/[agencyId]/positions/[positionId]/intake-drafts/route'; +import { GET as progressGet } from './v1/agencies/[agencyId]/positions/[positionId]/progress/route'; import { PUT as orderPut, DELETE as orderDelete, @@ -1434,6 +1438,13 @@ const endpoints: Endpoint[] = [ allowed: AGENCY_ALL, call: () => intakeDraftsGet(new Request('http://x.test/'), agencyParams), }, + { + name: 'GET position progress', + file: 'v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts', + audience: 'agency', + allowed: AGENCY_ALL, + call: () => progressGet(new Request('http://x.test/'), agencyParams), + }, { name: 'POST batch analysis', file: 'v1/agencies/[agencyId]/positions/[positionId]/analyses/route.ts', diff --git a/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts new file mode 100644 index 0000000..1ce0fc5 --- /dev/null +++ b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +// route-audience: agency +import { authorizeAgency } from '@/lib/auth/authorize'; +import { withAgency } from '@/lib/db'; +import { handleRoute, ok } from '@/lib/http/responses'; +import { getPositionProgress } from '@/server/positions/progress'; + +export const dynamic = 'force-dynamic'; + +/** Where the position stands in the hiring process (G#10). Anyone who can see the position. */ +export async function GET( + _req: Request, + ctx: { params: Promise<{ agencyId: string; positionId: string }> }, +): Promise { + return handleRoute(async () => { + const { agencyId, positionId } = await ctx.params; + const authed = await authorizeAgency(agencyId); + if (authed instanceof Response) return authed; + return ok( + await withAgency( + authed.agency.id, + (tx) => getPositionProgress(tx, authed.actor, positionId), + { userId: authed.userId }, + ), + ); + }); +} diff --git a/apps/web/src/components/positions/position-detail.tsx b/apps/web/src/components/positions/position-detail.tsx index a30a0cb..3a1ea04 100644 --- a/apps/web/src/components/positions/position-detail.tsx +++ b/apps/web/src/components/positions/position-detail.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { POSITION_TRANSITIONS, REASON_REQUIRED, @@ -18,14 +18,31 @@ import { EntityTimeline } from '@/components/activity/entity-timeline'; import { Button } from '@/components/ui/button'; import { SelectField, TextField } from '@/components/ui/field'; import { ApiClientError, apiFetch } from '@/lib/api-client'; +import type { PositionProgress } from '@/server/positions/progress'; import type { PositionView } from '@/server/positions/service'; import { JdPanel } from './jd-panel'; import { PositionForm } from './position-form'; import { PortalSharing } from '@/components/pipeline/portal-sharing'; import { IntakePanel } from './intake-panel'; +import { nextAction, processSteps, type ProcessStep } from './position-progress'; import { WeightsEditor } from './weights-editor'; -/** The position page shell (T-1-04): details, edit, status control and timeline. JD, intake and pipeline attach here later. */ +const STATUS_BADGE = + 'rounded-md border border-primary-strong px-2 py-0.5 text-sm text-primary-strong'; +const NEXT_CARD = + 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-primary-strong bg-surface p-4'; + +/** A card of links on the position page: [label, href] pairs. */ +interface LinkGroup { + key: 'prepare' | 'candidates' | 'find' | 'insights'; + links: [string, string][]; +} + +/** + * The position page (T-1-04, reorganized for G#10): the header with the key facts, where the search + * stands in the hiring process and what to do next, links grouped by task, and the details, + * JD and intake, weights, status and activity folded below. + */ export function PositionDetail({ agencyId, agencySlug, @@ -88,17 +105,73 @@ export function PositionDetail({ [d('fields.workAuthorization'), position.workAuthorization ?? none], ]; + const base = `/a/${agencySlug}/positions/${positionId}`; + const place = position.workArrangement + ? `${position.location} · ${arrangementLabel(position.workArrangement, t)}` + : position.location; + const experience = position.requiredExperience + ? d('experienceChip', { value: position.requiredExperience }) + : null; + const chips = [ + place, + compensation === none ? null : compensation, + experience, + d('openingsChip', { count: position.openings }), + ].filter((c): c is string => Boolean(c)); + // Links grouped by what the recruiter is doing; editing pages only for those who may edit. + const groups: LinkGroup[] = [ + { + key: 'prepare', + links: canEdit + ? [ + [d('structureLink'), `${base}/structure`], + [d('questionsLink'), `${base}/questions`], + [d('profileLink'), `${base}/profile`], + ] + : [], + }, + { key: 'candidates', links: [[d('boardLink'), `${base}/candidates`]] }, + { + key: 'find', + links: canEdit + ? [ + [d('searchLink'), `${base}/search`], + [d('poolLink'), `${base}/pool`], + ] + : [], + }, + { + key: 'insights', + links: canEdit + ? [ + [d('benchmarkLink'), `${base}/benchmark`], + [d('historyLink'), `${base}/client-history`], + ] + : [[d('historyLink'), `${base}/client-history`]], + }, + ]; + return (
-
-

{position.title}

-

- {position.clientName} · {t(`status.${position.status}`)} -

+
+
+

{position.title}

+ {t(`status.${position.status}`)} +

- {d('ownerLine', { name: position.ownerName ?? t('unknownOwner') })} + {d('clientOwnerLine', { + client: position.clientName, + name: position.ownerName ?? t('unknownOwner'), + })}

+
    + {chips.map((chip) => ( +
  • + {chip} +
  • + ))} +
{editing || !canEdit ? null : (
); } +/** A folded section of the position page: the summary stays visible, the content opens on demand. */ +function Fold({ title, id, children }: { title: string; id?: string; children: ReactNode }) { + return ( +
+ {title} +
{children}
+
+ ); +} + +/** + * The hiring process at a glance (G#10): six steps from the brief's workflow, one suggested next + * action and the candidate numbers. Read-only; every action is a link the recruiter chooses to follow. + */ +function ProcessOverview({ + agencyId, + positionId, + base, +}: { + agencyId: string; + positionId: string; + base: string; +}) { + const d = useTranslations('positions.detail'); + const p = useTranslations('positions.detail.process'); + const progress = useQuery({ + queryKey: ['position-progress', agencyId, positionId], + queryFn: ({ signal }) => + apiFetch(`/api/v1/agencies/${agencyId}/positions/${positionId}/progress`, { + signal, + }), + }); + const data = progress.data?.data; + if (!data) return null; + + const next = nextAction(data); + const detail = (step: ProcessStep): string => { + if (step === 'jd') return p(`jdStates.${data.jd}`); + if (step === 'profile') return p(`profileStates.${data.successProfile}`); + const count = { + sourcing: data.counts.pipeline, + matching: data.counts.analyzed, + outreach: data.counts.contacted, + submission: data.counts.submitted, + }[step]; + return p(`counts.${step}`, { count }); + }; + const numbers = [ + ['pipeline', data.counts.pipeline], + ['analyzed', data.counts.analyzed], + ['interested', data.counts.interested], + ['submitted', data.counts.submitted], + ] as const; + + return ( +
+

+ {p('label')} +

+
    + {processSteps(data).map(({ step, state }, i) => ( +
  1. + + {p('stepTitle', { n: i + 1, name: p(`steps.${step}`) })} + + + {p('stepDetail', { state: p(`states.${state}`), detail: detail(step) })} + +
  2. + ))} +
+ +
+

+ {d('next.label')}{' '} + {d(`next.${next.key}`, { count: next.count })} +

+ + {d('next.open')} + +
+ +
+ {numbers.map(([key, value]) => ( +
+
{d(`numbers.${key}`)}
+
{value}
+
+ ))} +
+
+ ); +} + function arrangementLabel(value: string, t: ReturnType): string { return t.has(`form.arrangement.${value}`) ? t(`form.arrangement.${value}`) : value; } diff --git a/apps/web/src/components/positions/position-progress.test.ts b/apps/web/src/components/positions/position-progress.test.ts new file mode 100644 index 0000000..614e3b9 --- /dev/null +++ b/apps/web/src/components/positions/position-progress.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import type { PositionProgress } from '@/server/positions/progress'; +import { nextAction, processSteps } from './position-progress'; + +const progress = ( + over: Partial> = {}, + counts: Partial = {}, +): PositionProgress => ({ + jd: 'approved', + successProfile: 'confirmed', + pendingDrafts: 0, + ...over, + counts: { pipeline: 0, analyzed: 0, contacted: 0, interested: 0, submitted: 0, ...counts }, +}); +const states = (p: PositionProgress) => processSteps(p).map((s) => s.state); + +describe('processSteps (G#10)', () => { + it('starts at the job description', () => { + expect(states(progress({ jd: 'none', successProfile: 'none' }))).toEqual([ + 'current', + 'waiting', + 'waiting', + 'waiting', + 'waiting', + 'waiting', + ]); + }); + + it('marks the first step that is not done as current', () => { + expect(states(progress({}, { pipeline: 5, analyzed: 3 }))).toEqual([ + 'done', + 'done', + 'done', + 'done', + 'current', + 'waiting', + ]); + }); + + it('keeps later progress visible even when an earlier step is open', () => { + expect(states(progress({ successProfile: 'draft' }, { pipeline: 2 }))).toEqual([ + 'done', + 'current', + 'done', + 'waiting', + 'waiting', + 'waiting', + ]); + }); +}); + +describe('nextAction', () => { + it('follows the order of the search', () => { + expect(nextAction(progress({ jd: 'none' })).key).toBe('addJd'); + expect(nextAction(progress({ jd: 'in_progress' }))).toEqual({ + key: 'approveJd', + count: 0, + page: 'structure', + }); + expect(nextAction(progress({ successProfile: 'draft' })).page).toBe('profile'); + expect(nextAction(progress({ pendingDrafts: 3 }))).toEqual({ + key: 'reviewDrafts', + count: 3, + page: 'candidates', + }); + expect(nextAction(progress()).key).toBe('addCandidates'); + expect(nextAction(progress({}, { pipeline: 5, analyzed: 2 }))).toEqual({ + key: 'analyze', + count: 3, + page: 'candidates', + }); + expect(nextAction(progress({}, { pipeline: 5, analyzed: 5 })).key).toBe('outreach'); + expect(nextAction(progress({}, { pipeline: 5, analyzed: 5, contacted: 1 })).key).toBe('board'); + }); +}); diff --git a/apps/web/src/components/positions/position-progress.ts b/apps/web/src/components/positions/position-progress.ts new file mode 100644 index 0000000..9bbae48 --- /dev/null +++ b/apps/web/src/components/positions/position-progress.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import type { PositionProgress } from '@/server/positions/progress'; + +/** The six steps of a search on the position page, in the brief's order (section 2). */ +export const PROCESS_STEPS = [ + 'jd', + 'profile', + 'sourcing', + 'matching', + 'outreach', + 'submission', +] as const; +export type ProcessStep = (typeof PROCESS_STEPS)[number]; +export type StepState = 'done' | 'current' | 'waiting'; + +function isDone(step: ProcessStep, p: PositionProgress): boolean { + switch (step) { + case 'jd': + return p.jd === 'approved'; + case 'profile': + return p.successProfile === 'confirmed'; + case 'sourcing': + return p.counts.pipeline > 0; + case 'matching': + return p.counts.analyzed > 0; + case 'outreach': + return p.counts.contacted > 0; + case 'submission': + return p.counts.submitted > 0; + } +} + +/** Each step done, current (the first one not done) or waiting. */ +export function processSteps(p: PositionProgress): { step: ProcessStep; state: StepState }[] { + const firstOpen = PROCESS_STEPS.findIndex((s) => !isDone(s, p)); + return PROCESS_STEPS.map((step, i) => ({ + step, + state: isDone(step, p) ? 'done' : i === firstOpen ? 'current' : 'waiting', + })); +} + +export type NextActionKey = + | 'addJd' + | 'approveJd' + | 'confirmProfile' + | 'reviewDrafts' + | 'addCandidates' + | 'analyze' + | 'outreach' + | 'board'; + +/** + * The one suggestion for the "next" card, worked out by fixed rules from the counts: a pointer for + * the recruiter, never something that runs by itself (brief 1.1). `page` is the position's sub-page + * to open, or null for a section of the position page itself. + */ +export function nextAction(p: PositionProgress): { + key: NextActionKey; + count: number; + page: 'structure' | 'profile' | 'candidates' | null; +} { + const { counts } = p; + if (p.jd === 'none') return { key: 'addJd', count: 0, page: null }; + if (p.jd === 'in_progress') return { key: 'approveJd', count: 0, page: 'structure' }; + if (p.successProfile !== 'confirmed') return { key: 'confirmProfile', count: 0, page: 'profile' }; + if (p.pendingDrafts > 0) { + return { key: 'reviewDrafts', count: p.pendingDrafts, page: 'candidates' }; + } + if (counts.pipeline === 0) return { key: 'addCandidates', count: 0, page: 'candidates' }; + if (counts.analyzed < counts.pipeline) { + return { key: 'analyze', count: counts.pipeline - counts.analyzed, page: 'candidates' }; + } + if (counts.contacted === 0) return { key: 'outreach', count: 0, page: 'candidates' }; + return { key: 'board', count: 0, page: 'candidates' }; +} diff --git a/apps/web/src/components/positions/positions.test.tsx b/apps/web/src/components/positions/positions.test.tsx index 3081c24..fef0d3f 100644 --- a/apps/web/src/components/positions/positions.test.tsx +++ b/apps/web/src/components/positions/positions.test.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2026 Team of Wakers // SPDX-License-Identifier: MIT -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { renderApp, stubApi } from '@/test/render'; @@ -189,19 +189,28 @@ describe('PositionDetail', () => { [`GET ${api}/activity`]: () => json({ data: [], nextCursor: null }), [`GET ${api}/positions/p1/jd`]: () => json({ data: [] }), [`GET ${api}/positions/p1/intake`]: () => json({ data: { answers: {}, jdApproval: null } }), + [`GET ${api}/positions/p1/progress`]: () => json({ data: progress() }), ...extra, }); vi.stubGlobal('fetch', stub.impl); return stub; }; + const progress = (over: Record = {}) => ({ + jd: 'approved', + successProfile: 'confirmed', + pendingDrafts: 0, + counts: { pipeline: 6, analyzed: 4, contacted: 0, interested: 0, submitted: 0 }, + ...over, + }); it('shows the details, owner and only the status moves that are allowed', async () => { serve(); renderApp(); expect(await screen.findByRole('heading', { name: 'Staff Engineer' })).toBeTruthy(); - expect(screen.getByText('90000 – 120000 USD')).toBeTruthy(); - expect(screen.getByText('Go, SQL')).toBeTruthy(); - expect(screen.getByText('Hybrid')).toBeTruthy(); + const details = screen.getByText('Position details').closest('details')!; + expect(within(details).getByText('90000 – 120000 USD')).toBeTruthy(); + expect(within(details).getByText('Go, SQL')).toBeTruthy(); + expect(within(details).getByText('Hybrid')).toBeTruthy(); const options = Array.from((screen.getByLabelText('Move to') as HTMLSelectElement).options).map( (o) => o.textContent, ); @@ -272,6 +281,61 @@ describe('PositionDetail', () => { expect(screen.queryByRole('button', { name: 'Go ahead anyway' })).toBeNull(); }); + it('shows the key facts, where the search stands and the next step (G#10)', async () => { + serve(); + renderApp(); + const owner = await screen.findByText('Northwind · Owner: Pat Owner'); + const header = owner.closest('header')!; + expect(within(header).getByText('Seoul · Hybrid')).toBeTruthy(); + expect(within(header).getByText('90000 – 120000 USD')).toBeTruthy(); + expect(within(header).getByText('2 openings')).toBeTruthy(); + + const current = await screen.findByText('5. Outreach'); + expect(current.closest('li')!.getAttribute('aria-current')).toBe('step'); + expect(screen.getByText('Done · 4 analyzed')).toBeTruthy(); + expect(screen.getByText('2 candidates have no match analysis yet.')).toBeTruthy(); + const open = screen.getByRole('link', { name: 'Open' }); + expect(open.getAttribute('href')).toBe('/a/acme/positions/p1/candidates'); + const pipeline = screen.getByText('In pipeline').closest('div')!; + expect(within(pipeline).getByText('6')).toBeTruthy(); + }); + + it('points to the success profile until it is confirmed', async () => { + serve({ + [`GET ${api}/positions/p1/progress`]: () => + json({ data: progress({ successProfile: 'draft' }) }), + }); + renderApp(); + expect( + await screen.findByText( + 'Write and confirm the success profile. Matching compares candidates with it.', + ), + ).toBeTruthy(); + const open = screen.getByRole('link', { name: 'Open' }); + expect(open.getAttribute('href')).toBe('/a/acme/positions/p1/profile'); + const step = screen.getByText('2. Success profile').closest('li')!; + expect(step.getAttribute('aria-current')).toBe('step'); + }); + + it('groups the links and hides the editing pages from someone who cannot edit', async () => { + serve(); + renderApp( + , + ); + const nav = await screen.findByRole('navigation', { name: 'Work on this position' }); + const board = within(nav).getByRole('link', { name: 'Candidates in this position' }); + expect(board.getAttribute('href')).toBe('/a/acme/positions/p1/candidates'); + expect(within(nav).queryByRole('link', { name: 'Structure the JD' })).toBeNull(); + expect(within(nav).queryByRole('link', { name: 'Market benchmark' })).toBeNull(); + const history = within(nav).getByRole('link', { name: 'What has this client liked before?' }); + expect(history.getAttribute('href')).toBe('/a/acme/positions/p1/client-history'); + }); + it('lets a warn guard be overridden with a reason, which is sent with the request', async () => { let attempt = 0; const stub = serve( diff --git a/apps/web/src/server/positions/progress.db.test.ts b/apps/web/src/server/positions/progress.db.test.ts new file mode 100644 index 0000000..09d9550 --- /dev/null +++ b/apps/web/src/server/positions/progress.db.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { randomUUID } from 'node:crypto'; +import type pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { AgencyActor, PipelineState } from '@easemuse/core'; +import type { DbHandle } from '@/lib/db/client'; +import { addDraftVersion, approveVersion, createArtifact } from '@/server/artifacts/service'; +import { createClient } from '@/server/clients/service'; +import { addCandidateToPosition } from '@/server/pipeline/service'; +import { + adminClient, + appHandle, + appScope, + cleanup, + createAgency, + createUser, + dbAvailable, +} from '@/test/db'; +import { getPositionProgress } from './progress'; +import { createPosition } from './service'; + +describe.skipIf(!dbAvailable())('position progress (G#10)', () => { + let admin: pg.Client; + let handle: DbHandle; + let agencyA: string; + let agencyB: string; + let lead: AgencyActor; + let outsider: AgencyActor; + let clientId: string; + type Tx = Parameters['withAgency']>[1]>[0]; + const run = (fn: (tx: Tx) => Promise) => appScope(handle).withAgency(agencyA, fn); + + const newPosition = async () => + (await run((tx) => createPosition(tx, lead, { clientId, title: 'Role', location: 'Seoul' }))) + .id; + const progressOf = (positionId: string) => run((tx) => getPositionProgress(tx, lead, positionId)); + /** A confirmed candidate in the position, moved straight to `state` (the moves are tested elsewhere). */ + const inState = async (positionId: string, state: PipelineState) => { + const candidate = await run((tx) => + tx.candidate.create({ + data: { + agencyId: agencyA, + fullName: `Cand ${randomUUID().slice(0, 6)}`, + source: 'linkedin', + createdById: lead.userId, + status: 'active', + }, + select: { id: true }, + }), + ); + const entry = await run((tx) => + addCandidateToPosition(tx, lead, positionId, { candidateId: candidate.id }), + ); + await run((tx) => tx.pipelineEntry.update({ where: { id: entry.id }, data: { state } })); + }; + const successProfile = (positionId: string, approve: boolean) => + run(async (tx) => { + const artifact = await createArtifact(tx, { + agencyId: agencyA, + kind: 'success_profile', + subject: { type: 'position', id: positionId }, + }); + const v = await addDraftVersion(tx, { + agencyId: agencyA, + artifactId: artifact.id, + content: { + summary: 'Ideal candidate', + criteria: [{ id: 'c1', text: '8 years in HR', category: 'must_have', basis: 'jd' }], + expectations: { sixMonths: '', twelveMonths: '' }, + keyChallenges: [], + }, + origin: 'human', + createdById: lead.userId, + }); + if (approve) await approveVersion(tx, lead, v.id); + }); + + beforeAll(async () => { + admin = await adminClient(); + handle = appHandle(2); + agencyA = await createAgency(admin, 'Progress A'); + agencyB = await createAgency(admin, 'Progress B'); + const [u1, u2] = [await createUser(admin), await createUser(admin)]; + await admin.query( + `INSERT INTO "Membership" (id, "agencyId", "userId", role, active, "updatedAt") VALUES (gen_random_uuid(), $1, $2, 'lead', true, now())`, + [agencyA, u1], + ); + lead = { kind: 'agency', userId: u1, agencyId: agencyA, role: 'lead' }; + outsider = { kind: 'agency', userId: u2, agencyId: agencyB, role: 'recruiter' }; + clientId = (await run((tx) => createClient(tx, lead, { name: 'Progress Client' }))).client.id; + }); + afterAll(async () => { + await cleanup(admin, { agencies: [agencyA, agencyB] }); + await admin.end(); + await handle.close(); + }); + + it('starts empty for a new position', async () => { + const positionId = await newPosition(); + expect(await progressOf(positionId)).toEqual({ + jd: 'none', + successProfile: 'none', + pendingDrafts: 0, + counts: { pipeline: 0, analyzed: 0, contacted: 0, interested: 0, submitted: 0 }, + }); + }); + + it('tells a draft success profile from a confirmed one, and sees a structured JD', async () => { + const draft = await newPosition(); + await successProfile(draft, false); + await run((tx) => + createArtifact(tx, { + agencyId: agencyA, + kind: 'structured_jd', + subject: { type: 'position', id: draft }, + }), + ); + expect(await progressOf(draft)).toMatchObject({ jd: 'in_progress', successProfile: 'draft' }); + const confirmed = await newPosition(); + await successProfile(confirmed, true); + expect((await progressOf(confirmed)).successProfile).toBe('confirmed'); + }); + + it('counts the pipeline by how far each candidate got', async () => { + const positionId = await newPosition(); + for (const state of [ + 'sourced', + 'reviewed', + 'contacting', + 'interested', + 'qualified', + 'submitted', + 'interviewing', + 'rejected', + ] as const) { + await inState(positionId, state); + } + expect((await progressOf(positionId)).counts).toEqual({ + pipeline: 8, + analyzed: 0, + contacted: 5, + interested: 2, + submitted: 2, + }); + }); + + it('counts resumes uploaded for the position that wait for review', async () => { + const positionId = await newPosition(); + await run((tx) => + tx.candidate.create({ + data: { + agencyId: agencyA, + fullName: 'Draft: cv', + source: 'linkedin', + createdById: lead.userId, + status: 'draft', + intakePositionId: positionId, + }, + }), + ); + expect((await progressOf(positionId)).pendingDrafts).toBe(1); + }); + + it('is not visible from another agency', async () => { + const positionId = await newPosition(); + await expect( + appScope(handle).withAgency(agencyB, (tx) => getPositionProgress(tx, outsider, positionId)), + ).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/apps/web/src/server/positions/progress.ts b/apps/web/src/server/positions/progress.ts new file mode 100644 index 0000000..a7aade6 --- /dev/null +++ b/apps/web/src/server/positions/progress.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import type { AgencyActor, PipelineState } from '@easemuse/core'; +import type { ScopedTx } from '@/lib/db'; +import { findArtifact, readApproved } from '@/server/artifacts/service'; +import { isJdApproved } from './jd-approval'; +import { requirePosition } from './service'; + +/** + * Where a position stands in the hiring process (G#10; brief 2, 3, 4, 5.3), for the position page's + * process bar and "next" card. Read-only counts and states: nothing here runs AI or moves anyone. + */ +export interface PositionProgress { + /** none: no structured JD yet; in_progress: structured, not yet approved by the hiring manager. */ + jd: 'none' | 'in_progress' | 'approved'; + /** none: not written; draft: written, not confirmed; confirmed: the version matching uses (FR-INT-11). */ + successProfile: 'none' | 'draft' | 'confirmed'; + /** Resumes uploaded for this position that still wait for the recruiter's review (G#9). */ + pendingDrafts: number; + counts: { + pipeline: number; + analyzed: number; + contacted: number; + interested: number; + submitted: number; + }; +} + +/** States reached only after the first contact. Rejections and closures earlier on are not counted. */ +const CONTACTED: readonly PipelineState[] = [ + 'contacting', + 'no_response', + 'not_interested', + 'interested', + 'qualified', + 'resume_ready', + 'candidate_approved', + 'submitted', + 'client_rejected', + 'interviewing', + 'offer', + 'declined', + 'accepted', + 'reneged', + 'started', +]; +const INTERESTED: readonly PipelineState[] = [ + 'interested', + 'qualified', + 'resume_ready', + 'candidate_approved', +]; +const SUBMITTED: readonly PipelineState[] = [ + 'submitted', + 'client_rejected', + 'interviewing', + 'offer', + 'declined', + 'accepted', + 'reneged', + 'started', +]; + +export async function getPositionProgress( + tx: ScopedTx, + actor: AgencyActor, + positionId: string, +): Promise { + await requirePosition(tx, actor, positionId, 'read'); + const subject = { type: 'position' as const, id: positionId }; + const [jdArtifact, jdApproved, profileArtifact, profileApproved, entries, analyzed, drafts] = + await Promise.all([ + findArtifact(tx, { kind: 'structured_jd', subject }), + isJdApproved(tx, positionId), + findArtifact(tx, { kind: 'success_profile', subject }), + readApproved(tx, { kind: 'success_profile', subject }), + tx.pipelineEntry.findMany({ where: { positionId }, select: { state: true } }), + tx.matchAnalysis.findMany({ + where: { entry: { positionId } }, + distinct: ['entryId'], + select: { entryId: true }, + }), + tx.candidate.count({ + where: { intakePositionId: positionId, status: 'draft', mergedIntoId: null }, + }), + ]); + const inStates = (states: readonly PipelineState[]) => + entries.filter((e) => states.includes(e.state as PipelineState)).length; + return { + jd: jdApproved ? 'approved' : jdArtifact ? 'in_progress' : 'none', + successProfile: profileApproved ? 'confirmed' : profileArtifact ? 'draft' : 'none', + pendingDrafts: drafts, + counts: { + pipeline: entries.length, + analyzed: analyzed.length, + contacted: inStates(CONTACTED), + interested: inStates(INTERESTED), + submitted: inStates(SUBMITTED), + }, + }; +} diff --git a/docs/planning/api-contracts.md b/docs/planning/api-contracts.md index 7a3f7da..f285432 100644 --- a/docs/planning/api-contracts.md +++ b/docs/planning/api-contracts.md @@ -185,6 +185,8 @@ Also (session D): `GET / POST /positions/:positionId/pool-matches` ("Find in my Also (G#9, upload for a position; brief 4.1, 4.2, 5): `POST /candidates/intake` takes an optional `positionId` (anyone who can see the position; 409 when it is closed) and keeps it on the draft; nothing is added to the position yet. `GET /positions/:positionId/intake-drafts` lists those drafts still waiting for review (`{candidateId, filename, createdAt, state: reading | readFailed | drafting | aiFailed | ready}`). `POST /candidates` takes optional `positionId` and `analyze`: the confirmed candidate is added to the position in the same transaction (an existing entry is reused; 409 `position_closed`), then the match analysis is queued as a batch of one. The answer adds `placement {entryId, added, canAnalyze}` and `analysis {status: queued | not_requested | needs_success_profile | not_allowed | failed, jobRunId?}`; an analysis that cannot start never undoes the confirmation. +Also (G#10, position page): `GET /positions/:positionId/progress` (anyone who can see the position) returns `{jd: none | in_progress | approved, successProfile: none | draft | confirmed, pendingDrafts, counts {pipeline, analyzed, contacted, interested, submitted}}` for the process bar and the "next" card. Read-only; it runs nothing. + ## 13. Reuse and portal extras (session C, 2026-10-07) Agency API, under `/api/v1/agencies/:agencyId`. Every role may call them; the service narrows it further (a recruiter sees the positions the agency's visibility rule shows, and only the entry's owner, a lead or an admin resolves a proposal or asks for a draft). 404 for another agency, 409 for a state conflict. From 22c377edf1e66a67313eb89c4ea8c0c81cb8b980 Mon Sep 17 00:00:00 2001 From: Jinny oh Date: Thu, 15 Oct 2026 18:04:09 -0700 Subject: [PATCH 2/5] style(positions): match prettier in the position page Generated with AI assistance Co-Authored-By: Interactor Build --- .../components/positions/position-detail.tsx | 81 ++++++++----------- 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/positions/position-detail.tsx b/apps/web/src/components/positions/position-detail.tsx index 3a1ea04..a91662c 100644 --- a/apps/web/src/components/positions/position-detail.tsx +++ b/apps/web/src/components/positions/position-detail.tsx @@ -24,11 +24,16 @@ import { JdPanel } from './jd-panel'; import { PositionForm } from './position-form'; import { PortalSharing } from '@/components/pipeline/portal-sharing'; import { IntakePanel } from './intake-panel'; -import { nextAction, processSteps, type ProcessStep } from './position-progress'; +import { nextAction, processSteps, type ProcessStep, type StepState } from './position-progress'; import { WeightsEditor } from './weights-editor'; const STATUS_BADGE = 'rounded-md border border-primary-strong px-2 py-0.5 text-sm text-primary-strong'; +const STEP_CLASS: Record = { + done: 'flex flex-col border-t-4 border-primary-strong pt-2 text-sm', + current: 'flex flex-col border-t-4 border-primary pt-2 text-sm', + waiting: 'flex flex-col border-t-4 border-border pt-2 text-sm text-muted', +}; const NEXT_CARD = 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-primary-strong bg-surface p-4'; @@ -150,6 +155,7 @@ export function PositionDetail({ : [[d('historyLink'), `${base}/client-history`]], }, ]; + const visibleGroups = groups.filter((g) => g.links.length > 0); return (
@@ -167,9 +173,7 @@ export function PositionDetail({

    {chips.map((chip) => ( -
  • - {chip} -
  • +
  • {chip}
  • ))}
@@ -195,29 +199,23 @@ export function PositionDetail({
@@ -291,12 +289,10 @@ function ProcessOverview({ }) { const d = useTranslations('positions.detail'); const p = useTranslations('positions.detail.process'); + const path = `/api/v1/agencies/${agencyId}/positions/${positionId}/progress`; const progress = useQuery({ queryKey: ['position-progress', agencyId, positionId], - queryFn: ({ signal }) => - apiFetch(`/api/v1/agencies/${agencyId}/positions/${positionId}/progress`, { - signal, - }), + queryFn: ({ signal }) => apiFetch(path, { signal }), }); const data = progress.data?.data; if (!data) return null; @@ -322,21 +318,13 @@ function ProcessOverview({ return (
-

- {p('label')} -

+

{p('label')}

    {processSteps(data).map(({ step, state }, i) => (
  1. {p('stepTitle', { n: i + 1, name: p(`steps.${step}`) })} @@ -349,14 +337,11 @@ function ProcessOverview({
-

- {d('next.label')}{' '} - {d(`next.${next.key}`, { count: next.count })} +

+ {d('next.label')} + {d(`next.${next.key}`, { count: next.count })}

- + {d('next.open')}
From 6b36f13d6bc189844341526f58266bde77b25049 Mon Sep 17 00:00:00 2001 From: Jinny oh Date: Thu, 15 Oct 2026 18:09:20 -0700 Subject: [PATCH 3/5] style(positions): build the link groups so prettier keeps them as written Generated with AI assistance Co-Authored-By: Interactor Build --- .../components/positions/position-detail.tsx | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/positions/position-detail.tsx b/apps/web/src/components/positions/position-detail.tsx index a91662c..ba955e8 100644 --- a/apps/web/src/components/positions/position-detail.tsx +++ b/apps/web/src/components/positions/position-detail.tsx @@ -124,35 +124,28 @@ export function PositionDetail({ d('openingsChip', { count: position.openings }), ].filter((c): c is string => Boolean(c)); // Links grouped by what the recruiter is doing; editing pages only for those who may edit. + const link = (label: string, page: string): [string, string] => [label, `${base}/${page}`]; + const editOnly = (links: [string, string][]) => (canEdit ? links : []); const groups: LinkGroup[] = [ { key: 'prepare', - links: canEdit - ? [ - [d('structureLink'), `${base}/structure`], - [d('questionsLink'), `${base}/questions`], - [d('profileLink'), `${base}/profile`], - ] - : [], + links: editOnly([ + link(d('structureLink'), 'structure'), + link(d('questionsLink'), 'questions'), + link(d('profileLink'), 'profile'), + ]), }, - { key: 'candidates', links: [[d('boardLink'), `${base}/candidates`]] }, + { key: 'candidates', links: [link(d('boardLink'), 'candidates')] }, { key: 'find', - links: canEdit - ? [ - [d('searchLink'), `${base}/search`], - [d('poolLink'), `${base}/pool`], - ] - : [], + links: editOnly([link(d('searchLink'), 'search'), link(d('poolLink'), 'pool')]), }, { key: 'insights', - links: canEdit - ? [ - [d('benchmarkLink'), `${base}/benchmark`], - [d('historyLink'), `${base}/client-history`], - ] - : [[d('historyLink'), `${base}/client-history`]], + links: [ + ...editOnly([link(d('benchmarkLink'), 'benchmark')]), + link(d('historyLink'), 'client-history'), + ], }, ]; const visibleGroups = groups.filter((g) => g.links.length > 0); From 2745b95af7859f9a5a44401c1656aac54e446f90 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Thu, 15 Oct 2026 21:21:30 -0700 Subject: [PATCH 4/5] fix(positions): format the page and stop suggesting analysis for finished candidates The position page was not Prettier-formatted, which failed CI before any test ran. The next-step card also counted every pipeline entry that had no analysis, so candidates already rejected or closed kept it suggesting an analysis. It now counts only people still in play (toAnalyze). Generated with AI assistance Co-Authored-By: Interactor Build --- .../positions/position-progress.test.ts | 16 ++++++++++++++-- .../components/positions/position-progress.ts | 4 ++-- .../src/components/positions/positions.test.tsx | 2 +- .../web/src/server/positions/progress.db.test.ts | 4 +++- apps/web/src/server/positions/progress.ts | 11 +++++++++-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/positions/position-progress.test.ts b/apps/web/src/components/positions/position-progress.test.ts index 614e3b9..753427c 100644 --- a/apps/web/src/components/positions/position-progress.test.ts +++ b/apps/web/src/components/positions/position-progress.test.ts @@ -12,7 +12,15 @@ const progress = ( successProfile: 'confirmed', pendingDrafts: 0, ...over, - counts: { pipeline: 0, analyzed: 0, contacted: 0, interested: 0, submitted: 0, ...counts }, + counts: { + pipeline: 0, + analyzed: 0, + toAnalyze: 0, + contacted: 0, + interested: 0, + submitted: 0, + ...counts, + }, }); const states = (p: PositionProgress) => processSteps(p).map((s) => s.state); @@ -66,12 +74,16 @@ describe('nextAction', () => { page: 'candidates', }); expect(nextAction(progress()).key).toBe('addCandidates'); - expect(nextAction(progress({}, { pipeline: 5, analyzed: 2 }))).toEqual({ + expect(nextAction(progress({}, { pipeline: 5, analyzed: 2, toAnalyze: 3 }))).toEqual({ key: 'analyze', count: 3, page: 'candidates', }); expect(nextAction(progress({}, { pipeline: 5, analyzed: 5 })).key).toBe('outreach'); + // Candidates who are out of the search (a final state) are never suggested for analysis. + expect(nextAction(progress({}, { pipeline: 5, analyzed: 2, toAnalyze: 0 })).key).toBe( + 'outreach', + ); expect(nextAction(progress({}, { pipeline: 5, analyzed: 5, contacted: 1 })).key).toBe('board'); }); }); diff --git a/apps/web/src/components/positions/position-progress.ts b/apps/web/src/components/positions/position-progress.ts index 9bbae48..833629b 100644 --- a/apps/web/src/components/positions/position-progress.ts +++ b/apps/web/src/components/positions/position-progress.ts @@ -68,8 +68,8 @@ export function nextAction(p: PositionProgress): { return { key: 'reviewDrafts', count: p.pendingDrafts, page: 'candidates' }; } if (counts.pipeline === 0) return { key: 'addCandidates', count: 0, page: 'candidates' }; - if (counts.analyzed < counts.pipeline) { - return { key: 'analyze', count: counts.pipeline - counts.analyzed, page: 'candidates' }; + if (counts.toAnalyze > 0) { + return { key: 'analyze', count: counts.toAnalyze, page: 'candidates' }; } if (counts.contacted === 0) return { key: 'outreach', count: 0, page: 'candidates' }; return { key: 'board', count: 0, page: 'candidates' }; diff --git a/apps/web/src/components/positions/positions.test.tsx b/apps/web/src/components/positions/positions.test.tsx index fef0d3f..3dcee3c 100644 --- a/apps/web/src/components/positions/positions.test.tsx +++ b/apps/web/src/components/positions/positions.test.tsx @@ -199,7 +199,7 @@ describe('PositionDetail', () => { jd: 'approved', successProfile: 'confirmed', pendingDrafts: 0, - counts: { pipeline: 6, analyzed: 4, contacted: 0, interested: 0, submitted: 0 }, + counts: { pipeline: 6, analyzed: 4, toAnalyze: 2, contacted: 0, interested: 0, submitted: 0 }, ...over, }); diff --git a/apps/web/src/server/positions/progress.db.test.ts b/apps/web/src/server/positions/progress.db.test.ts index 09d9550..fd3f7f1 100644 --- a/apps/web/src/server/positions/progress.db.test.ts +++ b/apps/web/src/server/positions/progress.db.test.ts @@ -102,7 +102,7 @@ describe.skipIf(!dbAvailable())('position progress (G#10)', () => { jd: 'none', successProfile: 'none', pendingDrafts: 0, - counts: { pipeline: 0, analyzed: 0, contacted: 0, interested: 0, submitted: 0 }, + counts: { pipeline: 0, analyzed: 0, toAnalyze: 0, contacted: 0, interested: 0, submitted: 0 }, }); }); @@ -139,6 +139,8 @@ describe.skipIf(!dbAvailable())('position progress (G#10)', () => { expect((await progressOf(positionId)).counts).toEqual({ pipeline: 8, analyzed: 0, + // The rejected candidate is out of the search, so only the other seven wait for an analysis. + toAnalyze: 7, contacted: 5, interested: 2, submitted: 2, diff --git a/apps/web/src/server/positions/progress.ts b/apps/web/src/server/positions/progress.ts index a7aade6..87bbd48 100644 --- a/apps/web/src/server/positions/progress.ts +++ b/apps/web/src/server/positions/progress.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 Team of Wakers // SPDX-License-Identifier: MIT -import type { AgencyActor, PipelineState } from '@easemuse/core'; +import { TERMINAL_STATES, type AgencyActor, type PipelineState } from '@easemuse/core'; import type { ScopedTx } from '@/lib/db'; import { findArtifact, readApproved } from '@/server/artifacts/service'; import { isJdApproved } from './jd-approval'; @@ -20,6 +20,8 @@ export interface PositionProgress { counts: { pipeline: number; analyzed: number; + /** People still in play (not in a final state) with no analysis yet: what the "analyze" suggestion counts. */ + toAnalyze: number; contacted: number; interested: number; submitted: number; @@ -74,7 +76,7 @@ export async function getPositionProgress( isJdApproved(tx, positionId), findArtifact(tx, { kind: 'success_profile', subject }), readApproved(tx, { kind: 'success_profile', subject }), - tx.pipelineEntry.findMany({ where: { positionId }, select: { state: true } }), + tx.pipelineEntry.findMany({ where: { positionId }, select: { id: true, state: true } }), tx.matchAnalysis.findMany({ where: { entry: { positionId } }, distinct: ['entryId'], @@ -84,6 +86,10 @@ export async function getPositionProgress( where: { intakePositionId: positionId, status: 'draft', mergedIntoId: null }, }), ]); + const analyzedIds = new Set(analyzed.map((a) => a.entryId)); + const toAnalyze = entries.filter( + (e) => !TERMINAL_STATES.includes(e.state as PipelineState) && !analyzedIds.has(e.id), + ).length; const inStates = (states: readonly PipelineState[]) => entries.filter((e) => states.includes(e.state as PipelineState)).length; return { @@ -93,6 +99,7 @@ export async function getPositionProgress( counts: { pipeline: entries.length, analyzed: analyzed.length, + toAnalyze, contacted: inStates(CONTACTED), interested: inStates(INTERESTED), submitted: inStates(SUBMITTED), From b53a1e9047b28026f8d3c1b7b55d1f641d879bd2 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Thu, 15 Oct 2026 21:46:24 -0700 Subject: [PATCH 5/5] style(positions): format position-detail with the project's Prettier The two earlier format commits were written without running Prettier and CI kept failing on this one file; this is its actual Prettier output. Generated with AI assistance Co-Authored-By: Interactor Build --- .../src/components/positions/position-detail.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/positions/position-detail.tsx b/apps/web/src/components/positions/position-detail.tsx index ba955e8..e3250db 100644 --- a/apps/web/src/components/positions/position-detail.tsx +++ b/apps/web/src/components/positions/position-detail.tsx @@ -166,7 +166,9 @@ export function PositionDetail({

    {chips.map((chip) => ( -
  • {chip}
  • +
  • + {chip} +
  • ))}
@@ -198,12 +200,16 @@ export function PositionDetail({ aria-labelledby={`group-${g.key}`} className="flex flex-col gap-1 rounded-lg border border-border p-4" > -

{d(`groups.${g.key}`)}

+

+ {d(`groups.${g.key}`)} +

{d(`groups.${g.key}Hint`)}

    {g.links.map(([label, href]) => (
  • - {label} + + {label} +
  • ))}
@@ -311,7 +317,9 @@ function ProcessOverview({ return (
-

{p('label')}

+

+ {p('label')} +

    {processSteps(data).map(({ step, state }, i) => (