diff --git a/src/app/(frontend)/intelligence/missions/calendar/page.tsx b/src/app/(frontend)/intelligence/missions/calendar/page.tsx new file mode 100644 index 0000000..9055eb8 --- /dev/null +++ b/src/app/(frontend)/intelligence/missions/calendar/page.tsx @@ -0,0 +1,158 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import { + FridayCalendar, + type FridayCalendarEntry, + type FridayCalendarMission, +} from "@/components/frontend/intelligence/FridayCalendar"; +import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog"; + +export const metadata = { + title: "Friday Operations", +}; + +const MAX_SCHEDULED_AHEAD_MISSIONS = 2; + +const FRIDAYS_SHOWN = 8; + +function nextFridays(count: number): Date[] { + const fridays: Date[] = []; + const cursor = new Date(); + cursor.setHours(0, 0, 0, 0); + while (fridays.length < count) { + if (cursor.getDay() === 5) { + fridays.push(new Date(cursor)); + } + cursor.setDate(cursor.getDate() + 1); + } + return fridays; +} + +export default async function FridayCalendarPage() { + const payload = await getPayload({ config }); + + const { user } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + + const fridays = nextFridays(FRIDAYS_SHOWN); + const rangeStart = fridays[0]; + const rangeEnd = new Date(fridays[fridays.length - 1]); + rangeEnd.setDate(rangeEnd.getDate() + 1); + + const missionsRes = await payload.find({ + collection: "missions", + where: { + "classification.startDateTime": { + greater_than_equal: rangeStart.toISOString(), + less_than: rangeEnd.toISOString(), + }, + }, + sort: "classification.startDateTime", + depth: 1, + limit: 200, + select: { + name: true, + codeName: true, + operationType: true, + ownershipAndStatus: { status: true }, + classification: { startDateTime: true, map: true }, + createdBy: true, + }, + }); + + const entries: FridayCalendarEntry[] = fridays.map((friday) => { + const dayStart = new Date(friday); + const dayEnd = new Date(friday); + dayEnd.setDate(dayEnd.getDate() + 1); + const missions: FridayCalendarMission[] = missionsRes.docs + .filter((m) => { + const start = new Date(m.classification.startDateTime); + return start >= dayStart && start < dayEnd; + }) + .map((m) => { + const map = m.classification.map; + const createdBy = m.createdBy; + return { + id: m.id, + name: m.name, + codeName: m.codeName, + status: m.ownershipAndStatus.status, + startDateTime: m.classification.startDateTime, + operationType: m.operationType, + mapName: typeof map === "object" && map !== null ? map.name : null, + isOwn: + user != null && + (typeof createdBy === "object" ? createdBy?.id : createdBy) === user.id, + }; + }); + return { fridayIso: friday.toISOString(), missions }; + }); + + let remainingSlots = 0; + let maps: SideOpDialogOption[] = []; + let campaigns: SideOpDialogOption[] = []; + let modLists: SideOpDialogOption[] = []; + + if (user) { + const userId = user.id as number; + const [scheduledCount, mapsRes, campaignsRes, modListsRes] = await Promise.all([ + payload.count({ + collection: "missions", + where: { + and: [ + { createdBy: { equals: userId } }, + { "classification.startDateTime": { greater_than: new Date().toISOString() } }, + { "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } }, + ], + }, + }), + payload.find({ + collection: "maps", + overrideAccess: true, + depth: 0, + limit: 200, + sort: "name", + select: { name: true }, + }), + payload.find({ + collection: "campaigns", + where: { owner: { equals: userId } }, + depth: 0, + limit: 100, + sort: "name", + select: { name: true }, + }), + payload.find({ + collection: "mod-lists", + overrideAccess: true, + depth: 0, + limit: 200, + sort: "modlistName", + select: { modlistName: true }, + }), + ]); + + remainingSlots = Math.max(0, MAX_SCHEDULED_AHEAD_MISSIONS - scheduledCount.totalDocs); + maps = mapsRes.docs.map((m) => ({ id: m.id, name: m.name })); + campaigns = campaignsRes.docs.map((c) => ({ id: c.id, name: c.name })); + modLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName })); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/frontend/intelligence/CreateSideOpDialog.tsx b/src/components/frontend/intelligence/CreateSideOpDialog.tsx new file mode 100644 index 0000000..be2e210 --- /dev/null +++ b/src/components/frontend/intelligence/CreateSideOpDialog.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + createFridaySideOp, + updateFridaySideOp, + uploadSideOpModlist, + type FridaySideOpInput, +} from "@/app/(frontend)/intelligence/missions/actions"; +import { + SideOpNarrativeFields, + type SideOpNarrative, +} from "@/components/frontend/intelligence/SideOpNarrativeFields"; + +const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const; +const DEFAULT_BRIEFING_SECTIONS = ["Situation", "Mission", "Execution", "Signals"]; + +function defaultNarrative(): SideOpNarrative { + return { + briefing: DEFAULT_BRIEFING_SECTIONS.map((sectionName) => ({ sectionName, details: "" })), + missionObjectives: [], + civilianPresence: "None", + radioPlan: "", + insertion: [], + extraction: [], + assetsAvailable: [], + }; +} + +export interface SideOpDialogOption { + id: number; + name: string; + disabled?: boolean; +} + +export interface SideOpDialogInitial { + missionId: number; + name: string; + codeName: string; + summary: string; + mapId: number | null; + missionType: string; + startDateTime: string; + estimatedDuration: number; + maxPlayers: number; + campaignId: number | null; + requiredModsId?: number | null; + briefing?: { sectionName: string; details: string }[]; + missionObjectives?: { + title: string; + description: string; + missionCritical: boolean; + successOrFailConditions: string; + redacted: boolean; + }[]; + civilianPresence?: string; + radioPlan?: string; + insertion?: string[]; + extraction?: string[]; + assetsAvailable?: { + assetType: string; + assetCount: string; + doesRespawn: boolean; + assetCallsign: string; + }[]; +} + +interface CreateSideOpDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + maps: SideOpDialogOption[]; + campaigns: SideOpDialogOption[]; + modLists: SideOpDialogOption[]; + defaultStartDateTime?: string; + initial?: SideOpDialogInitial; + onSaved?: () => void; + onModlistUploaded?: () => void; +} + +export function CreateSideOpDialog({ + open, + onOpenChange, + maps, + campaigns, + modLists, + defaultStartDateTime, + initial, + onSaved, + onModlistUploaded, +}: CreateSideOpDialogProps) { + const isEdit = initial !== undefined; + const [name, setName] = useState(initial?.name ?? ""); + const [codeName, setCodeName] = useState(initial?.codeName ?? ""); + const [summary, setSummary] = useState(initial?.summary ?? ""); + const [mapId, setMapId] = useState(initial?.mapId ? String(initial.mapId) : ""); + const [mapChoice, setMapChoice] = useState( + initial?.mapId ? String(initial.mapId) : "existing", + ); + const [customMapName, setCustomMapName] = useState(""); + const [missionType, setMissionType] = useState(initial?.missionType ?? "Zeus"); + const [requiredModsId, setRequiredModsId] = useState( + initial?.requiredModsId ? String(initial.requiredModsId) : "none", + ); + const [modlistName, setModlistName] = useState(""); + const [modlistUploading, setModlistUploading] = useState(false); + const [estimatedDuration, setEstimatedDuration] = useState( + String(initial?.estimatedDuration ?? 240), + ); + const [maxPlayers, setMaxPlayers] = useState(String(initial?.maxPlayers ?? 32)); + const [campaignChoice, setCampaignChoice] = useState( + initial?.campaignId ? String(initial.campaignId) : "none", + ); + const [newCampaignName, setNewCampaignName] = useState(""); + const [narrative, setNarrative] = useState(defaultNarrative); + const [narrativeOpen, setNarrativeOpen] = useState(false); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (open) { + setError(null); + setNarrativeOpen(initial !== undefined); + if (initial) { + setName(initial.name); + setCodeName(initial.codeName); + setSummary(initial.summary); + setMapId(initial.mapId ? String(initial.mapId) : ""); + setMapChoice(initial.mapId ? String(initial.mapId) : "custom"); + setCustomMapName(""); + setMissionType(initial.missionType); + setRequiredModsId(initial.requiredModsId ? String(initial.requiredModsId) : "none"); + setModlistName(""); + setEstimatedDuration(String(initial.estimatedDuration)); + setMaxPlayers(String(initial.maxPlayers)); + setCampaignChoice(initial.campaignId ? String(initial.campaignId) : "none"); + setNarrative({ + briefing: initial.briefing ?? [], + missionObjectives: initial.missionObjectives ?? [], + civilianPresence: initial.civilianPresence ?? "None", + radioPlan: initial.radioPlan ?? "", + insertion: initial.insertion ?? [], + extraction: initial.extraction ?? [], + assetsAvailable: initial.assetsAvailable ?? [], + }); + } else { + setNarrative(defaultNarrative()); + } + } + }, [open, initial]); + + function narrativeInput() { + return { + briefing: narrative.briefing + .filter((e) => e.sectionName.trim() !== "" || e.details.trim() !== "") + .map((e) => ({ sectionName: e.sectionName.trim() || "Untitled", details: e.details })), + missionObjectives: narrative.missionObjectives + .filter((o) => o.title.trim() !== "") + .map((o) => ({ + title: o.title.trim(), + description: o.description || null, + missionCritical: o.missionCritical, + successOrFailConditions: o.successOrFailConditions || null, + redacted: o.redacted, + })), + civilianPresence: narrative.civilianPresence as "None" | "Low" | "Medium" | "High", + radioPlan: narrative.radioPlan, + insertion: narrative.insertion as FridaySideOpInput["insertion"], + extraction: narrative.extraction as FridaySideOpInput["extraction"], + assetsAvailable: narrative.assetsAvailable.map((a) => ({ + assetType: a.assetType.trim(), + assetCount: a.assetCount || null, + doesRespawn: a.doesRespawn, + assetCallsign: a.assetCallsign || null, + })), + }; + } + + function mapInput() { + return mapChoice === "custom" && customMapName.trim() !== "" + ? { customMapName } + : mapId + ? { mapId: Number(mapId), customMapName: null } + : { customMapName: null }; + } + + function modsInput() { + return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) }; + } + + async function handleModlistUpload(file: File) { + setModlistUploading(true); + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("name", modlistName.trim() || file.name.replace(/\.html?$/i, "")); + const result = await uploadSideOpModlist(formData); + if (!result.success || !result.data) { + toast.error(result.error ?? "Failed to upload the mod list."); + return; + } + setRequiredModsId(String(result.data.modlistId)); + toast.success(`Mod list "${result.data.modlistName}" uploaded.`); + onModlistUploaded?.(); + } finally { + setModlistUploading(false); + } + } + + function handleSubmit() { + setError(null); + if (!name.trim() || !codeName.trim() || !summary.trim()) { + setError("Name, code name, and summary are required."); + return; + } + if (mapChoice === "custom" && !customMapName.trim()) { + setError("Please enter a name for the custom map."); + return; + } + if (mapChoice !== "custom" && !mapId) { + setError("Please choose a map."); + return; + } + if (!isEdit && !defaultStartDateTime) { + setError("Missing start date — schedule from a Friday on the calendar."); + return; + } + const startDateIso = defaultStartDateTime ?? ""; + + startTransition(async () => { + if (isEdit) { + const initialChoice = initial.campaignId ? String(initial.campaignId) : "none"; + const campaignChanged = campaignChoice !== initialChoice; + const result = await updateFridaySideOp({ + missionId: initial.missionId, + name, + codeName, + summary, + ...mapInput(), + missionType: missionType as FridaySideOpInput["missionType"], + estimatedDuration: Number(estimatedDuration) || 240, + maxPlayers: Number(maxPlayers) || 32, + ...modsInput(), + ...(campaignChanged + ? campaignChoice === "none" + ? { noCampaign: true } + : campaignChoice === "new" + ? { newCampaignName: newCampaignName || null } + : { campaignId: Number(campaignChoice) } + : {}), + ...narrativeInput(), + }); + if (!result.success) { + setError(result.error ?? "Failed to update the side operation."); + return; + } + toast.success("Side operation updated."); + onOpenChange(false); + onSaved?.(); + return; + } + + const result = await createFridaySideOp({ + name, + codeName, + summary, + ...mapInput(), + missionType: missionType as FridaySideOpInput["missionType"], + startDateTime: startDateIso, + estimatedDuration: Number(estimatedDuration) || 240, + maxPlayers: Number(maxPlayers) || 32, + ...modsInput(), + campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice), + newCampaignName: campaignChoice === "new" ? newCampaignName || null : null, + noCampaign: campaignChoice === "none", + ...narrativeInput(), + }); + if (!result.success) { + setError(result.error ?? "Failed to schedule the side operation."); + return; + } + toast.success("Side operation scheduled."); + onOpenChange(false); + onSaved?.(); + }); + } + + return ( + + + + {isEdit ? "Edit Side Operation" : "Schedule Side Operation"} + + {isEdit + ? "Update the details of this Friday side operation." + : "Player-run Friday side operations include Discord roll-call and RSVP automatically."} + + + +
+
+ + setName(e.target.value)} + placeholder="Operation Broken Arrow" + /> +
+
+ + setCodeName(e.target.value)} + placeholder="OP:BRKARW" + /> +
+
+ +