405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import config from "@payload-config";
|
|
import { getPayload } from "payload";
|
|
import { notFound } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { headers as nextHeaders } from "next/headers";
|
|
import type {
|
|
Mission,
|
|
Campaign,
|
|
Map,
|
|
User,
|
|
Faction,
|
|
Qualification,
|
|
Rank,
|
|
} from "@/payload-types";
|
|
import {
|
|
ArrowLeftIcon,
|
|
ArrowRightIcon,
|
|
CalendarDaysIcon,
|
|
ChevronRightIcon,
|
|
ClockIcon,
|
|
MapPinIcon,
|
|
CrosshairIcon,
|
|
ServerIcon,
|
|
FlagIcon,
|
|
} from "lucide-react";
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemDescription,
|
|
ItemHeader,
|
|
ItemMedia,
|
|
ItemTitle,
|
|
} from "@/components/ui/item";
|
|
import { MissionHeader } from "@/components/frontend/intelligence/MissionHeader";
|
|
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
|
|
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
|
|
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
|
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
|
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
|
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
|
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
|
import {
|
|
getMissionAttendance,
|
|
getUserAttendance,
|
|
isMissionPast,
|
|
type AttendanceResponse,
|
|
} from "@/lib/attendance";
|
|
|
|
interface MissionDetailPageProps {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
Concept: "bg-muted text-muted-foreground",
|
|
Planning: "bg-muted text-muted-foreground",
|
|
Ready: "bg-blue-500/20 text-blue-400",
|
|
Scheduled: "bg-amber-500/20 text-amber-400",
|
|
Active: "bg-green-500/20 text-green-400",
|
|
Completed: "bg-emerald-500/20 text-emerald-400",
|
|
Cancelled: "bg-red-500/20 text-red-400",
|
|
};
|
|
|
|
export default async function MissionDetailPage({
|
|
params,
|
|
}: MissionDetailPageProps) {
|
|
const { id } = await params;
|
|
const payload = await getPayload({ config });
|
|
|
|
const mission = await payload
|
|
.findByID({
|
|
collection: "missions",
|
|
id: Number(id),
|
|
depth: 2,
|
|
})
|
|
.catch(() => null);
|
|
|
|
if (!mission) {
|
|
notFound();
|
|
}
|
|
|
|
const map =
|
|
typeof mission.classification.map === "object"
|
|
? (mission.classification.map as Map)
|
|
: null;
|
|
const campaign =
|
|
typeof mission.classification.campaign === "object"
|
|
? (mission.classification.campaign as Campaign)
|
|
: null;
|
|
|
|
const startDate = new Date(mission.classification.startDateTime);
|
|
const dateStr = startDate.toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
const timeStr = startDate.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
const durationHours = Math.floor(
|
|
mission.classification.estimatedDuration / 60,
|
|
);
|
|
const durationMins = mission.classification.estimatedDuration % 60;
|
|
const durationStr =
|
|
durationMins > 0
|
|
? `${durationHours}h ${durationMins}m`
|
|
: `${durationHours}h`;
|
|
|
|
// Fetch sibling missions in the same campaign
|
|
const campaignId =
|
|
typeof mission.classification.campaign === "object"
|
|
? (mission.classification.campaign as Campaign).id
|
|
: mission.classification.campaign;
|
|
|
|
const siblingMissionsRes = await payload
|
|
.find({
|
|
collection: "missions",
|
|
where: {
|
|
and: [
|
|
{ "classification.campaign": { equals: campaignId } },
|
|
{ id: { not_equals: mission.id } },
|
|
],
|
|
},
|
|
sort: "-classification.startDateTime",
|
|
limit: 5,
|
|
depth: 0,
|
|
select: {
|
|
name: true,
|
|
codeName: true,
|
|
ownershipAndStatus: { status: true },
|
|
classification: { startDateTime: true },
|
|
},
|
|
})
|
|
.catch(() => ({ docs: [] }));
|
|
|
|
const siblingMissions = siblingMissionsRes.docs;
|
|
|
|
const { user: currentUser } = await payload.auth({
|
|
headers: await nextHeaders(),
|
|
canSetHeaders: false,
|
|
});
|
|
|
|
const [attendance, userAttendance] = await Promise.all([
|
|
getMissionAttendance(payload, mission.id),
|
|
currentUser
|
|
? getUserAttendance(payload, currentUser.id as number, [mission.id])
|
|
: Promise.resolve<Record<number, AttendanceResponse>>({}),
|
|
]);
|
|
const currentResponse = userAttendance[mission.id] ?? null;
|
|
const attendanceClosed = isMissionPast(mission.classification.startDateTime);
|
|
|
|
const attendanceProps = {
|
|
yes: attendance.yes.map((u) => ({ id: u.id, name: u.displayName || u.username })),
|
|
no: attendance.no.map((u) => ({ id: u.id, name: u.displayName || u.username })),
|
|
tentative: attendance.tentative.map((u) => ({
|
|
id: u.id,
|
|
name: u.displayName || u.username,
|
|
})),
|
|
};
|
|
|
|
return (
|
|
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
|
<Link
|
|
href="/intelligence/missions"
|
|
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
|
|
>
|
|
<ArrowLeftIcon className="size-3" />
|
|
Back to Missions
|
|
</Link>
|
|
|
|
<MissionHeader
|
|
name={mission.name}
|
|
codeName={mission.codeName}
|
|
operationType={mission.operationType}
|
|
status={mission.ownershipAndStatus.status}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
|
{/* ── Main Content (2/3) ──────────────────────────── */}
|
|
<div className="xl:col-span-2 flex flex-col gap-6">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Item variant="muted" size="sm">
|
|
<ItemMedia variant="icon">
|
|
<CalendarDaysIcon />
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
<ItemTitle>Date</ItemTitle>
|
|
<ItemDescription>{dateStr}</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
<Item variant="muted" size="sm">
|
|
<ItemMedia variant="icon">
|
|
<ClockIcon />
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
<ItemTitle>Time & Duration</ItemTitle>
|
|
<ItemDescription>
|
|
{timeStr} · {durationStr}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
{map && (
|
|
<Item variant="muted" size="sm">
|
|
<ItemMedia variant="icon">
|
|
<MapPinIcon />
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
<ItemTitle>Map</ItemTitle>
|
|
<ItemDescription>{map.name}</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
)}
|
|
{campaign && (
|
|
<Link href={`/intelligence/campaigns/${campaign.id}`}>
|
|
<Item
|
|
variant="muted"
|
|
size="sm"
|
|
className="cursor-pointer hover:border-primary/50 transition-colors"
|
|
>
|
|
<ItemMedia variant="icon">
|
|
<CrosshairIcon />
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
<ItemTitle>Campaign</ItemTitle>
|
|
<ItemDescription>{campaign.name}</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">{mission.summary}</p>
|
|
|
|
<Item variant="outline">
|
|
<ItemHeader>
|
|
<ItemTitle>Mission Details</ItemTitle>
|
|
</ItemHeader>
|
|
<ItemContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
|
<div>
|
|
<span className="text-muted-foreground">Type</span>
|
|
<p className="font-medium">
|
|
{mission.classification.missionType}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Operation Type</span>
|
|
<p className="font-medium capitalize">
|
|
{mission.operationType}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Max Players</span>
|
|
<p className="font-medium">
|
|
{mission.missionRoles?.maxPlayers ?? "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</ItemContent>
|
|
</Item>
|
|
|
|
<MissionBriefingSection briefing={mission.briefing ?? null} />
|
|
|
|
<MissionObjectives objectives={mission.missionObjectives ?? null} />
|
|
|
|
<MissionFactions
|
|
factionsInvolved={mission.factionsInvolved ?? null}
|
|
/>
|
|
|
|
<MissionRoles
|
|
roles={mission.missionRoles?.providedRoles ?? null}
|
|
notes={mission.missionRoles?.notes ?? null}
|
|
maxPlayers={mission.missionRoles?.maxPlayers ?? 32}
|
|
/>
|
|
|
|
<MissionLogistics
|
|
logisticsAndSupport={mission.logisticsAndSupport ?? null}
|
|
/>
|
|
|
|
<MissionComms commsAndDocs={mission.commsAndDocs ?? null} />
|
|
</div>
|
|
|
|
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
|
|
<div className="flex flex-col gap-4">
|
|
<MissionAttendance
|
|
missionId={mission.id}
|
|
currentUserId={currentUser ? (currentUser.id as number) : null}
|
|
currentResponse={currentResponse}
|
|
attendanceClosed={attendanceClosed}
|
|
{...attendanceProps}
|
|
/>
|
|
|
|
{/* Campaign Card */}
|
|
{campaign && (
|
|
<Item variant="outline">
|
|
<ItemHeader>
|
|
<div className="flex items-center gap-2">
|
|
<FlagIcon className="size-4 text-muted-foreground" />
|
|
<ItemTitle>Campaign</ItemTitle>
|
|
</div>
|
|
<Link
|
|
href={`/intelligence/campaigns/${campaign.id}`}
|
|
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
>
|
|
View
|
|
</Link>
|
|
</ItemHeader>
|
|
<ItemContent>
|
|
<div className="flex flex-col gap-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{campaign.name}</h3>
|
|
<span
|
|
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded mt-1 inline-block ${campaign.status === "inProgress" ? "bg-blue-500/20 text-blue-400" : "bg-muted text-muted-foreground"}`}
|
|
>
|
|
{campaign.status === "inProgress"
|
|
? "In Progress"
|
|
: campaign.status}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground line-clamp-3">
|
|
{campaign.summary}
|
|
</p>
|
|
</div>
|
|
</ItemContent>
|
|
</Item>
|
|
)}
|
|
|
|
{/* Sibling Missions */}
|
|
{siblingMissions.length > 0 && (
|
|
<Item variant="outline">
|
|
<ItemHeader>
|
|
<div className="flex items-center gap-2">
|
|
<CrosshairIcon className="size-4 text-muted-foreground" />
|
|
<ItemTitle>Other Missions in Campaign</ItemTitle>
|
|
</div>
|
|
</ItemHeader>
|
|
<ItemContent>
|
|
<div className="flex flex-col gap-1.5">
|
|
{siblingMissions.map((m) => {
|
|
const mDate = new Date(
|
|
m.classification.startDateTime,
|
|
);
|
|
return (
|
|
<Link
|
|
key={m.id}
|
|
href={`/intelligence/missions/${m.id}`}
|
|
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group"
|
|
>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<span className="font-mono text-[10px] text-muted-foreground">
|
|
{m.codeName}
|
|
</span>
|
|
<span className="text-sm truncate group-hover:text-primary transition-colors">
|
|
{m.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span
|
|
className={`text-[10px] font-semibold uppercase tracking-wider px-1 py-0.5 rounded ${statusColors[m.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
|
|
>
|
|
{m.ownershipAndStatus.status}
|
|
</span>
|
|
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</ItemContent>
|
|
</Item>
|
|
)}
|
|
|
|
{/* Server Details */}
|
|
{mission.gameDetails?.serverDetails?.serverIp && (
|
|
<Item variant="outline">
|
|
<ItemHeader>
|
|
<div className="flex items-center gap-2">
|
|
<ServerIcon className="size-4 text-muted-foreground" />
|
|
<ItemTitle>Server</ItemTitle>
|
|
</div>
|
|
</ItemHeader>
|
|
<ItemContent>
|
|
<div className="flex flex-col gap-1.5 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Address</span>
|
|
<span className="font-mono">
|
|
{mission.gameDetails.serverDetails.serverIp}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Port</span>
|
|
<span className="font-mono">
|
|
{mission.gameDetails.serverDetails.serverPort}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</ItemContent>
|
|
</Item>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|