feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [8/10] — wave 5-2: job-detail application-status panel — mine-read first, the seven D-099 states, timestamped history, placement confirmation, withdraw CTA with honest 409/403 copy (A-304 badge honesty)
This commit is contained in:
@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
|
||||
import type {
|
||||
ApplicationConfirmation,
|
||||
ApplicationStatus,
|
||||
LearnerApplicationStatus,
|
||||
PostingDetail,
|
||||
EmployerProfile,
|
||||
SearchResult,
|
||||
@@ -24,8 +26,11 @@ import {
|
||||
VerifyRequiredError,
|
||||
applyPosting,
|
||||
getEmployer,
|
||||
getLearnerApplicationStatus,
|
||||
getMyApplication,
|
||||
getPosting,
|
||||
readSearchSelection,
|
||||
withdrawApplication,
|
||||
} from '../../lib/engine-client';
|
||||
import { segmentFromPath } from '../../lib/route-id';
|
||||
|
||||
@@ -54,6 +59,12 @@ export function JobDetailClient() {
|
||||
// P3 G-106: the board's search handoff, read ONCE on mount (empty
|
||||
// when the visit is not from a search result — no score, no panel).
|
||||
const [searchSelection, setSearchSelection] = useState<SearchResult | null>(null);
|
||||
// P5 (G-322): the learner's application on THIS posting — composed
|
||||
// via the mine-read + the status read on every visit; never a
|
||||
// client-persisted application id.
|
||||
const [appStatus, setAppStatus] = useState<LearnerApplicationStatus | null>(null);
|
||||
const [withdrawing, setWithdrawing] = useState(false);
|
||||
const [withdrawError, setWithdrawError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -76,6 +87,28 @@ export function JobDetailClient() {
|
||||
} catch {
|
||||
/* employer optional for the detail view */
|
||||
}
|
||||
// P5 (G-322): mine-read discovery — the panel composes from this
|
||||
// read, never a client-persisted application_id; 404 = no
|
||||
// application → the apply CTA renders (honest absence); gate
|
||||
// arms render their existing states.
|
||||
try {
|
||||
const mine = await getMyApplication(p.id, undefined, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
const status = await getLearnerApplicationStatus(
|
||||
mine.application_id,
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
if (!controller.signal.aborted) setAppStatus(status);
|
||||
} catch (mineErr) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (mineErr instanceof EngineError && mineErr.status === 404) return;
|
||||
if (mineErr instanceof VerifyRequiredError) {
|
||||
setVerifyCta(mineErr.verifyCta);
|
||||
}
|
||||
// any other failure leaves the apply CTA as the honest
|
||||
// surface — its own flow re-surfaces the gate states.
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof EngineError && err.status === 404) {
|
||||
@@ -97,6 +130,14 @@ export function JobDetailClient() {
|
||||
try {
|
||||
const c = await applyPosting(posting.id);
|
||||
setConfirmation(c);
|
||||
// P5: compose the status panel from the real read (the direct
|
||||
// response's application_id — never persisted).
|
||||
try {
|
||||
const status = await getLearnerApplicationStatus(c.application_id);
|
||||
setAppStatus(status);
|
||||
} catch {
|
||||
/* the confirmation box renders the honest state regardless */
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof VerifyRequiredError) {
|
||||
setVerifyCta(err.verifyCta);
|
||||
@@ -112,6 +153,35 @@ export function JobDetailClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onWithdraw() {
|
||||
if (!appStatus) return;
|
||||
setWithdrawing(true);
|
||||
setWithdrawError(null);
|
||||
try {
|
||||
const c = await withdrawApplication(appStatus.application.id);
|
||||
const status = await getLearnerApplicationStatus(c.application_id);
|
||||
setAppStatus(status);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 409) {
|
||||
// G-323: the matrix refused — the honest terminal copy; the
|
||||
// state refreshes instead of a stale withdraw button.
|
||||
try {
|
||||
const status = await getLearnerApplicationStatus(appStatus.application.id);
|
||||
setAppStatus(status);
|
||||
} catch {
|
||||
/* the current render stays — the 409 copy below is honest */
|
||||
}
|
||||
setWithdrawError('This application is terminal — it can no longer be withdrawn.');
|
||||
} else if (err instanceof EngineError && err.status === 403) {
|
||||
setWithdrawError('This application belongs to another learner.');
|
||||
} else {
|
||||
setWithdrawError(err instanceof Error ? err.message : 'Withdraw failed.');
|
||||
}
|
||||
} finally {
|
||||
setWithdrawing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
notFound();
|
||||
}
|
||||
@@ -242,8 +312,86 @@ export function JobDetailClient() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 md:items-end">
|
||||
{/* Apply flow (D-058): every state renders honestly. */}
|
||||
{confirmation ? (
|
||||
{/* Apply flow (D-058): every state renders honestly. P5
|
||||
* (G-322/D-104): the status panel renders when the
|
||||
* mine-read finds the application; the apply CTA only
|
||||
* when it does not. */}
|
||||
{appStatus ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full flex-col gap-3 rounded-lg border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Your application
|
||||
</span>
|
||||
<Badge
|
||||
variant={appStatus.application.status === 'hired' ? 'success' : 'default'}
|
||||
>
|
||||
{applicationStatusLabel(appStatus.application.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{appStatus.application.created_at
|
||||
? `Applied ${relativeTime(appStatus.application.created_at)} · `
|
||||
: ''}
|
||||
application {appStatus.application.id}
|
||||
</span>
|
||||
{appStatus.events.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{appStatus.events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{applicationEventLabel(event.event_type)}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
{relativeTime(event.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
No status updates yet.
|
||||
</span>
|
||||
)}
|
||||
{appStatus.placement && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
|
||||
>
|
||||
<span className="font-semibold">Placement recorded</span>
|
||||
<span className="text-xs">
|
||||
{appStatus.placement.role_title} at {employerName} · starts{' '}
|
||||
{appStatus.placement.start_date}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{WITHDRAWABLE_STATUSES.has(appStatus.application.status) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
loading={withdrawing}
|
||||
onClick={() => void onWithdraw()}
|
||||
>
|
||||
Withdraw application
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{terminalCopy(appStatus.application.status)}
|
||||
</p>
|
||||
)}
|
||||
{withdrawError && (
|
||||
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
|
||||
{withdrawError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : confirmation ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
|
||||
@@ -457,3 +605,78 @@ export function JobDetailClient() {
|
||||
function matchPercent(score: number): number {
|
||||
return Math.max(0, Math.round(score * 100));
|
||||
}
|
||||
|
||||
|
||||
/** The seven D-099 states, honestly labeled (no stage invented beyond
|
||||
* the matrix; unknown statuses render verbatim, never invented). */
|
||||
function applicationStatusLabel(status: ApplicationStatus): string {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return 'Submitted';
|
||||
case 'in_review':
|
||||
return 'In review';
|
||||
case 'interview':
|
||||
return 'Interview';
|
||||
case 'offer':
|
||||
return 'Offer';
|
||||
case 'hired':
|
||||
return 'Hired';
|
||||
case 'rejected':
|
||||
return 'Rejected';
|
||||
case 'withdrawn':
|
||||
return 'Withdrawn';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ledger event labels (the D-101 vocabulary) — unknown event names
|
||||
* render verbatim, never invented. */
|
||||
function applicationEventLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case 'application.in_review':
|
||||
return 'Moved to review';
|
||||
case 'application.interview':
|
||||
return 'Interview stage';
|
||||
case 'application.offer':
|
||||
return 'Offer received';
|
||||
case 'application.hired':
|
||||
return 'Hired';
|
||||
case 'application.rejected':
|
||||
return 'Rejected';
|
||||
case 'application.withdrawn':
|
||||
return 'Withdrawn';
|
||||
case 'placement.created':
|
||||
return 'Placement recorded';
|
||||
case 'placement.revoked':
|
||||
return 'Placement revoked (operator correction)';
|
||||
case 'placement.rehired':
|
||||
return 'Placement re-recorded (operator correction)';
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
/** The learner's legal withdraw sources (D-099: terminals have no
|
||||
* outgoing edges). */
|
||||
const WITHDRAWABLE_STATUSES: ReadonlySet<ApplicationStatus> = new Set([
|
||||
'submitted',
|
||||
'in_review',
|
||||
'interview',
|
||||
'offer',
|
||||
]);
|
||||
|
||||
/** Terminal copy (the honest end states — no resurrection, no
|
||||
* fabricated next step). */
|
||||
function terminalCopy(status: ApplicationStatus): string {
|
||||
switch (status) {
|
||||
case 'hired':
|
||||
return 'Hired — placement details appear above when a placement is recorded.';
|
||||
case 'rejected':
|
||||
return 'This application was not taken forward.';
|
||||
case 'withdrawn':
|
||||
return 'You withdrew this application.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user