diff --git a/src/app/(frontend)/profile/[username]/page.tsx b/src/app/(frontend)/profile/[username]/page.tsx index fde431c..0e9a7c1 100644 --- a/src/app/(frontend)/profile/[username]/page.tsx +++ b/src/app/(frontend)/profile/[username]/page.tsx @@ -1,10 +1,16 @@ import config from "@payload-config"; import { getPayload } from "payload"; import { notFound } from "next/navigation"; +import { headers as nextHeaders } from "next/headers"; import Link from "next/link"; import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types"; import { RichText } from "@payloadcms/richtext-lexical/react"; 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 { ArrowLeftIcon, AwardIcon, @@ -34,14 +40,128 @@ import { } from "@/components/ui/item"; import AwardCard from "@/components/frontend/account/AwardCard"; 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 { params: Promise<{ username: string }>; } +type Payload = Awaited>; + +async function findEligibleEvaluationMissions( + payload: Payload, + viewerId: number, + now: Date, +): Promise { + 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 { + 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(); + 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) { const { username } = await params; 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 .find({ @@ -70,6 +190,69 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const profile = profileRes.docs[0]; 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 = viewer + ? findEligibleEvaluationMissions(payload, viewer.id, new Date()) + : Promise.resolve([]); + const privilegedViewerPromise: Promise = viewer + ? isSuperuser(payload, viewer) + : Promise.resolve(false); + const directLeaderPromise: Promise = + 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 awards = (profile.progression?.awards ?? []).flatMap((entry) => { if (typeof entry !== "object" || entry === null) return []; @@ -87,7 +270,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) { collection: "experience", sort: "requiredPoints", limit: 100, - select: { name: true, requiredPoints: true }, + select: { name: true, description: true, requiredPoints: true }, }); const xpLevel = resolveLevel(xp, experienceRes.docs); @@ -236,6 +419,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) { )} + + {/* ── Dossier ────────────────────────────────────────── */}