/* 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 . 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 { return safeJsonParse>(value, { fallback: {}, silent: true, }) } function parseUsableMap(value: string): Record { return safeJsonParse>(value, { fallback: {}, silent: true, }) } function parseNestedRatioMap( value: string ): Record> { return safeJsonParse>>(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 = {} const userUsableGroups: Record = {} const topupGroupRatio: Record = {} 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 ( {t('Not in pricing table')} ) } 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 ( ) } export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({ groupRatio, topupGroupRatio, userUsableGroups, groupGroupRatio, autoGroups, groupSpecialUsableGroup, onChange, }: GroupRatioVisualEditorProps) { const { t } = useTranslation() const [detailGroup, setDetailGroup] = useState(null) const registry = useMemo(() => { 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(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 (
{/* Auto Groups */} {t('Auto assignment order')} {t( 'Priority order for tokens in the auto group. The system tries groups from top to bottom.' )}
{autoGroupsList.length > 0 && (
{autoGroupsList.map((group, index) => (
{group} {!registryNames.includes(group) && }
))}
)}
{ if (!open) setDetailGroup(null) }} registry={registry} topupGroupRatio={topupGroupRatio} userUsableGroups={userUsableGroups} groupGroupRatio={groupGroupRatio} autoGroups={autoGroupsList} groupSpecialUsableGroup={groupSpecialUsableGroup} />
) }) 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(() => 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, 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() 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 (
{t('Pricing groups')} {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.' )}
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) => ( updateRow(row._id, 'name', event.target.value) } aria-invalid={duplicateNames.includes(row.name.trim())} /> ), }, { id: 'ratio', header: t('Ratio'), className: 'w-28', cell: (row) => ( updateRow(row._id, 'ratio', event.target.value) } /> ), }, { id: 'topup-ratio', header: t('Top-up ratio'), className: 'w-28', cell: (row) => ( updateRow(row._id, 'topupRatio', event.target.value) } /> ), }, { id: 'selectable', header: t('User selectable'), className: 'w-28 text-center', cell: (row) => (
updateRow(row._id, 'selectable', checked === true) } aria-label={t('User selectable')} />
), }, { id: 'description', header: t('Description'), className: 'min-w-56', cell: (row) => row.selectable ? ( updateRow(row._id, 'description', event.target.value) } /> ) : ( - ), }, { id: 'actions', header: t('Actions'), className: 'text-right', cellClassName: 'text-right', cell: (row) => (
), }, ]} /> {duplicateNames.length > 0 && (

{t('Duplicate group names: {{names}}', { names: duplicateNames.join(', '), })}

)}
) } 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(null) const [overrideDialogOpen, setOverrideDialogOpen] = useState(false) const [overrideUserGroup, setOverrideUserGroup] = useState( null ) const [overrideEditData, setOverrideEditData] = useState(null) const registryNames = useMemo( () => registry.map((entry) => entry.name), [registry] ) const baseRatioByName = useMemo(() => { const map = new Map() 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>) => { 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 ( {t('Special ratio rules')} {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.' )}
{groupGroupRatioList.length > 0 && (
{groupGroupRatioList.map((userGroupData) => (
} > {userGroupData.userGroup} {!registryNames.includes(userGroupData.userGroup) && ( )} {t('{{count}} override', { count: userGroupData.overrides.length, })}
{userGroupData.overrides.length > 0 && (
override.targetGroup} columns={[ { id: 'target-group', header: t('Billing group'), cellClassName: 'font-medium', cell: (override) => ( {override.targetGroup} {!registryNames.includes( override.targetGroup ) && ( )} ), }, { id: 'ratio', header: t('Ratio'), cell: (override) => { const baseRatio = baseRatioByName.get( override.targetGroup ) return ( {override.ratio} {baseRatio !== undefined && baseRatio !== override.ratio && ( {t('(instead of {{ratio}})', { ratio: baseRatio, })} )} ) }, }, { id: 'actions', header: t('Actions'), className: 'text-right', cellClassName: 'text-right', cell: (override) => ( handleOverrideEdit( userGroupData.userGroup, override ) } onDelete={() => handleOverrideDelete( userGroupData.userGroup, override.targetGroup ) } /> ), }, ]} />
)}
))}
)}
{/* Add user group dialog */} } >
) } 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 } function GroupOverrideDialog({ open, onOpenChange, onSave, editData, userGroup, groupOptions, baseRatioByName, }: GroupOverrideDialogProps) { const { t } = useTranslation() const [targetGroup, setTargetGroup] = useState(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 ( } >

{t('The token group that will have a custom ratio')}

{ const val = e.target.value if (val === '' || !Number.isNaN(Number.parseFloat(val))) { setRatio(val) } }} placeholder={baseRatio === undefined ? '0.9' : String(baseRatio)} />

{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'), })}

) } 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>>( 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 ( {t('Group details')} {name ? `: ${name}` : ''} {t('Everything configured for this group, in one place.')} {detail && (

{t('Overview')}

{t('Ratio')}
{detail.ratio ?? '-'}
{t('Top-up ratio')}
{detail.topupRatio ?? t('Not set')}
{t('User selectable')}
{detail.selectable ? t('Yes') : t('No')}
{detail.selectable && detail.description && (
{t('Description')}
{detail.description}
)}
{t('Auto assignment order')}
{detail.autoIndex >= 0 ? t('Position {{position}}', { position: detail.autoIndex + 1, }) : t('Not included')}

{t('Ratio overrides when billed as this group')}

{detail.incomingOverrides.length === 0 ? (

{t('None')}

) : (
    {detail.incomingOverrides.map((item) => (
  • {t('Users in {{group}}', { group: item.userGroup })} {item.ratio}
  • ))}
)}

{t('Ratio overrides for users of this group')}

{detail.outgoingOverrides.length === 0 ? (

{t('None')}

) : (
    {detail.outgoingOverrides.map((item) => (
  • {t('When billed as {{group}}', { group: item.targetGroup, })} {item.ratio}
  • ))}
)}

{t('Special visibility rules')}

{detail.visibilityRules.length === 0 ? (

{t('None')}

) : (
    {detail.visibilityRules.map((rule) => (
  • {rule.visible ? t('Extra visible to {{group}}', { group: rule.userGroup, }) : t('Hidden from {{group}}', { group: rule.userGroup, })} {rule.visible ? t('Visible') : t('Hidden')}
  • ))}
)}
)}
) }