Files
new-api/web/default/src/features/system-settings/models/group-ratio-visual-editor.tsx
T
feitianbubu 394b023dbf fix: keep group ratio input as string draft to allow decimal typing (#5995)
The ratio column normalized input to number on every keystroke, so
typing 0.05 got stuck at 0.0 which Number() collapses to 0. Keep the
value as a string draft like the top-up ratio column and only convert
to number when serializing.

Fixes #5986
2026-07-07 21:12:54 +08:00

1361 lines
43 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
AlertTriangle,
ChevronDown,
GripVertical,
Info,
Plus,
Trash2,
} from 'lucide-react'
import { useState, useMemo, useEffect, useCallback, memo } from 'react'
import { useTranslation } from 'react-i18next'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Dialog } from '@/components/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { safeJsonParse } from '../utils/json-parser'
type GroupRatioVisualEditorProps = {
groupRatio: string
topupGroupRatio: string
userUsableGroups: string
groupGroupRatio: string
autoGroups: string
groupSpecialUsableGroup: string
onChange: (field: string, value: string) => void
}
type GroupPricingRow = {
_id: string
name: string
ratio: string
topupRatio: string
selectable: boolean
description: string
}
type RegistryEntry = {
name: string
ratio: number
}
const sectionCardClassName =
'relative shadow-sm ring-0 before:pointer-events-none before:absolute before:inset-0 before:rounded-xl before:border before:border-border/90'
const sectionHeaderClassName = 'border-b bg-muted/20'
let groupPricingIdCounter = 0
function createGroupPricingId() {
groupPricingIdCounter += 1
return `gpr_${groupPricingIdCounter}`
}
function normalizeRatio(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 1
}
function parseRatioMap(value: string): Record<string, number> {
return safeJsonParse<Record<string, number>>(value, {
fallback: {},
silent: true,
})
}
function parseUsableMap(value: string): Record<string, string> {
return safeJsonParse<Record<string, string>>(value, {
fallback: {},
silent: true,
})
}
function parseNestedRatioMap(
value: string
): Record<string, Record<string, number>> {
return safeJsonParse<Record<string, Record<string, number>>>(value, {
fallback: {},
silent: true,
})
}
function buildGroupPricingRows(
groupRatio: string,
userUsableGroups: string,
topupGroupRatio: string
): GroupPricingRow[] {
const ratioMap = parseRatioMap(groupRatio)
const usableMap = parseUsableMap(userUsableGroups)
const topupMap = parseRatioMap(topupGroupRatio)
const names = new Set([
...Object.keys(ratioMap),
...Object.keys(usableMap),
...Object.keys(topupMap),
])
return [...names].map((name) => ({
_id: createGroupPricingId(),
name,
ratio: String(normalizeRatio(ratioMap[name])),
topupRatio: Object.hasOwn(topupMap, name) ? String(topupMap[name]) : '',
selectable: Object.hasOwn(usableMap, name),
description: String(usableMap[name] ?? ''),
}))
}
function serializeGroupPricingRows(rows: GroupPricingRow[]) {
const groupRatio: Record<string, number> = {}
const userUsableGroups: Record<string, string> = {}
const topupGroupRatio: Record<string, number> = {}
for (const row of rows) {
const name = row.name.trim()
if (!name) continue
groupRatio[name] = normalizeRatio(row.ratio)
if (row.selectable) {
userUsableGroups[name] = row.description
}
const topup = row.topupRatio.trim()
if (topup !== '' && Number.isFinite(Number(topup))) {
topupGroupRatio[name] = Number(topup)
}
}
return {
GroupRatio: JSON.stringify(groupRatio, null, 2),
UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
TopupGroupRatio: JSON.stringify(topupGroupRatio, null, 2),
}
}
function groupPricingSignature(rows: GroupPricingRow[]): string {
const serialized = serializeGroupPricingRows(rows)
return JSON.stringify({
groupRatio: parseRatioMap(serialized.GroupRatio),
userUsableGroups: parseUsableMap(serialized.UserUsableGroups),
topupGroupRatio: parseRatioMap(serialized.TopupGroupRatio),
})
}
function sourceGroupPricingSignature(
groupRatio: string,
userUsableGroups: string,
topupGroupRatio: string
): string {
return JSON.stringify({
groupRatio: parseRatioMap(groupRatio),
userUsableGroups: parseUsableMap(userUsableGroups),
topupGroupRatio: parseRatioMap(topupGroupRatio),
})
}
function UnknownGroupBadge() {
const { t } = useTranslation()
return (
<StatusBadge variant='danger' copyable={false}>
<AlertTriangle className='mr-1 h-3 w-3' />
{t('Not in pricing table')}
</StatusBadge>
)
}
type GroupNameSelectProps = {
options: string[]
value: string | null
placeholder: string
onValueChange: (value: string) => void
className?: string
}
function GroupNameSelect(props: GroupNameSelectProps) {
const options = useMemo(() => {
if (props.value && !props.options.includes(props.value)) {
return [props.value, ...props.options]
}
return props.options
}, [props.options, props.value])
return (
<Select
value={props.value === '' ? null : props.value}
onValueChange={(v) => {
if (typeof v === 'string' && v !== '') props.onValueChange(v)
}}
>
<SelectTrigger className={props.className ?? 'w-48'}>
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{options.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
groupRatio,
topupGroupRatio,
userUsableGroups,
groupGroupRatio,
autoGroups,
groupSpecialUsableGroup,
onChange,
}: GroupRatioVisualEditorProps) {
const { t } = useTranslation()
const [detailGroup, setDetailGroup] = useState<string | null>(null)
const registry = useMemo<RegistryEntry[]>(() => {
const ratioMap = parseRatioMap(groupRatio)
const usableMap = parseUsableMap(userUsableGroups)
const topupMap = parseRatioMap(topupGroupRatio)
const names = new Set([
...Object.keys(ratioMap),
...Object.keys(usableMap),
...Object.keys(topupMap),
])
return [...names].map((name) => ({
name,
ratio: normalizeRatio(ratioMap[name]),
}))
}, [groupRatio, userUsableGroups, topupGroupRatio])
const registryNames = useMemo(
() => registry.map((entry) => entry.name),
[registry]
)
// Auto groups
const autoGroupsList = useMemo(() => {
return safeJsonParse<string[]>(autoGroups, {
fallback: [],
context: 'auto groups',
})
}, [autoGroups])
const handleAutoGroupAdd = useCallback(
(name: string) => {
if (autoGroupsList.includes(name)) return
onChange('AutoGroups', JSON.stringify([...autoGroupsList, name], null, 2))
},
[autoGroupsList, onChange]
)
const handleAutoGroupDelete = useCallback(
(index: number) => {
const list = autoGroupsList.filter((_, i) => i !== index)
onChange('AutoGroups', JSON.stringify(list, null, 2))
},
[autoGroupsList, onChange]
)
const handleAutoGroupMove = useCallback(
(index: number, direction: 'up' | 'down') => {
const list = [...autoGroupsList]
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= list.length) return
;[list[index], list[newIndex]] = [list[newIndex], list[index]]
onChange('AutoGroups', JSON.stringify(list, null, 2))
},
[autoGroupsList, onChange]
)
const autoGroupCandidates = useMemo(
() => registryNames.filter((name) => !autoGroupsList.includes(name)),
[registryNames, autoGroupsList]
)
return (
<div className='space-y-4'>
<GroupPricingTable
groupRatio={groupRatio}
userUsableGroups={userUsableGroups}
topupGroupRatio={topupGroupRatio}
onChange={onChange}
onShowDetail={setDetailGroup}
/>
<GroupOverrideRules
registry={registry}
groupGroupRatio={groupGroupRatio}
onChange={onChange}
/>
{/* Auto Groups */}
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<CardTitle>{t('Auto assignment order')}</CardTitle>
<CardDescription>
{t(
'Priority order for tokens in the auto group. The system tries groups from top to bottom.'
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<GroupNameSelect
options={autoGroupCandidates}
value={null}
placeholder={t('Add group')}
onValueChange={handleAutoGroupAdd}
/>
{autoGroupsList.length > 0 && (
<div className='space-y-2'>
{autoGroupsList.map((group, index) => (
<div
key={group}
className='flex items-center gap-2 rounded-md border p-3'
>
<GripVertical className='text-muted-foreground h-4 w-4' />
<span className='font-medium'>{group}</span>
{!registryNames.includes(group) && <UnknownGroupBadge />}
<div className='ml-auto flex gap-1'>
<Button
variant='ghost'
size='sm'
disabled={index === 0}
onClick={() => handleAutoGroupMove(index, 'up')}
>
</Button>
<Button
variant='ghost'
size='sm'
disabled={index === autoGroupsList.length - 1}
onClick={() => handleAutoGroupMove(index, 'down')}
>
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleAutoGroupDelete(index)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
<GroupDetailSheet
groupName={detailGroup}
onOpenChange={(open) => {
if (!open) setDetailGroup(null)
}}
registry={registry}
topupGroupRatio={topupGroupRatio}
userUsableGroups={userUsableGroups}
groupGroupRatio={groupGroupRatio}
autoGroups={autoGroupsList}
groupSpecialUsableGroup={groupSpecialUsableGroup}
/>
</div>
)
})
type GroupPricingTableProps = {
groupRatio: string
userUsableGroups: string
topupGroupRatio: string
onChange: (field: string, value: string) => void
onShowDetail: (name: string) => void
}
function GroupPricingTable({
groupRatio,
userUsableGroups,
topupGroupRatio,
onChange,
onShowDetail,
}: GroupPricingTableProps) {
const { t } = useTranslation()
const [rows, setRows] = useState<GroupPricingRow[]>(() =>
buildGroupPricingRows(groupRatio, userUsableGroups, topupGroupRatio)
)
useEffect(() => {
const incomingSignature = sourceGroupPricingSignature(
groupRatio,
userUsableGroups,
topupGroupRatio
)
setRows((currentRows) => {
if (groupPricingSignature(currentRows) === incomingSignature) {
return currentRows
}
return buildGroupPricingRows(
groupRatio,
userUsableGroups,
topupGroupRatio
)
})
}, [groupRatio, userUsableGroups, topupGroupRatio])
const emitRows = useCallback(
(nextRows: GroupPricingRow[]) => {
setRows(nextRows)
const serialized = serializeGroupPricingRows(nextRows)
onChange('GroupRatio', serialized.GroupRatio)
onChange('UserUsableGroups', serialized.UserUsableGroups)
onChange('TopupGroupRatio', serialized.TopupGroupRatio)
},
[onChange]
)
const updateRow = useCallback(
(
id: string,
field: Exclude<keyof GroupPricingRow, '_id'>,
value: string | number | boolean
) => {
emitRows(
rows.map((row) => (row._id === id ? { ...row, [field]: value } : row))
)
},
[emitRows, rows]
)
const addRow = useCallback(() => {
const existingNames = new Set(rows.map((row) => row.name))
let index = 1
let name = `group_${index}`
while (existingNames.has(name)) {
index += 1
name = `group_${index}`
}
emitRows([
...rows,
{
_id: createGroupPricingId(),
name,
ratio: '1',
topupRatio: '',
selectable: true,
description: '',
},
])
}, [emitRows, rows])
const removeRow = useCallback(
(id: string) => {
emitRows(rows.filter((row) => row._id !== id))
},
[emitRows, rows]
)
const duplicateNames = useMemo(() => {
const counts = new Map<string, number>()
for (const row of rows) {
const name = row.name.trim()
if (!name) continue
counts.set(name, (counts.get(name) ?? 0) + 1)
}
return [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([name]) => name)
}, [rows])
return (
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div>
<CardTitle>{t('Pricing groups')}</CardTitle>
<CardDescription>
{t(
'All group names live here. Ratio applies when calls are billed as this group; top-up ratio applies to users whose account is in this group.'
)}
</CardDescription>
</div>
<Button onClick={addRow} size='sm' className='sm:self-start'>
<Plus className='mr-2 h-4 w-4' />
{t('Add group')}
</Button>
</div>
</CardHeader>
<CardContent>
<div className='space-y-3'>
<StaticDataTable
data={rows}
getRowKey={(row) => row._id}
emptyClassName='text-muted-foreground h-20 text-sm'
emptyContent={t('No groups yet. Add a group to get started.')}
columns={[
{
id: 'group',
header: t('Group name'),
className: 'min-w-40',
cell: (row) => (
<Input
value={row.name}
onChange={(event) =>
updateRow(row._id, 'name', event.target.value)
}
aria-invalid={duplicateNames.includes(row.name.trim())}
/>
),
},
{
id: 'ratio',
header: t('Ratio'),
className: 'w-28',
cell: (row) => (
<Input
type='number'
min={0}
step={0.1}
value={row.ratio}
onChange={(event) =>
updateRow(row._id, 'ratio', event.target.value)
}
/>
),
},
{
id: 'topup-ratio',
header: t('Top-up ratio'),
className: 'w-28',
cell: (row) => (
<Input
type='number'
min={0}
step={0.1}
value={row.topupRatio}
placeholder={t('Not set')}
onChange={(event) =>
updateRow(row._id, 'topupRatio', event.target.value)
}
/>
),
},
{
id: 'selectable',
header: t('User selectable'),
className: 'w-28 text-center',
cell: (row) => (
<div className='flex justify-center'>
<Checkbox
checked={row.selectable}
onCheckedChange={(checked) =>
updateRow(row._id, 'selectable', checked === true)
}
aria-label={t('User selectable')}
/>
</div>
),
},
{
id: 'description',
header: t('Description'),
className: 'min-w-56',
cell: (row) =>
row.selectable ? (
<Input
value={row.description}
placeholder={t('Group description')}
onChange={(event) =>
updateRow(row._id, 'description', event.target.value)
}
/>
) : (
<span className='text-muted-foreground px-3 text-sm'>
-
</span>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (row) => (
<div className='flex justify-end gap-1'>
<Button
variant='ghost'
size='sm'
onClick={() => onShowDetail(row.name.trim())}
disabled={!row.name.trim()}
aria-label={t('Details')}
>
<Info className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => removeRow(row._id)}
aria-label={t('Delete')}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
},
]}
/>
{duplicateNames.length > 0 && (
<p className='text-destructive text-sm'>
{t('Duplicate group names: {{names}}', {
names: duplicateNames.join(', '),
})}
</p>
)}
</div>
</CardContent>
</Card>
)
}
type GroupOverride = {
targetGroup: string
ratio: number
}
type GroupOverrideRulesProps = {
registry: RegistryEntry[]
groupGroupRatio: string
onChange: (field: string, value: string) => void
}
function GroupOverrideRules({
registry,
groupGroupRatio,
onChange,
}: GroupOverrideRulesProps) {
const { t } = useTranslation()
const [userGroupDialogOpen, setUserGroupDialogOpen] = useState(false)
const [userGroupInput, setUserGroupInput] = useState<string | null>(null)
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false)
const [overrideUserGroup, setOverrideUserGroup] = useState<string | null>(
null
)
const [overrideEditData, setOverrideEditData] =
useState<GroupOverride | null>(null)
const registryNames = useMemo(
() => registry.map((entry) => entry.name),
[registry]
)
const baseRatioByName = useMemo(() => {
const map = new Map<string, number>()
for (const entry of registry) map.set(entry.name, entry.ratio)
return map
}, [registry])
const groupGroupRatioList = useMemo(() => {
const map = parseNestedRatioMap(groupGroupRatio)
return Object.entries(map).map(([userGroup, overrides]) => ({
userGroup,
overrides: Object.entries(overrides).map(([targetGroup, ratio]) => ({
targetGroup,
ratio,
})),
}))
}, [groupGroupRatio])
const emitMap = useCallback(
(map: Record<string, Record<string, number>>) => {
onChange('GroupGroupRatio', JSON.stringify(map, null, 2))
},
[onChange]
)
const handleUserGroupSave = useCallback(() => {
if (!userGroupInput) return
const map = parseNestedRatioMap(groupGroupRatio)
if (!map[userGroupInput]) {
map[userGroupInput] = {}
}
emitMap(map)
setUserGroupDialogOpen(false)
setUserGroupInput(null)
}, [userGroupInput, groupGroupRatio, emitMap])
const handleUserGroupDelete = useCallback(
(userGroup: string) => {
const map = parseNestedRatioMap(groupGroupRatio)
delete map[userGroup]
emitMap(map)
},
[groupGroupRatio, emitMap]
)
const handleOverrideAdd = useCallback((userGroup: string) => {
setOverrideUserGroup(userGroup)
setOverrideEditData(null)
setOverrideDialogOpen(true)
}, [])
const handleOverrideEdit = useCallback(
(userGroup: string, override: GroupOverride) => {
setOverrideUserGroup(userGroup)
setOverrideEditData(override)
setOverrideDialogOpen(true)
},
[]
)
const handleOverrideSave = useCallback(
(targetGroup: string, ratio: number, oldTargetGroup?: string) => {
if (!overrideUserGroup) return
const map = parseNestedRatioMap(groupGroupRatio)
if (!map[overrideUserGroup]) {
map[overrideUserGroup] = {}
}
if (oldTargetGroup && oldTargetGroup !== targetGroup) {
delete map[overrideUserGroup][oldTargetGroup]
}
map[overrideUserGroup][targetGroup] = ratio
emitMap(map)
setOverrideDialogOpen(false)
},
[overrideUserGroup, groupGroupRatio, emitMap]
)
const handleOverrideDelete = useCallback(
(userGroup: string, targetGroup: string) => {
const map = parseNestedRatioMap(groupGroupRatio)
if (map[userGroup]) {
delete map[userGroup][targetGroup]
if (Object.keys(map[userGroup]).length === 0) {
delete map[userGroup]
}
}
emitMap(map)
},
[groupGroupRatio, emitMap]
)
return (
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<CardTitle>{t('Special ratio rules')}</CardTitle>
<CardDescription>
{t(
'Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.'
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<Button
onClick={() => {
setUserGroupInput(null)
setUserGroupDialogOpen(true)
}}
size='sm'
>
<Plus className='mr-2 h-4 w-4' />
{t('Add user group')}
</Button>
{groupGroupRatioList.length > 0 && (
<div className='space-y-3'>
{groupGroupRatioList.map((userGroupData) => (
<Collapsible key={userGroupData.userGroup}>
<div className='rounded-lg border'>
<div className='flex items-center justify-between p-4'>
<div className='flex items-center gap-2'>
<CollapsibleTrigger
render={<Button variant='ghost' size='sm' />}
>
<ChevronDown className='h-4 w-4' />
</CollapsibleTrigger>
<span className='font-semibold'>
{userGroupData.userGroup}
</span>
{!registryNames.includes(userGroupData.userGroup) && (
<AlertTriangle
className='text-destructive h-4 w-4'
aria-label={t('Not in pricing table')}
/>
)}
<span className='text-muted-foreground text-sm'>
{t('{{count}} override', {
count: userGroupData.overrides.length,
})}
</span>
</div>
<div className='flex gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleOverrideAdd(userGroupData.userGroup)
}
>
<Plus className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleUserGroupDelete(userGroupData.userGroup)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
<CollapsibleContent>
{userGroupData.overrides.length > 0 && (
<div className='border-t'>
<StaticDataTable
className='rounded-none border-0'
data={userGroupData.overrides}
getRowKey={(override) => override.targetGroup}
columns={[
{
id: 'target-group',
header: t('Billing group'),
cellClassName: 'font-medium',
cell: (override) => (
<span className='inline-flex items-center gap-1.5'>
{override.targetGroup}
{!registryNames.includes(
override.targetGroup
) && (
<AlertTriangle
className='text-destructive h-3.5 w-3.5'
aria-label={t('Not in pricing table')}
/>
)}
</span>
),
},
{
id: 'ratio',
header: t('Ratio'),
cell: (override) => {
const baseRatio = baseRatioByName.get(
override.targetGroup
)
return (
<span className='inline-flex items-center gap-1.5'>
{override.ratio}
{baseRatio !== undefined &&
baseRatio !== override.ratio && (
<span className='text-muted-foreground text-xs'>
{t('(instead of {{ratio}})', {
ratio: baseRatio,
})}
</span>
)}
</span>
)
},
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'text-right',
cell: (override) => (
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() =>
handleOverrideEdit(
userGroupData.userGroup,
override
)
}
onDelete={() =>
handleOverrideDelete(
userGroupData.userGroup,
override.targetGroup
)
}
/>
),
},
]}
/>
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
))}
</div>
)}
</div>
</CardContent>
{/* Add user group dialog */}
<Dialog
open={userGroupDialogOpen}
onOpenChange={setUserGroupDialogOpen}
title={t('Add user group')}
description={t(
'Create a new user group to configure ratio overrides for.'
)}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => setUserGroupDialogOpen(false)}
>
{t('Cancel')}
</Button>
<Button onClick={handleUserGroupSave} disabled={!userGroupInput}>
{t('Add')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('User group name')}</Label>
<GroupNameSelect
className='w-full'
options={registryNames}
value={userGroupInput}
placeholder={t('Select a group')}
onValueChange={setUserGroupInput}
/>
</div>
</div>
</Dialog>
<GroupOverrideDialog
open={overrideDialogOpen}
onOpenChange={setOverrideDialogOpen}
onSave={handleOverrideSave}
editData={overrideEditData}
userGroup={overrideUserGroup}
groupOptions={registryNames}
baseRatioByName={baseRatioByName}
/>
</Card>
)
}
type GroupOverrideDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (targetGroup: string, ratio: number, oldTargetGroup?: string) => void
editData: GroupOverride | null
userGroup: string | null
groupOptions: string[]
baseRatioByName: Map<string, number>
}
function GroupOverrideDialog({
open,
onOpenChange,
onSave,
editData,
userGroup,
groupOptions,
baseRatioByName,
}: GroupOverrideDialogProps) {
const { t } = useTranslation()
const [targetGroup, setTargetGroup] = useState<string | null>(null)
const [ratio, setRatio] = useState('')
useEffect(() => {
if (!open) {
setTargetGroup(null)
setRatio('')
return
}
setTargetGroup(editData?.targetGroup ?? null)
setRatio(editData ? String(editData.ratio) : '')
}, [editData, open])
const baseRatio = targetGroup ? baseRatioByName.get(targetGroup) : undefined
const handleSave = () => {
if (!targetGroup || !ratio.trim()) return
const parsedRatio = Number.parseFloat(ratio)
if (Number.isNaN(parsedRatio)) return
onSave(targetGroup, parsedRatio, editData?.targetGroup)
setTargetGroup(null)
setRatio('')
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={editData ? t('Edit ratio override') : t('Add ratio override')}
description={
userGroup
? t(
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
{ userGroup }
)
: t(
'Configure a custom ratio for when users use a specific token group.'
)
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSave}>
{editData ? t('Update') : t('Add')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Billing group')}</Label>
<GroupNameSelect
className='w-full'
options={groupOptions}
value={targetGroup}
placeholder={t('Select a group')}
onValueChange={setTargetGroup}
/>
<p className='text-muted-foreground text-xs'>
{t('The token group that will have a custom ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={ratio}
onChange={(e) => {
const val = e.target.value
if (val === '' || !Number.isNaN(Number.parseFloat(val))) {
setRatio(val)
}
}}
placeholder={baseRatio === undefined ? '0.9' : String(baseRatio)}
/>
<p className='text-muted-foreground text-xs'>
{baseRatio !== undefined
? t('(instead of {{ratio}})', { ratio: baseRatio })
: t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
userGroup: userGroup || t('this user group'),
targetGroup: targetGroup || t('this token group'),
})}
</p>
</div>
</div>
</Dialog>
)
}
type GroupDetailSheetProps = {
groupName: string | null
onOpenChange: (open: boolean) => void
registry: RegistryEntry[]
topupGroupRatio: string
userUsableGroups: string
groupGroupRatio: string
autoGroups: string[]
groupSpecialUsableGroup: string
}
type VisibilityRule = {
userGroup: string
visible: boolean
description: string
}
function parseSpecialGroupKey(rawKey: string): {
visible: boolean
groupName: string
} {
if (rawKey.startsWith('-:')) {
return { visible: false, groupName: rawKey.slice(2) }
}
if (rawKey.startsWith('+:')) {
return { visible: true, groupName: rawKey.slice(2) }
}
return { visible: true, groupName: rawKey }
}
function GroupDetailSheet(props: GroupDetailSheetProps) {
const { t } = useTranslation()
const name = props.groupName
const detail = useMemo(() => {
if (!name) return null
const entry = props.registry.find((item) => item.name === name)
const topupMap = parseRatioMap(props.topupGroupRatio)
const usableMap = parseUsableMap(props.userUsableGroups)
const overrideMap = parseNestedRatioMap(props.groupGroupRatio)
const specialMap = safeJsonParse<Record<string, Record<string, string>>>(
props.groupSpecialUsableGroup,
{ fallback: {}, silent: true }
)
// Overrides that apply when other user groups bill as this group
const incomingOverrides: { userGroup: string; ratio: number }[] = []
for (const [userGroup, overrides] of Object.entries(overrideMap)) {
if (Object.hasOwn(overrides, name)) {
incomingOverrides.push({ userGroup, ratio: overrides[name] })
}
}
// Overrides that apply when users of this group bill as other groups
const outgoingOverrides = Object.entries(overrideMap[name] ?? {}).map(
([targetGroup, ratio]) => ({ targetGroup, ratio })
)
// Visibility rules targeting this group
const visibilityRules: VisibilityRule[] = []
for (const [userGroup, inner] of Object.entries(specialMap)) {
if (typeof inner !== 'object' || inner === null) continue
for (const [rawKey, desc] of Object.entries(inner)) {
const parsed = parseSpecialGroupKey(rawKey)
if (parsed.groupName !== name) continue
visibilityRules.push({
userGroup,
visible: parsed.visible,
description: typeof desc === 'string' ? desc : '',
})
}
}
const autoIndex = props.autoGroups.indexOf(name)
return {
ratio: entry?.ratio,
topupRatio: Object.hasOwn(topupMap, name) ? String(topupMap[name]) : null,
selectable: Object.hasOwn(usableMap, name),
description: String(usableMap[name] ?? ''),
incomingOverrides,
outgoingOverrides,
visibilityRules,
autoIndex,
}
}, [
name,
props.registry,
props.topupGroupRatio,
props.userUsableGroups,
props.groupGroupRatio,
props.autoGroups,
props.groupSpecialUsableGroup,
])
return (
<Sheet open={name !== null} onOpenChange={props.onOpenChange}>
<SheetContent
side='right'
className={sideDrawerContentClassName('sm:max-w-lg')}
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{t('Group details')}
{name ? `: ${name}` : ''}
</SheetTitle>
<SheetDescription>
{t('Everything configured for this group, in one place.')}
</SheetDescription>
</SheetHeader>
{detail && (
<div className={sideDrawerFormClassName('gap-5')}>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>{t('Overview')}</h3>
<dl className='space-y-1.5 text-sm'>
<div className='flex justify-between'>
<dt className='text-muted-foreground'>{t('Ratio')}</dt>
<dd className='font-medium'>{detail.ratio ?? '-'}</dd>
</div>
<div className='flex justify-between'>
<dt className='text-muted-foreground'>{t('Top-up ratio')}</dt>
<dd className='font-medium'>
{detail.topupRatio ?? t('Not set')}
</dd>
</div>
<div className='flex justify-between'>
<dt className='text-muted-foreground'>
{t('User selectable')}
</dt>
<dd className='font-medium'>
{detail.selectable ? t('Yes') : t('No')}
</dd>
</div>
{detail.selectable && detail.description && (
<div className='flex justify-between gap-4'>
<dt className='text-muted-foreground'>
{t('Description')}
</dt>
<dd className='text-right font-medium'>
{detail.description}
</dd>
</div>
)}
<div className='flex justify-between'>
<dt className='text-muted-foreground'>
{t('Auto assignment order')}
</dt>
<dd className='font-medium'>
{detail.autoIndex >= 0
? t('Position {{position}}', {
position: detail.autoIndex + 1,
})
: t('Not included')}
</dd>
</div>
</dl>
</section>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>
{t('Ratio overrides when billed as this group')}
</h3>
{detail.incomingOverrides.length === 0 ? (
<p className='text-muted-foreground text-sm'>{t('None')}</p>
) : (
<ul className='space-y-1 text-sm'>
{detail.incomingOverrides.map((item) => (
<li
key={item.userGroup}
className='flex justify-between rounded-md border px-3 py-1.5'
>
<span>
{t('Users in {{group}}', { group: item.userGroup })}
</span>
<span className='font-medium'>{item.ratio}</span>
</li>
))}
</ul>
)}
</section>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>
{t('Ratio overrides for users of this group')}
</h3>
{detail.outgoingOverrides.length === 0 ? (
<p className='text-muted-foreground text-sm'>{t('None')}</p>
) : (
<ul className='space-y-1 text-sm'>
{detail.outgoingOverrides.map((item) => (
<li
key={item.targetGroup}
className='flex justify-between rounded-md border px-3 py-1.5'
>
<span>
{t('When billed as {{group}}', {
group: item.targetGroup,
})}
</span>
<span className='font-medium'>{item.ratio}</span>
</li>
))}
</ul>
)}
</section>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>
{t('Special visibility rules')}
</h3>
{detail.visibilityRules.length === 0 ? (
<p className='text-muted-foreground text-sm'>{t('None')}</p>
) : (
<ul className='space-y-1 text-sm'>
{detail.visibilityRules.map((rule) => (
<li
key={`${rule.userGroup}-${rule.visible}`}
className='flex items-center justify-between rounded-md border px-3 py-1.5'
>
<span>
{rule.visible
? t('Extra visible to {{group}}', {
group: rule.userGroup,
})
: t('Hidden from {{group}}', {
group: rule.userGroup,
})}
</span>
<StatusBadge
variant={rule.visible ? 'info' : 'danger'}
copyable={false}
>
{rule.visible ? t('Visible') : t('Hidden')}
</StatusBadge>
</li>
))}
</ul>
)}
</section>
</div>
)}
</SheetContent>
</Sheet>
)
}