Compare commits
5 commits
2a1c94072f
...
90195286c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 90195286c3 | |||
| a1bdf4ffb1 | |||
| 2e1fbf8006 | |||
| d952a33ee7 | |||
| d8867a643a |
17 changed files with 21288 additions and 227 deletions
3
TODO.md
3
TODO.md
|
|
@ -6,7 +6,8 @@
|
|||
- [x] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any.
|
||||
- [x] **Assignment transfer request system** - Allow users to request transfers to new assignments (i.e. transfer from infantry to aviation, medical to infantry, medical to aviation, infantry to intel, logistics to infantry, etc., covering all cases). The leader of the user's current assignment and the leader of the user's requested assignment should be notified of the request via app and via bot, and should have the option to "approve" or "reject with reason" in either interface. Approvals are only collected from assignments that actually have a leader: if the user's current assignment has no leader (e.g. it lost its leader), only the requested assignment's leader needs to approve — and vice versa if the requested assignment is leaderless. If neither assignment has a leader, skip the leaders entirely and send the request directly to me (or a superuser) for a final call. Once all required approvals are in, the assignment is automatically transferred and updated everywhere. If any required approval is rejected, the rejection reason is sent to the other leader (if one exists) and to the individual making the request, and the individual is given an option to appeal via the app or the bot — this applies even when only one approval was required. If they appeal, all remaining leaders are bypassed and the request goes directly to me (or a superuser) who can then make the final call.
|
||||
- [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely.
|
||||
- [ ] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
|
||||
- [x] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
|
||||
- [ ] **User-maintained wiki** - There really should be a wiki system (similar to MediaWiki or Fandom) where members of the unit can contribute information they have gathered about the campaign, its world and lore, characters, plot points, media, etc., with revision history, collaborative editing, template and tag support, wikilinks, and similar functionality. This is a BIG lift, but also an essential component, ESPECIALLY for our intelligence division members. While everyone should be allowed to contribute and make edits, intelligence division members should have moderator powers over this wiki, with the capability to lockdown pages, restore versions, etc.
|
||||
- [ ] **"Personnel" navigation group** - A "Personnel" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
|
||||
- [ ] **Respawn tickets based on attendance** - I want to track respawn tickets as a "global variable" that is automatically incremented based on reported attendance for a mission compared against real attendance checked via the webapp<->Arma sync system. For every person who marks that they will attend and DOES attend, 1 respawn ticket is awarded. For every person who marks they MIGHT attend and DOES attend, 0.5 respawn tickets will be awarded. For every person who marks that they will NOT attend and does attend, 0.25 respawn tickets will be awarded. All respawn tickets are accumulated and applied to the next operation.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import {
|
|||
getGrantorAssignments,
|
||||
isRecipientEligible,
|
||||
} from "@/lib/awards/grant";
|
||||
import type { Profile } from "@/payload-types";
|
||||
import { cleanupDuplicateAwards, type CleanupResult } from "@/lib/awards/index";
|
||||
import type { Assignment, Profile } from "@/payload-types";
|
||||
|
||||
interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -37,14 +38,24 @@ async function authenticate() {
|
|||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function grantAward(input: {
|
||||
export interface GrantResult {
|
||||
recipientUserId: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function grantAward(input: {
|
||||
recipientUserIds: number[];
|
||||
awardId: number;
|
||||
reason?: string;
|
||||
}): Promise<ActionResult> {
|
||||
}): Promise<ActionResult<{ results: GrantResult[]; granted: number; failed: number }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
if (input.recipientUserIds.length === 0) {
|
||||
return { success: false, error: "No recipients selected." };
|
||||
}
|
||||
|
||||
let award;
|
||||
try {
|
||||
award = await payload.findByID({
|
||||
|
|
@ -60,11 +71,17 @@ export async function grantAward(input: {
|
|||
let grantorProfile: Profile | null = null;
|
||||
const superuser = await isSuperuser(payload, user);
|
||||
|
||||
if (award.type === "medal") {
|
||||
if (!superuser) {
|
||||
return { success: false, error: "Only superusers can grant medals." };
|
||||
}
|
||||
} else if (!superuser) {
|
||||
const isMedal = award.type === "medal";
|
||||
if (isMedal && !superuser) {
|
||||
return { success: false, error: "Only superusers can grant medals." };
|
||||
}
|
||||
|
||||
let grantorAssignmentId: number | undefined;
|
||||
let scope: "same_command" | "whitelist" | "anyone" | null | undefined;
|
||||
let whitelist: (number | Assignment)[] | null | undefined;
|
||||
let remaining: number | null = null;
|
||||
|
||||
if (!superuser && !isMedal) {
|
||||
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
|
||||
if (allowedTypes.length === 0) {
|
||||
return { success: false, error: "This award cannot be granted by leaders." };
|
||||
|
|
@ -78,7 +95,9 @@ export async function grantAward(input: {
|
|||
if (grantorAssignments.length === 0) {
|
||||
return { success: false, error: "You are not authorized to grant this award." };
|
||||
}
|
||||
const grantorAssignmentId = grantorAssignments[0].id;
|
||||
grantorAssignmentId = grantorAssignments[0].id;
|
||||
scope = award.grantConfig?.recipientScope ?? "same_command";
|
||||
whitelist = award.grantConfig?.eligibleRecipientAssignments;
|
||||
|
||||
const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
|
||||
if (maxGrants) {
|
||||
|
|
@ -87,29 +106,14 @@ export async function grantAward(input: {
|
|||
return { success: false, error: "You do not have a profile." };
|
||||
}
|
||||
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
|
||||
if (issued >= maxGrants) {
|
||||
remaining = maxGrants - issued;
|
||||
if (remaining <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "You have reached the maximum number of grants for this award.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const eligible = await isRecipientEligible(
|
||||
payload,
|
||||
input.recipientUserId,
|
||||
award.grantConfig?.recipientScope ?? "same_command",
|
||||
grantorAssignmentId,
|
||||
award.grantConfig?.eligibleRecipientAssignments,
|
||||
);
|
||||
if (!eligible) {
|
||||
return { success: false, error: "Recipient is not eligible for this award." };
|
||||
}
|
||||
}
|
||||
|
||||
const recipientProfile = await findUserProfile(payload, input.recipientUserId);
|
||||
if (!recipientProfile) {
|
||||
return { success: false, error: "Recipient does not have a profile." };
|
||||
}
|
||||
|
||||
if (!grantorProfile) {
|
||||
|
|
@ -119,37 +123,115 @@ export async function grantAward(input: {
|
|||
return { success: false, error: "You do not have a profile." };
|
||||
}
|
||||
|
||||
const progression = recipientProfile.progression;
|
||||
const currentAwards = progression?.awards ?? [];
|
||||
const results: GrantResult[] = [];
|
||||
let granted = 0;
|
||||
let processed = 0;
|
||||
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: recipientProfile.id,
|
||||
data: {
|
||||
progression: {
|
||||
...progression,
|
||||
awards: [
|
||||
...currentAwards,
|
||||
{
|
||||
award: award.id,
|
||||
awardedBy: grantorProfile.id,
|
||||
awardedAt: new Date().toISOString(),
|
||||
awardReason: input.reason ?? null,
|
||||
},
|
||||
],
|
||||
for (const recipientUserId of input.recipientUserIds) {
|
||||
if (remaining !== null && processed >= remaining) break;
|
||||
|
||||
if (grantorAssignmentId !== undefined) {
|
||||
const eligible = await isRecipientEligible(
|
||||
payload,
|
||||
recipientUserId,
|
||||
scope,
|
||||
grantorAssignmentId,
|
||||
whitelist,
|
||||
);
|
||||
if (!eligible) {
|
||||
results.push({
|
||||
recipientUserId,
|
||||
success: false,
|
||||
error: "Recipient is not eligible for this award.",
|
||||
});
|
||||
processed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const recipientProfile = await findUserProfile(payload, recipientUserId);
|
||||
if (!recipientProfile) {
|
||||
results.push({
|
||||
recipientUserId,
|
||||
success: false,
|
||||
error: "Recipient does not have a profile.",
|
||||
});
|
||||
processed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const progression = recipientProfile.progression;
|
||||
const currentAwards = progression?.awards ?? [];
|
||||
|
||||
const alreadyHasAward = currentAwards.some((e) => {
|
||||
const id = typeof e.award === "number" ? e.award : e.award?.id;
|
||||
return id === award.id;
|
||||
});
|
||||
if (alreadyHasAward) {
|
||||
results.push({
|
||||
recipientUserId,
|
||||
success: false,
|
||||
error: "Recipient already has this award.",
|
||||
});
|
||||
processed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: recipientProfile.id,
|
||||
data: {
|
||||
progression: {
|
||||
...progression,
|
||||
awards: [
|
||||
...currentAwards,
|
||||
{
|
||||
award: award.id,
|
||||
awardedBy: grantorProfile.id,
|
||||
awardedAt: new Date().toISOString(),
|
||||
awardReason: input.reason ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.AwardGrant,
|
||||
message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`,
|
||||
actor: user.id,
|
||||
data: { awardId: award.id, grantorProfileId: grantorProfile.id },
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.AwardGrant,
|
||||
message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`,
|
||||
actor: user.id,
|
||||
data: { awardId: award.id, grantorProfileId: grantorProfile.id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
results.push({ recipientUserId, success: true });
|
||||
granted += 1;
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { results, granted, failed: results.length - granted },
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupDuplicateAwardsAction(): Promise<
|
||||
ActionResult<CleanupResult>
|
||||
> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const superuser = await isSuperuser(payload, user);
|
||||
if (!superuser) {
|
||||
return { success: false, error: "Only superusers can run award cleanup." };
|
||||
}
|
||||
const result = await cleanupDuplicateAwards(payload);
|
||||
return { success: true, data: result };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -73,8 +73,17 @@ export default async function AwardsPage() {
|
|||
{section.items.map((award) => {
|
||||
const awardImage =
|
||||
typeof award.image === "object" && award.image ? (award.image as Media) : null;
|
||||
const backgroundImage =
|
||||
typeof award.backgroundImage === "object" && award.backgroundImage
|
||||
? (award.backgroundImage as Media)
|
||||
: null;
|
||||
return (
|
||||
<AwardWithGrantButton key={award.id} award={award} awardImage={awardImage} />
|
||||
<AwardWithGrantButton
|
||||
key={award.id}
|
||||
award={award}
|
||||
awardImage={awardImage}
|
||||
backgroundImage={backgroundImage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -623,7 +623,18 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
.map((award) => {
|
||||
const awardImage =
|
||||
award.image && typeof award.image === "object" ? (award.image as Media) : null;
|
||||
return <AwardCard key={award.id} awardImage={awardImage} award={award} />;
|
||||
const backgroundImage =
|
||||
award.backgroundImage && typeof award.backgroundImage === "object"
|
||||
? (award.backgroundImage as Media)
|
||||
: null;
|
||||
return (
|
||||
<AwardCard
|
||||
key={award.id}
|
||||
awardImage={awardImage}
|
||||
backgroundImage={backgroundImage}
|
||||
award={award}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -406,38 +406,69 @@
|
|||
|
||||
.medal-card::after {
|
||||
content: "";
|
||||
z-index: 2;
|
||||
background: radial-gradient(circle, transparent, rgb(253 224 71 / 0.1));
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
animation: pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.medal-card > .medal-card-bg,
|
||||
.medal-card > .medal-card-scrim {
|
||||
position: absolute !important;
|
||||
inset: 0;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.medal-card > .medal-card-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.medal-card > .medal-card-scrim {
|
||||
border-radius: inherit;
|
||||
background-color: rgb(0 0 0 / 0.42);
|
||||
box-shadow: inset 0 0 2.25rem rgb(0 0 0 / 0.38);
|
||||
}
|
||||
|
||||
.medal-card.medal-card-overflow {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.award-xp-high {
|
||||
background: linear-gradient(110deg, rgb(245 158 11), rgb(254 249 195), rgb(250 204 21), rgb(245 158 11));
|
||||
background-size: 200% auto;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 0.3rem rgb(250 204 21 / 0.35));
|
||||
animation: award-xp-shimmer 2.5s linear infinite;
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
rgb(245 158 11),
|
||||
rgb(254 249 195),
|
||||
rgb(250 204 21),
|
||||
rgb(245 158 11)
|
||||
);
|
||||
background-size: 200% auto;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 0.3rem rgb(250 204 21 / 0.35));
|
||||
animation: award-xp-shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes award-xp-shimmer {
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.award-xp-high {
|
||||
animation: none;
|
||||
}
|
||||
.award-xp-high {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.commendation-card {
|
||||
align-self: start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
align-self: start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Scrollbars — styled to match the dark frontend theme */
|
||||
|
|
|
|||
|
|
@ -74,6 +74,16 @@ export const Awards: CollectionConfig = {
|
|||
condition: (data) => data?.type === "ribbon",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backgroundImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
admin: {
|
||||
description:
|
||||
"A background image for the award card. Displayed as a faded backdrop behind the card content.",
|
||||
condition: (data) => data?.type === "medal",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "medalDesign",
|
||||
type: "json",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ const getExperiencePointsClass = (experiencePoints: number) => {
|
|||
return "text-muted-foreground";
|
||||
};
|
||||
|
||||
export default function AwardCard(props: { awardImage: Media | null; award: Award }) {
|
||||
export default function AwardCard(props: {
|
||||
awardImage: Media | null;
|
||||
award: Award;
|
||||
backgroundImage?: Media | null;
|
||||
}) {
|
||||
const isCommendation = props.award.type === "commendation" || !props.award.type;
|
||||
const commendationColor =
|
||||
isCommendation &&
|
||||
|
|
@ -22,12 +26,14 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
|
|||
? props.award.commendationColor
|
||||
: null;
|
||||
|
||||
const bgUrl = props.backgroundImage?.url;
|
||||
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
size={isCommendation ? "sm" : "default"}
|
||||
className={cn("text-center", {
|
||||
"medal-card": props.award.type === "medal",
|
||||
className={cn("relative overflow-hidden text-center", {
|
||||
"medal-card medal-card-overflow": props.award.type === "medal",
|
||||
"commendation-card self-start h-fit": isCommendation,
|
||||
})}
|
||||
style={
|
||||
|
|
@ -39,7 +45,21 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
|
|||
: undefined
|
||||
}
|
||||
>
|
||||
<ItemHeader className="flex flex-col items-center">
|
||||
{bgUrl && (
|
||||
<>
|
||||
<img
|
||||
src={bgUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="medal-card-bg absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="medal-card-scrim absolute inset-0 pointer-events-none bg-gradient-to-b from-background/70 via-background/60 to-background/80"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ItemHeader className="relative z-10 flex flex-col items-center">
|
||||
{props.award.ribbonDesign && (
|
||||
<RibbonViewer
|
||||
ribbonDesign={props.award.ribbonDesign as RibbonDesign}
|
||||
|
|
@ -69,7 +89,7 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
|
|||
</div>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<ItemContent className="relative z-10">
|
||||
<ItemDescription
|
||||
className={cn("text-center", isCommendation ? "line-clamp-none text-xs" : "line-clamp-3")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { AwardIcon, LockIcon } from "lucide-react";
|
|||
interface AwardListingCardProps {
|
||||
award: Award;
|
||||
awardImage?: Media | null;
|
||||
backgroundImage?: Media | null;
|
||||
showGrantButton?: boolean;
|
||||
onGrantClick?: () => void;
|
||||
canGrant?: boolean | null;
|
||||
|
|
@ -29,6 +30,7 @@ interface AwardListingCardProps {
|
|||
export function AwardListingCard({
|
||||
award,
|
||||
awardImage,
|
||||
backgroundImage,
|
||||
showGrantButton = false,
|
||||
onGrantClick,
|
||||
canGrant,
|
||||
|
|
@ -42,16 +44,31 @@ export function AwardListingCard({
|
|||
typeof award.ribbonDesign === "object" &&
|
||||
Array.isArray((award.ribbonDesign as RibbonDesign).stripes);
|
||||
const hasArt = hasRibbonArt || Boolean(awardImage?.url);
|
||||
const bgUrl = backgroundImage?.url;
|
||||
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"min-w-0 h-full flex flex-col flex-nowrap text-center",
|
||||
isMedal && "justify-center medal-card",
|
||||
"relative overflow-hidden min-w-0 h-full flex flex-col flex-nowrap text-center",
|
||||
isMedal && "justify-center medal-card medal-card-overflow",
|
||||
)}
|
||||
>
|
||||
<ItemHeader className={cn("flex flex-col items-center gap-2", isMedal && "justify-center")}>
|
||||
{bgUrl && (
|
||||
<>
|
||||
<img
|
||||
src={bgUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="medal-card-bg absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="medal-card-scrim absolute inset-0 pointer-events-none bg-gradient-to-b from-background/70 via-background/60 to-background/80"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ItemHeader className={cn("relative z-10 flex flex-col items-center gap-2", isMedal && "justify-center")}>
|
||||
{hasArt && (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -80,7 +97,7 @@ export function AwardListingCard({
|
|||
</span>
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="flex flex-col gap-2">
|
||||
<ItemContent className="relative z-10 flex flex-col gap-2">
|
||||
<ItemDescription className="min-w-0 break-words text-center text-sm leading-relaxed">
|
||||
{award.description
|
||||
? convertLexicalToPlaintext({ data: award.description })
|
||||
|
|
@ -94,7 +111,7 @@ export function AwardListingCard({
|
|||
</ItemContent>
|
||||
|
||||
{showGrantButton && (
|
||||
<div className="mt-auto w-full">
|
||||
<div className="relative z-10 mt-auto w-full">
|
||||
{canGrant === false ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import { getGrantStats } from "@/app/(frontend)/awards/grant-context";
|
|||
interface AwardWithGrantButtonProps {
|
||||
award: Award;
|
||||
awardImage?: Media | null;
|
||||
backgroundImage?: Media | null;
|
||||
}
|
||||
|
||||
export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButtonProps) {
|
||||
export function AwardWithGrantButton({ award, awardImage, backgroundImage }: AwardWithGrantButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [canGrant, setCanGrant] = useState<boolean | null>(null);
|
||||
const [remainingGrants, setRemainingGrants] = useState<number | null | undefined>(undefined);
|
||||
|
|
@ -40,6 +41,7 @@ export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButton
|
|||
<AwardListingCard
|
||||
award={award}
|
||||
awardImage={awardImage}
|
||||
backgroundImage={backgroundImage}
|
||||
showGrantButton
|
||||
onGrantClick={() => setOpen(true)}
|
||||
canGrant={canGrant}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { grantAward } from "@/app/(frontend)/awards/actions";
|
||||
import { grantAward, type GrantResult } from "@/app/(frontend)/awards/actions";
|
||||
import { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context";
|
||||
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
|
||||
import { Loader2Icon, TriangleAlertIcon } from "lucide-react";
|
||||
import { CheckIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface GrantAwardDialogProps {
|
||||
|
|
@ -33,9 +33,10 @@ interface GrantAwardDialogProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Two-step grant flow: pick a recipient + reason, then confirm by typing the
|
||||
* recipient's @username. Grant limits are pre-checked via getGrantStats so
|
||||
* users see warnings before submitting.
|
||||
* Two-step grant flow: pick recipient(s) + reason, then confirm by typing the
|
||||
* award name. Grant limits are pre-checked via getGrantStats so selection is
|
||||
* capped and users see warnings before submitting. Individual ineligible
|
||||
* recipients are skipped and reported after the fact.
|
||||
*/
|
||||
export function GrantAwardDialog({
|
||||
awardId,
|
||||
|
|
@ -48,14 +49,19 @@ export function GrantAwardDialog({
|
|||
onOpenChange,
|
||||
onGranted,
|
||||
}: GrantAwardDialogProps) {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [recipient, setRecipient] = useState<RecipientSelection | null>(null);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [recipients, setRecipients] = useState<RecipientSelection[]>([]);
|
||||
const [reason, setReason] = useState("");
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [stats, setStats] = useState<GrantStats | null>(null);
|
||||
const [statsError, setStatsError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [results, setResults] = useState<GrantResult[] | null>(null);
|
||||
|
||||
// Map a recipient id back to its username for result rendering.
|
||||
const usernameOf = (id: number) =>
|
||||
recipients.find((r) => r.id === id)?.username ?? String(id);
|
||||
|
||||
// Reset the flow each time the dialog opens; pre-fill a fixed recipient.
|
||||
useEffect(() => {
|
||||
|
|
@ -67,10 +73,11 @@ export function GrantAwardDialog({
|
|||
setStatsError(null);
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
setRecipient(
|
||||
setResults(null);
|
||||
setRecipients(
|
||||
!showRecipientPicker && recipientUserId != null && recipientUsername
|
||||
? { id: recipientUserId, username: recipientUsername }
|
||||
: null,
|
||||
? [{ id: recipientUserId, username: recipientUsername }]
|
||||
: [],
|
||||
);
|
||||
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
|
||||
|
||||
|
|
@ -88,26 +95,36 @@ export function GrantAwardDialog({
|
|||
};
|
||||
}, [open, awardId]);
|
||||
|
||||
const expectedConfirm = recipient ? `@${recipient.username}` : null;
|
||||
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm;
|
||||
const expectedConfirm = awardName;
|
||||
const confirmMatches = confirmText.trim() === expectedConfirm;
|
||||
const limited = stats?.remainingGrants != null;
|
||||
const maxSelections = limited ? stats?.remainingGrants ?? null : null;
|
||||
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
|
||||
|
||||
async function handleGrant() {
|
||||
if (!recipient || !confirmMatches || submitting) return;
|
||||
if (recipients.length === 0 || !confirmMatches || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const res = await grantAward({
|
||||
recipientUserId: recipient.id,
|
||||
recipientUserIds: recipients.map((r) => r.id),
|
||||
awardId,
|
||||
reason: reason.trim() ? reason.trim() : undefined,
|
||||
});
|
||||
if (res.success) {
|
||||
toast.success("Award granted", {
|
||||
description: `${awardName} granted to @${recipient.username}.`,
|
||||
});
|
||||
onGranted?.();
|
||||
onOpenChange(false);
|
||||
if (res.success && res.data) {
|
||||
const { failed } = res.data;
|
||||
if (failed === 0) {
|
||||
toast.success("Award granted", {
|
||||
description: `${awardName} granted to ${recipients.length} recipient${
|
||||
recipients.length === 1 ? "" : "s"
|
||||
}.`,
|
||||
});
|
||||
onGranted?.();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setResults(res.data.results);
|
||||
setStep(3);
|
||||
setSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
setError(res.error ?? "Failed to grant award.");
|
||||
setSubmitting(false);
|
||||
|
|
@ -125,10 +142,12 @@ export function GrantAwardDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Grant {awardName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{showRecipientPicker
|
||||
? "Select a recipient, then confirm the grant."
|
||||
: `Granting to @${recipientUsername}.`}
|
||||
{stats && stats.canGrant && (
|
||||
{step === 3
|
||||
? "Grant summary."
|
||||
: showRecipientPicker
|
||||
? "Select recipients, then confirm the grant."
|
||||
: `Granting to @${recipientUsername}.`}
|
||||
{step !== 3 && stats && stats.canGrant && (
|
||||
<span className="block mt-1 text-xs opacity-50">
|
||||
{stats.remainingGrants === null
|
||||
? "Unlimited grants available."
|
||||
|
|
@ -146,11 +165,18 @@ export function GrantAwardDialog({
|
|||
|
||||
{showRecipientPicker ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Recipient</Label>
|
||||
<Label>Recipients</Label>
|
||||
<RecipientPicker
|
||||
selectedUser={recipient}
|
||||
onSelect={setRecipient}
|
||||
onClear={() => setRecipient(null)}
|
||||
selectedUsers={recipients}
|
||||
onSelect={(u) =>
|
||||
setRecipients((prev) =>
|
||||
prev.some((r) => r.id === u.id) ? prev : [...prev, u],
|
||||
)
|
||||
}
|
||||
onRemove={(id) =>
|
||||
setRecipients((prev) => prev.filter((r) => r.id !== id))
|
||||
}
|
||||
maxSelections={maxSelections ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -204,37 +230,53 @@ export function GrantAwardDialog({
|
|||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!recipient || (stats !== null && !stats.canGrant)}
|
||||
disabled={recipients.length === 0 || (stats !== null && !stats.canGrant)}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
) : step === 2 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Alert>
|
||||
<TriangleAlertIcon data-icon="inline-start" />
|
||||
<AlertTitle>Confirm grant</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are about to grant "{awardName}" to @{recipient?.username}. This action cannot
|
||||
be undone.
|
||||
You are about to grant "{awardName}" to{" "}
|
||||
{recipients.length === 1
|
||||
? `@${recipients[0].username}`
|
||||
: `${recipients.length} recipients`}
|
||||
. This action cannot be undone.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{showRecipientPicker && recipients.length > 1 && (
|
||||
<ul className="flex flex-wrap gap-1.5 text-sm">
|
||||
{recipients.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="rounded-full border bg-muted/40 px-2 py-0.5 text-xs"
|
||||
>
|
||||
@{r.username}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{limited && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(stats?.remainingGrants ?? 0) <= 1
|
||||
? "This will use your LAST grant for this award."
|
||||
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - 1}.`}
|
||||
{maxSelections != null && maxSelections <= recipients.length
|
||||
? "This will use ALL of your remaining grants for this award."
|
||||
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - recipients.length}.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Type @{recipient?.username} to confirm</Label>
|
||||
<Label>Type the award name to confirm</Label>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={`@${recipient?.username}`}
|
||||
placeholder={awardName}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -256,6 +298,54 @@ export function GrantAwardDialog({
|
|||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{results && (
|
||||
<>
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon data-icon="inline-start" />
|
||||
<AlertTitle>
|
||||
{results.filter((r) => !r.success).length} of {results.length} grants could
|
||||
not be completed
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The following recipients were skipped; the rest were granted successfully.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ul className="flex flex-col gap-1.5 text-sm">
|
||||
{results.map((r) => (
|
||||
<li key={r.recipientUserId} className="flex items-start gap-2">
|
||||
{r.success ? (
|
||||
<CheckIcon className="mt-0.5 size-4 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<XIcon className="mt-0.5 size-4 shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">
|
||||
@{usernameOf(r.recipientUserId)}
|
||||
</span>
|
||||
{!r.success && r.error && (
|
||||
<span className="text-xs text-muted-foreground">{r.error}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onGranted?.();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ export interface RecipientSelection {
|
|||
}
|
||||
|
||||
interface RecipientPickerProps {
|
||||
selectedUsers: RecipientSelection[];
|
||||
onSelect: (user: RecipientSelection) => void;
|
||||
selectedUser: RecipientSelection | null;
|
||||
onClear: () => void;
|
||||
onRemove: (id: number) => void;
|
||||
maxSelections?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SearchResult extends RecipientSelection {
|
||||
|
|
@ -35,7 +37,13 @@ function initialsOf(username: string): string {
|
|||
* `profile` relationship, so searching /api/users would not surface rank
|
||||
* data) and enriches each hit with the user's primary assignment.
|
||||
*/
|
||||
export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPickerProps) {
|
||||
export function RecipientPicker({
|
||||
selectedUsers,
|
||||
onSelect,
|
||||
onRemove,
|
||||
maxSelections,
|
||||
disabled = false,
|
||||
}: RecipientPickerProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
|
@ -136,119 +144,146 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
|||
profileId: result.profileId,
|
||||
});
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
if (selectedUser) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
|
||||
<Avatar className="size-7">
|
||||
<AvatarFallback>{initialsOf(selectedUser.username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedUser.displayName || selectedUser.username}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear recipient"
|
||||
onClick={onClear}
|
||||
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const atMax = maxSelections != null && selectedUsers.length >= maxSelections;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!open || results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const active = results[activeIndex];
|
||||
if (active) select(active);
|
||||
}
|
||||
}}
|
||||
placeholder="Search by username or display name..."
|
||||
className="pl-8"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls="recipient-picker-results"
|
||||
/>
|
||||
{open && query.trim().length > 0 && (
|
||||
<div
|
||||
id="recipient-picker-results"
|
||||
role="listbox"
|
||||
className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
Searching...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
|
||||
) : (
|
||||
results.map((result, i) => (
|
||||
<div ref={wrapperRef} className="flex flex-col gap-2">
|
||||
{selectedUsers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedUsers.map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/40 py-1 pl-1 pr-1.5 text-sm"
|
||||
>
|
||||
<Avatar className="size-5">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{initialsOf(u.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-[10rem] truncate font-medium">
|
||||
{u.displayName || u.username}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">@{u.username}</span>
|
||||
<button
|
||||
key={result.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => select(result)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2 text-left",
|
||||
i === activeIndex && "bg-accent",
|
||||
)}
|
||||
aria-label={`Remove ${u.username}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(u.id)}
|
||||
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
{result.insigniaUrl ? (
|
||||
<AvatarImage src={result.insigniaUrl} alt="" />
|
||||
) : (
|
||||
<AvatarFallback>{initialsOf(result.username)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{result.displayName || result.username}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
@{result.username}
|
||||
{result.rankName ? ` · ${result.rankName}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{result.assignmentLabel && (
|
||||
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{result.assignmentLabel}
|
||||
</span>
|
||||
)}
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
disabled={disabled || atMax}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!open || results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const active = results[activeIndex];
|
||||
if (active) select(active);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
atMax
|
||||
? "Maximum recipients selected"
|
||||
: "Search by username or display name..."
|
||||
}
|
||||
className="pl-8"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls="recipient-picker-results"
|
||||
/>
|
||||
{open && query.trim().length > 0 && (
|
||||
<div
|
||||
id="recipient-picker-results"
|
||||
role="listbox"
|
||||
className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
Searching...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
|
||||
) : (
|
||||
results.map((result, i) => {
|
||||
const alreadySelected = selectedUsers.some((u) => u.id === result.id);
|
||||
return (
|
||||
<button
|
||||
key={result.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
disabled={alreadySelected || atMax}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => select(result)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2 text-left",
|
||||
i === activeIndex && "bg-accent",
|
||||
(alreadySelected || atMax) && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
{result.insigniaUrl ? (
|
||||
<AvatarImage src={result.insigniaUrl} alt="" />
|
||||
) : (
|
||||
<AvatarFallback>{initialsOf(result.username)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{result.displayName || result.username}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
@{result.username}
|
||||
{result.rankName ? ` · ${result.rankName}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{result.assignmentLabel && (
|
||||
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{result.assignmentLabel}
|
||||
</span>
|
||||
)}
|
||||
{alreadySelected && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,3 +160,85 @@ export async function notifyAwardGrants(
|
|||
console.error("[Awards] Failed to send award-grant notifications:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
profilesChecked: number;
|
||||
profilesModified: number;
|
||||
duplicatesRemoved: number;
|
||||
details: { profileId: number; removed: number }[];
|
||||
}
|
||||
|
||||
export async function cleanupDuplicateAwards(
|
||||
payload: PayloadType,
|
||||
): Promise<CleanupResult> {
|
||||
const result: CleanupResult = {
|
||||
profilesChecked: 0,
|
||||
profilesModified: 0,
|
||||
duplicatesRemoved: 0,
|
||||
details: [],
|
||||
};
|
||||
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const profiles = await payload.find({
|
||||
collection: "profiles",
|
||||
limit: 500,
|
||||
page,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
result.profilesChecked += profiles.totalDocs;
|
||||
|
||||
for (const profile of profiles.docs) {
|
||||
const awards = (profile as unknown as ProgressionDoc).progression?.awards;
|
||||
if (!awards || awards.length <= 1) continue;
|
||||
|
||||
const seen = new Map<number, { entry: AwardGrantEntry; index: number }>();
|
||||
const indicesToRemove: number[] = [];
|
||||
|
||||
for (let i = 0; i < awards.length; i++) {
|
||||
const entry = awards[i];
|
||||
const id = awardIdOf(entry);
|
||||
if (id === null) continue;
|
||||
|
||||
const existing = seen.get(id);
|
||||
if (existing) {
|
||||
if (entry.awardedAt < existing.entry.awardedAt) {
|
||||
indicesToRemove.push(existing.index);
|
||||
seen.set(id, { entry, index: i });
|
||||
} else {
|
||||
indicesToRemove.push(i);
|
||||
}
|
||||
} else {
|
||||
seen.set(id, { entry, index: i });
|
||||
}
|
||||
}
|
||||
|
||||
if (indicesToRemove.length > 0) {
|
||||
const cleaned = awards.filter((_, i) => !indicesToRemove.includes(i));
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: profile.id,
|
||||
data: {
|
||||
progression: {
|
||||
...(profile as unknown as ProgressionDoc).progression,
|
||||
awards: cleaned,
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
result.profilesModified += 1;
|
||||
result.duplicatesRemoved += indicesToRemove.length;
|
||||
result.details.push({ profileId: profile.id, removed: indicesToRemove.length });
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = page < profiles.totalPages;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
20630
src/migrations/20260902_195336_add_medal_backgrounds.json
Normal file
20630
src/migrations/20260902_195336_add_medal_backgrounds.json
Normal file
File diff suppressed because it is too large
Load diff
16
src/migrations/20260902_195336_add_medal_backgrounds.ts
Normal file
16
src/migrations/20260902_195336_add_medal_backgrounds.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "awards" ADD COLUMN "background_image_id" integer;
|
||||
ALTER TABLE "awards" ADD CONSTRAINT "awards_background_image_id_media_id_fk" FOREIGN KEY ("background_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "awards_background_image_idx" ON "awards" USING btree ("background_image_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "awards" DROP CONSTRAINT "awards_background_image_id_media_id_fk";
|
||||
|
||||
DROP INDEX "awards_background_image_idx";
|
||||
ALTER TABLE "awards" DROP COLUMN "background_image_id";`)
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import * as migration_20260831_180233_assignment_transfers from './20260831_1802
|
|||
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
|
||||
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
|
||||
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
|
||||
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -94,6 +95,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260901_222200_add_admin_page_permissions.up,
|
||||
down: migration_20260901_222200_add_admin_page_permissions.down,
|
||||
name: '20260901_222200_add_admin_page_permissions'
|
||||
name: '20260901_222200_add_admin_page_permissions',
|
||||
},
|
||||
{
|
||||
up: migration_20260902_195336_add_medal_backgrounds.up,
|
||||
down: migration_20260902_195336_add_medal_backgrounds.down,
|
||||
name: '20260902_195336_add_medal_backgrounds'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1824,6 +1824,10 @@ export interface Award {
|
|||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* A background image for the award card. Displayed as a faded backdrop behind the card content.
|
||||
*/
|
||||
backgroundImage?: (number | null) | Media;
|
||||
medalDesign?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
|
|
@ -3805,6 +3809,7 @@ export interface AwardsSelect<T extends boolean = true> {
|
|||
image?: T;
|
||||
experiencePoints?: T;
|
||||
ribbonDesign?: T;
|
||||
backgroundImage?: T;
|
||||
medalDesign?: T;
|
||||
grantConfig?:
|
||||
| T
|
||||
|
|
|
|||
14
src/tools/cli/grant_award.ts
Normal file
14
src/tools/cli/grant_award.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
|
||||
(async () => {
|
||||
const payload = await getPayload({ config });
|
||||
|
||||
// payload.logger.log("Retrieving user profiles...");
|
||||
|
||||
// await payload.find({
|
||||
// collection: "profiles",
|
||||
// overrideAccess: true,
|
||||
// select
|
||||
// })
|
||||
})();
|
||||
Loading…
Reference in a new issue