2 Commits

Author SHA1 Message Date
ttipo 6316666bea Merge branch 'feat/admin-ui-redesign'
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
2026-10-06 01:38:32 +09:00
ttipo b5ed8850c7 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>
2026-10-06 01:37:49 +09:00
7 changed files with 1948 additions and 1154 deletions
@@ -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
View File
File diff suppressed because it is too large Load Diff