|
|
@@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
|
|
|
*/
|
|
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
|
+import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
import { Building2, Eye, Info, Mail, Pencil, Plus, Power, PowerOff } from 'lucide-react'
|
|
|
-import { nanoid } from 'nanoid'
|
|
|
-import { useMemo, useState } from 'react'
|
|
|
+import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
import { useForm, type Resolver } from 'react-hook-form'
|
|
|
import { useTranslation } from 'react-i18next'
|
|
|
import { toast } from 'sonner'
|
|
|
@@ -72,8 +72,18 @@ import {
|
|
|
TooltipTrigger,
|
|
|
} from '@/components/ui/tooltip'
|
|
|
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
|
|
-import { formatQuota, formatTimestamp } from '@/lib/format'
|
|
|
+import { formatNumber, formatTimestamp } from '@/lib/format'
|
|
|
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
|
|
|
+import { useAuthStore } from '@/stores/auth-store'
|
|
|
|
|
|
+import { getPermissionsWithRole, type PermissionItem } from '../permissions/api'
|
|
|
+import { getGroups } from '../users/api'
|
|
|
+import {
|
|
|
+ createDirectChildTenant,
|
|
|
+ getDirectChildTenantDetail,
|
|
|
+ getDirectChildTenants,
|
|
|
+ updateDirectChildTenant,
|
|
|
+} from './api'
|
|
|
import type { Tenant, TenantFormData, TenantStatus } from './types'
|
|
|
|
|
|
const TENANT_BASE_TIME = 1_725_523_200
|
|
|
@@ -82,12 +92,10 @@ const tenantStatusConfig: Record<
|
|
|
TenantStatus,
|
|
|
{ labelKey: string; variant: StatusVariant }
|
|
|
> = {
|
|
|
- active: { labelKey: 'Normal', variant: 'success' },
|
|
|
+ active: { labelKey: 'Enabled', variant: 'success' },
|
|
|
disabled: { labelKey: 'Disabled', variant: 'neutral' },
|
|
|
}
|
|
|
|
|
|
-const tenantGroupOptions = ['医疗机构', '区域平台', '科研试用']
|
|
|
-
|
|
|
const tenantPermissionModules = [
|
|
|
{
|
|
|
key: 'channels',
|
|
|
@@ -111,6 +119,63 @@ const tenantPermissionModules = [
|
|
|
},
|
|
|
]
|
|
|
|
|
|
+type TenantPermissionModule = (typeof tenantPermissionModules)[number]
|
|
|
+
|
|
|
+function getTenantPermissionKey(permission: PermissionItem) {
|
|
|
+ return (
|
|
|
+ permission.module_code ??
|
|
|
+ permission.module_key ??
|
|
|
+ permission.module ??
|
|
|
+ permission.key ??
|
|
|
+ permission.permission_key ??
|
|
|
+ permission.code ??
|
|
|
+ permission.permission_code ??
|
|
|
+ permission.resource ??
|
|
|
+ String(permission.id ?? '')
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function getTenantPermissionTitle(permission: PermissionItem, fallback: string) {
|
|
|
+ return (
|
|
|
+ permission.module_name ??
|
|
|
+ permission.module_label ??
|
|
|
+ permission.title ??
|
|
|
+ permission.name ??
|
|
|
+ permission.label ??
|
|
|
+ fallback
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function permissionHasRole(permission: PermissionItem) {
|
|
|
+ return permission.has_permission ?? permission.checked ?? permission.selected ?? false
|
|
|
+}
|
|
|
+
|
|
|
+function toTenantPermissionModules(permissions: PermissionItem[]) {
|
|
|
+ if (!permissions.length) return tenantPermissionModules
|
|
|
+
|
|
|
+ return permissions.map((permission) => {
|
|
|
+ const key = getTenantPermissionKey(permission)
|
|
|
+ return {
|
|
|
+ key,
|
|
|
+ title: getTenantPermissionTitle(permission, key),
|
|
|
+ description: permission.module_desc ?? permission.description ?? '',
|
|
|
+ }
|
|
|
+ }).filter((module) => module.key) as TenantPermissionModule[]
|
|
|
+}
|
|
|
+
|
|
|
+function toCheckedPermissionKeys(permissions: PermissionItem[]) {
|
|
|
+ const keys: string[] = []
|
|
|
+ for (const permission of permissions) {
|
|
|
+ const key = getTenantPermissionKey(permission)
|
|
|
+ if (key && permissionHasRole(permission)) keys.push(key)
|
|
|
+ for (const child of permission.children ?? permission.permissions ?? []) {
|
|
|
+ const childKey = getTenantPermissionKey(child)
|
|
|
+ if (childKey && permissionHasRole(child)) keys.push(childKey)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return keys
|
|
|
+}
|
|
|
+
|
|
|
const defaultTenantPermissionKeys = tenantPermissionModules.map((module) => module.key)
|
|
|
|
|
|
const initialTenants: Tenant[] = [
|
|
|
@@ -167,34 +232,45 @@ const initialTenants: Tenant[] = [
|
|
|
},
|
|
|
]
|
|
|
|
|
|
-function createTenantCode(name: string) {
|
|
|
- const normalizedName = name.trim().toLowerCase()
|
|
|
- if (!normalizedName) return `tenant-${Date.now()}`
|
|
|
- return normalizedName.replaceAll(/\s+/g, '-').slice(0, 32)
|
|
|
-}
|
|
|
-
|
|
|
function toDateInputValue(timestamp: number | null) {
|
|
|
if (!timestamp) return ''
|
|
|
return new Date(timestamp * 1000).toISOString().slice(0, 10)
|
|
|
}
|
|
|
|
|
|
-function toTimestamp(value: string) {
|
|
|
- if (!value) return null
|
|
|
- return Math.floor(new Date(`${value}T23:59:59`).getTime() / 1000)
|
|
|
+function toApiDateTime(value: string, time: string) {
|
|
|
+ if (!value) return undefined
|
|
|
+ return `${value}T${time}+08:00`
|
|
|
+}
|
|
|
+
|
|
|
+function formatRootRole(rootRole: Tenant['root_role']) {
|
|
|
+ if (!rootRole) return ''
|
|
|
+ if (typeof rootRole === 'string') return rootRole
|
|
|
+ const roleName = rootRole['name'] ?? rootRole['role_name'] ?? rootRole['display_name']
|
|
|
+ return typeof roleName === 'string' ? roleName : JSON.stringify(rootRole)
|
|
|
}
|
|
|
|
|
|
-function createMockPlatformId() {
|
|
|
- return nanoid(32)
|
|
|
+function getInvitationLink(platformId: string, tenantName?: string) {
|
|
|
+ const invitationPath = `/token/${platformId}/register`
|
|
|
+ const searchParams = new URLSearchParams()
|
|
|
+ if (tenantName) searchParams.set('tenants', tenantName)
|
|
|
+ const queryString = searchParams.toString()
|
|
|
+ const invitationUrl = queryString ? `${invitationPath}?${queryString}` : invitationPath
|
|
|
+ if (typeof window === 'undefined') return invitationUrl
|
|
|
+ return `${window.location.origin}${invitationUrl}`
|
|
|
}
|
|
|
|
|
|
-function getInvitationLink(platformId: string) {
|
|
|
- const invitationPath = `/token/${platformId}/register?ent=ent_001`
|
|
|
- if (typeof window === 'undefined') return invitationPath
|
|
|
- return `${window.location.origin}${invitationPath}`
|
|
|
+function getInvitationLinkDisplay(platformId: string, tenantName?: string) {
|
|
|
+ return decodeURI(getInvitationLink(platformId, tenantName))
|
|
|
+}
|
|
|
+
|
|
|
+function getTenantPlatformId(tenant: Tenant) {
|
|
|
+ return tenant.code || String(tenant.id)
|
|
|
}
|
|
|
|
|
|
export function Tenants() {
|
|
|
const { t } = useTranslation()
|
|
|
+ const queryClient = useQueryClient()
|
|
|
+ const currentUser = useAuthStore((state) => state.auth.user)
|
|
|
const [searchValue, setSearchValue] = useState('')
|
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
|
const [editOpen, setEditOpen] = useState(false)
|
|
|
@@ -205,6 +281,76 @@ export function Tenants() {
|
|
|
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null)
|
|
|
const [tenants, setTenants] = useState<Tenant[]>(initialTenants)
|
|
|
const [submitAction, setSubmitAction] = useState<'save' | 'saveInvite'>('save')
|
|
|
+ const canCreateTenant = hasPermissionCode(
|
|
|
+ currentUser,
|
|
|
+ PERMISSION_CODES.TENANT_CREATE
|
|
|
+ )
|
|
|
+ const canUpdateTenant = hasPermissionCode(
|
|
|
+ currentUser,
|
|
|
+ PERMISSION_CODES.TENANT_UPDATE
|
|
|
+ )
|
|
|
+ const canDisableTenant = hasPermissionCode(
|
|
|
+ currentUser,
|
|
|
+ PERMISSION_CODES.TENANT_DISABLE
|
|
|
+ )
|
|
|
+
|
|
|
+ const {
|
|
|
+ data: directChildTenants = initialTenants,
|
|
|
+ isLoading: isTenantsLoading,
|
|
|
+ isFetching: isTenantsFetching,
|
|
|
+ } = useQuery({
|
|
|
+ queryKey: ['tenants', 'direct-children'],
|
|
|
+ queryFn: async () => {
|
|
|
+ const result = await getDirectChildTenants()
|
|
|
+ if (!result.success) {
|
|
|
+ toast.error(result.message || t('Failed to load tenants'))
|
|
|
+ return initialTenants
|
|
|
+ }
|
|
|
+ return result.data
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ const { data: rolePermissions = [] } = useQuery({
|
|
|
+ queryKey: ['permissions', 'tenant-create-with-role'],
|
|
|
+ queryFn: () => getPermissionsWithRole(),
|
|
|
+ })
|
|
|
+
|
|
|
+ const { data: groupsData } = useQuery({
|
|
|
+ queryKey: ['groups'],
|
|
|
+ queryFn: getGroups,
|
|
|
+ staleTime: 5 * 60 * 1000,
|
|
|
+ })
|
|
|
+
|
|
|
+ const tenantDetailUniqueId = currentTenant?.code || String(currentTenant?.id ?? '')
|
|
|
+
|
|
|
+ const {
|
|
|
+ data: tenantDetail,
|
|
|
+ isLoading: isTenantDetailLoading,
|
|
|
+ isFetching: isTenantDetailFetching,
|
|
|
+ } = useQuery({
|
|
|
+ queryKey: ['tenants', 'direct-children', tenantDetailUniqueId],
|
|
|
+ queryFn: async () => {
|
|
|
+ const result = await getDirectChildTenantDetail(tenantDetailUniqueId)
|
|
|
+ if (!result.success) {
|
|
|
+ toast.error(result.message || t('Failed to load tenant details'))
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+ return result.data
|
|
|
+ },
|
|
|
+ enabled: (viewOpen || editOpen) && Boolean(tenantDetailUniqueId),
|
|
|
+ })
|
|
|
+
|
|
|
+ const tenantGroupOptions = groupsData?.data ?? []
|
|
|
+
|
|
|
+ const permissionModules = useMemo(
|
|
|
+ () => toTenantPermissionModules(rolePermissions),
|
|
|
+ [rolePermissions]
|
|
|
+ )
|
|
|
+
|
|
|
+ const currentTenantPermissionKeys = useMemo(() => {
|
|
|
+ const checkedKeys = toCheckedPermissionKeys(rolePermissions)
|
|
|
+ return checkedKeys.length ? checkedKeys : permissionModules.map((module) => module.key)
|
|
|
+ }, [permissionModules, rolePermissions])
|
|
|
|
|
|
const tenantFormSchema = z.object({
|
|
|
name: z.string().min(1, t('Tenant name is required')),
|
|
|
@@ -238,9 +384,10 @@ export function Tenants() {
|
|
|
})
|
|
|
|
|
|
const filteredTenants = useMemo(() => {
|
|
|
+ const tenantItems = directChildTenants.length ? directChildTenants : tenants
|
|
|
const keyword = searchValue.trim().toLowerCase()
|
|
|
- if (!keyword) return tenants
|
|
|
- return tenants.filter((tenant) =>
|
|
|
+ if (!keyword) return tenantItems
|
|
|
+ return tenantItems.filter((tenant) =>
|
|
|
[
|
|
|
tenant.name,
|
|
|
tenant.code,
|
|
|
@@ -250,9 +397,9 @@ export function Tenants() {
|
|
|
tenant.contact_phone,
|
|
|
].some((field) => field.toLowerCase().includes(keyword))
|
|
|
)
|
|
|
- }, [searchValue, tenants])
|
|
|
+ }, [directChildTenants, searchValue, tenants])
|
|
|
|
|
|
- const resetForm = (tenant?: Tenant | null) => {
|
|
|
+ const resetForm = useCallback((tenant?: Tenant | null) => {
|
|
|
form.reset({
|
|
|
name: tenant?.name ?? '',
|
|
|
group: tenant?.group ?? '',
|
|
|
@@ -262,9 +409,13 @@ export function Tenants() {
|
|
|
quota_limit: tenant?.quota_limit ?? 0,
|
|
|
starts_at: toDateInputValue(tenant?.starts_at ?? null),
|
|
|
ends_at: toDateInputValue(tenant?.ends_at ?? null),
|
|
|
- permission_keys: tenant?.permission_keys ?? defaultTenantPermissionKeys,
|
|
|
+ permission_keys: tenant?.permission_keys ?? currentTenantPermissionKeys,
|
|
|
})
|
|
|
- }
|
|
|
+ }, [currentTenantPermissionKeys, form])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (editOpen && tenantDetail) resetForm(tenantDetail)
|
|
|
+ }, [editOpen, resetForm, tenantDetail])
|
|
|
|
|
|
const handleCreateOpenChange = (open: boolean) => {
|
|
|
setCreateOpen(open)
|
|
|
@@ -296,68 +447,85 @@ export function Tenants() {
|
|
|
setInviteOpen(open)
|
|
|
if (open) {
|
|
|
setCurrentTenant(tenant)
|
|
|
- setInvitePlatformId(createMockPlatformId())
|
|
|
+ setInvitePlatformId(getTenantPlatformId(tenant))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- const handleSubmit = (data: TenantFormData) => {
|
|
|
- const timestamp = Math.floor(Date.now() / 1000)
|
|
|
+ const openInvitationDialog = (tenant: Tenant) => {
|
|
|
+ setCurrentTenant(tenant)
|
|
|
+ setInvitePlatformId(getTenantPlatformId(tenant))
|
|
|
+ setInviteOpen(true)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleSubmit = async (data: TenantFormData) => {
|
|
|
if (currentTenant) {
|
|
|
- setTenants((prev) =>
|
|
|
- prev.map((tenant) =>
|
|
|
- tenant.id === currentTenant.id
|
|
|
- ? {
|
|
|
- ...tenant,
|
|
|
- name: data.name,
|
|
|
- group: data.group,
|
|
|
- contact_phone: data.contact_phone,
|
|
|
- admin_name: data.contact_name,
|
|
|
- admin_email: data.contact_email,
|
|
|
- quota_limit: data.quota_limit,
|
|
|
- starts_at: toTimestamp(data.starts_at),
|
|
|
- ends_at: toTimestamp(data.ends_at),
|
|
|
- permission_keys: data.permission_keys,
|
|
|
- }
|
|
|
- : tenant
|
|
|
- )
|
|
|
- )
|
|
|
- toast.success(t('Tenant updated successfully'))
|
|
|
- if (submitAction === 'saveInvite' && data.contact_email) {
|
|
|
- toast.success(t('Invitation sent to {{email}}', { email: data.contact_email }))
|
|
|
+ const uniqueId = currentTenant.code || String(currentTenant.id)
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await updateDirectChildTenant(uniqueId, {
|
|
|
+ tenant_name: data.name,
|
|
|
+ contact_person: data.contact_name || undefined,
|
|
|
+ contact_phone: data.contact_phone || undefined,
|
|
|
+ contact_email: data.contact_email || undefined,
|
|
|
+ group_name: data.group,
|
|
|
+ quota_limit: data.quota_limit,
|
|
|
+ start_time: toApiDateTime(data.starts_at, '00:00:00'),
|
|
|
+ end_time: toApiDateTime(data.ends_at, '23:59:59'),
|
|
|
+ module_codes: data.permission_keys,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ toast.error(result.message || t('Failed to update tenant'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ toast.success(t('Tenant updated successfully'))
|
|
|
+ const savedTenant = result.data ?? currentTenant
|
|
|
+ setEditOpen(false)
|
|
|
+ await Promise.all([
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children'] }),
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children', uniqueId] }),
|
|
|
+ ])
|
|
|
+ if (submitAction === 'saveInvite') openInvitationDialog(savedTenant)
|
|
|
+ } catch {
|
|
|
+ toast.error(t('Failed to update tenant'))
|
|
|
}
|
|
|
- setEditOpen(false)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- setTenants((prev) => [
|
|
|
- {
|
|
|
- id: Math.max(0, ...prev.map((tenant) => tenant.id)) + 1,
|
|
|
- name: data.name,
|
|
|
- code: createTenantCode(data.name),
|
|
|
- group: data.group,
|
|
|
- contact_phone: data.contact_phone,
|
|
|
- admin_name: data.contact_name,
|
|
|
- admin_email: data.contact_email,
|
|
|
- admin_registered: false,
|
|
|
- status: 'active',
|
|
|
+ try {
|
|
|
+ const result = await createDirectChildTenant({
|
|
|
+ tenant_name: data.name,
|
|
|
+ group_name: data.group,
|
|
|
+ contact_person: data.contact_name || undefined,
|
|
|
+ contact_phone: data.contact_phone || undefined,
|
|
|
+ contact_email: data.contact_email || undefined,
|
|
|
quota_limit: data.quota_limit,
|
|
|
- starts_at: toTimestamp(data.starts_at),
|
|
|
- ends_at: toTimestamp(data.ends_at),
|
|
|
- permission_keys: data.permission_keys,
|
|
|
- created_at: timestamp,
|
|
|
- remark: '',
|
|
|
- },
|
|
|
- ...prev,
|
|
|
- ])
|
|
|
- toast.success(t('Tenant created successfully'))
|
|
|
- if (submitAction === 'saveInvite' && data.contact_email) {
|
|
|
- toast.success(t('Invitation sent to {{email}}', { email: data.contact_email }))
|
|
|
+ start_time: toApiDateTime(data.starts_at, '00:00:00'),
|
|
|
+ end_time: toApiDateTime(data.ends_at, '23:59:59'),
|
|
|
+ module_codes: data.permission_keys,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ toast.error(result.message || t('Failed to create tenant'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ toast.success(t('Tenant created successfully'))
|
|
|
+ const savedTenant = result.data
|
|
|
+ setCreateOpen(false)
|
|
|
+ await queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children'] })
|
|
|
+ if (submitAction === 'saveInvite' && savedTenant) openInvitationDialog(savedTenant)
|
|
|
+ } catch {
|
|
|
+ toast.error(t('Failed to create tenant'))
|
|
|
}
|
|
|
- setCreateOpen(false)
|
|
|
}
|
|
|
|
|
|
const handleCopyInvitationLink = async () => {
|
|
|
- const invitationLink = getInvitationLink(invitePlatformId || createMockPlatformId())
|
|
|
+ const invitationLink = getInvitationLink(
|
|
|
+ invitePlatformId || (currentTenant ? getTenantPlatformId(currentTenant) : ''),
|
|
|
+ currentTenant?.name
|
|
|
+ )
|
|
|
const copied = await copyToClipboard(invitationLink)
|
|
|
|
|
|
if (copied) {
|
|
|
@@ -368,11 +536,29 @@ export function Tenants() {
|
|
|
toast.error(t('Failed to copy invitation link'))
|
|
|
}
|
|
|
|
|
|
- const handleToggleStatus = () => {
|
|
|
+ const handleToggleStatus = async () => {
|
|
|
if (!currentTenant) return
|
|
|
|
|
|
const nextStatus: TenantStatus =
|
|
|
currentTenant.status === 'active' ? 'disabled' : 'active'
|
|
|
+ const nextTenantStatus = currentTenant.status === 'active' ? 2 : 1
|
|
|
+ const uniqueId = currentTenant.code || String(currentTenant.id)
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await updateDirectChildTenant(uniqueId, {
|
|
|
+ tenant_status: nextTenantStatus,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ toast.error(result.message || t('Failed to update tenant'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ await queryClient.invalidateQueries({ queryKey: ['tenants', 'direct-children'] })
|
|
|
+ } catch {
|
|
|
+ toast.error(t('Failed to update tenant'))
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
setTenants((prev) =>
|
|
|
prev.map((item) =>
|
|
|
@@ -387,7 +573,11 @@ export function Tenants() {
|
|
|
setToggleOpen(false)
|
|
|
}
|
|
|
|
|
|
- const renderTenantForm = (onCancel: () => void, submitText: string) => (
|
|
|
+ const renderTenantForm = (
|
|
|
+ onCancel: () => void,
|
|
|
+ submitText: string,
|
|
|
+ saveInviteDisabled = false
|
|
|
+ ) => (
|
|
|
<Form {...form}>
|
|
|
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
|
|
<div className='rounded-lg border bg-card p-4'>
|
|
|
@@ -553,7 +743,7 @@ export function Tenants() {
|
|
|
render={({ field }) => (
|
|
|
<FormItem>
|
|
|
<div className='grid gap-3 sm:grid-cols-2'>
|
|
|
- {tenantPermissionModules.map((module) => (
|
|
|
+ {permissionModules.map((module) => (
|
|
|
<label
|
|
|
key={module.key}
|
|
|
className='hover:bg-accent flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm'
|
|
|
@@ -589,7 +779,11 @@ export function Tenants() {
|
|
|
<Button type='submit' onClick={() => setSubmitAction('save')}>
|
|
|
{submitText}
|
|
|
</Button>
|
|
|
- <Button type='submit' onClick={() => setSubmitAction('saveInvite')}>
|
|
|
+ <Button
|
|
|
+ type='submit'
|
|
|
+ onClick={() => setSubmitAction('saveInvite')}
|
|
|
+ disabled={saveInviteDisabled}
|
|
|
+ >
|
|
|
{t('Save and invite administrator')}
|
|
|
</Button>
|
|
|
</div>
|
|
|
@@ -655,7 +849,7 @@ export function Tenants() {
|
|
|
header: t('Quota Limit'),
|
|
|
cell: ({ row }) => (
|
|
|
<span className='font-medium tabular-nums'>
|
|
|
- {formatQuota(row.getValue('quota_limit') as number)}
|
|
|
+ ¥{formatNumber(row.getValue('quota_limit') as number)}
|
|
|
</span>
|
|
|
),
|
|
|
size: 160,
|
|
|
@@ -666,12 +860,11 @@ export function Tenants() {
|
|
|
cell: ({ row }) => {
|
|
|
const tenant = row.original
|
|
|
return (
|
|
|
- <div className='space-y-1 text-sm'>
|
|
|
- <div>{tenant.starts_at ? formatTimestamp(tenant.starts_at) : t('Unlimited')}</div>
|
|
|
- <div className='text-muted-foreground'>
|
|
|
- {tenant.ends_at ? formatTimestamp(tenant.ends_at) : t('Unlimited')}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ <span className='text-sm'>
|
|
|
+ {tenant.starts_at ? formatTimestamp(tenant.starts_at) : t('Unlimited')}
|
|
|
+ {' - '}
|
|
|
+ {tenant.ends_at ? formatTimestamp(tenant.ends_at) : t('Unlimited')}
|
|
|
+ </span>
|
|
|
)
|
|
|
},
|
|
|
size: 220,
|
|
|
@@ -696,7 +889,11 @@ export function Tenants() {
|
|
|
header: t('Actions'),
|
|
|
cell: ({ row }) => {
|
|
|
const tenant = row.original
|
|
|
+ const isViewingTenant = viewOpen && currentTenant?.id === tenant.id
|
|
|
+ const detailTenant = isViewingTenant && tenantDetail ? tenantDetail : tenant
|
|
|
+ const detailLoading = isViewingTenant && (isTenantDetailLoading || isTenantDetailFetching)
|
|
|
const isActive = tenant.status === 'active'
|
|
|
+ const inviteDisabled = !isActive
|
|
|
const toggleLabel = isActive ? t('Disable') : t('Enable')
|
|
|
const toggleDescription = isActive
|
|
|
? t('Are you sure you want to disable tenant {{name}}?', {
|
|
|
@@ -708,7 +905,7 @@ export function Tenants() {
|
|
|
return (
|
|
|
<div className='flex items-center gap-1'>
|
|
|
<Dialog
|
|
|
- open={viewOpen && currentTenant?.id === tenant.id}
|
|
|
+ open={isViewingTenant}
|
|
|
onOpenChange={(open) => handleViewOpenChange(open, tenant)}
|
|
|
>
|
|
|
<Tooltip>
|
|
|
@@ -735,6 +932,11 @@ export function Tenants() {
|
|
|
</DialogDescription>
|
|
|
</DialogHeader>
|
|
|
<div className='space-y-4'>
|
|
|
+ {detailLoading && (
|
|
|
+ <div className='text-muted-foreground rounded-lg border bg-card p-3 text-sm'>
|
|
|
+ {t('Loading tenant details...')}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
<div className='flex items-center gap-3 rounded-lg border bg-card p-4'>
|
|
|
<div className='flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary'>
|
|
|
<Building2 className='size-5' />
|
|
|
@@ -742,55 +944,55 @@ export function Tenants() {
|
|
|
<div className='min-w-0 flex-1'>
|
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
|
<LongText className='max-w-[280px] font-medium'>
|
|
|
- {tenant.name}
|
|
|
+ {detailTenant.name}
|
|
|
</LongText>
|
|
|
<StatusBadge
|
|
|
- label={t(tenantStatusConfig[tenant.status].labelKey)}
|
|
|
- variant={tenantStatusConfig[tenant.status].variant}
|
|
|
+ label={t(tenantStatusConfig[detailTenant.status].labelKey)}
|
|
|
+ variant={tenantStatusConfig[detailTenant.status].variant}
|
|
|
copyable={false}
|
|
|
/>
|
|
|
</div>
|
|
|
<LongText className='text-muted-foreground mt-1 max-w-[360px] text-xs'>
|
|
|
- {tenant.code}
|
|
|
+ {detailTenant.code}
|
|
|
</LongText>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className='grid overflow-hidden rounded-lg border text-sm sm:grid-cols-2'>
|
|
|
<div className='border-b p-4 sm:border-r'>
|
|
|
- <div className='text-muted-foreground'>{t('Tenant ID')}</div>
|
|
|
- <div className='mt-1 font-medium tabular-nums'>{tenant.id}</div>
|
|
|
+ <div className='text-muted-foreground'>{t('Tenant Name')}</div>
|
|
|
+ <div className='mt-1 font-medium'>{detailTenant.name}</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4'>
|
|
|
- <div className='text-muted-foreground'>{t('Tenant Name')}</div>
|
|
|
- <div className='mt-1 font-medium'>{tenant.name}</div>
|
|
|
+ <div className='text-muted-foreground'>{t('Tenant Code')}</div>
|
|
|
+ <div className='mt-1 font-medium'>{detailTenant.code}</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4 sm:border-r'>
|
|
|
- <div className='text-muted-foreground'>{t('Tenant Code')}</div>
|
|
|
- <div className='mt-1 font-medium'>{tenant.code}</div>
|
|
|
+ <div className='text-muted-foreground'>{t('Group')}</div>
|
|
|
+ <div className='mt-1 font-medium'>{detailTenant.group}</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4'>
|
|
|
- <div className='text-muted-foreground'>{t('Group')}</div>
|
|
|
- <div className='mt-1 font-medium'>{tenant.group}</div>
|
|
|
+ <div className='text-muted-foreground'>{t('Administrator')}</div>
|
|
|
+ <div className='mt-1 font-medium'>{detailTenant.admin_name || t('N/A')}</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4 sm:border-r'>
|
|
|
- <div className='text-muted-foreground'>{t('Administrator')}</div>
|
|
|
- <div className='mt-1 font-medium'>{tenant.admin_name}</div>
|
|
|
+ <div className='text-muted-foreground'>{t('Contact Phone')}</div>
|
|
|
+ <div className='mt-1 font-medium'>{detailTenant.contact_phone || t('N/A')}</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4'>
|
|
|
<div className='text-muted-foreground'>{t('Administrator Email')}</div>
|
|
|
- <div className='mt-1 font-medium'>{tenant.admin_email}</div>
|
|
|
+ <div className='mt-1 font-medium'>{detailTenant.admin_email || t('N/A')}</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4 sm:border-r'>
|
|
|
<div className='text-muted-foreground'>{t('Administrator Status')}</div>
|
|
|
<div className='mt-2'>
|
|
|
<StatusBadge
|
|
|
label={
|
|
|
- tenant.admin_registered
|
|
|
+ detailTenant.admin_registered
|
|
|
? t('Registered')
|
|
|
: t('Unregistered')
|
|
|
}
|
|
|
- variant={tenant.admin_registered ? 'success' : 'warning'}
|
|
|
+ variant={detailTenant.admin_registered ? 'success' : 'warning'}
|
|
|
copyable={false}
|
|
|
/>
|
|
|
</div>
|
|
|
@@ -799,8 +1001,8 @@ export function Tenants() {
|
|
|
<div className='text-muted-foreground'>{t('Status')}</div>
|
|
|
<div className='mt-2'>
|
|
|
<StatusBadge
|
|
|
- label={t(tenantStatusConfig[tenant.status].labelKey)}
|
|
|
- variant={tenantStatusConfig[tenant.status].variant}
|
|
|
+ label={t(tenantStatusConfig[detailTenant.status].labelKey)}
|
|
|
+ variant={tenantStatusConfig[detailTenant.status].variant}
|
|
|
copyable={false}
|
|
|
/>
|
|
|
</div>
|
|
|
@@ -808,31 +1010,37 @@ export function Tenants() {
|
|
|
<div className='border-b p-4 sm:border-r'>
|
|
|
<div className='text-muted-foreground'>{t('Quota Limit')}</div>
|
|
|
<div className='mt-1 font-medium tabular-nums'>
|
|
|
- {formatQuota(tenant.quota_limit)}
|
|
|
+ ¥{formatNumber(detailTenant.quota_limit)}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className='border-b p-4'>
|
|
|
<div className='text-muted-foreground'>{t('Validity Period')}</div>
|
|
|
<div className='mt-1 font-medium'>
|
|
|
- {tenant.starts_at
|
|
|
- ? formatTimestamp(tenant.starts_at)
|
|
|
+ {detailTenant.starts_at
|
|
|
+ ? formatTimestamp(detailTenant.starts_at)
|
|
|
: t('Unlimited')}
|
|
|
{' - '}
|
|
|
- {tenant.ends_at
|
|
|
- ? formatTimestamp(tenant.ends_at)
|
|
|
+ {detailTenant.ends_at
|
|
|
+ ? formatTimestamp(detailTenant.ends_at)
|
|
|
: t('Unlimited')}
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div className='border-b p-4 sm:border-r sm:border-b-0'>
|
|
|
+ <div className='border-b p-4 sm:border-r'>
|
|
|
<div className='text-muted-foreground'>{t('Created At')}</div>
|
|
|
<div className='mt-1 font-medium'>
|
|
|
- {formatTimestamp(tenant.created_at)}
|
|
|
+ {formatTimestamp(detailTenant.created_at)}
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div className='p-4'>
|
|
|
+ <div className='border-b p-4'>
|
|
|
+ <div className='text-muted-foreground'>{t('Root Role')}</div>
|
|
|
+ <div className='mt-1 font-medium'>
|
|
|
+ {formatRootRole(detailTenant.root_role) || t('N/A')}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className='p-4 sm:col-span-2'>
|
|
|
<div className='text-muted-foreground'>{t('Remark')}</div>
|
|
|
<div className='mt-1 font-medium'>
|
|
|
- {tenant.remark || t('No remark')}
|
|
|
+ {detailTenant.remark || t('No remark')}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -847,8 +1055,8 @@ export function Tenants() {
|
|
|
</p>
|
|
|
</div>
|
|
|
<div className='grid gap-3 sm:grid-cols-2'>
|
|
|
- {tenantPermissionModules.map((module) => {
|
|
|
- const moduleEnabled = tenant.permission_keys.includes(module.key)
|
|
|
+ {permissionModules.map((module) => {
|
|
|
+ const moduleEnabled = detailTenant.permission_keys.includes(module.key)
|
|
|
|
|
|
return (
|
|
|
<div key={module.key} className='rounded-md border p-3'>
|
|
|
@@ -875,42 +1083,41 @@ export function Tenants() {
|
|
|
</div>
|
|
|
</DialogContent>
|
|
|
</Dialog>
|
|
|
- <Sheet
|
|
|
- open={editOpen && currentTenant?.id === tenant.id}
|
|
|
- onOpenChange={(open) => handleEditOpenChange(open, tenant)}
|
|
|
- >
|
|
|
- <Tooltip>
|
|
|
- <TooltipTrigger
|
|
|
- render={
|
|
|
- <Button
|
|
|
- type='button'
|
|
|
- variant='ghost'
|
|
|
- size='icon-sm'
|
|
|
- onClick={() => handleEditOpenChange(true, tenant)}
|
|
|
- aria-label={t('Edit')}
|
|
|
- />
|
|
|
- }
|
|
|
- >
|
|
|
- <Pencil />
|
|
|
- </TooltipTrigger>
|
|
|
- <TooltipContent>{t('Edit')}</TooltipContent>
|
|
|
- </Tooltip>
|
|
|
- <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
|
|
|
- <SheetHeader>
|
|
|
- <SheetTitle>{t('Edit Tenant')}</SheetTitle>
|
|
|
- <SheetDescription>
|
|
|
- {t('Update tenant information and resource settings.')}
|
|
|
- </SheetDescription>
|
|
|
- </SheetHeader>
|
|
|
- <div className='mt-6'>
|
|
|
- {renderTenantForm(() => setEditOpen(false), t('Save changes'))}
|
|
|
- </div>
|
|
|
- </SheetContent>
|
|
|
- </Sheet>
|
|
|
- <Dialog
|
|
|
- open={inviteOpen && currentTenant?.id === tenant.id}
|
|
|
- onOpenChange={(open) => handleInviteOpenChange(open, tenant)}
|
|
|
- >
|
|
|
+ {canUpdateTenant && (
|
|
|
+ <Sheet
|
|
|
+ open={editOpen && currentTenant?.id === tenant.id}
|
|
|
+ onOpenChange={(open) => handleEditOpenChange(open, tenant)}
|
|
|
+ >
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger
|
|
|
+ render={
|
|
|
+ <Button
|
|
|
+ type='button'
|
|
|
+ variant='ghost'
|
|
|
+ size='icon-sm'
|
|
|
+ onClick={() => handleEditOpenChange(true, tenant)}
|
|
|
+ aria-label={t('Edit')}
|
|
|
+ />
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <Pencil />
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>{t('Edit')}</TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
|
|
|
+ <SheetHeader>
|
|
|
+ <SheetTitle>{t('Edit Tenant')}</SheetTitle>
|
|
|
+ <SheetDescription>
|
|
|
+ {t('Update tenant information and resource settings.')}
|
|
|
+ </SheetDescription>
|
|
|
+ </SheetHeader>
|
|
|
+ <div className='mt-6'>
|
|
|
+ {renderTenantForm(() => setEditOpen(false), t('Save changes'), !isActive)}
|
|
|
+ </div>
|
|
|
+ </SheetContent>
|
|
|
+ </Sheet>
|
|
|
+ )}
|
|
|
+ {canCreateTenant && (
|
|
|
<Tooltip>
|
|
|
<TooltipTrigger
|
|
|
render={
|
|
|
@@ -919,6 +1126,7 @@ export function Tenants() {
|
|
|
variant='ghost'
|
|
|
size='icon-sm'
|
|
|
onClick={() => handleInviteOpenChange(true, tenant)}
|
|
|
+ disabled={inviteDisabled}
|
|
|
aria-label={t('Invite administrator to register')}
|
|
|
/>
|
|
|
}
|
|
|
@@ -927,86 +1135,52 @@ export function Tenants() {
|
|
|
</TooltipTrigger>
|
|
|
<TooltipContent>{t('Invite administrator to register')}</TooltipContent>
|
|
|
</Tooltip>
|
|
|
- <DialogContent className='sm:max-w-md'>
|
|
|
- <DialogHeader>
|
|
|
- <DialogTitle>{t('Administrator Registration')}</DialogTitle>
|
|
|
- </DialogHeader>
|
|
|
- <div className='space-y-4'>
|
|
|
- <div className='bg-muted/50 flex gap-3 rounded-lg border p-3'>
|
|
|
- <div className='bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full'>
|
|
|
- <Info className='size-4' />
|
|
|
- </div>
|
|
|
- <div className='space-y-2'>
|
|
|
- <p className='text-foreground text-base font-medium'>
|
|
|
- {t('Send the link below to the administrator of {{tenantName}}', {
|
|
|
- tenantName: tenant.name,
|
|
|
- })}
|
|
|
- </p>
|
|
|
- <p className='text-muted-foreground text-sm leading-6'>
|
|
|
- {t(
|
|
|
- 'After opening the link, the administrator enters the welcome registration page and only needs to fill in the account and password to complete registration and enter the institution portal. They will automatically see the menu and button permissions you assigned.'
|
|
|
- )}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <Input
|
|
|
- value={getInvitationLink(invitePlatformId || createMockPlatformId())}
|
|
|
- readOnly
|
|
|
- />
|
|
|
- </div>
|
|
|
- <DialogFooter>
|
|
|
- <Button variant='outline' onClick={() => setInviteOpen(false)}>
|
|
|
- {t('Close')}
|
|
|
- </Button>
|
|
|
- <Button onClick={handleCopyInvitationLink}>
|
|
|
- {t('Copy invitation link')}
|
|
|
- </Button>
|
|
|
- </DialogFooter>
|
|
|
- </DialogContent>
|
|
|
- </Dialog>
|
|
|
- <Dialog
|
|
|
- open={toggleOpen && currentTenant?.id === tenant.id}
|
|
|
- onOpenChange={(open) => handleToggleOpenChange(open, tenant)}
|
|
|
- >
|
|
|
- <Tooltip>
|
|
|
- <TooltipTrigger
|
|
|
- render={
|
|
|
- <Button
|
|
|
- type='button'
|
|
|
- variant='ghost'
|
|
|
- size='icon-sm'
|
|
|
- onClick={() => handleToggleOpenChange(true, tenant)}
|
|
|
- aria-label={toggleLabel}
|
|
|
- className={
|
|
|
- isActive
|
|
|
- ? 'text-destructive hover:text-destructive'
|
|
|
- : 'text-success hover:text-success'
|
|
|
- }
|
|
|
- />
|
|
|
- }
|
|
|
- >
|
|
|
- {isActive ? <PowerOff /> : <Power />}
|
|
|
- </TooltipTrigger>
|
|
|
- <TooltipContent>{toggleLabel}</TooltipContent>
|
|
|
- </Tooltip>
|
|
|
- <DialogContent>
|
|
|
- <DialogHeader>
|
|
|
- <DialogTitle>{toggleLabel}</DialogTitle>
|
|
|
- <DialogDescription>{toggleDescription}</DialogDescription>
|
|
|
- </DialogHeader>
|
|
|
- <DialogFooter>
|
|
|
- <Button variant='outline' onClick={() => setToggleOpen(false)}>
|
|
|
- {t('Cancel')}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- variant={isActive ? 'destructive' : 'default'}
|
|
|
- onClick={handleToggleStatus}
|
|
|
+ )}
|
|
|
+ {canDisableTenant && (
|
|
|
+ <Dialog
|
|
|
+ open={toggleOpen && currentTenant?.id === tenant.id}
|
|
|
+ onOpenChange={(open) => handleToggleOpenChange(open, tenant)}
|
|
|
+ >
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger
|
|
|
+ render={
|
|
|
+ <Button
|
|
|
+ type='button'
|
|
|
+ variant='ghost'
|
|
|
+ size='icon-sm'
|
|
|
+ onClick={() => handleToggleOpenChange(true, tenant)}
|
|
|
+ aria-label={toggleLabel}
|
|
|
+ className={
|
|
|
+ isActive
|
|
|
+ ? 'text-destructive hover:text-destructive'
|
|
|
+ : 'text-success hover:text-success'
|
|
|
+ }
|
|
|
+ />
|
|
|
+ }
|
|
|
>
|
|
|
- {toggleLabel}
|
|
|
- </Button>
|
|
|
- </DialogFooter>
|
|
|
- </DialogContent>
|
|
|
- </Dialog>
|
|
|
+ {isActive ? <PowerOff /> : <Power />}
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>{toggleLabel}</TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ <DialogContent>
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>{toggleLabel}</DialogTitle>
|
|
|
+ <DialogDescription>{toggleDescription}</DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
+ <DialogFooter>
|
|
|
+ <Button variant='outline' onClick={() => setToggleOpen(false)}>
|
|
|
+ {t('Cancel')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant={isActive ? 'destructive' : 'default'}
|
|
|
+ onClick={handleToggleStatus}
|
|
|
+ >
|
|
|
+ {toggleLabel}
|
|
|
+ </Button>
|
|
|
+ </DialogFooter>
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)
|
|
|
},
|
|
|
@@ -1032,34 +1206,76 @@ export function Tenants() {
|
|
|
onChange={(event) => setSearchValue(event.target.value)}
|
|
|
className='w-72'
|
|
|
/>
|
|
|
- <Sheet open={createOpen} onOpenChange={handleCreateOpenChange}>
|
|
|
- <SheetTrigger render={<Button />}>
|
|
|
- <Plus className='mr-2 h-4 w-4' />
|
|
|
- {t('Create Tenant')}
|
|
|
- </SheetTrigger>
|
|
|
- <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
|
|
|
- <SheetHeader>
|
|
|
- <SheetTitle>{t('Create Tenant')}</SheetTitle>
|
|
|
- <SheetDescription>
|
|
|
- {t('Create a new tenant and assign initial resource settings.')}
|
|
|
- </SheetDescription>
|
|
|
- </SheetHeader>
|
|
|
- <div className='mt-6'>
|
|
|
- {renderTenantForm(() => setCreateOpen(false), t('Create'))}
|
|
|
- </div>
|
|
|
- </SheetContent>
|
|
|
- </Sheet>
|
|
|
+ {canCreateTenant && (
|
|
|
+ <Sheet open={createOpen} onOpenChange={handleCreateOpenChange}>
|
|
|
+ <SheetTrigger render={<Button />}>
|
|
|
+ <Plus className='mr-2 h-4 w-4' />
|
|
|
+ {t('Create Tenant')}
|
|
|
+ </SheetTrigger>
|
|
|
+ <SheetContent className='w-full overflow-y-auto sm:max-w-2xl'>
|
|
|
+ <SheetHeader>
|
|
|
+ <SheetTitle>{t('Create Tenant')}</SheetTitle>
|
|
|
+ <SheetDescription>
|
|
|
+ {t('Create a new tenant and assign initial resource settings.')}
|
|
|
+ </SheetDescription>
|
|
|
+ </SheetHeader>
|
|
|
+ <div className='mt-6'>
|
|
|
+ {renderTenantForm(() => setCreateOpen(false), t('Create'))}
|
|
|
+ </div>
|
|
|
+ </SheetContent>
|
|
|
+ </Sheet>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</SectionPageLayout.Actions>
|
|
|
<SectionPageLayout.Content>
|
|
|
<DataTablePage
|
|
|
table={table}
|
|
|
columns={columns}
|
|
|
+ isLoading={isTenantsLoading}
|
|
|
+ isFetching={isTenantsFetching}
|
|
|
emptyTitle={t('No Tenants Found')}
|
|
|
emptyDescription={t('No tenants available. Try adjusting your search.')}
|
|
|
skeletonKeyPrefix='tenants-skeleton'
|
|
|
applyHeaderSize
|
|
|
/>
|
|
|
+ <Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
|
|
+ <DialogContent className='sm:max-w-md'>
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>{t('Administrator Registration')}</DialogTitle>
|
|
|
+ </DialogHeader>
|
|
|
+ <div className='space-y-4'>
|
|
|
+ <div className='bg-muted/50 flex gap-3 rounded-lg border p-3'>
|
|
|
+ <div className='bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full'>
|
|
|
+ <Info className='size-4' />
|
|
|
+ </div>
|
|
|
+ <div className='space-y-2'>
|
|
|
+ <p className='text-foreground text-base font-medium'>
|
|
|
+ {t('Send the link below to the administrator of {{tenantName}}', {
|
|
|
+ tenantName: currentTenant?.name ?? '',
|
|
|
+ })}
|
|
|
+ </p>
|
|
|
+ <p className='text-muted-foreground text-sm leading-6'>
|
|
|
+ {t(
|
|
|
+ 'After opening the link, the administrator enters the welcome registration page and only needs to fill in the account and password to complete registration and enter the institution portal. They will automatically see the menu and button permissions you assigned.'
|
|
|
+ )}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <Input
|
|
|
+ value={getInvitationLinkDisplay(invitePlatformId, currentTenant?.name)}
|
|
|
+ readOnly
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <DialogFooter>
|
|
|
+ <Button variant='outline' onClick={() => setInviteOpen(false)}>
|
|
|
+ {t('Close')}
|
|
|
+ </Button>
|
|
|
+ <Button onClick={handleCopyInvitationLink}>
|
|
|
+ {t('Copy invitation link')}
|
|
|
+ </Button>
|
|
|
+ </DialogFooter>
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
</SectionPageLayout.Content>
|
|
|
</SectionPageLayout>
|
|
|
)
|