|
|
@@ -36,7 +36,7 @@ import { useTranslation } from 'react-i18next'
|
|
|
import { toast } from 'sonner'
|
|
|
import { z } from 'zod'
|
|
|
|
|
|
-import { DataTablePage, useDataTable } from '@/components/data-table'
|
|
|
+import { BadgeCell, DataTablePage, useDataTable } from '@/components/data-table'
|
|
|
import { DatePicker } from '@/components/date-picker'
|
|
|
import {
|
|
|
SideDrawerSection,
|
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
sideDrawerFormClassName,
|
|
|
sideDrawerHeaderClassName,
|
|
|
} from '@/components/drawer-layout'
|
|
|
+import { GroupBadge } from '@/components/group-badge'
|
|
|
import { SectionPageLayout } from '@/components/layout'
|
|
|
import { LongText } from '@/components/long-text'
|
|
|
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
|
|
|
@@ -61,6 +62,7 @@ import {
|
|
|
import {
|
|
|
Form,
|
|
|
FormControl,
|
|
|
+ FormDescription,
|
|
|
FormField,
|
|
|
FormItem,
|
|
|
FormLabel,
|
|
|
@@ -92,6 +94,7 @@ import { useAuthStore } from '@/stores/auth-store'
|
|
|
import {
|
|
|
ApiKeyGroupCombobox,
|
|
|
type ApiKeyGroupOption,
|
|
|
+ type SelectedGroupData,
|
|
|
} from '../keys/components/api-key-group-combobox'
|
|
|
import { getPermissions, type PermissionItem } from '../permissions/api'
|
|
|
import { getGroups } from '../users/api'
|
|
|
@@ -101,7 +104,7 @@ import {
|
|
|
getDirectChildTenants,
|
|
|
updateDirectChildTenant,
|
|
|
} from './api'
|
|
|
-import type { Tenant, TenantFormData, TenantStatus } from './types'
|
|
|
+import type { Tenant, TenantFormData, TenantGroupData, TenantStatus } from './types'
|
|
|
|
|
|
const tenantStatusConfig: Record<
|
|
|
TenantStatus,
|
|
|
@@ -313,6 +316,9 @@ export function Tenants() {
|
|
|
const [submitAction, setSubmitAction] = useState<'save' | 'saveInvite'>(
|
|
|
'save'
|
|
|
)
|
|
|
+ // State for tracking selected group names and models
|
|
|
+ const [selectedGroupNames, setSelectedGroupNames] = useState<string>('')
|
|
|
+ const [selectedModelsByGroup, setSelectedModelsByGroup] = useState<Record<string, string[]>>({})
|
|
|
const [pagination, setPagination] = useState<PaginationState>({
|
|
|
pageIndex: 0,
|
|
|
pageSize: 10,
|
|
|
@@ -392,12 +398,16 @@ export function Tenants() {
|
|
|
})
|
|
|
|
|
|
const tenantGroupOptions = useMemo<ApiKeyGroupOption[]>(
|
|
|
- () =>
|
|
|
- (groupsData?.data ?? []).map((group) => ({
|
|
|
- value: group,
|
|
|
- label: group,
|
|
|
- desc: group,
|
|
|
- })),
|
|
|
+ () => {
|
|
|
+ const groups = (groupsData as any)?.data?.items ?? []
|
|
|
+ return groups.map((group: any) => ({
|
|
|
+ value: group.group_name,
|
|
|
+ label: group.group_name,
|
|
|
+ desc: group.group_desc || group.group_name,
|
|
|
+ models: group.models || [],
|
|
|
+ id: group.id,
|
|
|
+ }))
|
|
|
+ },
|
|
|
[groupsData?.data]
|
|
|
)
|
|
|
|
|
|
@@ -416,7 +426,10 @@ export function Tenants() {
|
|
|
const tenantFormSchema = z
|
|
|
.object({
|
|
|
name: z.string().min(1, t('Tenant name is required')),
|
|
|
- group: z.string().min(1, t('Group is required')),
|
|
|
+ groups: z.array(z.object({
|
|
|
+ group_id: z.string(),
|
|
|
+ models: z.array(z.string())
|
|
|
+ })).min(1, t('Please select at least one group')),
|
|
|
contact_name: z.string(),
|
|
|
contact_phone: z.string(),
|
|
|
contact_email: z
|
|
|
@@ -452,7 +465,7 @@ export function Tenants() {
|
|
|
) as unknown as Resolver<TenantFormData>,
|
|
|
defaultValues: {
|
|
|
name: '',
|
|
|
- group: '',
|
|
|
+ groups: [],
|
|
|
contact_name: '',
|
|
|
contact_phone: '',
|
|
|
contact_email: '',
|
|
|
@@ -478,11 +491,73 @@ export function Tenants() {
|
|
|
)
|
|
|
}, [directChildTenants, searchValue])
|
|
|
|
|
|
+ const handleGroupsChange = useCallback(
|
|
|
+ (groups: SelectedGroupData[]) => {
|
|
|
+ const formData: TenantGroupData[] = groups.map((g) => ({
|
|
|
+ group_id: g.group_id,
|
|
|
+ models: g.models,
|
|
|
+ }))
|
|
|
+ form.setValue('groups', formData, { shouldValidate: true })
|
|
|
+
|
|
|
+ if (groups.length > 0) {
|
|
|
+ form.clearErrors('groups')
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sync selectedModelsByGroup state
|
|
|
+ const modelsByGroup: Record<string, string[]> = {}
|
|
|
+ groups.forEach((g) => {
|
|
|
+ modelsByGroup[g.group_name] = g.models
|
|
|
+ })
|
|
|
+
|
|
|
+ const currentSerialized = JSON.stringify(selectedModelsByGroup)
|
|
|
+ const newSerialized = JSON.stringify(modelsByGroup)
|
|
|
+ if (currentSerialized !== newSerialized) {
|
|
|
+ setSelectedModelsByGroup(modelsByGroup)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [form, selectedModelsByGroup]
|
|
|
+ )
|
|
|
+
|
|
|
const resetForm = useCallback(
|
|
|
(tenant?: Tenant | null) => {
|
|
|
+ // Reset group selection states
|
|
|
+ setSelectedGroupNames('')
|
|
|
+ setSelectedModelsByGroup({})
|
|
|
+
|
|
|
+ // Build groups data for form
|
|
|
+ let groupsData: TenantGroupData[] = []
|
|
|
+ if (tenant?.groups && Array.isArray(tenant.groups) && tenant.groups.length > 0) {
|
|
|
+ groupsData = tenant.groups.map((g) => ({
|
|
|
+ group_id: g.group_id,
|
|
|
+ models: g.models || [],
|
|
|
+ }))
|
|
|
+
|
|
|
+ // Set initial display state
|
|
|
+ const groupNames = tenant.groups
|
|
|
+ .map((g) => g.group_name || g.group_id)
|
|
|
+ .filter(Boolean)
|
|
|
+ .join(',')
|
|
|
+ setSelectedGroupNames(groupNames)
|
|
|
+
|
|
|
+ // Set initial models state
|
|
|
+ const modelsByGroup: Record<string, string[]> = {}
|
|
|
+ tenant.groups.forEach((g) => {
|
|
|
+ const groupName = g.group_name || g.group_id
|
|
|
+ if (groupName && g.models) {
|
|
|
+ modelsByGroup[groupName] = g.models
|
|
|
+ }
|
|
|
+ })
|
|
|
+ setSelectedModelsByGroup(modelsByGroup)
|
|
|
+ } else if (tenant?.group) {
|
|
|
+ // Backward compatibility: convert legacy group to groups format
|
|
|
+ groupsData = [{ group_id: tenant.group, models: [] }]
|
|
|
+ setSelectedGroupNames(tenant.group)
|
|
|
+ setSelectedModelsByGroup({ [tenant.group]: [] })
|
|
|
+ }
|
|
|
+
|
|
|
form.reset({
|
|
|
name: tenant?.name ?? '',
|
|
|
- group: tenant?.group ?? '',
|
|
|
+ groups: groupsData,
|
|
|
contact_name: tenant?.admin_name ?? '',
|
|
|
contact_phone: tenant?.contact_phone ?? '',
|
|
|
contact_email: tenant?.admin_email ?? '',
|
|
|
@@ -554,7 +629,7 @@ export function Tenants() {
|
|
|
contact_person: data.contact_name || undefined,
|
|
|
contact_phone: data.contact_phone || undefined,
|
|
|
contact_email: data.contact_email || undefined,
|
|
|
- group_name: data.group,
|
|
|
+ groups: data.groups,
|
|
|
quota_limit: data.quota_limit === '' ? undefined : data.quota_limit,
|
|
|
start_time: toApiDateTime(data.starts_at, '00:00:00'),
|
|
|
end_time: toApiDateTime(data.ends_at, '23:59:59'),
|
|
|
@@ -587,7 +662,7 @@ export function Tenants() {
|
|
|
try {
|
|
|
const result = await createDirectChildTenant({
|
|
|
tenant_name: data.name,
|
|
|
- group_name: data.group,
|
|
|
+ groups: data.groups,
|
|
|
contact_person: data.contact_name || undefined,
|
|
|
contact_phone: data.contact_phone || undefined,
|
|
|
contact_email: data.contact_email || undefined,
|
|
|
@@ -735,25 +810,33 @@ export function Tenants() {
|
|
|
)}
|
|
|
/>
|
|
|
</div>
|
|
|
- <FormField
|
|
|
- control={form.control}
|
|
|
- name='group'
|
|
|
- render={({ field }) => (
|
|
|
- <FormItem className='mt-4 w-full min-w-0'>
|
|
|
- <FormLabel>{t('Group')}</FormLabel>
|
|
|
- <FormControl>
|
|
|
- <ApiKeyGroupCombobox
|
|
|
- options={tenantGroupOptions}
|
|
|
- value={field.value}
|
|
|
- onValueChange={field.onChange}
|
|
|
- placeholder={t('Select group')}
|
|
|
- className='w-full min-w-0'
|
|
|
- />
|
|
|
- </FormControl>
|
|
|
- <FormMessage />
|
|
|
- </FormItem>
|
|
|
+ <FormItem className='mt-4 w-full min-w-0'>
|
|
|
+ <FormLabel>
|
|
|
+ {t('Groups')}
|
|
|
+ <span className='text-destructive ml-1'>*</span>
|
|
|
+ </FormLabel>
|
|
|
+ <FormControl>
|
|
|
+ <ApiKeyGroupCombobox
|
|
|
+ options={tenantGroupOptions}
|
|
|
+ value={selectedGroupNames}
|
|
|
+ onValueChange={setSelectedGroupNames}
|
|
|
+ onGroupsChange={handleGroupsChange}
|
|
|
+ initialSelectedModelsByGroup={selectedModelsByGroup}
|
|
|
+ placeholder={t('Select groups')}
|
|
|
+ className='w-full min-w-0'
|
|
|
+ showModelSelector={true}
|
|
|
+ multiple={true}
|
|
|
+ />
|
|
|
+ </FormControl>
|
|
|
+ {/* <FormDescription>
|
|
|
+ {t('Select tenant groups and models for this account')}
|
|
|
+ </FormDescription> */}
|
|
|
+ {form.formState.errors.groups && (
|
|
|
+ <p className="text-destructive text-sm">
|
|
|
+ {form.formState.errors.groups.message as string}
|
|
|
+ </p>
|
|
|
)}
|
|
|
- />
|
|
|
+ </FormItem>
|
|
|
</SideDrawerSection>
|
|
|
|
|
|
<SideDrawerSection>
|
|
|
@@ -932,16 +1015,41 @@ export function Tenants() {
|
|
|
size: 120,
|
|
|
},
|
|
|
{
|
|
|
- accessorKey: 'group',
|
|
|
+ accessorKey: 'groups',
|
|
|
header: t('Group'),
|
|
|
- cell: ({ row }) => (
|
|
|
- <StatusBadge
|
|
|
- label={row.getValue('group') as string}
|
|
|
- autoColor={row.getValue('group') as string}
|
|
|
- copyable={false}
|
|
|
- />
|
|
|
- ),
|
|
|
- size: 140,
|
|
|
+ cell: ({ row }) => {
|
|
|
+ const tenant = row.original
|
|
|
+ const groups = tenant.groups
|
|
|
+ const hasGroups = Array.isArray(groups) && groups.length > 0
|
|
|
+
|
|
|
+ if (!hasGroups) {
|
|
|
+ // Fallback to legacy group field
|
|
|
+ if (tenant.group) {
|
|
|
+ return (
|
|
|
+ <GroupBadge
|
|
|
+ group={tenant.group}
|
|
|
+ label={tenant.group}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return <span className='text-muted-foreground'>-</span>
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <BadgeCell>
|
|
|
+ <div className='flex flex-wrap gap-1'>
|
|
|
+ {groups.map((g, index) => (
|
|
|
+ <GroupBadge
|
|
|
+ key={g.group_id || index}
|
|
|
+ group={g.group_name || g.group_id}
|
|
|
+ label={g.group_name || g.group_id}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </BadgeCell>
|
|
|
+ )
|
|
|
+ },
|
|
|
+ size: 200,
|
|
|
},
|
|
|
{
|
|
|
accessorKey: 'quota_limit',
|
|
|
@@ -1088,9 +1196,19 @@ export function Tenants() {
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4 sm:border-r'>
|
|
|
- <div className='text-muted-foreground'>{t('Group')}</div>
|
|
|
- <div className='mt-1 font-medium'>
|
|
|
- {detailTenant.group || '-'}
|
|
|
+ <div className='text-muted-foreground'>{t('Groups')}</div>
|
|
|
+ <div className='mt-1 flex flex-wrap gap-1'>
|
|
|
+ {detailTenant.groups && detailTenant.groups.length > 0
|
|
|
+ ? detailTenant.groups.map((g, index) => (
|
|
|
+ <GroupBadge
|
|
|
+ key={g.group_id || index}
|
|
|
+ group={g.group_name || g.group_id}
|
|
|
+ label={g.group_name || g.group_id}
|
|
|
+ />
|
|
|
+ ))
|
|
|
+ : detailTenant.group
|
|
|
+ ? <GroupBadge group={detailTenant.group} label={detailTenant.group} />
|
|
|
+ : '-'}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4'>
|