feat(awards): support bulk recipient selection in award grant flow
Refactors grantAward to accept an array of recipientUserIds, adds duplicate-award and eligibility checks per recipient, and returns a per-recipient result summary. GrantAwardDialog gains a third step showing grant results when some recipients fail. RecipientPicker is rewritten to support multi-select with chip-based selected state and a maxSelections cap.
This commit is contained in:
parent
d952a33ee7
commit
2e1fbf8006
3 changed files with 403 additions and 196 deletions
|
|
@ -12,7 +12,8 @@ import {
|
||||||
getGrantorAssignments,
|
getGrantorAssignments,
|
||||||
isRecipientEligible,
|
isRecipientEligible,
|
||||||
} from "@/lib/awards/grant";
|
} 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> {
|
interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -37,14 +38,24 @@ async function authenticate() {
|
||||||
return { payload, user };
|
return { payload, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function grantAward(input: {
|
export interface GrantResult {
|
||||||
recipientUserId: number;
|
recipientUserId: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantAward(input: {
|
||||||
|
recipientUserIds: number[];
|
||||||
awardId: number;
|
awardId: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult<{ results: GrantResult[]; granted: number; failed: number }>> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (input.recipientUserIds.length === 0) {
|
||||||
|
return { success: false, error: "No recipients selected." };
|
||||||
|
}
|
||||||
|
|
||||||
let award;
|
let award;
|
||||||
try {
|
try {
|
||||||
award = await payload.findByID({
|
award = await payload.findByID({
|
||||||
|
|
@ -60,11 +71,17 @@ export async function grantAward(input: {
|
||||||
let grantorProfile: Profile | null = null;
|
let grantorProfile: Profile | null = null;
|
||||||
const superuser = await isSuperuser(payload, user);
|
const superuser = await isSuperuser(payload, user);
|
||||||
|
|
||||||
if (award.type === "medal") {
|
const isMedal = award.type === "medal";
|
||||||
if (!superuser) {
|
if (isMedal && !superuser) {
|
||||||
return { success: false, error: "Only superusers can grant medals." };
|
return { success: false, error: "Only superusers can grant medals." };
|
||||||
}
|
}
|
||||||
} else if (!superuser) {
|
|
||||||
|
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 ?? [];
|
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
|
||||||
if (allowedTypes.length === 0) {
|
if (allowedTypes.length === 0) {
|
||||||
return { success: false, error: "This award cannot be granted by leaders." };
|
return { success: false, error: "This award cannot be granted by leaders." };
|
||||||
|
|
@ -78,7 +95,9 @@ export async function grantAward(input: {
|
||||||
if (grantorAssignments.length === 0) {
|
if (grantorAssignments.length === 0) {
|
||||||
return { success: false, error: "You are not authorized to grant this award." };
|
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;
|
const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
|
||||||
if (maxGrants) {
|
if (maxGrants) {
|
||||||
|
|
@ -87,29 +106,14 @@ export async function grantAward(input: {
|
||||||
return { success: false, error: "You do not have a profile." };
|
return { success: false, error: "You do not have a profile." };
|
||||||
}
|
}
|
||||||
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
|
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
|
||||||
if (issued >= maxGrants) {
|
remaining = maxGrants - issued;
|
||||||
|
if (remaining <= 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "You have reached the maximum number of grants for this award.",
|
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) {
|
if (!grantorProfile) {
|
||||||
|
|
@ -119,9 +123,60 @@ export async function grantAward(input: {
|
||||||
return { success: false, error: "You do not have a profile." };
|
return { success: false, error: "You do not have a profile." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const results: GrantResult[] = [];
|
||||||
|
let granted = 0;
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
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 progression = recipientProfile.progression;
|
||||||
const currentAwards = progression?.awards ?? [];
|
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({
|
await payload.update({
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
id: recipientProfile.id,
|
id: recipientProfile.id,
|
||||||
|
|
@ -149,7 +204,34 @@ export async function grantAward(input: {
|
||||||
data: { awardId: award.id, grantorProfileId: grantorProfile.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) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ import {
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context";
|
||||||
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
|
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
|
||||||
import { Loader2Icon, TriangleAlertIcon } from "lucide-react";
|
import { CheckIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface GrantAwardDialogProps {
|
interface GrantAwardDialogProps {
|
||||||
|
|
@ -33,9 +33,10 @@ interface GrantAwardDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-step grant flow: pick a recipient + reason, then confirm by typing the
|
* Two-step grant flow: pick recipient(s) + reason, then confirm by typing the
|
||||||
* recipient's @username. Grant limits are pre-checked via getGrantStats so
|
* award name. Grant limits are pre-checked via getGrantStats so selection is
|
||||||
* users see warnings before submitting.
|
* capped and users see warnings before submitting. Individual ineligible
|
||||||
|
* recipients are skipped and reported after the fact.
|
||||||
*/
|
*/
|
||||||
export function GrantAwardDialog({
|
export function GrantAwardDialog({
|
||||||
awardId,
|
awardId,
|
||||||
|
|
@ -48,14 +49,19 @@ export function GrantAwardDialog({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onGranted,
|
onGranted,
|
||||||
}: GrantAwardDialogProps) {
|
}: GrantAwardDialogProps) {
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||||
const [recipient, setRecipient] = useState<RecipientSelection | null>(null);
|
const [recipients, setRecipients] = useState<RecipientSelection[]>([]);
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [confirmText, setConfirmText] = useState("");
|
const [confirmText, setConfirmText] = useState("");
|
||||||
const [stats, setStats] = useState<GrantStats | null>(null);
|
const [stats, setStats] = useState<GrantStats | null>(null);
|
||||||
const [statsError, setStatsError] = useState<string | null>(null);
|
const [statsError, setStatsError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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.
|
// Reset the flow each time the dialog opens; pre-fill a fixed recipient.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -67,10 +73,11 @@ export function GrantAwardDialog({
|
||||||
setStatsError(null);
|
setStatsError(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setRecipient(
|
setResults(null);
|
||||||
|
setRecipients(
|
||||||
!showRecipientPicker && recipientUserId != null && recipientUsername
|
!showRecipientPicker && recipientUserId != null && recipientUsername
|
||||||
? { id: recipientUserId, username: recipientUsername }
|
? [{ id: recipientUserId, username: recipientUsername }]
|
||||||
: null,
|
: [],
|
||||||
);
|
);
|
||||||
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
|
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
|
||||||
|
|
||||||
|
|
@ -88,26 +95,36 @@ export function GrantAwardDialog({
|
||||||
};
|
};
|
||||||
}, [open, awardId]);
|
}, [open, awardId]);
|
||||||
|
|
||||||
const expectedConfirm = recipient ? `@${recipient.username}` : null;
|
const expectedConfirm = awardName;
|
||||||
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm;
|
const confirmMatches = confirmText.trim() === expectedConfirm;
|
||||||
const limited = stats?.remainingGrants != null;
|
const limited = stats?.remainingGrants != null;
|
||||||
|
const maxSelections = limited ? stats?.remainingGrants ?? null : null;
|
||||||
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
|
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
|
||||||
|
|
||||||
async function handleGrant() {
|
async function handleGrant() {
|
||||||
if (!recipient || !confirmMatches || submitting) return;
|
if (recipients.length === 0 || !confirmMatches || submitting) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await grantAward({
|
const res = await grantAward({
|
||||||
recipientUserId: recipient.id,
|
recipientUserIds: recipients.map((r) => r.id),
|
||||||
awardId,
|
awardId,
|
||||||
reason: reason.trim() ? reason.trim() : undefined,
|
reason: reason.trim() ? reason.trim() : undefined,
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success && res.data) {
|
||||||
|
const { failed } = res.data;
|
||||||
|
if (failed === 0) {
|
||||||
toast.success("Award granted", {
|
toast.success("Award granted", {
|
||||||
description: `${awardName} granted to @${recipient.username}.`,
|
description: `${awardName} granted to ${recipients.length} recipient${
|
||||||
|
recipients.length === 1 ? "" : "s"
|
||||||
|
}.`,
|
||||||
});
|
});
|
||||||
onGranted?.();
|
onGranted?.();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
setResults(res.data.results);
|
||||||
|
setStep(3);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(res.error ?? "Failed to grant award.");
|
setError(res.error ?? "Failed to grant award.");
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -125,10 +142,12 @@ export function GrantAwardDialog({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Grant {awardName}</DialogTitle>
|
<DialogTitle>Grant {awardName}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{showRecipientPicker
|
{step === 3
|
||||||
? "Select a recipient, then confirm the grant."
|
? "Grant summary."
|
||||||
|
: showRecipientPicker
|
||||||
|
? "Select recipients, then confirm the grant."
|
||||||
: `Granting to @${recipientUsername}.`}
|
: `Granting to @${recipientUsername}.`}
|
||||||
{stats && stats.canGrant && (
|
{step !== 3 && stats && stats.canGrant && (
|
||||||
<span className="block mt-1 text-xs opacity-50">
|
<span className="block mt-1 text-xs opacity-50">
|
||||||
{stats.remainingGrants === null
|
{stats.remainingGrants === null
|
||||||
? "Unlimited grants available."
|
? "Unlimited grants available."
|
||||||
|
|
@ -146,11 +165,18 @@ export function GrantAwardDialog({
|
||||||
|
|
||||||
{showRecipientPicker ? (
|
{showRecipientPicker ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Recipient</Label>
|
<Label>Recipients</Label>
|
||||||
<RecipientPicker
|
<RecipientPicker
|
||||||
selectedUser={recipient}
|
selectedUsers={recipients}
|
||||||
onSelect={setRecipient}
|
onSelect={(u) =>
|
||||||
onClear={() => setRecipient(null)}
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -204,37 +230,53 @@ export function GrantAwardDialog({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setStep(2)}
|
onClick={() => setStep(2)}
|
||||||
disabled={!recipient || (stats !== null && !stats.canGrant)}
|
disabled={recipients.length === 0 || (stats !== null && !stats.canGrant)}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : step === 2 ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Alert>
|
<Alert>
|
||||||
<TriangleAlertIcon data-icon="inline-start" />
|
<TriangleAlertIcon data-icon="inline-start" />
|
||||||
<AlertTitle>Confirm grant</AlertTitle>
|
<AlertTitle>Confirm grant</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You are about to grant "{awardName}" to @{recipient?.username}. This action cannot
|
You are about to grant "{awardName}" to{" "}
|
||||||
be undone.
|
{recipients.length === 1
|
||||||
|
? `@${recipients[0].username}`
|
||||||
|
: `${recipients.length} recipients`}
|
||||||
|
. This action cannot be undone.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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 && (
|
{limited && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{(stats?.remainingGrants ?? 0) <= 1
|
{maxSelections != null && maxSelections <= recipients.length
|
||||||
? "This will use your LAST grant for this award."
|
? "This will use ALL of your remaining grants for this award."
|
||||||
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - 1}.`}
|
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - recipients.length}.`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Type @{recipient?.username} to confirm</Label>
|
<Label>Type the award name to confirm</Label>
|
||||||
<Input
|
<Input
|
||||||
value={confirmText}
|
value={confirmText}
|
||||||
onChange={(e) => setConfirmText(e.target.value)}
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
placeholder={`@${recipient?.username}`}
|
placeholder={awardName}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -256,6 +298,54 @@ export function GrantAwardDialog({
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</div>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@ export interface RecipientSelection {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecipientPickerProps {
|
interface RecipientPickerProps {
|
||||||
|
selectedUsers: RecipientSelection[];
|
||||||
onSelect: (user: RecipientSelection) => void;
|
onSelect: (user: RecipientSelection) => void;
|
||||||
selectedUser: RecipientSelection | null;
|
onRemove: (id: number) => void;
|
||||||
onClear: () => void;
|
maxSelections?: number;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResult extends RecipientSelection {
|
interface SearchResult extends RecipientSelection {
|
||||||
|
|
@ -35,7 +37,13 @@ function initialsOf(username: string): string {
|
||||||
* `profile` relationship, so searching /api/users would not surface rank
|
* `profile` relationship, so searching /api/users would not surface rank
|
||||||
* data) and enriches each hit with the user's primary assignment.
|
* 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 [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
@ -136,36 +144,48 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
||||||
profileId: result.profileId,
|
profileId: result.profileId,
|
||||||
});
|
});
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setOpen(false);
|
setActiveIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedUser) {
|
const atMax = maxSelections != null && selectedUsers.length >= maxSelections;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
|
<div ref={wrapperRef} className="flex flex-col gap-2">
|
||||||
<Avatar className="size-7">
|
{selectedUsers.length > 0 && (
|
||||||
<AvatarFallback>{initialsOf(selectedUser.username)}</AvatarFallback>
|
<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>
|
</Avatar>
|
||||||
<span className="text-sm font-medium">
|
<span className="max-w-[10rem] truncate font-medium">
|
||||||
{selectedUser.displayName || selectedUser.username}
|
{u.displayName || u.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span>
|
<span className="truncate text-xs text-muted-foreground">@{u.username}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Clear recipient"
|
aria-label={`Remove ${u.username}`}
|
||||||
onClick={onClear}
|
disabled={disabled}
|
||||||
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
onClick={() => onRemove(u.id)}
|
||||||
|
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<div className="relative">
|
||||||
<div ref={wrapperRef} className="relative">
|
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
|
disabled={disabled || atMax}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
|
@ -189,7 +209,11 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
||||||
if (active) select(active);
|
if (active) select(active);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search by username or display name..."
|
placeholder={
|
||||||
|
atMax
|
||||||
|
? "Maximum recipients selected"
|
||||||
|
: "Search by username or display name..."
|
||||||
|
}
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
|
@ -209,18 +233,22 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
|
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
|
||||||
) : (
|
) : (
|
||||||
results.map((result, i) => (
|
results.map((result, i) => {
|
||||||
|
const alreadySelected = selectedUsers.some((u) => u.id === result.id);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={result.id}
|
key={result.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
|
disabled={alreadySelected || atMax}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => select(result)}
|
onClick={() => select(result)}
|
||||||
onMouseEnter={() => setActiveIndex(i)}
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 px-3 py-2 text-left",
|
"flex w-full items-center gap-3 px-3 py-2 text-left",
|
||||||
i === activeIndex && "bg-accent",
|
i === activeIndex && "bg-accent",
|
||||||
|
(alreadySelected || atMax) && "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar className="size-8">
|
<Avatar className="size-8">
|
||||||
|
|
@ -244,11 +272,18 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
||||||
{result.assignmentLabel}
|
{result.assignmentLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{alreadySelected && (
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||||
|
Selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue