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