feat(pool): one board of pools with split/merge by drag; plan names from tier and seat
- The Users page account pool is one board: a column per pool holding its Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its members. Accounts sit in one pool; drag moves them. Drop on New pool to split one off; drag a pool's only account into another pool to merge (members follow, the emptied pool is deleted on Save). One password Save. - Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said 'pro' after an upgrade and 'team' for both seats. Shown on profiles, the status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
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<ServerUserUsage, "id" | "role">,
|
||||
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<ServerUserUsage, "id" | "role">[];
|
||||
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 (
|
||||
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
|
||||
{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 (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onFilter(c.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
|
||||
on
|
||||
? "border-accent/50 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
<span className="tabular-nums opacity-70">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberPicker({
|
||||
users,
|
||||
chosen,
|
||||
onChange,
|
||||
groupOf,
|
||||
grouped,
|
||||
note,
|
||||
}: {
|
||||
users: ServerUserUsage[] | null;
|
||||
chosen: Set<number>;
|
||||
onChange: (next: Set<number>) => 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<MemberFilter>("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 (
|
||||
<div className="space-y-2" data-testid="member-picker">
|
||||
{users && users.length > 0 && (
|
||||
<>
|
||||
<MemberFilterChips
|
||||
users={users}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
groupOf={groupOf}
|
||||
grouped={grouped}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
className="input w-full py-1 pl-7 text-xs"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("pool.members.search")}
|
||||
aria-label={t("pool.members.search")}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={allShown}
|
||||
disabled={shown.length === 0}
|
||||
onChange={(on) =>
|
||||
setMany(
|
||||
shown.map((u) => u.id),
|
||||
on
|
||||
)
|
||||
}
|
||||
label={t("pool.members.tickShown", { count: shown.length })}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users && users.length > 0 && shown.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
|
||||
)}
|
||||
{shown.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => setMany([u.id], on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||
{note?.(u)}
|
||||
</span>
|
||||
}
|
||||
className="min-w-0 flex-1"
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user