mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface SuperAdminTenantListResponse {
|
||||
export interface SuperAdminTenantListParams {
|
||||
page: number
|
||||
limit: number
|
||||
search?: string
|
||||
status?: string
|
||||
sortBy?: string
|
||||
sortDir?: 'asc' | 'desc'
|
||||
|
||||
Reference in New Issue
Block a user