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:
2026-10-28 11:47:19 +07:00
parent 9d07f64154
commit 26aabd6660
18 changed files with 1638 additions and 642 deletions
@@ -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>
);
}