feat(profile): integrate evaluations into profile pages
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
b8ba036965
commit
6bbafc7c5a
1 changed files with 194 additions and 1 deletions
|
|
@ -1,10 +1,16 @@
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types";
|
import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types";
|
||||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
import { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations";
|
||||||
|
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
|
||||||
|
import { getEvaluationSummary } from "./actions";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
AwardIcon,
|
AwardIcon,
|
||||||
|
|
@ -34,14 +40,128 @@ import {
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import AwardCard from "@/components/frontend/account/AwardCard";
|
import AwardCard from "@/components/frontend/account/AwardCard";
|
||||||
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
||||||
|
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
||||||
|
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
||||||
|
import type {
|
||||||
|
EvaluationMissionOption,
|
||||||
|
EvaluationSummary,
|
||||||
|
LatestMissionRow,
|
||||||
|
} from "@/components/frontend/profile/evaluation-types";
|
||||||
|
|
||||||
interface ProfilePageProps {
|
interface ProfilePageProps {
|
||||||
params: Promise<{ username: string }>;
|
params: Promise<{ username: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Payload = Awaited<ReturnType<typeof getPayload>>;
|
||||||
|
|
||||||
|
async function findEligibleEvaluationMissions(
|
||||||
|
payload: Payload,
|
||||||
|
viewerId: number,
|
||||||
|
now: Date,
|
||||||
|
): Promise<readonly EvaluationMissionOption[]> {
|
||||||
|
const attendanceRes = await payload.find({
|
||||||
|
collection: "mission-attendances",
|
||||||
|
where: {
|
||||||
|
and: [{ user: { equals: viewerId } }, { response: { equals: "yes" } }],
|
||||||
|
},
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
select: { mission: true },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const missionIds = [
|
||||||
|
...new Set(
|
||||||
|
attendanceRes.docs.map((attendance) =>
|
||||||
|
typeof attendance.mission === "number" ? attendance.mission : attendance.mission.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (missionIds.length === 0) return [];
|
||||||
|
|
||||||
|
const missionRes = await payload.find({
|
||||||
|
collection: "missions",
|
||||||
|
where: { id: { in: missionIds } },
|
||||||
|
limit: missionIds.length,
|
||||||
|
depth: 0,
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
classification: { startDateTime: true },
|
||||||
|
ownershipAndStatus: { status: true },
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...missionRes.docs]
|
||||||
|
.filter((mission) =>
|
||||||
|
isMissionEvaluable(
|
||||||
|
mission.ownershipAndStatus.status,
|
||||||
|
mission.classification.startDateTime,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(first, second) =>
|
||||||
|
new Date(second.classification.startDateTime).getTime() -
|
||||||
|
new Date(first.classification.startDateTime).getTime(),
|
||||||
|
)
|
||||||
|
.map((mission) => ({
|
||||||
|
id: mission.id,
|
||||||
|
name: mission.name,
|
||||||
|
startDateTime: mission.classification.startDateTime,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findLatestLeaderMissionRows(
|
||||||
|
payload: Payload,
|
||||||
|
rateeId: number,
|
||||||
|
): Promise<readonly LatestMissionRow[]> {
|
||||||
|
const evaluationRes = await payload.find({
|
||||||
|
collection: "evaluations",
|
||||||
|
where: {
|
||||||
|
and: [{ ratee: { equals: rateeId } }, { kind: { in: ["leader-direct", "leader-indirect"] } }],
|
||||||
|
},
|
||||||
|
sort: "-updatedAt",
|
||||||
|
limit: 10,
|
||||||
|
depth: 1,
|
||||||
|
select: { mission: true, level: true, updatedAt: true },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const missionIds = new Set<number>();
|
||||||
|
const rows: LatestMissionRow[] = [];
|
||||||
|
|
||||||
|
for (const evaluation of evaluationRes.docs) {
|
||||||
|
if (typeof evaluation.mission === "number") continue;
|
||||||
|
const level = PERFORMANCE_LEVELS.find((candidate) => candidate.id === evaluation.level);
|
||||||
|
if (!level || missionIds.has(evaluation.mission.id)) continue;
|
||||||
|
|
||||||
|
missionIds.add(evaluation.mission.id);
|
||||||
|
rows.push({
|
||||||
|
missionId: evaluation.mission.id,
|
||||||
|
missionName: evaluation.mission.name,
|
||||||
|
startDateTime: evaluation.mission.classification.startDateTime,
|
||||||
|
level: {
|
||||||
|
id: level.id,
|
||||||
|
label: getLeadershipDisplayLabel(level),
|
||||||
|
score: level.score,
|
||||||
|
color: level.color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length === 5) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProfilePage({ params }: ProfilePageProps) {
|
export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
const { username } = await params;
|
const { username } = await params;
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const viewer = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
|
||||||
const profileRes = await payload
|
const profileRes = await payload
|
||||||
.find({
|
.find({
|
||||||
|
|
@ -70,6 +190,69 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
|
|
||||||
const profile = profileRes.docs[0];
|
const profile = profileRes.docs[0];
|
||||||
const user = profile.user as User;
|
const user = profile.user as User;
|
||||||
|
const isProfileOwner = viewer?.id === user.id;
|
||||||
|
const isLegacyPrivileged =
|
||||||
|
viewer?.roles?.some((role) => role === "admin" || role === "developer") === true;
|
||||||
|
const evaluationSummaryPromise = viewer ? getEvaluationSummary(username) : Promise.resolve(null);
|
||||||
|
const eligibleMissionsPromise: Promise<readonly EvaluationMissionOption[]> = viewer
|
||||||
|
? findEligibleEvaluationMissions(payload, viewer.id, new Date())
|
||||||
|
: Promise.resolve([]);
|
||||||
|
const privilegedViewerPromise: Promise<boolean> = viewer
|
||||||
|
? isSuperuser(payload, viewer)
|
||||||
|
: Promise.resolve(false);
|
||||||
|
const directLeaderPromise: Promise<boolean> =
|
||||||
|
viewer && !isProfileOwner
|
||||||
|
? isDirectLeaderOf(payload, viewer.id, user.id)
|
||||||
|
: Promise.resolve(false);
|
||||||
|
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader] =
|
||||||
|
await Promise.all([
|
||||||
|
evaluationSummaryPromise,
|
||||||
|
eligibleMissionsPromise,
|
||||||
|
privilegedViewerPromise,
|
||||||
|
directLeaderPromise,
|
||||||
|
]);
|
||||||
|
const canViewSubordinate =
|
||||||
|
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
|
||||||
|
const latestMissionRows =
|
||||||
|
evaluationSummaryResult?.success && evaluationSummaryResult.data.leader.visible
|
||||||
|
? await findLatestLeaderMissionRows(payload, user.id)
|
||||||
|
: [];
|
||||||
|
const evaluationSummary: EvaluationSummary | null = evaluationSummaryResult?.success
|
||||||
|
? {
|
||||||
|
leader: {
|
||||||
|
totalRaters: evaluationSummaryResult.data.leader.totalRaters,
|
||||||
|
visible: evaluationSummaryResult.data.leader.visible,
|
||||||
|
averageScore: evaluationSummaryResult.data.leader.averageScore,
|
||||||
|
levels:
|
||||||
|
evaluationSummaryResult.data.leader.levels?.map((level) => ({
|
||||||
|
id: level.id,
|
||||||
|
label: level.label,
|
||||||
|
score: level.score,
|
||||||
|
color: level.color,
|
||||||
|
count: level.count,
|
||||||
|
percentage: level.percentage,
|
||||||
|
})) ?? null,
|
||||||
|
},
|
||||||
|
subordinate:
|
||||||
|
canViewSubordinate && evaluationSummaryResult.data.subordinate
|
||||||
|
? {
|
||||||
|
level: {
|
||||||
|
id: evaluationSummaryResult.data.subordinate.level.id,
|
||||||
|
label: evaluationSummaryResult.data.subordinate.level.label,
|
||||||
|
score: evaluationSummaryResult.data.subordinate.level.score,
|
||||||
|
color: evaluationSummaryResult.data.subordinate.level.color,
|
||||||
|
},
|
||||||
|
missionId: evaluationSummaryResult.data.subordinate.missionId,
|
||||||
|
at: evaluationSummaryResult.data.subordinate.at,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const evaluationSummaryError =
|
||||||
|
evaluationSummaryResult && !evaluationSummaryResult.success
|
||||||
|
? evaluationSummaryResult.error
|
||||||
|
: null;
|
||||||
|
const canSubmitEvaluation = viewer !== null && !isProfileOwner;
|
||||||
const rank = profile.rank as Rank;
|
const rank = profile.rank as Rank;
|
||||||
const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
|
const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
|
||||||
if (typeof entry !== "object" || entry === null) return [];
|
if (typeof entry !== "object" || entry === null) return [];
|
||||||
|
|
@ -87,7 +270,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
collection: "experience",
|
collection: "experience",
|
||||||
sort: "requiredPoints",
|
sort: "requiredPoints",
|
||||||
limit: 100,
|
limit: 100,
|
||||||
select: { name: true, requiredPoints: true },
|
select: { name: true, description: true, requiredPoints: true },
|
||||||
});
|
});
|
||||||
const xpLevel = resolveLevel(xp, experienceRes.docs);
|
const xpLevel = resolveLevel(xp, experienceRes.docs);
|
||||||
|
|
||||||
|
|
@ -236,6 +419,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<LeadershipEvaluationSection
|
||||||
|
username={username}
|
||||||
|
summary={evaluationSummary}
|
||||||
|
summaryError={evaluationSummaryError}
|
||||||
|
eligibleMissions={eligibleMissions}
|
||||||
|
latestMissionRows={latestMissionRows}
|
||||||
|
canSubmitEvaluation={canSubmitEvaluation}
|
||||||
|
canViewSubordinate={canViewSubordinate}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Dossier ────────────────────────────────────────── */}
|
{/* ── Dossier ────────────────────────────────────────── */}
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue