/** * @file MemberPicker.tsx * @description The pool dialogs' member list (an account's Edit, a group's * Edit): filter chips by role (Admin / Developer / Analyst, each with its * count) plus "No group" for the users in no pool group, a search box, a * tick-all for what is shown, and one checkbox per user with their role and * where they are now. Pure UI: the caller owns the ticked set. * @author Nguyễn Ngọc Trí Vĩ */ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Search } from "lucide-react"; import type { AuthUser, ServerUserUsage } from "../../lib/api"; import { Checkbox } from "../Checkbox"; import { UserAvatar } from "./UserAvatar"; export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"]; /** A role, every user, or the users in no pool group. */ export type MemberFilter = "all" | "nogroup" | AuthUser["role"]; /** The chips' filter applied to one user. */ export function matchesFilter( u: Pick, filter: MemberFilter, groupOf: (userId: number) => string | null ): boolean { if (filter === "all") return true; if (filter === "nogroup") return groupOf(u.id) === null; return u.role === filter; } /** A row of filter chips with counts; `nogroup` only when groups exist. */ export function MemberFilterChips({ users, filter, onFilter, groupOf, grouped, }: { users: Pick[]; filter: MemberFilter; onFilter: (f: MemberFilter) => void; groupOf: (userId: number) => string | null; grouped: boolean; }) { const { t } = useTranslation("users"); const chips: { id: MemberFilter; label: string }[] = [ { id: "all", label: t("pool.members.all") }, ...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })), ...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []), ]; return (
{chips.map((c) => { const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length; if (n === 0 && c.id !== "all" && c.id !== filter) return null; const on = filter === c.id; return ( ); })}
); } export function MemberPicker({ users, chosen, onChange, groupOf, grouped, note, }: { users: ServerUserUsage[] | null; chosen: Set; onChange: (next: Set) => void; /** The pool group a user is in now, by name, or null. */ groupOf: (userId: number) => string | null; /** Whether any pool group exists (shows the "No group" chip). */ grouped: boolean; /** Extra text after a user's name (where they run now). */ note?: (u: ServerUserUsage) => ReactNode; }) { const { t } = useTranslation("users"); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); const q = query.trim().toLowerCase(); const shown = (users ?? []).filter( (u) => matchesFilter(u, filter, groupOf) && (!q || u.username.toLowerCase().includes(q) || (u.displayName ?? "").toLowerCase().includes(q)) ); const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id)); const setMany = (ids: number[], on: boolean) => { const next = new Set(chosen); for (const id of ids) { if (on) next.add(id); else next.delete(id); } onChange(next); }; return (
{users && users.length > 0 && ( <>
setMany( shown.map((u) => u.id), on ) } label={t("pool.members.tickShown", { count: shown.length })} labelClassName="text-[11px] text-fg-secondary" />
)}
{users === null && (

{t("pool.loadingUsers")}

)} {users?.length === 0 && (

{t("pool.noUsers")}

)} {users && users.length > 0 && shown.length === 0 && (

{t("pool.members.noneShown")}

)} {shown.map((u) => (
setMany([u.id], on)} label={ {u.username} {u.disabled && ({t("disabled")})} {note?.(u)} } className="min-w-0 flex-1" labelClassName="text-sm text-fg-primary" /> {t(`roles.${u.role}`)}
))}
); }