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,
|
||||
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) {
|
||||
const isMedal = award.type === "medal";
|
||||
if (isMedal && !superuser) {
|
||||
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 ?? [];
|
||||
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,9 +123,60 @@ export async function grantAward(input: {
|
|||
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 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,
|
||||
|
|
@ -149,7 +204,34 @@ export async function grantAward(input: {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
if (res.success && res.data) {
|
||||
const { failed } = res.data;
|
||||
if (failed === 0) {
|
||||
toast.success("Award granted", {
|
||||
description: `${awardName} granted to @${recipient.username}.`,
|
||||
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."
|
||||
{step === 3
|
||||
? "Grant summary."
|
||||
: showRecipientPicker
|
||||
? "Select recipients, then confirm the grant."
|
||||
: `Granting to @${recipientUsername}.`}
|
||||
{stats && stats.canGrant && (
|
||||
{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,36 +144,48 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
|||
profileId: result.profileId,
|
||||
});
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
if (selectedUser) {
|
||||
const atMax = maxSelections != null && selectedUsers.length >= maxSelections;
|
||||
|
||||
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>
|
||||
<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="text-sm font-medium">
|
||||
{selectedUser.displayName || selectedUser.username}
|
||||
<span className="max-w-[10rem] truncate font-medium">
|
||||
{u.displayName || u.username}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">@{u.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"
|
||||
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"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<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);
|
||||
|
|
@ -189,7 +209,11 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
|||
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"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
|
|
@ -209,18 +233,22 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
|||
) : results.length === 0 ? (
|
||||
<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
|
||||
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">
|
||||
|
|
@ -244,11 +272,18 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
|
|||
{result.assignmentLabel}
|
||||
</span>
|
||||
)}
|
||||
{alreadySelected && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue