feat: enhance tenant management with search functionality and DTO updates

- Introduced search capability in tenant listing and management components.
- Updated SuperAdminTenantController to utilize new DTOs for tenant ID and query parameters.
- Enhanced tenant repository and service to support search filtering.
- Modified frontend components to include a search input for better tenant management experience.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-06 00:20:00 +08:00
parent b8eeb95c4f
commit 8c9e8a9575
7 changed files with 115 additions and 47 deletions

View File

@@ -10,7 +10,13 @@ import { desc, eq } from 'drizzle-orm'
import type { BillingPlanId } from '../billing/billing-plan.types'
import { DataManagementService } from '../data-management/data-management.service'
import { UpdateTenantBanDto, UpdateTenantPlanDto } from './super-admin.dto'
import {
ListTenantsQueryDto,
TenantIdParamDto,
TenantPhotosQueryDto,
UpdateTenantBanDto,
UpdateTenantPlanDto,
} from './super-admin.dto'
@Controller('super-admin/tenants')
@Roles('superadmin')
@@ -25,13 +31,13 @@ export class SuperAdminTenantController {
) {}
@Get('/:tenantId/photos')
async getTenantPhotos(@Param('tenantId') tenantId: string, @Query('limit') limit = '20') {
async getTenantPhotos(@Param() params: TenantIdParamDto, @Query() query: TenantPhotosQueryDto) {
const photos = await this.db
.get()
.select()
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenantId))
.limit(Number(limit))
.where(eq(photoAssets.tenantId, params.tenantId))
.limit(query.limit)
.orderBy(desc(photoAssets.createdAt))
return {
@@ -43,20 +49,15 @@ export class SuperAdminTenantController {
}
@Get('/')
async listTenants(
@Query('page') page = '1',
@Query('limit') limit = '20',
@Query('status') status?: string,
@Query('sortBy') sortBy?: 'createdAt' | 'name',
@Query('sortDir') sortDir?: 'asc' | 'desc',
) {
async listTenants(@Query() query: ListTenantsQueryDto) {
const [tenantResult, plans] = await Promise.all([
this.tenantService.listTenants({
page: Number(page),
limit: Number(limit),
status: status as any,
sortBy,
sortDir,
page: query.page,
limit: query.limit,
search: query.search,
status: query.status,
sortBy: query.sortBy,
sortDir: query.sortDir,
}),
Promise.resolve(this.billingPlanService.getPlanDefinitions()),
])
@@ -77,19 +78,19 @@ export class SuperAdminTenantController {
}
@Patch('/:tenantId/plan')
async updateTenantPlan(@Param('tenantId') tenantId: string, @Body() dto: UpdateTenantPlanDto) {
await this.billingPlanService.updateTenantPlan(tenantId, dto.planId as BillingPlanId)
async updateTenantPlan(@Param() params: TenantIdParamDto, @Body() dto: UpdateTenantPlanDto) {
await this.billingPlanService.updateTenantPlan(params.tenantId, dto.planId as BillingPlanId)
return { updated: true }
}
@Patch('/:tenantId/ban')
async updateTenantBan(@Param('tenantId') tenantId: string, @Body() dto: UpdateTenantBanDto) {
await this.tenantService.setBanned(tenantId, dto.banned)
async updateTenantBan(@Param() params: TenantIdParamDto, @Body() dto: UpdateTenantBanDto) {
await this.tenantService.setBanned(params.tenantId, dto.banned)
return { updated: true }
}
@Delete('/:tenantId')
async deleteTenant(@Param('tenantId') tenantId: string) {
return await this.dataManagementService.deleteTenantAccountById(tenantId)
async deleteTenant(@Param() params: TenantIdParamDto) {
return await this.dataManagementService.deleteTenantAccountById(params.tenantId)
}
}

View File

@@ -1,4 +1,4 @@
import { createZodDto } from '@afilmory/framework'
import { createZodDto, createZodSchemaDto } from '@afilmory/framework'
import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants'
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
import { z } from 'zod'
@@ -96,3 +96,24 @@ const updateTenantBanSchema = z.object({
})
export class UpdateTenantBanDto extends createZodDto(updateTenantBanSchema) {}
const tenantIdParamSchema = z.object({
tenantId: z.string().trim().min(1),
})
const listTenantsQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().default(20),
search: z.string().trim().optional(),
status: z.enum(['pending', 'active', 'inactive', 'suspended']).optional(),
sortBy: z.enum(['createdAt', 'name']).optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
})
const tenantPhotosQuerySchema = z.object({
limit: z.coerce.number().int().positive().default(20),
})
export class TenantIdParamDto extends createZodSchemaDto(tenantIdParamSchema) {}
export class ListTenantsQueryDto extends createZodSchemaDto(listTenantsQuerySchema) {}
export class TenantPhotosQueryDto extends createZodSchemaDto(tenantPhotosQuerySchema) {}

View File

@@ -3,7 +3,7 @@ import { RESERVED_TENANT_SLUGS } from '@afilmory/utils'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
import { and, asc, count, desc, eq, notInArray } from 'drizzle-orm'
import { and, asc, count, desc, eq, ilike, notInArray, or } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { TenantAggregate, TenantRecord } from './tenant.types'
@@ -79,12 +79,13 @@ export class TenantRepository {
async listTenants(options: {
page: number
limit: number
search?: string
status?: TenantRecord['status']
sortBy?: 'createdAt' | 'name'
sortDir?: 'asc' | 'desc'
}): Promise<{ items: TenantAggregate[]; total: number }> {
const db = this.dbAccessor.get()
const { page, limit, status, sortBy = 'createdAt', sortDir = 'desc' } = options
const { page, limit, search, status, sortBy = 'createdAt', sortDir = 'desc' } = options
const conditions = [notInArray(tenants.slug, RESERVED_TENANT_SLUGS)]
@@ -92,6 +93,11 @@ export class TenantRepository {
conditions.push(eq(tenants.status, status))
}
if (search) {
const searchLike = `%${search}%`
conditions.push(or(ilike(tenants.name, searchLike), ilike(tenants.slug, searchLike)))
}
const where = and(...conditions)
const [total] = await db.select({ count: count() }).from(tenants).where(where)

View File

@@ -118,6 +118,7 @@ export class TenantService {
async listTenants(options?: {
page?: number
limit?: number
search?: string
status?: TenantRecord['status']
sortBy?: 'createdAt' | 'name'
sortDir?: 'asc' | 'desc'
@@ -125,6 +126,7 @@ export class TenantService {
return await this.repository.listTenants({
page: options?.page ?? 1,
limit: options?.limit ?? 20,
search: options?.search,
status: options?.status,
sortBy: options?.sortBy,
sortDir: options?.sortDir,

View File

@@ -47,6 +47,7 @@ export async function fetchSuperAdminTenants(
if (params) {
if (params.page) query.set('page', String(params.page))
if (params.limit) query.set('limit', String(params.limit))
if (params.search) query.set('search', params.search)
if (params.status) query.set('status', params.status)
if (params.sortBy) query.set('sortBy', params.sortBy)
if (params.sortDir) query.set('sortDir', params.sortDir)

View File

@@ -1,8 +1,18 @@
import { Button, Modal, Prompt, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui'
import {
Button,
Input,
Modal,
Prompt,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, RefreshCcwIcon } from 'lucide-react'
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, RefreshCcwIcon, SearchIcon } from 'lucide-react'
import { m } from 'motion/react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -30,10 +40,21 @@ export function SuperAdminTenantManager() {
const [status, setStatus] = useState<string>('all')
const [sortBy, setSortBy] = useState<string>('createdAt')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(timer)
}, [search])
const tenantsQuery = useSuperAdminTenantsQuery({
page,
limit,
search: debouncedSearch,
status: status === 'all' ? undefined : status,
sortBy,
sortDir,
@@ -163,25 +184,36 @@ export function SuperAdminTenantManager() {
return (
<m.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={Spring.presets.smooth}>
<header className="flex items-center justify-between gap-3 mb-4">
<div className="w-[200px]">
<Select
value={status}
onValueChange={(val) => {
setStatus(val)
setPage(1)
}}
>
<SelectTrigger>
<SelectValue placeholder={t('superadmin.tenants.filter.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('superadmin.tenants.filter.all')}</SelectItem>
<SelectItem value="active">{t('superadmin.tenants.status.active')}</SelectItem>
<SelectItem value="inactive">{t('superadmin.tenants.status.inactive')}</SelectItem>
<SelectItem value="suspended">{t('superadmin.tenants.status.suspended')}</SelectItem>
<SelectItem value="pending">{t('superadmin.tenants.status.pending')}</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-3">
<div className="w-[200px]">
<Select
value={status}
onValueChange={(val) => {
setStatus(val)
setPage(1)
}}
>
<SelectTrigger>
<SelectValue placeholder={t('superadmin.tenants.filter.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('superadmin.tenants.filter.all')}</SelectItem>
<SelectItem value="active">{t('superadmin.tenants.status.active')}</SelectItem>
<SelectItem value="inactive">{t('superadmin.tenants.status.inactive')}</SelectItem>
<SelectItem value="suspended">{t('superadmin.tenants.status.suspended')}</SelectItem>
<SelectItem value="pending">{t('superadmin.tenants.status.pending')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-[240px] relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-text-tertiary pointer-events-none" />
<Input
className="pl-9"
placeholder={t('superadmin.tenants.search.placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<Button
type="button"
@@ -311,6 +343,8 @@ export function SuperAdminTenantManager() {
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
className="size-8"
disabled={page <= 1 || tenantsQuery.isFetching}
onClick={() => setPage((p) => p - 1)}
@@ -321,6 +355,8 @@ export function SuperAdminTenantManager() {
{page} / {totalPages || 1}
</div>
<Button
type="button"
variant="ghost"
className="size-8"
disabled={page >= totalPages || tenantsQuery.isFetching}
onClick={() => setPage((p) => p + 1)}

View File

@@ -118,6 +118,7 @@ export interface SuperAdminTenantListResponse {
export interface SuperAdminTenantListParams {
page: number
limit: number
search?: string
status?: string
sortBy?: string
sortDir?: 'asc' | 'desc'