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:
parent
959f65b51e
commit
b8ba036965
3 changed files with 525 additions and 0 deletions
218
src/components/frontend/profile/LeadershipEvaluationDialog.tsx
Normal file
218
src/components/frontend/profile/LeadershipEvaluationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
src/components/frontend/profile/LeadershipEvaluationSection.tsx
Normal file
264
src/components/frontend/profile/LeadershipEvaluationSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/frontend/profile/evaluation-types.ts
Normal file
43
src/components/frontend/profile/evaluation-types.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in a new issue