1
0
Fork 0

feat(profile): add leadership evaluation controls

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-08-27 18:36:04 -04:00
parent 959f65b51e
commit b8ba036965
3 changed files with 525 additions and 0 deletions

View file

@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon, PencilIcon } from "lucide-react";
import { toast } from "sonner";
import {
getLeadershipDisplayLabel,
PERFORMANCE_LEVELS,
isPerformanceLevelId,
type PerformanceLevelId,
} from "@/lib/evaluations/levels";
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { EvaluationMissionOption } from "./evaluation-types";
interface LeadershipEvaluationDialogProps {
readonly username: string;
readonly missions: readonly EvaluationMissionOption[];
}
function formatMissionDate(value: string): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(value));
}
export function LeadershipEvaluationDialog({
username,
missions,
}: LeadershipEvaluationDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [missionId, setMissionId] = useState("");
const [level, setLevel] = useState<PerformanceLevelId | "">("");
const [comment, setComment] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setMissionId("");
setLevel("");
setComment("");
setPending(false);
setError(null);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const selectedMission = missions.find((mission) => String(mission.id) === missionId);
if (!selectedMission) {
setError("Select a mission to rate.");
return;
}
if (!isPerformanceLevelId(level)) {
setError("Select a performance level.");
return;
}
setPending(true);
setError(null);
try {
const trimmedComment = comment.trim();
const result = await submitEvaluation({
username,
missionId: selectedMission.id,
level,
...(trimmedComment ? { comment: trimmedComment } : {}),
});
if (!result.success) {
setError(result.error ?? "Could not save the rating.");
return;
}
setOpen(false);
reset();
toast.success("Leadership rating saved");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save the rating.");
} finally {
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && pending) return;
setOpen(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogTrigger asChild>
<Button type="button" size="sm" variant="outline">
<PencilIcon data-icon="inline-start" />
Rate leadership
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rate leadership</DialogTitle>
<DialogDescription>
Submit a new rating or update your existing rating for one completed mission. Your
rating is included anonymously in the leadership aggregate.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-mission">Mission</Label>
<Select value={missionId} onValueChange={setMissionId} disabled={pending}>
<SelectTrigger id="evaluation-mission" aria-invalid={error !== null}>
<SelectValue placeholder="Select a completed mission" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{missions.map((mission) => (
<SelectItem key={mission.id} value={String(mission.id)}>
{mission.name} · {formatMissionDate(mission.startDateTime)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-level">Performance level</Label>
<Select
value={level}
onValueChange={(value) => {
if (isPerformanceLevelId(value)) setLevel(value);
}}
disabled={pending}
>
<SelectTrigger id="evaluation-level" aria-invalid={error !== null}>
<SelectValue placeholder="Select a level" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PERFORMANCE_LEVELS.map((performanceLevel) => (
<SelectItem
key={performanceLevel.id}
value={performanceLevel.id}
style={{ color: performanceLevel.color }}
>
{getLeadershipDisplayLabel(performanceLevel)} · {performanceLevel.score}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-comment">Comment (optional)</Label>
<Textarea
id="evaluation-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Add context for the leadership team."
maxLength={1000}
rows={4}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Saving..." : "Save rating"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,264 @@
"use client";
import { BadgeCheckIcon, ClipboardCheckIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
import { getLeadershipDisplayLabel, PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
import { LeadershipEvaluationDialog } from "./LeadershipEvaluationDialog";
import type {
EvaluationMissionOption,
EvaluationSummary,
LatestMissionRow,
} from "./evaluation-types";
interface LeadershipEvaluationSectionProps {
readonly username: string;
readonly summary: EvaluationSummary | null;
readonly summaryError: string | null;
readonly eligibleMissions: readonly EvaluationMissionOption[];
readonly latestMissionRows: readonly LatestMissionRow[];
readonly canSubmitEvaluation: boolean;
readonly canViewSubordinate: boolean;
}
const PERFORMANCE_GRADIENT = `linear-gradient(to right, ${[...PERFORMANCE_LEVELS]
.reverse()
.map((level) => level.color)
.join(", ")})`;
function formatDate(value: string): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(value));
}
function formatPercentage(value: number): string {
return `${value.toFixed(value % 1 === 0 ? 0 : 1)}%`;
}
export function LeadershipEvaluationSection({
username,
summary,
summaryError,
eligibleMissions,
latestMissionRows,
canSubmitEvaluation,
canViewSubordinate,
}: LeadershipEvaluationSectionProps) {
return (
<section className="flex flex-col gap-3" aria-labelledby="leadership-evaluation-heading">
<div className="flex items-center gap-2">
<BadgeCheckIcon className="size-4 text-muted-foreground" />
<h2
id="leadership-evaluation-heading"
className="text-sm font-semibold uppercase tracking-wider text-muted-foreground"
>
Leadership Evaluation
</h2>
</div>
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center justify-between gap-3 text-base">
<span>Leadership performance</span>
{canSubmitEvaluation && eligibleMissions.length > 0 && (
<LeadershipEvaluationDialog username={username} missions={eligibleMissions} />
)}
</CardTitle>
<CardDescription>
Anonymous feedback from operators who have served alongside this leader.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{summaryError ? (
<p role="alert" className="text-sm text-destructive">
{summaryError}
</p>
) : summary ? (
<>
{summary.leader.visible &&
summary.leader.averageScore !== null &&
summary.leader.levels !== null ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
<div className="flex items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Average rating
</span>
<span className="font-mono text-4xl font-semibold tracking-tight">
{Math.round(summary.leader.averageScore)}%
</span>
</div>
<span className="font-mono text-xs text-muted-foreground">
{summary.leader.totalRaters} responses
</span>
</div>
<div className="flex flex-col gap-1.5">
<div
role="img"
aria-label={`Average leadership rating: ${Math.round(summary.leader.averageScore)} percent`}
className="relative h-5"
>
<div
className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 overflow-hidden rounded-full"
style={{ background: PERFORMANCE_GRADIENT }}
>
<span
aria-hidden="true"
className="absolute inset-0"
style={{
background: PERFORMANCE_GRADIENT,
filter: "grayscale(1)",
maskImage: `linear-gradient(to right, black 0%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, black 100%)`,
WebkitMaskImage: `linear-gradient(to right, black 0%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, black 100%)`,
}}
/>
</div>
<span
aria-hidden="true"
className="absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-full bg-foreground ring-2 ring-background"
style={{
left: `${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%`,
}}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Needs work</span>
<span>Exceptional</span>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Level distribution</h3>
<span className="text-xs text-muted-foreground">All submitted ratings</span>
</div>
<div className="flex flex-col gap-2.5">
{PERFORMANCE_LEVELS.map((performanceLevel) => {
const levelSummary = summary.leader.levels?.find(
(level) => level.id === performanceLevel.id,
);
const percentage = levelSummary?.percentage ?? 0;
const count = levelSummary?.count ?? 0;
return (
<div key={performanceLevel.id} className="flex items-center gap-3">
<span className="w-28 shrink-0 text-xs text-muted-foreground">
{getLeadershipDisplayLabel(performanceLevel)}
</span>
<div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{
width: `${percentage}%`,
backgroundColor: performanceLevel.color,
}}
/>
</div>
<span className="w-20 shrink-0 text-right font-mono text-xs text-muted-foreground">
{formatPercentage(percentage)} · {count}
</span>
</div>
);
})}
</div>
</div>
{latestMissionRows.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<ClipboardCheckIcon className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Latest mission ratings</h3>
</div>
<ItemGroup className="gap-2">
{latestMissionRows.map((mission) => (
<Item key={mission.missionId} variant="muted" size="sm">
<ItemMedia variant="icon">
<ClipboardCheckIcon />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full truncate">
{mission.missionName}
</ItemTitle>
<ItemDescription>{formatDate(mission.startDateTime)}</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto">
<Badge
variant="outline"
className="font-mono text-xs"
style={{ color: mission.level.color }}
>
{getLeadershipDisplayLabel(mission.level)}
</Badge>
</ItemActions>
</Item>
))}
</ItemGroup>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-4">
<span className="text-sm font-semibold">Confidential baseline</span>
<p className="text-sm text-muted-foreground">
Leadership ratings remain private until at least three distinct operators have
responded.
</p>
</div>
)}
{canViewSubordinate && summary.subordinate && (
<>
<Separator />
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold">Subordinate performance</span>
<span className="text-xs text-muted-foreground">Latest command review</span>
</div>
<Badge
variant="outline"
className="font-mono text-xs"
style={{ color: summary.subordinate.level.color }}
>
{summary.subordinate.level.label}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
Updated {formatDate(summary.subordinate.at)}
</span>
</div>
</>
)}
</>
) : (
<p role="alert" className="text-sm text-muted-foreground">
Evaluation data is unavailable right now.
</p>
)}
{canSubmitEvaluation && eligibleMissions.length === 0 && (
<p className="text-xs text-muted-foreground">
No completed missions with a Yes RSVP are available to rate yet.
</p>
)}
</CardContent>
</Card>
</section>
);
}

View file

@ -0,0 +1,43 @@
export type EvaluationMissionOption = {
readonly id: number;
readonly name: string;
readonly startDateTime: string;
};
export type EvaluationDisplayLevel = {
readonly id: string;
readonly label: string;
readonly score: number;
readonly color: string;
};
export type EvaluationLevelSummary = EvaluationDisplayLevel & {
readonly id: string;
readonly count: number;
readonly percentage: number;
};
export type AnonymousLeaderSummary = {
readonly totalRaters: number;
readonly visible: boolean;
readonly levels: readonly EvaluationLevelSummary[] | null;
readonly averageScore: number | null;
};
export type AnonymousSubordinateSummary = {
readonly level: EvaluationDisplayLevel;
readonly missionId: number;
readonly at: string;
};
export type EvaluationSummary = {
readonly leader: AnonymousLeaderSummary;
readonly subordinate: AnonymousSubordinateSummary | null;
};
export type LatestMissionRow = {
readonly missionId: number;
readonly missionName: string;
readonly startDateTime: string;
readonly level: EvaluationDisplayLevel;
};