fix(default): improve billing settings forms
This commit is contained in:
@@ -51,10 +51,10 @@ export function ProfileSettingsCard({
|
||||
icon={<Settings className='h-4 w-4' />}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
|
||||
<TabsList className='grid h-10 w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1'>
|
||||
<TabsTrigger
|
||||
value='bindings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
|
||||
>
|
||||
<Link2 className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
|
||||
@@ -62,7 +62,7 @@ export function ProfileSettingsCard({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
|
||||
>
|
||||
<Settings className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>
|
||||
|
||||
@@ -45,9 +45,13 @@ const BILLING_SECTIONS = [
|
||||
QuotaForInviter: settings.QuotaForInviter,
|
||||
QuotaForInvitee: settings.QuotaForInvitee,
|
||||
TopUpLink: settings.TopUpLink,
|
||||
'general_setting.docs_link': settings['general_setting.docs_link'],
|
||||
'quota_setting.enable_free_model_pre_consume':
|
||||
settings['quota_setting.enable_free_model_pre_consume'],
|
||||
general_setting: {
|
||||
docs_link: settings['general_setting.docs_link'],
|
||||
},
|
||||
quota_setting: {
|
||||
enable_free_model_pre_consume:
|
||||
settings['quota_setting.enable_free_model_pre_consume'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
+23
-11
@@ -1,4 +1,5 @@
|
||||
import * as z from 'zod'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -25,9 +26,13 @@ const quotaSchema = z.object({
|
||||
PreConsumedQuota: z.coerce.number().min(0),
|
||||
QuotaForInviter: z.coerce.number().min(0),
|
||||
QuotaForInvitee: z.coerce.number().min(0),
|
||||
TopUpLink: z.string().url().optional().or(z.literal('')),
|
||||
'general_setting.docs_link': z.string().url().optional().or(z.literal('')),
|
||||
'quota_setting.enable_free_model_pre_consume': z.boolean(),
|
||||
TopUpLink: z.string(),
|
||||
general_setting: z.object({
|
||||
docs_link: z.string(),
|
||||
}),
|
||||
quota_setting: z.object({
|
||||
enable_free_model_pre_consume: z.boolean(),
|
||||
}),
|
||||
})
|
||||
|
||||
type QuotaFormValues = z.infer<typeof quotaSchema>
|
||||
@@ -41,6 +46,13 @@ export function QuotaSettingsSection({
|
||||
}: QuotaSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const handleNumberChange =
|
||||
(onChange: (value: number | string) => void) =>
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(
|
||||
event.target.value === '' ? '' : event.currentTarget.valueAsNumber
|
||||
)
|
||||
}
|
||||
|
||||
const { form, handleSubmit, isDirty, isSubmitting } =
|
||||
useSettingsForm<QuotaFormValues>({
|
||||
@@ -79,8 +91,8 @@ export function QuotaSettingsSection({
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleNumberChange(field.onChange)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
@@ -103,8 +115,8 @@ export function QuotaSettingsSection({
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleNumberChange(field.onChange)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
@@ -127,8 +139,8 @@ export function QuotaSettingsSection({
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleNumberChange(field.onChange)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
@@ -151,8 +163,8 @@ export function QuotaSettingsSection({
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleNumberChange(field.onChange)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { type UseFormReturn } from 'react-hook-form'
|
||||
import { Code2, Eye } from 'lucide-react'
|
||||
import { Code2, Eye, HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -14,6 +20,13 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
|
||||
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
|
||||
|
||||
@@ -40,6 +53,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
}: GroupRatioFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof GroupFormValues, value: string) => {
|
||||
@@ -57,7 +71,15 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex justify-end'>
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setGuideOpen(true)}
|
||||
>
|
||||
<HelpCircle className='mr-2 h-4 w-4' />
|
||||
{t('Usage guide')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
@@ -73,6 +95,8 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GroupPricingGuide open={guideOpen} onOpenChange={setGuideOpen} />
|
||||
|
||||
<Form {...form}>
|
||||
{editMode === 'visual' ? (
|
||||
<div className='space-y-6'>
|
||||
@@ -276,3 +300,165 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
type GroupPricingGuideProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function GuideCodeBlock({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className='bg-muted/60 overflow-x-auto rounded-lg border px-3 py-2 text-xs leading-6 whitespace-pre-wrap'>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side='right' className='w-full gap-0 p-0 sm:max-w-2xl'>
|
||||
<SheetHeader className='border-b p-4'>
|
||||
<SheetTitle>{t('Group pricing usage guide')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t(
|
||||
'Understand how user groups, token groups, ratios, and special rules work together.'
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='space-y-5 overflow-y-auto p-4'>
|
||||
<section className='space-y-2'>
|
||||
<h3 className='text-sm font-semibold'>{t('Core concepts')}</h3>
|
||||
<div className='text-muted-foreground space-y-2 text-sm leading-6'>
|
||||
<p>
|
||||
<span className='text-foreground font-medium'>
|
||||
{t('User group')}
|
||||
</span>
|
||||
{': '}
|
||||
{t(
|
||||
'Assigned by administrators and used to represent a user level, such as default or vip.'
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<span className='text-foreground font-medium'>
|
||||
{t('Token group')}
|
||||
</span>
|
||||
{': '}
|
||||
{t(
|
||||
'Selected when creating a token and used as the default billing group for API calls.'
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<span className='text-foreground font-medium'>
|
||||
{t('Ratio')}
|
||||
</span>
|
||||
{': '}
|
||||
{t(
|
||||
'A billing multiplier. Lower ratios mean lower API call costs.'
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<span className='text-foreground font-medium'>
|
||||
{t('User selectable')}
|
||||
</span>
|
||||
{': '}
|
||||
{t(
|
||||
'When enabled, users can pick this group when creating tokens.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Accordion className='rounded-lg border px-3'>
|
||||
<AccordionItem value='groups'>
|
||||
<AccordionTrigger>{t('Pricing group example')}</AccordionTrigger>
|
||||
<AccordionContent className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.'
|
||||
)}
|
||||
</p>
|
||||
<GuideCodeBlock>
|
||||
{`${t('Group name')} ${t('Ratio')} ${t('User selectable')} ${t('Description')}
|
||||
standard 1.0 ${t('Yes')} ${t('Standard price')}
|
||||
premium 0.5 ${t('Yes')} ${t('Premium plan, half price')}
|
||||
vip 0.5 ${t('No')} ${t('Assigned by administrator only')}`}
|
||||
</GuideCodeBlock>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.'
|
||||
)}
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='auto'>
|
||||
<AccordionTrigger>{t('Auto group behavior')}</AccordionTrigger>
|
||||
<AccordionContent className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.'
|
||||
)}
|
||||
</p>
|
||||
<GuideCodeBlock>{`["default", "vip"]`}</GuideCodeBlock>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'If default auto group is enabled, newly created tokens start with auto instead of an empty group.'
|
||||
)}
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='special-ratio'>
|
||||
<AccordionTrigger>{t('Special ratio rules')}</AccordionTrigger>
|
||||
<AccordionContent className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'Special ratios override the token group ratio for specific user group and token group combinations.'
|
||||
)}
|
||||
</p>
|
||||
<GuideCodeBlock>{`{
|
||||
"vip": {
|
||||
"standard": 0.8,
|
||||
"premium": 0.3
|
||||
}
|
||||
}`}</GuideCodeBlock>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'Only configured combinations are overridden. All other calls keep the token group base ratio.'
|
||||
)}
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='usable'>
|
||||
<AccordionTrigger>{t('Special usable group rules')}</AccordionTrigger>
|
||||
<AccordionContent className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'Special usable group rules can add, remove, or append selectable token groups for a specific user group.'
|
||||
)}
|
||||
</p>
|
||||
<GuideCodeBlock>{`{
|
||||
"vip": {
|
||||
"+:premium": "${t('Premium plan, half price')}",
|
||||
"-:default": "remove",
|
||||
"special": "${t('Special group')}"
|
||||
}
|
||||
}`}</GuideCodeBlock>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
'Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.'
|
||||
)}
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
+353
-313
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, memo } from 'react'
|
||||
import { useState, useMemo, useEffect, useCallback, memo } from 'react'
|
||||
import { Pencil, Plus, Trash2, GripVertical, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -48,8 +49,11 @@ type SimpleGroup = {
|
||||
value: string
|
||||
}
|
||||
|
||||
type UsableGroup = {
|
||||
type GroupPricingRow = {
|
||||
_id: string
|
||||
name: string
|
||||
ratio: number
|
||||
selectable: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -58,6 +62,90 @@ type GroupOverride = {
|
||||
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 buildGroupPricingRows(
|
||||
groupRatio: string,
|
||||
userUsableGroups: string
|
||||
): GroupPricingRow[] {
|
||||
const ratioMap = safeJsonParse<Record<string, number>>(groupRatio, {
|
||||
fallback: {},
|
||||
context: 'group ratios',
|
||||
})
|
||||
const usableMap = safeJsonParse<Record<string, string>>(userUsableGroups, {
|
||||
fallback: {},
|
||||
context: 'user usable groups',
|
||||
})
|
||||
const names = new Set([...Object.keys(ratioMap), ...Object.keys(usableMap)])
|
||||
|
||||
return Array.from(names).map((name) => ({
|
||||
_id: createGroupPricingId(),
|
||||
name,
|
||||
ratio: normalizeRatio(ratioMap[name]),
|
||||
selectable: Object.prototype.hasOwnProperty.call(usableMap, name),
|
||||
description: String(usableMap[name] ?? ''),
|
||||
}))
|
||||
}
|
||||
|
||||
function serializeGroupPricingRows(rows: GroupPricingRow[]) {
|
||||
const groupRatio: Record<string, number> = {}
|
||||
const userUsableGroups: Record<string, string> = {}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
GroupRatio: JSON.stringify(groupRatio, null, 2),
|
||||
UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
function groupPricingSignature(rows: GroupPricingRow[]): string {
|
||||
const serialized = serializeGroupPricingRows(rows)
|
||||
return JSON.stringify({
|
||||
groupRatio: safeJsonParse(serialized.GroupRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
}),
|
||||
userUsableGroups: safeJsonParse(serialized.UserUsableGroups, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function sourceGroupPricingSignature(
|
||||
groupRatio: string,
|
||||
userUsableGroups: string
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
groupRatio: safeJsonParse(groupRatio, { fallback: {}, silent: true }),
|
||||
userUsableGroups: safeJsonParse(userUsableGroups, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
groupRatio,
|
||||
topupGroupRatio,
|
||||
@@ -73,9 +161,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
>(null)
|
||||
const [simpleEditData, setSimpleEditData] = useState<SimpleGroup | null>(null)
|
||||
|
||||
const [usableDialogOpen, setUsableDialogOpen] = useState(false)
|
||||
const [usableEditData, setUsableEditData] = useState<UsableGroup | null>(null)
|
||||
|
||||
const [autoGroupDialogOpen, setAutoGroupDialogOpen] = useState(false)
|
||||
const [autoGroupInput, setAutoGroupInput] = useState('')
|
||||
|
||||
@@ -89,18 +174,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
const [userGroupDialogOpen, setUserGroupDialogOpen] = useState(false)
|
||||
const [userGroupInput, setUserGroupInput] = useState('')
|
||||
|
||||
// Parse group ratios
|
||||
const groupRatioList = useMemo(() => {
|
||||
const map = safeJsonParse<Record<string, number>>(groupRatio, {
|
||||
fallback: {},
|
||||
context: 'group ratios',
|
||||
})
|
||||
return Object.entries(map).map(([name, value]) => ({
|
||||
name,
|
||||
value: String(value),
|
||||
}))
|
||||
}, [groupRatio])
|
||||
|
||||
// Parse topup group ratios
|
||||
const topupRatioList = useMemo(() => {
|
||||
const map = safeJsonParse<Record<string, number>>(topupGroupRatio, {
|
||||
@@ -113,18 +186,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
}))
|
||||
}, [topupGroupRatio])
|
||||
|
||||
// Parse usable groups
|
||||
const usableGroupsList = useMemo(() => {
|
||||
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
|
||||
fallback: {},
|
||||
context: 'user usable groups',
|
||||
})
|
||||
return Object.entries(map).map(([name, description]) => ({
|
||||
name,
|
||||
description: String(description),
|
||||
}))
|
||||
}, [userUsableGroups])
|
||||
|
||||
// Parse auto groups
|
||||
const autoGroupsList = useMemo(() => {
|
||||
return safeJsonParse<string[]>(autoGroups, {
|
||||
@@ -204,42 +265,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
onChange(field, JSON.stringify(map, null, 2))
|
||||
}
|
||||
|
||||
// Usable groups handlers
|
||||
const handleUsableAdd = () => {
|
||||
setUsableEditData(null)
|
||||
setUsableDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleUsableEdit = (group: UsableGroup) => {
|
||||
setUsableEditData(group)
|
||||
setUsableDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleUsableSave = (name: string, description: string) => {
|
||||
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
|
||||
if (usableEditData && usableEditData.name !== name) {
|
||||
delete map[usableEditData.name]
|
||||
}
|
||||
|
||||
map[name] = description
|
||||
|
||||
onChange('UserUsableGroups', JSON.stringify(map, null, 2))
|
||||
setUsableDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleUsableDelete = (name: string) => {
|
||||
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
delete map[name]
|
||||
onChange('UserUsableGroups', JSON.stringify(map, null, 2))
|
||||
}
|
||||
|
||||
// Auto groups handlers
|
||||
const handleAutoGroupAdd = () => {
|
||||
setAutoGroupInput('')
|
||||
@@ -366,75 +391,16 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Group Ratios */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('Group ratios')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('Base multipliers applied when users select specific groups.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<Button onClick={() => handleSimpleAdd('groupRatio')} size='sm'>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add group')}
|
||||
</Button>
|
||||
{groupRatioList.length > 0 && (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group name')}</TableHead>
|
||||
<TableHead>{t('Ratio')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupRatioList.map((group) => (
|
||||
<TableRow key={group.name}>
|
||||
<TableCell className='font-medium'>
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>{group.value}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleEdit('groupRatio', group)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleDelete('groupRatio', group.name)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className='space-y-4'>
|
||||
<GroupPricingTable
|
||||
groupRatio={groupRatio}
|
||||
userUsableGroups={userUsableGroups}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
{/* Topup Group Ratios */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className={sectionCardClassName}>
|
||||
<CardHeader className={sectionHeaderClassName}>
|
||||
<CardTitle>{t('Top-up group ratios')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('Multipliers for recharge pricing based on user groups.')}
|
||||
@@ -504,8 +470,8 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
</Card>
|
||||
|
||||
{/* Inter-group ratio overrides */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className={sectionCardClassName}>
|
||||
<CardHeader className={sectionHeaderClassName}>
|
||||
<CardTitle>{t('Inter-group ratio overrides')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -625,70 +591,9 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usable Groups */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('Selectable groups')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('Groups that users can select when creating API keys.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<Button onClick={handleUsableAdd} size='sm'>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add group')}
|
||||
</Button>
|
||||
{usableGroupsList.length > 0 && (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group name')}</TableHead>
|
||||
<TableHead>{t('Description')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usableGroupsList.map((group) => (
|
||||
<TableRow key={group.name}>
|
||||
<TableCell className='font-medium'>
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>{group.description}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleUsableEdit(group)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleUsableDelete(group.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Auto Groups */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className={sectionCardClassName}>
|
||||
<CardHeader className={sectionHeaderClassName}>
|
||||
<CardTitle>{t('Auto assignment order')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -753,14 +658,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
type={simpleDialogType}
|
||||
/>
|
||||
|
||||
{/* Usable Group Dialog */}
|
||||
<UsableGroupDialog
|
||||
open={usableDialogOpen}
|
||||
onOpenChange={setUsableDialogOpen}
|
||||
onSave={handleUsableSave}
|
||||
editData={usableEditData}
|
||||
/>
|
||||
|
||||
{/* Auto Group Dialog */}
|
||||
<Dialog open={autoGroupDialogOpen} onOpenChange={setAutoGroupDialogOpen}>
|
||||
<DialogContent>
|
||||
@@ -835,6 +732,233 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
)
|
||||
})
|
||||
|
||||
type GroupPricingTableProps = {
|
||||
groupRatio: string
|
||||
userUsableGroups: string
|
||||
onChange: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
function GroupPricingTable({
|
||||
groupRatio,
|
||||
userUsableGroups,
|
||||
onChange,
|
||||
}: GroupPricingTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const [rows, setRows] = useState<GroupPricingRow[]>(() =>
|
||||
buildGroupPricingRows(groupRatio, userUsableGroups)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const incomingSignature = sourceGroupPricingSignature(
|
||||
groupRatio,
|
||||
userUsableGroups
|
||||
)
|
||||
setRows((currentRows) => {
|
||||
if (groupPricingSignature(currentRows) === incomingSignature) {
|
||||
return currentRows
|
||||
}
|
||||
return buildGroupPricingRows(groupRatio, userUsableGroups)
|
||||
})
|
||||
}, [groupRatio, userUsableGroups])
|
||||
|
||||
const emitRows = useCallback(
|
||||
(nextRows: GroupPricingRow[]) => {
|
||||
setRows(nextRows)
|
||||
const serialized = serializeGroupPricingRows(nextRows)
|
||||
onChange('GroupRatio', serialized.GroupRatio)
|
||||
onChange('UserUsableGroups', serialized.UserUsableGroups)
|
||||
},
|
||||
[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,
|
||||
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 Array.from(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('Edit billing ratios and user-selectable groups in one table.')}
|
||||
</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'>
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='min-w-40'>{t('Group name')}</TableHead>
|
||||
<TableHead className='w-28'>{t('Ratio')}</TableHead>
|
||||
<TableHead className='w-28 text-center'>
|
||||
{t('User selectable')}
|
||||
</TableHead>
|
||||
<TableHead className='min-w-56'>{t('Description')}</TableHead>
|
||||
<TableHead className='w-16 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className='text-muted-foreground h-20 text-center text-sm'
|
||||
>
|
||||
{t('No groups yet. Add a group to get started.')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row._id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'name', event.target.value)
|
||||
}
|
||||
aria-invalid={duplicateNames.includes(
|
||||
row.name.trim()
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={String(row.ratio)}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'ratio',
|
||||
normalizeRatio(event.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex justify-center'>
|
||||
<Checkbox
|
||||
checked={row.selectable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'selectable',
|
||||
checked === true
|
||||
)
|
||||
}
|
||||
aria-label={t('User selectable')}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{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>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeRow(row._id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{duplicateNames.length > 0 && (
|
||||
<p className='text-destructive text-sm'>
|
||||
{t('Duplicate group names: {{names}}', {
|
||||
names: duplicateNames.join(', '),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple Group Dialog Component
|
||||
type SimpleGroupDialogProps = {
|
||||
open: boolean
|
||||
@@ -857,6 +981,17 @@ function SimpleGroupDialog({
|
||||
|
||||
const title = type === 'groupRatio' ? t('group ratio') : t('top-up ratio')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName('')
|
||||
setValue('')
|
||||
return
|
||||
}
|
||||
|
||||
setName(editData?.name ?? '')
|
||||
setValue(editData?.value ?? '')
|
||||
}, [editData, open])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || !value.trim()) return
|
||||
onSave(name.trim(), value.trim())
|
||||
@@ -865,19 +1000,7 @@ function SimpleGroupDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open)
|
||||
if (open && editData) {
|
||||
setName(editData.name)
|
||||
setValue(editData.value)
|
||||
} else {
|
||||
setName('')
|
||||
setValue('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -926,88 +1049,6 @@ function SimpleGroupDialog({
|
||||
)
|
||||
}
|
||||
|
||||
// Usable Group Dialog Component
|
||||
type UsableGroupDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (name: string, description: string) => void
|
||||
editData: UsableGroup | null
|
||||
}
|
||||
|
||||
function UsableGroupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: UsableGroupDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || !description.trim()) return
|
||||
onSave(name.trim(), description.trim())
|
||||
setName('')
|
||||
setDescription('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open)
|
||||
if (open && editData) {
|
||||
setName(editData.name)
|
||||
setDescription(editData.description)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editData ? t('Edit selectable group') : t('Add selectable group')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Configure a group that users can select when creating API keys.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Group name')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('vip')}
|
||||
disabled={!!editData}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Description')}</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('VIP users with premium access')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editData ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Group Override Dialog Component
|
||||
type GroupOverrideDialogProps = {
|
||||
open: boolean
|
||||
@@ -1028,6 +1069,17 @@ function GroupOverrideDialog({
|
||||
const [targetGroup, setTargetGroup] = useState('')
|
||||
const [ratio, setRatio] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTargetGroup('')
|
||||
setRatio('')
|
||||
return
|
||||
}
|
||||
|
||||
setTargetGroup(editData?.targetGroup ?? '')
|
||||
setRatio(editData ? String(editData.ratio) : '')
|
||||
}, [editData, open])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!targetGroup.trim() || !ratio.trim()) return
|
||||
const parsedRatio = parseFloat(ratio)
|
||||
@@ -1039,19 +1091,7 @@ function GroupOverrideDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open)
|
||||
if (open && editData) {
|
||||
setTargetGroup(editData.targetGroup)
|
||||
setRatio(String(editData.ratio))
|
||||
} else {
|
||||
setTargetGroup('')
|
||||
setRatio('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
+5
-2
@@ -27,6 +27,9 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
const OP_ADD = 'add' as const
|
||||
const OP_REMOVE = 'remove' as const
|
||||
const OP_APPEND = 'append' as const
|
||||
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'
|
||||
|
||||
type OpType = typeof OP_ADD | typeof OP_REMOVE | typeof OP_APPEND
|
||||
|
||||
@@ -344,8 +347,8 @@ export function GroupSpecialUsableRulesEditor(
|
||||
}, [rules])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className={sectionCardClassName}>
|
||||
<CardHeader className={sectionHeaderClassName}>
|
||||
<CardTitle>{t('Special usable group rules')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
|
||||
@@ -132,7 +132,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
|
||||
{isSaving ? t('Saving...') : t('Save model ratios')}
|
||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -140,7 +140,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{t('Reset ratios')}
|
||||
{t('Reset prices')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,7 +323,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Button type='submit' disabled={isSaving}>
|
||||
{isSaving ? t('Saving...') : t('Save model ratios')}
|
||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -331,7 +331,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{t('Reset ratios')}
|
||||
{t('Reset prices')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -207,7 +207,7 @@ export function RatioSettingsCard({
|
||||
mutationFn: resetModelRatios,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success(t('Model ratios reset successfully'))
|
||||
toast.success(t('Model prices reset successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: ['system-options'] })
|
||||
setConfirmOpen(false)
|
||||
} else {
|
||||
@@ -422,7 +422,7 @@ export function RatioSettingsCard({
|
||||
}, [resetMutate])
|
||||
|
||||
const tabLabels: Record<RatioTabId, string> = {
|
||||
models: 'Model ratios',
|
||||
models: 'Model prices',
|
||||
groups: 'Group ratios',
|
||||
'tool-prices': 'Tool prices',
|
||||
'upstream-sync': 'Upstream price sync',
|
||||
@@ -480,26 +480,30 @@ export function RatioSettingsCard({
|
||||
|
||||
return (
|
||||
<SettingsSection title={t(titleKey)} description={t(descriptionKey)}>
|
||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
||||
<TabsList className={`grid w-full ${tabsGridClass}`}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{t(tabLabels[tab])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{visibleTabs.length === 1 ? (
|
||||
renderTabContent(defaultTab)
|
||||
) : (
|
||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
||||
<TabsList className={`grid w-full ${tabsGridClass}`}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{t(tabLabels[tab])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title={t('Reset all model ratios?')}
|
||||
title={t('Reset all model prices?')}
|
||||
desc={t(
|
||||
'This will clear custom pricing ratios and revert to upstream defaults.'
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user