generated from ttipo/project-base
feat: 관리자 화면 전체 개편과 신청 내역 상품별·업체별 보기
관리자 화면이 기능을 덧붙이며 복잡해져(회원 표 15열, 업체 화면에 폼·표·설명이 모두 펼쳐짐, 브라우저 기본 prompt/confirm) 보기 어렵고, 신청을 업체 기준으로 볼 방법이 없었다. - 메뉴를 개요 / 운영(신청 내역·업체 링크·회원) / 설정(연동 관리)으로 묶음 - 개요: 오늘·7일 신청, 7일 업체 링크 신청, 7일 가입 지표 + 신청 유입·업체 실적 상위·최근 신청 - 신청 내역: 상품별 ↔ 업체별 전환 — 요약 표의 행을 누르면 목록과 유입 집계를 그 항목으로 좁힘, 신청자 표에 업체 열 추가, 행을 누르면 상세 패널(신청·유입·회원·가입 경로) - 업체 링크: 왼쪽 업체 목록 + 오른쪽 링크 표, 실적 지표, 링크 발급·수정은 대화상자, 사용/중지 스위치, 링크 실적은 상세 패널, 집계 기준은 접는 도움말 - 회원: 표를 7열로 줄이고 유입경로 필터는 접어 둠, 행을 누르면 상세 패널 - 토스트 알림·대화상자·상세 패널 공통 컴포넌트, 연락처 하이픈 표시, 휴대폰 화면 정리 - API: GET /api/admin/applications/groups (상품별·업체별 신청 수·오픈카톡 입장 수), 신청 목록·유입 집계에 partnerId 필터(업체 ID 또는 none), 신청 응답에 데려온 업체(partner) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,10 +23,12 @@ import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import {
|
||||
ApplicationGroupsQueryDto,
|
||||
ApplicationInflowStatsQueryDto,
|
||||
ApplicationListQueryDto,
|
||||
} from './dto/application-query.dto';
|
||||
import {
|
||||
ApplicationGroupsResponseDto,
|
||||
ApplicationInflowStatsResponseDto,
|
||||
ApplicationProductDto,
|
||||
ApplicationResponseDto,
|
||||
@@ -109,6 +111,21 @@ export class AdminApplicationsController {
|
||||
return this.applications.products();
|
||||
}
|
||||
|
||||
@Get('groups')
|
||||
@ApiOperation({
|
||||
summary: '상품별·업체별 신청 수 — 신청 내역의 요약 표 (기간만 거른다)',
|
||||
})
|
||||
@ApiStandardResponse(ApplicationGroupsResponseDto, {
|
||||
description: '조회 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('VAL_001', '기준·기간 형식 오류')
|
||||
groups(
|
||||
@Query() query: ApplicationGroupsQueryDto,
|
||||
): Promise<ApplicationGroupsResponseDto> {
|
||||
return this.applications.groups(query);
|
||||
}
|
||||
|
||||
@Get('inflow-stats')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AdminAuthModule } from '@/modules/admin-auth/admin-auth.module';
|
||||
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import { ImwebModule } from '@/modules/imweb/imweb.module';
|
||||
import { MembersModule } from '@/modules/members/members.module';
|
||||
import { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { PartnersModule } from '@/modules/partners/partners.module';
|
||||
import { MemberApplication } from './application.entity';
|
||||
import {
|
||||
@@ -16,7 +18,13 @@ import { ApplicationsService } from './applications.service';
|
||||
/** 강의 신청 기록 — 신청 완료 화면이 보내고, 관리자 화면에서 본다 */
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MemberApplication, ImwebMember]),
|
||||
// 신청을 데려온 업체를 보여주려고 업체 실적·업체를 읽는다
|
||||
TypeOrmModule.forFeature([
|
||||
MemberApplication,
|
||||
ImwebMember,
|
||||
PartnerConversion,
|
||||
Partner,
|
||||
]),
|
||||
AdminAuthModule,
|
||||
ImwebModule,
|
||||
MembersModule,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
|
||||
import type { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
|
||||
import type { MembersService } from '@/modules/members/members.service';
|
||||
import type { PartnerConversion } from '@/modules/partners/entities/partner-conversion.entity';
|
||||
import type { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import type { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
|
||||
import { MemberApplication } from './application.entity';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
@@ -51,6 +53,8 @@ function setup(member: ImwebMember | null = MEMBER) {
|
||||
{} as MembersService,
|
||||
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
|
||||
attribution as unknown as PartnerAttributionService,
|
||||
{} as Repository<PartnerConversion>,
|
||||
{} as Repository<Partner>,
|
||||
);
|
||||
return { service, store, applications, lookup, attribution, calls };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
In,
|
||||
IsNull,
|
||||
QueryFailedError,
|
||||
Repository,
|
||||
@@ -14,23 +15,33 @@ import {
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
|
||||
import { ImwebConfig } from '@/modules/imweb/imweb.config';
|
||||
import { ImwebMemberLookupService } from '@/modules/imweb/imweb-member-lookup.service';
|
||||
import { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
|
||||
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
|
||||
import { MembersService } from '@/modules/members/members.service';
|
||||
import {
|
||||
ConversionType,
|
||||
PartnerConversion,
|
||||
} from '@/modules/partners/entities/partner-conversion.entity';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { PartnerAttributionService } from '@/modules/partners/partner-attribution.service';
|
||||
import { InflowStatus, parseInflow } from '@/shared/utils/inflow';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import { MemberApplication } from './application.entity';
|
||||
import {
|
||||
ApplicationFilterDto,
|
||||
ApplicationGroupsQueryDto,
|
||||
ApplicationInflowBasis,
|
||||
ApplicationInflowStatsQueryDto,
|
||||
ApplicationListQueryDto,
|
||||
PARTNER_FILTER_NONE,
|
||||
} from './dto/application-query.dto';
|
||||
import {
|
||||
ApplicationGroupsResponseDto,
|
||||
ApplicationInflowStatsResponseDto,
|
||||
ApplicationPartnerDto,
|
||||
ApplicationProductDto,
|
||||
ApplicationResponseDto,
|
||||
} from './dto/application-response.dto';
|
||||
@@ -41,6 +52,9 @@ import {
|
||||
|
||||
const ALIAS = 'a';
|
||||
const MEMBER_ALIAS = 'm';
|
||||
const CONVERSION_ALIAS = 'pc';
|
||||
const PARTNER_ALIAS = 'p';
|
||||
const PRODUCT_ALIAS = 'ip';
|
||||
/** Postgres 유일 제약 위반 — 같은 회원·상품이 동시에 두 번 들어온 경우 */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
/** 집계 결과 최대 줄 수 (회원 유입 집계와 같다) */
|
||||
@@ -92,6 +106,10 @@ export class ApplicationsService {
|
||||
private readonly membersService: MembersService,
|
||||
private readonly config: ImwebConfig,
|
||||
private readonly attribution: PartnerAttributionService,
|
||||
@InjectRepository(PartnerConversion)
|
||||
private readonly conversions: Repository<PartnerConversion>,
|
||||
@InjectRepository(Partner)
|
||||
private readonly partners: Repository<Partner>,
|
||||
) {}
|
||||
|
||||
/** 신청 기록 — 같은 회원·상품이면 기존 기록을 돌려준다 (몇 번을 불러도 한 건) */
|
||||
@@ -230,10 +248,12 @@ export class ApplicationsService {
|
||||
const items = rows.slice(0, query.limit);
|
||||
const last = items.at(-1);
|
||||
|
||||
const [membersByCode, groupTitles] = await Promise.all([
|
||||
this.membersByCode(items.map((row) => row.memberCode)),
|
||||
this.membersService.groupTitleMap(),
|
||||
]);
|
||||
const [membersByCode, groupTitles, partnersByApplication] =
|
||||
await Promise.all([
|
||||
this.membersByCode(items.map((row) => row.memberCode)),
|
||||
this.membersService.groupTitleMap(),
|
||||
this.partnersByApplication(items.map((row) => row.id)),
|
||||
]);
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map((row) => {
|
||||
@@ -258,6 +278,7 @@ export class ApplicationsService {
|
||||
campaign: row.inflowLastCampaign,
|
||||
content: row.inflowLastContent,
|
||||
},
|
||||
partner: partnersByApplication.get(row.id) ?? null,
|
||||
};
|
||||
}),
|
||||
{
|
||||
@@ -338,6 +359,21 @@ export class ApplicationsService {
|
||||
if (filter.source) {
|
||||
qb.andWhere(`${ALIAS}.inflowSource = :source`, { source: filter.source });
|
||||
}
|
||||
// 업체 — 신청 실적은 신청당 하나라 잇는 행이 늘어나지 않는다
|
||||
if (filter.partnerId) {
|
||||
qb.leftJoin(
|
||||
PartnerConversion,
|
||||
CONVERSION_ALIAS,
|
||||
`${CONVERSION_ALIAS}.application_id = ${ALIAS}.id`,
|
||||
);
|
||||
if (filter.partnerId === PARTNER_FILTER_NONE) {
|
||||
qb.andWhere(`${CONVERSION_ALIAS}.id IS NULL`);
|
||||
} else {
|
||||
qb.andWhere(`${CONVERSION_ALIAS}.partner_id = :partnerId`, {
|
||||
partnerId: filter.partnerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 집계 막대를 눌러 거를 때 — 기준·항목·값이 모두 있어야 한다
|
||||
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
|
||||
qb.andWhere(
|
||||
@@ -354,6 +390,116 @@ export class ApplicationsService {
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품별·업체별 신청 수 — 신청 내역 화면의 요약 표.
|
||||
* 업체별은 업체 링크 실적으로 잡힌 신청을 업체로 묶고, 나머지는 key=null(업체 없음)로 센다.
|
||||
*/
|
||||
async groups(
|
||||
query: ApplicationGroupsQueryDto,
|
||||
): Promise<ApplicationGroupsResponseDto> {
|
||||
const base = this.applications.createQueryBuilder(ALIAS);
|
||||
if (query.appliedFrom) {
|
||||
base.andWhere(`${ALIAS}.appliedAt >= :appliedFrom`, {
|
||||
appliedFrom: query.appliedFrom,
|
||||
});
|
||||
}
|
||||
if (query.appliedTo) {
|
||||
base.andWhere(`${ALIAS}.appliedAt < :appliedTo`, {
|
||||
appliedTo: query.appliedTo,
|
||||
});
|
||||
}
|
||||
|
||||
const totals = await base
|
||||
.clone()
|
||||
.select('COUNT(*)', 'total')
|
||||
.addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoTotal')
|
||||
.getRawOne<{ total: string; kakaoTotal: string }>();
|
||||
|
||||
const grouped = base.clone();
|
||||
if (query.by === 'partner') {
|
||||
grouped
|
||||
.leftJoin(
|
||||
PartnerConversion,
|
||||
CONVERSION_ALIAS,
|
||||
`${CONVERSION_ALIAS}.application_id = ${ALIAS}.id AND ${CONVERSION_ALIAS}.type = :applicationType`,
|
||||
{ applicationType: ConversionType.APPLICATION },
|
||||
)
|
||||
.leftJoin(
|
||||
Partner,
|
||||
PARTNER_ALIAS,
|
||||
`${PARTNER_ALIAS}.id = ${CONVERSION_ALIAS}.partner_id`,
|
||||
)
|
||||
.select(`${CONVERSION_ALIAS}.partner_id`, 'key')
|
||||
.addSelect(`MAX(${PARTNER_ALIAS}.name)`, 'label');
|
||||
} else {
|
||||
// 상품명은 아임웹 상품 기준, 아직 불러오지 않은 상품이면 가장 최근 신청의 표시값
|
||||
grouped
|
||||
.leftJoin(
|
||||
ImwebProduct,
|
||||
PRODUCT_ALIAS,
|
||||
`CAST(${PRODUCT_ALIAS}.prod_no AS text) = ${ALIAS}.product_idx`,
|
||||
)
|
||||
.select(`${ALIAS}.product_idx`, 'key')
|
||||
.addSelect(
|
||||
`COALESCE(MAX(${PRODUCT_ALIAS}.name), (ARRAY_AGG(${ALIAS}.product_name ORDER BY ${ALIAS}.applied_at DESC))[1])`,
|
||||
'label',
|
||||
);
|
||||
}
|
||||
const rows = await grouped
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect(`COUNT(${ALIAS}.kakao_clicked_at)`, 'kakaoCount')
|
||||
.groupBy('key')
|
||||
.orderBy('count', 'DESC')
|
||||
.addOrderBy('key', 'ASC', 'NULLS LAST')
|
||||
.limit(STATS_MAX_ITEMS)
|
||||
.getRawMany<{
|
||||
key: string | null;
|
||||
label: string | null;
|
||||
count: string;
|
||||
kakaoCount: string;
|
||||
}>();
|
||||
|
||||
return {
|
||||
by: query.by,
|
||||
total: Number(totals?.total ?? 0),
|
||||
kakaoTotal: Number(totals?.kakaoTotal ?? 0),
|
||||
items: rows.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
count: Number(row.count),
|
||||
kakaoCount: Number(row.kakaoCount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 신청 ID → 그 신청을 데려온 업체 (업체 링크 실적이 없으면 빠진다) */
|
||||
private async partnersByApplication(
|
||||
applicationIds: string[],
|
||||
): Promise<Map<string, ApplicationPartnerDto>> {
|
||||
if (applicationIds.length === 0) return new Map();
|
||||
const conversions = await this.conversions.find({
|
||||
select: { applicationId: true, partnerId: true },
|
||||
where: { applicationId: In(applicationIds) },
|
||||
});
|
||||
const partnerIds = [...new Set(conversions.map((c) => c.partnerId))];
|
||||
if (partnerIds.length === 0) return new Map();
|
||||
const partners = await this.partners.find({
|
||||
select: { id: true, name: true },
|
||||
where: { id: In(partnerIds) },
|
||||
});
|
||||
const nameById = new Map(partners.map((p) => [p.id, p.name]));
|
||||
|
||||
const result = new Map<string, ApplicationPartnerDto>();
|
||||
for (const conversion of conversions) {
|
||||
if (!conversion.applicationId) continue;
|
||||
result.set(conversion.applicationId, {
|
||||
id: conversion.partnerId,
|
||||
name: nameById.get(conversion.partnerId) ?? '',
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 최근 신청이 있는 상품부터 */
|
||||
async products(): Promise<ApplicationProductDto[]> {
|
||||
const rows = await this.applications
|
||||
|
||||
@@ -14,6 +14,10 @@ import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
|
||||
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
|
||||
|
||||
const SEARCH_MAX_LENGTH = 100;
|
||||
/** 업체 필터 — 업체 ID(UUID) 또는 none(업체 링크를 거치지 않은 신청) */
|
||||
export const PARTNER_FILTER_NONE = 'none';
|
||||
const PARTNER_FILTER_PATTERN =
|
||||
/^(none|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
||||
|
||||
/**
|
||||
* 신청 유입을 어느 기록으로 볼 것인가.
|
||||
@@ -50,6 +54,47 @@ export class ApplicationFilterDto {
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
|
||||
appliedTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'업체 — 그 업체 링크 실적으로 잡힌 신청만. none 이면 업체 링크를 거치지 않은 신청',
|
||||
example: 'none',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(PARTNER_FILTER_PATTERN, { message: '업체 값이 올바르지 않습니다.' })
|
||||
partnerId?: string;
|
||||
}
|
||||
|
||||
/** 신청 요약 묶음 기준 — 상품별 / 업체별 */
|
||||
export const APPLICATION_GROUP_BY = ['product', 'partner'] as const;
|
||||
export type ApplicationGroupBy = (typeof APPLICATION_GROUP_BY)[number];
|
||||
|
||||
/** 상품별·업체별 신청 수 — 기간만 걸러서 센다 */
|
||||
export class ApplicationGroupsQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '묶음 기준 — product(상품별, 기본) · partner(업체별)',
|
||||
enum: APPLICATION_GROUP_BY,
|
||||
default: 'product',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(APPLICATION_GROUP_BY, { message: '묶음 기준 값이 올바르지 않습니다.' })
|
||||
by: ApplicationGroupBy = 'product';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '신청 시각 시작 (이상, ISO 8601)',
|
||||
example: '2026-09-30T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '신청 시작일 형식이 올바르지 않습니다.' })
|
||||
appliedFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '신청 시각 끝 (미만, ISO 8601)',
|
||||
example: '2026-10-31T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '신청 종료일 형식이 올바르지 않습니다.' })
|
||||
appliedTo?: string;
|
||||
}
|
||||
|
||||
/** 관리자 신청 내역 조회 */
|
||||
|
||||
@@ -3,8 +3,14 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { INFLOW_GROUP_BY } from '@/modules/members/dto/member-query.dto';
|
||||
import type { InflowGroupBy } from '@/modules/members/dto/member-query.dto';
|
||||
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
|
||||
import { APPLICATION_INFLOW_BASIS } from './application-query.dto';
|
||||
import type { ApplicationInflowBasis } from './application-query.dto';
|
||||
import {
|
||||
APPLICATION_GROUP_BY,
|
||||
APPLICATION_INFLOW_BASIS,
|
||||
} from './application-query.dto';
|
||||
import type {
|
||||
ApplicationGroupBy,
|
||||
ApplicationInflowBasis,
|
||||
} from './application-query.dto';
|
||||
|
||||
/** 신청 순간의 유입정보 */
|
||||
export class ApplicationInflowDto {
|
||||
@@ -21,6 +27,15 @@ export class ApplicationInflowDto {
|
||||
content!: string | null;
|
||||
}
|
||||
|
||||
/** 신청을 데려온 업체 (업체 링크 실적으로 잡힌 경우) */
|
||||
export class ApplicationPartnerDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
/** 관리자 신청 내역 한 건 */
|
||||
export class ApplicationResponseDto {
|
||||
@ApiProperty({ description: '신청 기록 ID' })
|
||||
@@ -67,6 +82,14 @@ export class ApplicationResponseDto {
|
||||
type: ApplicationInflowDto,
|
||||
})
|
||||
lastInflow!: ApplicationInflowDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'업체 링크 실적으로 잡힌 경우 그 업체 — 업체 링크를 거치지 않았으면 null',
|
||||
type: ApplicationPartnerDto,
|
||||
nullable: true,
|
||||
})
|
||||
partner!: ApplicationPartnerDto | null;
|
||||
}
|
||||
|
||||
/** 상품별 신청 수 — 필터 칩에 쓴다 */
|
||||
@@ -122,3 +145,56 @@ export class ApplicationInflowStatsResponseDto {
|
||||
})
|
||||
items!: ApplicationInflowStatItemDto[];
|
||||
}
|
||||
|
||||
/** 상품별·업체별 신청 수 한 줄 */
|
||||
export class ApplicationGroupItemDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'묶음 값 — 상품번호 또는 업체 ID. 업체별에서 업체 링크를 거치지 않은 신청은 null',
|
||||
nullable: true,
|
||||
example: '52',
|
||||
})
|
||||
key!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'표시 이름 — 상품명(아임웹 기준, 없으면 신청 화면 표시값) 또는 업체 이름',
|
||||
nullable: true,
|
||||
example: '[무료한정] 하루 2시간 월1000 버는 AI 효자 상품 찾기',
|
||||
})
|
||||
label!: string | null;
|
||||
|
||||
@ApiProperty({ description: '신청 수', example: 120 })
|
||||
count!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '그중 오픈카톡 입장 버튼을 누른 수',
|
||||
example: 87,
|
||||
})
|
||||
kakaoCount!: number;
|
||||
}
|
||||
|
||||
/** 상품별·업체별 신청 수 */
|
||||
export class ApplicationGroupsResponseDto {
|
||||
@ApiProperty({
|
||||
description: '묶음 기준',
|
||||
enum: APPLICATION_GROUP_BY,
|
||||
example: 'product',
|
||||
})
|
||||
by!: ApplicationGroupBy;
|
||||
|
||||
@ApiProperty({ description: '기간 내 전체 신청 수', example: 310 })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '기간 내 오픈카톡 입장 버튼을 누른 신청 수',
|
||||
example: 220,
|
||||
})
|
||||
kakaoTotal!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청 수 많은 순',
|
||||
type: [ApplicationGroupItemDto],
|
||||
})
|
||||
items!: ApplicationGroupItemDto[];
|
||||
}
|
||||
|
||||
+1645
-1147
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user