import type { MemberAllowanceDefault, MemberAllowanceRule, MemberAllowanceUsage, WorkspaceUsageResponse, } from "@opengeni/sdk/usage-allowances"; import { Slider } from "radix-ui"; import { useId, useMemo, useState, type ReactNode } from "react"; import { cn } from "../lib/cn"; import { usagePercentLabel } from "../usage/summary"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; /** How a member is named in the list. Usage rows carry only a subject id. */ export type UsageMemberIdentity = { name: string; /** A second line: an email, a role, or "You". */ detail?: string | null | undefined; }; /** * Opt in to fixed-amount limits. Amounts are USD micros on the wire; your * format/parse decide the unit people type ("$50.00", "500 credits"). */ export type UsageAmountFormat = { format: (micros: number) => string; /** Returns micros, or null when the text is not a valid amount. */ parse: (text: string) => number | null; /** Shown before the input, for example "$". */ prefix?: string | undefined; /** The amount input's step in the person's unit. */ step?: number | undefined; }; export type UsageMemberListLabels = { listLabel: string; used: (percent: string) => string; noLimitUsed: string; amounts: (used: string, limit: string) => string; nearLimit: string; limitReached: string; limit: string; defaultEqualShare: (percent: string) => string; defaultNone: string; defaultShare: (percent: string) => string; defaultAmount: (amount: string) => string; share: (percent: string) => string; amount: (amount: string) => string; fixedAmountHidden: string; edit: (name: string) => string; editorTitle: (name: string) => string; optionDefault: string; optionShare: string; optionAmount: string; fixedAmountHint: string; shareSlider: string; shareInput: string; amountInput: string; equalShareMultiple: (multiple: string) => string; sameAsEqualShare: string; atBudget: (amount: string) => string; save: string; saving: string; cancel: string; saveFailed: string; invalidAmount: string; totalWithin: (percent: string) => string; totalOver: (percent: string) => string; oversubscribedHelp: string; unlimitedMembers: (count: number) => string; empty: string; }; export const DEFAULT_USAGE_MEMBER_LIST_LABELS = (t: OpenGeniText): UsageMemberListLabels => ({ listLabel: t("usageMemberList.memberUsage"), used: (percent) => `${percent}% used`, noLimitUsed: t("usageMemberList.noIndividualLimit"), amounts: (used, limit) => `${used} of ${limit}`, nearLimit: t("usageMemberList.nearLimit"), limitReached: t("usageMemberList.limitReached"), limit: t("usageMemberList.limit"), defaultEqualShare: (percent) => t("usageMemberList.equalSharePercent", { percent }), defaultNone: t("usageMemberList.noIndividualLimit"), defaultShare: (percent) => t("usageMemberList.defaultPercent", { percent }), defaultAmount: (amount) => t("usageMemberList.defaultAmount", { amount }), share: (percent) => t("usageMemberList.percentOfBudget", { percent }), amount: (amount) => `${amount} fixed`, fixedAmountHidden: t("usageMemberList.fixedAmount"), edit: (name) => t("usageMemberList.changeLimitForName", { name }), editorTitle: (name) => t("usageMemberList.limitForName", { name }), optionDefault: t("usageMemberList.default"), optionShare: t("usageMemberList.shareOfBudget"), optionAmount: t("usageMemberList.fixedAmount"), fixedAmountHint: t("usageMemberList.eachPeriodDoesnTGrow"), shareSlider: t("usageMemberList.shareOfTheWorkspaceBudget"), shareInput: t("usageMemberList.sharePercent"), amountInput: t("usageMemberList.fixedAmount"), equalShareMultiple: (multiple) => t("usageMemberList.multipleAnEqualShare", { multiple }), sameAsEqualShare: t("usageMemberList.aboutAnEqualShare"), atBudget: (amount) => t("usageMemberList.aboutAmountAtTheCurrent", { amount }), save: t("usageMemberList.save"), saving: t("usageMemberList.saving"), cancel: t("usageMemberList.cancel"), saveFailed: t("usageMemberList.couldnTSaveTheLimit"), invalidAmount: t("usageMemberList.enterAnAmountOfZero"), totalWithin: (percent) => t("usageMemberList.memberLimitsAddUpTo", { percent }), totalOver: (percent) => t("usageMemberList.memberLimitsAddUpTo", { percent }), oversubscribedHelp: t("usageMemberList.thatSAllowedLimitsAre"), unlimitedMembers: (count) => count === 1 ? "1 member has no individual limit." : t("usageMemberList.countMembersHaveNoIndividual", { count }), empty: t("usageMemberList.noMembersYet"), }); export type UsageMemberListProps = { /** The full roster for one period (`getAllUsage` / every `getUsage` page). */ usage: WorkspaceUsageResponse; /** The workspace's `memberDefault`, to name what "default" means. */ memberDefault?: MemberAllowanceDefault | null | undefined; /** Name a member. Defaults to the external id, then the subject id. */ describe?: ((member: MemberAllowanceUsage) => UsageMemberIdentity) | undefined; /** * Save a member's rule (`null` restores the default). Omit for a read-only * list. Run it on your backend: member limits need workspace admin authority. */ onChangeRule?: | ((member: MemberAllowanceUsage, rule: MemberAllowanceRule) => Promise) | undefined; /** Opt in to amounts and fixed-amount limits. Shares only without it. */ amounts?: UsageAmountFormat | undefined; labels?: Partial | undefined; /** Draw the list's own border. Turn off inside a card that already has one. */ framed?: boolean | undefined; className?: string | undefined; }; const STATUS_RANK = { exhausted: 0, warning: 1, ok: 2 } as const; function shareBase(usage: WorkspaceUsageResponse): number { return usage.workspace.includedCredits + usage.workspace.grantsRemaining; } function formatPercent(value: number): string { // One decimal only where a whole number would hide a real difference. const scaled = value * 100; return Number.isInteger(Math.round(scaled * 10) / 10) || scaled >= 10 ? String(Math.round(scaled)) : (Math.round(scaled * 10) / 10).toFixed(1); } function formatMultiple(value: number): string { return value >= 10 ? String(Math.round(value)) : (Math.round(value * 10) / 10).toString(); } /** Words for a member's current limit, from their override or the default. */ export function describeMemberLimit( t: OpenGeniText, member: Pick, context: { memberDefault: MemberAllowanceDefault | null | undefined; shareBase: number; amounts?: UsageAmountFormat | undefined; labels?: Partial | undefined; }, ): string { const labels = { ...DEFAULT_USAGE_MEMBER_LIST_LABELS(t), ...context.labels }; const ofBudget = (limit: number | null) => limit !== null && context.shareBase > 0 ? formatPercent(limit / context.shareBase) : "0"; const rule = member.rule; if (rule && "share" in rule) return labels.share(formatPercent(rule.share)); if (rule && "credits" in rule) { return context.amounts ? labels.amount(context.amounts.format(rule.credits)) : labels.fixedAmountHidden; } const fallback = context.memberDefault ?? "none"; if (fallback === "none" || member.limit === null) return labels.defaultNone; if (fallback === "equal_share") return labels.defaultEqualShare(ofBudget(member.limit)); if ("share" in fallback) return labels.defaultShare(formatPercent(fallback.share)); return context.amounts ? labels.defaultAmount(context.amounts.format(fallback.credits)) : labels.defaultShare(ofBudget(member.limit)); } /** * Every member's usage against their own limit, with an optional editor: * a share-of-budget slider (default), a fixed amount, or the workspace * default. Oversubscription is shown, never blocked. */ export function UsageMemberList({ usage, memberDefault, describe, onChangeRule, amounts, labels: overrides, framed = true, className, }: UsageMemberListProps) { const t = useOpenGeniText(); const labels = { ...DEFAULT_USAGE_MEMBER_LIST_LABELS(t), ...overrides }; const base = shareBase(usage); const [editing, setEditing] = useState(null); const name = (member: MemberAllowanceUsage): UsageMemberIdentity => describe?.(member) ?? { name: member.externalIdentity?.externalId ?? member.subjectId, detail: null, }; const members = useMemo( () => [...usage.members].sort( (left, right) => STATUS_RANK[left.status] - STATUS_RANK[right.status] || right.used - left.used, ), [usage.members], ); if (members.length === 0) { return

{labels.empty}

; } return (
    {members.map((member) => ( setEditing((current) => current === member.subjectId ? null : member.subjectId, ) : undefined } onClose={() => setEditing(null)} onSave={ onChangeRule ? async (rule) => { await onChangeRule(member, rule); setEditing(null); } : undefined } /> ))}
); } function memberShares(usage: WorkspaceUsageResponse, base: number) { let total = 0; let unlimited = 0; for (const member of usage.members) { if (member.limit === null) unlimited += 1; else total += base > 0 ? member.limit / base : 0; } return { total, unlimited }; } function AllocationSummary({ usage, base, labels, framed, }: { usage: WorkspaceUsageResponse; base: number; labels: UsageMemberListLabels; framed: boolean; }) { if (base <= 0) return null; const { total, unlimited } = memberShares(usage, base); const limited = usage.members.length - unlimited; const over = total > 1.005; // An untouched default (everyone on an equal share, 100% in total) needs no // summary; say something only when the split has been shaped or overflows. const shaped = usage.members.some((member) => member.rule !== null); if (limited === 0 || (!over && !shaped && unlimited === 0)) return null; return (
{limited > 0 ? ( {over ? labels.totalOver(formatPercent(total)) : labels.totalWithin(formatPercent(total))} ) : null} {over ?

{labels.oversubscribedHelp}

: null} {unlimited > 0 ? (

{labels.unlimitedMembers(unlimited)}

) : null}
); } function AllocationBar({ total, over, children, }: { total: number; over: boolean; children: ReactNode; }) { // The track is the budget; past 100% the fill keeps going in a second tone // so oversubscription reads as "more promised than exists", not an error. const scale = Math.max(1, total); return (

{children}

{over ? ( <> ) : null}
); } function MemberRow({ member, identity, usage, base, memberDefault, amounts, labels, framed, editing, onEdit, onClose, onSave, }: { framed: boolean; member: MemberAllowanceUsage; identity: UsageMemberIdentity; usage: WorkspaceUsageResponse; base: number; memberDefault: MemberAllowanceDefault | null | undefined; amounts: UsageAmountFormat | undefined; labels: UsageMemberListLabels; editing: boolean; onEdit: (() => void) | undefined; onClose: () => void; onSave: ((rule: MemberAllowanceRule) => Promise) | undefined; }) { const t = useOpenGeniText(); const editorId = useId(); const limited = member.limit !== null; const fraction = member.fraction ?? 0; const limitText = describeMemberLimit(t, member, { memberDefault, shareBase: base, amounts, labels, }); return (
  • {identity.name}

    {identity.detail ? (

    {identity.detail}

    ) : null}
    {limited ? member.status === "exhausted" ? labels.limitReached : `${labels.used(usagePercentLabel(fraction))}${ member.status === "warning" ? ` · ${labels.nearLimit}` : "" }` : labels.noLimitUsed} {amounts ? ( {limited ? labels.amounts(amounts.format(member.used), amounts.format(member.limit!)) : amounts.format(member.used)} ) : null}
    {limited ? ( ) : null}
    {labels.limit} {onEdit ? ( ) : ( {limitText} )}
    {editing && onSave ? ( ) : null}
  • ); } type Mode = "default" | "share" | "amount"; /** * Opening the editor means "give this person more or less", so a member on * the default starts on the share slider at their current share. */ function initialMode(rule: MemberAllowanceRule, amounts: UsageAmountFormat | undefined): Mode { if (rule === null) return "share"; if ("share" in rule) return "share"; return amounts ? "amount" : "share"; } function MemberLimitEditor({ id, member, name, usage, base, memberDefault, amounts, labels, framed, onCancel, onSave, }: { framed: boolean; id: string; member: MemberAllowanceUsage; name: string; usage: WorkspaceUsageResponse; base: number; memberDefault: MemberAllowanceDefault | null | undefined; amounts: UsageAmountFormat | undefined; labels: UsageMemberListLabels; onCancel: () => void; onSave: (rule: MemberAllowanceRule) => Promise; }) { const t = useOpenGeniText(); const count = Math.max(1, usage.members.length); const equal = 1 / count; const currentShare = member.rule && "share" in member.rule ? member.rule.share : member.limit !== null && base > 0 ? member.limit / base : equal; const startPercent = Math.min(100, Math.max(0, Math.round(currentShare * 100))); const startAmount = amounts && member.rule && "credits" in member.rule ? stripPrefix(amounts.format(member.rule.credits), amounts.prefix) : amounts && member.limit !== null ? stripPrefix(amounts.format(member.limit), amounts.prefix) : ""; const [mode, setMode] = useState(() => initialMode(member.rule, amounts)); const [sharePercent, setSharePercent] = useState(startPercent); const [amountText, setAmountText] = useState(startAmount); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const share = sharePercent / 100; const parsedAmount = amounts && mode === "amount" ? amounts.parse(amountText) : null; const defaultLimitText = describeMemberLimit( t, { rule: null, limit: member.rule === null ? member.limit : defaultLimitFor(memberDefault, base, count), }, { memberDefault, shareBase: base, amounts, labels }, ); // Nothing to save until the person moves something: opening the slider on a // default member must not silently turn their default into an override. const unchanged = mode === "default" ? member.rule === null : mode === "share" ? (member.rule === null || "share" in member.rule) && sharePercent === startPercent : member.rule !== null && "credits" in member.rule && amountText === startAmount; // The roster total if this member's limit changes to the draft. const draftLimit = mode === "share" ? share * base : mode === "amount" ? parsedAmount : defaultLimitFor(memberDefault, base, count); const others = usage.members.reduce( (sum, other) => other.subjectId === member.subjectId || other.limit === null ? sum : sum + other.limit, 0, ); const draftTotal = base > 0 && draftLimit !== null ? (others + draftLimit) / base : null; async function save() { let rule: MemberAllowanceRule; if (mode === "default") rule = null; else if (mode === "share") rule = { share }; else { if (parsedAmount === null || !Number.isSafeInteger(parsedAmount) || parsedAmount < 0) { setError(labels.invalidAmount); return; } rule = { credits: parsedAmount }; } setSaving(true); setError(null); try { await onSave(rule); } catch (cause) { setError(cause instanceof Error && cause.message ? cause.message : labels.saveFailed); setSaving(false); } } const modes: { value: Mode; label: string }[] = [ { value: "share", label: labels.optionShare }, ...(amounts ? [{ value: "amount" as const, label: labels.optionAmount }] : []), { value: "default", label: labels.optionDefault }, ]; return (
    { if (event.key === "Escape" && !saving) { event.stopPropagation(); onCancel(); } }} >
    {labels.editorTitle(name)}
    {modes.map((option) => ( ))}
    {mode === "share" ? (
    setSharePercent(next[0] ?? 0)} data-og-share-slider="" className="relative flex h-6 min-w-0 flex-1 touch-none items-center select-none pointer-coarse:h-10" > {count > 1 ? ( ) : null}

    {[ count > 1 ? Math.abs(share * count - 1) < 0.05 ? labels.sameAsEqualShare : labels.equalShareMultiple(formatMultiple(share * count)) : null, amounts && base > 0 ? labels.atBudget(amounts.format(Math.round(share * base))) : null, ] .filter(Boolean) .join(" · ")}

    ) : mode === "amount" && amounts ? (
    {amounts.prefix ? {amounts.prefix} : null} setAmountText(event.target.value)} className="h-8 w-28 rounded-og-sm border border-og-border bg-og-bg px-2 tabular-nums focus-visible:outline-2 focus-visible:outline-og-accent pointer-coarse:h-10" /> {labels.fixedAmountHint}
    ) : (

    {defaultLimitText}

    )}
    {draftTotal !== null ? (

    1.005 ? "text-og-fg" : "text-og-fg-subtle")} > {draftTotal > 1.005 ? ( <> {labels.totalOver(formatPercent(draftTotal))}{" "} {labels.oversubscribedHelp} ) : ( labels.totalWithin(formatPercent(draftTotal)) )}

    ) : null} {error ? (

    {error}

    ) : null}
    ); } function stripPrefix(text: string, prefix: string | undefined): string { const plain = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text; return plain.replace(/,/g, ""); } /** The ceiling the workspace default gives one member (null: none). */ function defaultLimitFor( memberDefault: MemberAllowanceDefault | null | undefined, base: number, count: number, ): number | null { const fallback = memberDefault ?? "none"; if (fallback === "none") return null; if (fallback === "equal_share") return base / count; if ("share" in fallback) return fallback.share * base; return fallback.credits; }