Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 56 additions & 11 deletions src/pages/courts/CourtReportCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
courtLabel,
CourtStandingReference,
CourtTargetPreview,
CourtTriggerCounter,
formatCourtInstant,
} from "./components/CourtPrimitives";
import {
Expand All @@ -47,7 +48,8 @@ import {
import {
courtLaneDisplay,
courtOffenseDisplay,
courtStandingDisplay,
courtReportLaneChoiceLabel,
courtReportRouteDescription,
} from "./model/courtPresentation";
import {
courtErrorIssue,
Expand Down Expand Up @@ -316,6 +318,11 @@ const CourtReportCreate: React.FC = () => {
setSubmissionError("Choose a report reason.");
return;
}
if (!respondentId.trim()) {
setSubmissionError("A respondent address is required.");
focusCourtField("court-report-respondent");
return;
}
const startsAt = Date.parse(incidentStartsAt);
const endsAt = incidentEndsAt ? Date.parse(incidentEndsAt) : null;
if (endsAt !== null && endsAt < startsAt) {
Expand Down Expand Up @@ -499,7 +506,7 @@ const CourtReportCreate: React.FC = () => {
value={`${reason.offenseCode}:${reason.lane}`}
>
{courtOffenseDisplay(reason.offenseCode).label} ·{" "}
{courtLaneDisplay(reason.lane).label}
{courtReportLaneChoiceLabel(reason.lane)}
</option>
))}
</Select>
Expand Down Expand Up @@ -527,20 +534,51 @@ const CourtReportCreate: React.FC = () => {
direct={selectedReason.standing.directStanding}
source={selectedReason.standing.source}
/>
{`. ${courtStandingDisplay(selectedReason.standing).description}`}
{`. ${courtReportRouteDescription(
selectedReason.reason.lane,
selectedReason.standing,
)}`}
</>
) : capabilityLoading ? (
"The server is verifying the target, incident time, standing, and available lanes."
) : (
"Reasons are limited to those verified for this exact target and incident time."
)}
</p>
{selectedReason && capability?.population ? (
<p className="text-xs leading-5 text-muted md:col-span-2">
Population basis: {courtLabel(capability.population.basis)} ·{" "}
effective{" "}
{formatCourtInstant(capability.population.effectiveAt)}.
</p>
{selectedReason ? (
<div className="space-y-2 md:col-span-2">
{selectedReason.reason.lane === "court_report" &&
!selectedReason.standing.directStanding ? (
capability?.population?.communityThreshold ? (
<CourtTriggerCounter
required={capability.population.communityThreshold}
viewerCounts={
capability.population.viewerCountsTowardCommunity
}
/>
) : (
<p className="text-sm leading-6 text-muted">
A community Court trigger is unavailable because fewer
than three eligible Governors remain after excluding the
respondent.
</p>
)
) : (
<CourtTriggerCounter
description="One admissible report routes this action to its responsible authority."
label="Admissible reports"
required={1}
/>
)}
{capability?.population ? (
<p className="text-xs leading-5 text-muted">
Governor population:{" "}
{capability.population.eligibleGovernorCount} ·{" "}
{courtLabel(capability.population.basis)} · effective{" "}
{formatCourtInstant(capability.population.effectiveAt)}.
</p>
) : null}
</div>
) : null}
{capabilityError ? (
<div className="flex flex-wrap items-center gap-2 md:col-span-2">
Expand Down Expand Up @@ -570,14 +608,19 @@ const CourtReportCreate: React.FC = () => {
hint={
capability?.defaults.respondentIdSource ===
"sole_target_owner"
? "Suggested from the record owner. Change it if another Human Node is responsible."
: "The Human Node whose conduct is being reported, when known."
? "Set from the owner of the reported record."
: "The Human Node whose conduct is being reported."
}
>
<Input
id="court-report-respondent"
value={respondentId}
onChange={(event) => setRespondentId(event.target.value)}
readOnly={
capability?.defaults.respondentIdSource ===
"sole_target_owner"
}
required
/>
</CourtFormField>
<CourtFormField
Expand Down Expand Up @@ -694,6 +737,7 @@ const CourtReportCreate: React.FC = () => {
incidentStartsAt={incidentStartsAt}
onGoodFaithAttestedChange={setGoodFaithAttested}
protectiveReviewRequested={immediateProtectionRequested}
respondentId={respondentId}
selectedReason={selectedReason}
statementAccess={statementAccess}
statementLength={statement.trim().length}
Expand All @@ -715,6 +759,7 @@ const CourtReportCreate: React.FC = () => {
!capability ||
capabilityLoading ||
!reasonKey ||
!respondentId.trim() ||
statement.trim().length < COURT_STATEMENT_MIN_LENGTH ||
statement.trim().length > COURT_STATEMENT_MAX_LENGTH ||
!goodFaithAttested ||
Expand Down
28 changes: 28 additions & 0 deletions src/pages/courts/CourtReportDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
CourtStandingReference,
CourtStateSummary,
CourtTargetPreview,
CourtTriggerCounter,
courtTone,
formatCourtInstant,
} from "./components/CourtPrimitives";
Expand Down Expand Up @@ -349,6 +350,17 @@ const CourtReportDetail: React.FC = () => {
label="Target"
value={courtLabel(report.target.type)}
/>
{report.respondentId ? (
<GlassyKeyValue
label="Respondent"
value={
<CourtCopyValue
label="respondent address"
value={report.respondentId}
/>
}
/>
) : null}
<GlassyKeyValue
label="Incident"
value={formatCourtInstant(report.incident.startedAt)}
Expand Down Expand Up @@ -395,6 +407,13 @@ const CourtReportDetail: React.FC = () => {
}
/>
</GlassyCompactGrid>
{report.triggerProgress ? (
<CourtTriggerCounter
current={report.triggerProgress.qualifyingReports}
required={report.triggerProgress.requiredReports}
viewerCounts={report.triggerProgress.viewerReportCounts}
/>
) : null}
{process ? (
<p className="text-sm leading-6 text-muted">
Next: {process.nextStep}
Expand Down Expand Up @@ -492,7 +511,15 @@ const CourtReportDetail: React.FC = () => {
onChange={(event) =>
setRespondentId(event.target.value)
}
readOnly={Boolean(report.respondentId)}
required
/>
{report.respondentId ? (
<span className="text-xs leading-5 text-muted">
The respondent is fixed to the reported record and
cannot be changed by amendment.
</span>
) : null}
</label>
<label
className="grid gap-2 text-sm text-text"
Expand Down Expand Up @@ -585,6 +612,7 @@ const CourtReportDetail: React.FC = () => {
COURT_STATEMENT_MIN_LENGTH ||
supplement.trim().length >
COURT_STATEMENT_MAX_LENGTH ||
!respondentId.trim() ||
!amendmentChanged)) ||
(!report.actions.amend &&
!supplement.trim() &&
Expand Down
57 changes: 57 additions & 0 deletions src/pages/courts/components/CourtPrimitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,63 @@ export function CourtStandingReference({
);
}

export function CourtTriggerCounter({
current,
description,
label = "Governor reports",
required,
viewerCounts,
}: {
current?: number;
description?: string;
label?: string;
required: number;
viewerCounts?: boolean;
}) {
const completed = current === undefined ? 0 : Math.min(current, required);
const percentage = required > 0 ? (completed / required) * 100 : 0;
return (
<div className="space-y-2 border-t border-border/70 pt-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-sm font-medium text-text">{label}</p>
<p className="text-sm font-semibold text-text">
{current === undefined
? `${required} required`
: `${completed} / ${required}`}
</p>
</div>
{current !== undefined ? (
<div
aria-label={`${completed} of ${required} Governor reports received`}
aria-valuemax={required}
aria-valuemin={0}
aria-valuenow={completed}
className="h-1.5 overflow-hidden rounded-full bg-border/70"
role="progressbar"
>
<div
className="h-full rounded-full bg-primary transition-[width] motion-reduce:transition-none"
style={{ width: `${percentage}%` }}
/>
</div>
) : null}
<p className="text-xs leading-5 text-muted">
{description ??
(current === undefined
? `This action needs matching reports from ${required} eligible Governors.`
: completed >= required
? "The Governor threshold has been reached."
: `${required - completed} more matching Governor ${required - completed === 1 ? "report" : "reports"} required.`)}
{viewerCounts === undefined
? ""
: viewerCounts
? " Your report counts toward this threshold."
: " Your report is recorded, but it does not add Governor support."}
</p>
</div>
);
}

export function courtTone(
value: string,
): "danger" | "neutral" | "ok" | "primary" | "warn" {
Expand Down
19 changes: 19 additions & 0 deletions src/pages/courts/components/CourtRecordCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
CourtCopyValue,
CourtDeadline,
CourtStandingReference,
CourtTriggerCounter,
courtLabel,
courtTone,
formatCourtInstant,
Expand Down Expand Up @@ -171,6 +172,17 @@ export function CourtReportCard({
label="Updated"
value={formatCourtInstant(report.updatedAt)}
/>
{report.respondentId ? (
<GlassyKeyValue
label="Respondent"
value={
<CourtCopyValue
label="respondent address"
value={report.respondentId}
/>
}
/>
) : null}
{report.amendmentDueAt ? (
<CourtDeadline
dueAt={report.amendmentDueAt}
Expand All @@ -179,6 +191,13 @@ export function CourtReportCard({
/>
) : null}
</GlassyCompactGrid>
{report.triggerProgress ? (
<CourtTriggerCounter
current={report.triggerProgress.qualifyingReports}
required={report.triggerProgress.requiredReports}
viewerCounts={report.triggerProgress.viewerReportCounts}
/>
) : null}
<div className="mt-auto flex flex-wrap justify-end gap-2">
{report.target.route ? (
<Button asChild size="compact" variant="outline">
Expand Down
13 changes: 12 additions & 1 deletion src/pages/courts/forms/CourtReportFormSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import {
courtLaneDisplay,
courtOffenseDisplay,
courtReportRouteDescription,
} from "../model/courtPresentation";
import { courtEvidenceAccessLabel } from "./courtEvidence";

Expand Down Expand Up @@ -117,6 +118,7 @@ export function CourtReportReview({
incidentStartsAt,
onGoodFaithAttestedChange,
protectiveReviewRequested,
respondentId,
selectedReason,
statementAccess,
statementLength,
Expand All @@ -128,6 +130,7 @@ export function CourtReportReview({
incidentStartsAt: string;
onGoodFaithAttestedChange: (attested: boolean) => void;
protectiveReviewRequested: boolean;
respondentId: string;
selectedReason: CourtReportReasonCapability | undefined;
statementAccess: CourtEvidenceAccessV2Dto;
statementLength: number;
Expand All @@ -137,7 +140,7 @@ export function CourtReportReview({
<GlassyTile className="space-y-4">
{selectedReason ? (
<CourtStateSummary
description={`${courtOffenseDisplay(selectedReason.reason.offenseCode).description} ${courtLaneDisplay(selectedReason.reason.lane).description}`}
description={`${courtOffenseDisplay(selectedReason.reason.offenseCode).description} ${courtReportRouteDescription(selectedReason.reason.lane, selectedReason.standing)}`}
label={
<>
<CodexHint reference={selectedReason.reason.offenseCode}>
Expand All @@ -160,6 +163,14 @@ export function CourtReportReview({
<p>
Target: {courtLabel(target.type)} · {target.id}
</p>
<p className="flex min-w-0 items-center gap-2">
<span>Respondent:</span>
{respondentId ? (
<CourtCopyValue label="respondent address" value={respondentId} />
) : (
"Not set"
)}
</p>
<p>Incident: {formatCourtInstant(incidentStartsAt)}</p>
<p>
Incident end:{" "}
Expand Down
25 changes: 25 additions & 0 deletions src/pages/courts/model/courtPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ export function courtStandingDisplay(standing: {
};
}

export function courtReportLaneChoiceLabel(lane: CourtReportLaneV2Dto): string {
if (lane === "correction") return "Correction only";
if (lane === "court_report") return "Court review";
return courtLaneDisplay(lane).label;
}

export function courtReportRouteDescription(
lane: CourtReportLaneV2Dto,
standing: { direct?: boolean; directStanding?: boolean },
): string {
if (lane === "correction") {
return "This sends a correction request to the module that owns the record. It does not join a Court trigger or open a case.";
}
if (lane === "scoped_moderation") {
return "This sends the record to the authorized moderation process. It does not join a Court trigger or open a case.";
}
if (lane === "safety_or_protocol_incident") {
return "This sends evidence to the authorized safety or protocol incident process instead of the community Court trigger.";
}
const direct = standing.direct ?? standing.directStanding ?? false;
return direct
? "This enters Court review and can open a case through verified direct standing."
: "This enters the private community trigger. A case opens only after the protected reporting threshold is reached.";
}

export function courtReportProcessContext(
report: Pick<
CourtMyReportItemV2Dto,
Expand Down
Loading
Loading