[cs] WIP: 알림톡템플릿관리
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
import { NtftalkTempPage } from "@/pages/ntftalk-temp";
|
||||||
|
|
||||||
|
export default NtftalkTempPage;
|
||||||
@@ -46,14 +46,14 @@
|
|||||||
|
|
||||||
### 4-1. 필드 정의
|
### 4-1. 필드 정의
|
||||||
|
|
||||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||||
| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------------------------- |
|
| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------- |
|
||||||
| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | 부분/완전일치 여부는 백엔드 정책 확인 필요 |
|
| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | |
|
||||||
| 템플릿명 | 텍스트 | N | 공백 | `templateName` | 부분/완전일치 여부는 백엔드 정책 확인 필요 |
|
| 템플릿명 | 텍스트 | N | 공백 | `templateName` | |
|
||||||
| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) |
|
| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의 |
|
||||||
| 채널 | 드롭다운 | N | 전체 | `senderKey` | 화면명 `@채널`과 `senderKey` 매핑 |
|
| 채널 | 드롭다운 | N | 전체 | `senderKey` | |
|
||||||
| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | 예: 승인/반려/요청 등 코드값 |
|
| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | |
|
||||||
| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) |
|
| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의 |
|
||||||
|
|
||||||
### 4-2. 버튼
|
### 4-2. 버튼
|
||||||
|
|
||||||
@@ -155,3 +155,108 @@ Base: `ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR`(`/ntftalk-service/api/v1/ntftalk-va
|
|||||||
| 변수 등록 | POST | `/ntftalk-var` | `NtftalkVarSavePayload` | `NtftalkVar` | `saveNtftalkVar` |
|
| 변수 등록 | POST | `/ntftalk-var` | `NtftalkVarSavePayload` | `NtftalkVar` | `saveNtftalkVar` |
|
||||||
| 변수 수정 | PUT | `/ntftalk-var/{varCd}` | Path: `varCd`, Body: `NtftalkVarUpdatePayload` | `NtftalkVar` | `updateNtftalkVar` |
|
| 변수 수정 | PUT | `/ntftalk-var/{varCd}` | Path: `varCd`, Body: `NtftalkVarUpdatePayload` | `NtftalkVar` | `updateNtftalkVar` |
|
||||||
| 변수 삭제 | DELETE | `/ntftalk-var/{varCd}` | Path: `varCd` | 없음 | `deleteNtftalkVar` |
|
| 변수 삭제 | DELETE | `/ntftalk-var/{varCd}` | Path: `varCd` | 없음 | `deleteNtftalkVar` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UI 구현 가이드
|
||||||
|
|
||||||
|
### 8-1. 기술 스택·원칙
|
||||||
|
|
||||||
|
- **UI**: `@common/components` 공통 컴포넌트 사용
|
||||||
|
- **테이블**: `@tanstack/react-table` + `useDataTable`(`src/shared/lib/use-data-table.tsx`)
|
||||||
|
- **데이터**: `@tanstack/react-query`(등록/수정 폼에 검증이 필요하면 `react-hook-form` + `zod` 사용)
|
||||||
|
- **레이아웃**: App Router `app/(with-base-layout)/.../page.tsx`에서 페이지 UI 컴포넌트만 마운트
|
||||||
|
|
||||||
|
### 8-2. 모듈·파일 배치
|
||||||
|
|
||||||
|
| 구분 | 경로 |
|
||||||
|
| ------------- | ----------------------------------------------------------------------- |
|
||||||
|
| 라우트 엔트리 | `apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx` |
|
||||||
|
| 페이지 셸 | `apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx` |
|
||||||
|
| 컬럼 정의 | `apps/cs/src/pages/ntftalk-temp/model/*-table-columns.tsx` |
|
||||||
|
| API·타입 | `apps/cs/src/entities/ntftalk/model/*-interface.ts`, `api/*-service.ts` |
|
||||||
|
| 다이얼로그 | `apps/cs/src/pages/ntftalk-temp/ui/*-dialog.tsx` |
|
||||||
|
|
||||||
|
### 8-3. 영역별 UI 매핑
|
||||||
|
|
||||||
|
| 영역 | 화면 요소 | 권장 컴포넌트·훅 | 구현 비고 |
|
||||||
|
| ---- | ------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||||
|
| A | 검색 폼 | `Input`, `Label`, `Select`, `Button` | |
|
||||||
|
| B | 템플릿 그리드 | `useDataTable` + `ColumnDef`, `RowSelectionState`(컬럼 선택) | `manualPagination`, `onRowClick` → **8-4** 상세 다이얼로그 |
|
||||||
|
| B | 툴바 | `Button` 그룹 | 검수요청·삭제 등 확인은 `useConfirm` + `src/shared/ui/confirm-dialog.tsx` 등 기존 패턴 재사용 |
|
||||||
|
| C | 변수 그리드 | `useDataTable` + `ColumnDef`, `Input` + `zod` | **변수추가는 인라인**(6-1절). 선택 행 삭제·저장은 변수 API(7-3절)와 매핑. 검증은 `zod` 사용 |
|
||||||
|
| 공통 | 로딩·빈 목록 | `LoadingWithAnimation`, 빈 데이터 안내 문구 | 기타 목록 페이지와 동일 수준 처리 |
|
||||||
|
|
||||||
|
### 8-4. 다이얼로그·라우팅
|
||||||
|
|
||||||
|
| UX | 구현 단위 | 컨테이너·비고 |
|
||||||
|
| --------------------------------- | ------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| 템플릿등록 (5-1) | `NtftalkTempRegistDialog` | `Dialog` + 폼. `saveNtftalkTemp`(7-2) |
|
||||||
|
| 행 클릭 상세 (5-3) | `NtftalkTempDetailDialog` | **별도 라우트가 아닌 다이얼로그**로 구현. `getNtftalkTemp`(7-2)로 상세 로드 |
|
||||||
|
| 선택 검수요청·선택삭제·저장 (5-1) | mutation + confirm | 각각 대응 API(7-2) 및 백엔드 정책에 따름. 미확정 엔드포인트는 **TBD**로 표기 |
|
||||||
|
|
||||||
|
### 8-5. 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
// 최초 진입
|
||||||
|
CALL 템플릿목록조회(기본 검색 조건)
|
||||||
|
DISPLAY 알림톡템플릿그리드 (영역 B)
|
||||||
|
|
||||||
|
CALL 변수목록조회(검색 조건)
|
||||||
|
DISPLAY 변수그리드 (영역 C)
|
||||||
|
|
||||||
|
// 영역 A: 조회·초기화
|
||||||
|
ON [조회] 버튼 CLICK DO
|
||||||
|
CALL 템플릿목록조회(템플릿코드, 템플릿명, 템플릿내용, 채널, 검수상태, 메시지유형) // → 영역 B 갱신, 페이지 1로 초기화
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON [초기화] 버튼 CLICK DO
|
||||||
|
RESET 검색 조건 → 기본값(공백·전체)
|
||||||
|
END ON
|
||||||
|
|
||||||
|
// 영역 B: 알림톡 템플릿 (7-2절 참고)
|
||||||
|
ON [템플릿갱신] CLICK DO
|
||||||
|
CALL 템플릿목록조회(현재 검색 조건) // → 영역 B 갱신
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON [템플릿등록] CLICK DO
|
||||||
|
OPEN 템플릿등록 다이얼로그
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON 영역 B 행 CLICK DO
|
||||||
|
CALL 템플릿상세조회(선택된 templateCode)
|
||||||
|
DISPLAY 템플릿상세 다이얼로그
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON [선택 검수요청] CLICK DO
|
||||||
|
CONFIRM 사용자
|
||||||
|
CALL 검수요청 API(체크된 행)
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON [선택삭제] CLICK DO
|
||||||
|
CONFIRM 사용자
|
||||||
|
CALL 템플릿삭제 API(체크된 행)
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON [저장] CLICK DO
|
||||||
|
CALL 템플릿 일괄저장 API(그리드 변경분)
|
||||||
|
END ON
|
||||||
|
|
||||||
|
// 영역 C: 변수 (7-3절 참고)
|
||||||
|
ON [변수추가] CLICK DO
|
||||||
|
ADD 변수 행 (인라인 편집)
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON [선택삭제] CLICK DO (영역 C)
|
||||||
|
CONFIRM 사용자
|
||||||
|
CALL 변수삭제 API(선택 행)
|
||||||
|
END ON
|
||||||
|
|
||||||
|
ON [저장] CLICK DO (영역 C)
|
||||||
|
CALL 변수 등록·수정 API(변경분)
|
||||||
|
END ON
|
||||||
|
|
||||||
|
END
|
||||||
|
```
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const eslintConfig = [
|
|||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
"react-hooks/exhaustive-deps": "off",
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@common/auth": "workspace:*",
|
|
||||||
"@common/assets": "workspace:*",
|
"@common/assets": "workspace:*",
|
||||||
|
"@common/auth": "workspace:*",
|
||||||
"@common/components": "workspace:*",
|
"@common/components": "workspace:*",
|
||||||
"@common/config": "workspace:*",
|
"@common/config": "workspace:*",
|
||||||
"@fontsource-variable/noto-sans-kr": "^5.2.5",
|
"@fontsource-variable/noto-sans-kr": "^5.2.5",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"@types/webpack": "^5.28.5",
|
"@types/webpack": "^5.28.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "15.3.8",
|
"eslint-config-next": "15.3.8",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const LwpoCodeFormSchema = z.object({
|
|||||||
prntLwpoCd: z.string().max(10).nullish(),
|
prntLwpoCd: z.string().max(10).nullish(),
|
||||||
useYn: z.string(),
|
useYn: z.string(),
|
||||||
sortSeq: z.number(),
|
sortSeq: z.number(),
|
||||||
delYn: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LwpoCodeFormData = z.infer<typeof LwpoCodeFormSchema>;
|
export type LwpoCodeFormData = z.infer<typeof LwpoCodeFormSchema>;
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ export interface UpCodeDetail extends UpCodeFormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const upCodeFormSchema = z.object({
|
export const upCodeFormSchema = z.object({
|
||||||
upCd: z.string().max(10).nonempty(),
|
upCd: z
|
||||||
upCdNm: z.string().max(100).nonempty(),
|
.string()
|
||||||
|
.max(30, "최대 30자까지 입력할 수 있습니다.")
|
||||||
|
.nonempty("최소 1자 이상 입력해주세요."),
|
||||||
|
upCdNm: z.string().max(100).nonempty("최소 1자 이상 입력해주세요."),
|
||||||
upCdExpln: z.string().max(1000).nullish(),
|
upCdExpln: z.string().max(1000).nullish(),
|
||||||
cdSe: z.string().max(10).nonempty(),
|
cdSe: z.string().max(10).nonempty(),
|
||||||
siteId: z.string().max(2).nullish(),
|
siteId: z.string().max(2).nullish(),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { client } from "@/shared/api";
|
||||||
|
import { ENDPOINT } from "@/shared/config";
|
||||||
|
import {
|
||||||
|
type NtftalkBiztalkCheckInspectionResponse,
|
||||||
|
type NtftalkBiztalkInspectionRequestItem,
|
||||||
|
type NtftalkBiztalkInspectionResponse,
|
||||||
|
type NtftalkBiztalkSender,
|
||||||
|
type NtftalkBiztalkService,
|
||||||
|
type NtftalkBiztalkSyncTemplatesParams,
|
||||||
|
type NtftalkBiztalkSyncTemplatesResponse,
|
||||||
|
} from "../model/ntftalk-biztalk-interface";
|
||||||
|
|
||||||
|
export const NtftalkBiztalkApi: NtftalkBiztalkService = {
|
||||||
|
async getSender(): Promise<NtftalkBiztalkSender> {
|
||||||
|
const response = await client.get<NtftalkBiztalkSender>(
|
||||||
|
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sender`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncTemplates(
|
||||||
|
params?: NtftalkBiztalkSyncTemplatesParams,
|
||||||
|
): Promise<NtftalkBiztalkSyncTemplatesResponse> {
|
||||||
|
const response = await client.post<NtftalkBiztalkSyncTemplatesResponse>(
|
||||||
|
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sync-templates`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
senderKeyType: params?.senderKeyType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async requestInspection(
|
||||||
|
requestList: NtftalkBiztalkInspectionRequestItem[],
|
||||||
|
): Promise<NtftalkBiztalkInspectionResponse> {
|
||||||
|
const response = await client.post<NtftalkBiztalkInspectionResponse>(
|
||||||
|
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/request-inspection`,
|
||||||
|
requestList,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelInspection(
|
||||||
|
requestList: NtftalkBiztalkInspectionRequestItem[],
|
||||||
|
): Promise<NtftalkBiztalkInspectionResponse> {
|
||||||
|
const response = await client.post<NtftalkBiztalkInspectionResponse>(
|
||||||
|
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/cancel-inspection`,
|
||||||
|
requestList,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkInspection(
|
||||||
|
templateCode: string,
|
||||||
|
): Promise<NtftalkBiztalkCheckInspectionResponse> {
|
||||||
|
const response = await client.get<NtftalkBiztalkCheckInspectionResponse>(
|
||||||
|
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/check-inspection/${encodeURIComponent(templateCode)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { NtftalkVarApi } from "./api/ntftalk-var-service";
|
export { NtftalkVarApi } from "./api/ntftalk-var-service";
|
||||||
export { NtftalkTempApi } from "./api/ntftalk-temp-service";
|
export { NtftalkTempApi } from "./api/ntftalk-temp-service";
|
||||||
|
export { NtftalkBiztalkApi } from "./api/ntftalk-biztalk-service";
|
||||||
export type {
|
export type {
|
||||||
NtftalkVar,
|
NtftalkVar,
|
||||||
NtftalkVarPageList,
|
NtftalkVarPageList,
|
||||||
@@ -7,6 +8,14 @@ export type {
|
|||||||
NtftalkVarSearchParams,
|
NtftalkVarSearchParams,
|
||||||
NtftalkVarUpdatePayload,
|
NtftalkVarUpdatePayload,
|
||||||
} from "./model/ntftalk-var-interface";
|
} from "./model/ntftalk-var-interface";
|
||||||
|
export type {
|
||||||
|
NtftalkBiztalkCheckInspectionResponse,
|
||||||
|
NtftalkBiztalkInspectionRequestItem,
|
||||||
|
NtftalkBiztalkInspectionResponse,
|
||||||
|
NtftalkBiztalkSender,
|
||||||
|
NtftalkBiztalkSyncTemplatesParams,
|
||||||
|
NtftalkBiztalkSyncTemplatesResponse,
|
||||||
|
} from "./model/ntftalk-biztalk-interface";
|
||||||
export type {
|
export type {
|
||||||
NtftalkBtnTemp,
|
NtftalkBtnTemp,
|
||||||
NtftalkBtnTempSavePayload,
|
NtftalkBtnTempSavePayload,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export interface NtftalkBiztalkSender {
|
||||||
|
senderKey: string;
|
||||||
|
name: string;
|
||||||
|
uuid: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NtftalkBiztalkSyncTemplatesParams {
|
||||||
|
senderKeyType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NtftalkBiztalkSyncTemplatesResponse {
|
||||||
|
syncCount: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NtftalkBiztalkInspectionRequestItem {
|
||||||
|
senderKey: string;
|
||||||
|
templateCode: string;
|
||||||
|
senderKeyType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NtftalkBiztalkInspectionResponse {
|
||||||
|
processedCount: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NtftalkBiztalkCheckInspectionResponse {
|
||||||
|
templateCode: string;
|
||||||
|
approved: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NtftalkBiztalkService {
|
||||||
|
getSender(): Promise<NtftalkBiztalkSender>;
|
||||||
|
syncTemplates(
|
||||||
|
params?: NtftalkBiztalkSyncTemplatesParams,
|
||||||
|
): Promise<NtftalkBiztalkSyncTemplatesResponse>;
|
||||||
|
requestInspection(
|
||||||
|
requestList: NtftalkBiztalkInspectionRequestItem[],
|
||||||
|
): Promise<NtftalkBiztalkInspectionResponse>;
|
||||||
|
cancelInspection(
|
||||||
|
requestList: NtftalkBiztalkInspectionRequestItem[],
|
||||||
|
): Promise<NtftalkBiztalkInspectionResponse>;
|
||||||
|
checkInspection(
|
||||||
|
templateCode: string,
|
||||||
|
): Promise<NtftalkBiztalkCheckInspectionResponse>;
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@ import type { PageableList, PageableParams } from "@/shared/model";
|
|||||||
export interface NtftalkTempSearchParams extends PageableParams {
|
export interface NtftalkTempSearchParams extends PageableParams {
|
||||||
templateCode?: string;
|
templateCode?: string;
|
||||||
templateName?: string;
|
templateName?: string;
|
||||||
|
/** 백엔드 스펙 확정 시 전달 */
|
||||||
|
templateContent?: string;
|
||||||
inspectionStatus?: string;
|
inspectionStatus?: string;
|
||||||
senderKey?: string;
|
senderKey?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
/** 백엔드 스펙 확정 시 전달 */
|
||||||
|
templateMessageType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NtftalkBtnTemp {
|
export interface NtftalkBtnTemp {
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ const LwpoCodeManagementDialog = memo(
|
|||||||
prntLwpoCd: detailData?.prntLwpoCd ?? "",
|
prntLwpoCd: detailData?.prntLwpoCd ?? "",
|
||||||
useYn: detailData?.useYn ?? "Y",
|
useYn: detailData?.useYn ?? "Y",
|
||||||
sortSeq: detailData?.sortSeq ?? 0,
|
sortSeq: detailData?.sortSeq ?? 0,
|
||||||
delYn: "N",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ import {
|
|||||||
import { SiteSelect } from "@/entities/site";
|
import { SiteSelect } from "@/entities/site";
|
||||||
import { ENDPOINT } from "@/shared/config";
|
import { ENDPOINT } from "@/shared/config";
|
||||||
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
||||||
import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form";
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
|
||||||
type UpCodeManagementDialogProps = {
|
type UpCodeManagementDialogProps = {
|
||||||
upCd?: string;
|
upCd?: string;
|
||||||
@@ -139,6 +145,7 @@ const UpCodeManagementDialog = memo(
|
|||||||
disabled={!isRegistration}
|
disabled={!isRegistration}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -160,6 +167,7 @@ const UpCodeManagementDialog = memo(
|
|||||||
placeholder="상위코드명을 입력해주세요"
|
placeholder="상위코드명을 입력해주세요"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { NtftalkTempPage } from "./ui/ntftalk-temp-page";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ntftalkTempSaveFormSchema = z.object({
|
||||||
|
templateCode: z.string().min(1, "템플릿코드를 입력하세요."),
|
||||||
|
senderKey: z.string().min(1, "채널(senderKey)을 입력하세요."),
|
||||||
|
senderKeyType: z.string().min(1, "발신키 유형을 입력하세요."),
|
||||||
|
templateName: z.string().min(1, "템플릿명을 입력하세요."),
|
||||||
|
templateContent: z.string().min(1, "템플릿내용을 입력하세요."),
|
||||||
|
templateMessageType: z.string().min(1, "메시지유형을 입력하세요."),
|
||||||
|
templateEmphasizeType: z.string().min(1, "강조유형을 입력하세요."),
|
||||||
|
templateImageUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NtftalkTempSaveFormData = z.infer<typeof ntftalkTempSaveFormSchema>;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { NtftalkVar } from "@/entities/ntftalk";
|
||||||
|
|
||||||
|
export interface NtftalkVarGridRow extends NtftalkVar {
|
||||||
|
clientId?: string;
|
||||||
|
isDraft?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyVarDraft(clientId: string): NtftalkVarGridRow {
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
isDraft: true,
|
||||||
|
varCd: "",
|
||||||
|
varNm: "",
|
||||||
|
varDesc: "",
|
||||||
|
sender: "",
|
||||||
|
regDt: "",
|
||||||
|
regr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ColumnDef, Row, Table } from "@tanstack/react-table";
|
||||||
|
import { Checkbox } from "@common/components";
|
||||||
|
import type { NtftalkTemp } from "@/entities/ntftalk";
|
||||||
|
|
||||||
|
function formatButtons(row: NtftalkTemp): string {
|
||||||
|
if (!row.buttons?.length) return "—";
|
||||||
|
return row.buttons.map((b) => b.name).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTemplateColumns(): ColumnDef<NtftalkTemp>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }: { table: Table<NtftalkTemp> }) => (
|
||||||
|
<div
|
||||||
|
className="flex justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="전체 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: Row<NtftalkTemp> }) => (
|
||||||
|
<div
|
||||||
|
className="flex justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="행 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "senderKey",
|
||||||
|
header: "@채널",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "templateCode",
|
||||||
|
header: "템플릿코드",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "templateName",
|
||||||
|
header: "템플릿명",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "templateMessageType",
|
||||||
|
header: "메시지유형",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "templateContent",
|
||||||
|
header: "템플릿내용",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const v = getValue() as string;
|
||||||
|
if (!v) return "—";
|
||||||
|
return (
|
||||||
|
<span className="line-clamp-2 max-w-[28rem] text-left" title={v}>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "templateEmphasizeType",
|
||||||
|
header: "강조유형",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "buttons",
|
||||||
|
header: "버튼정보",
|
||||||
|
cell: ({ row }) => formatButtons(row.original),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "inspectionStatus",
|
||||||
|
header: "검수상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "등록일자",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { ColumnDef, Row, Table } from "@tanstack/react-table";
|
||||||
|
import { Checkbox, Input } from "@common/components";
|
||||||
|
import type { NtftalkVarGridRow } from "./ntftalk-var-grid";
|
||||||
|
|
||||||
|
export interface VarColumnHandlers {
|
||||||
|
onDraftFieldChange: (
|
||||||
|
clientId: string,
|
||||||
|
field: "varCd" | "varNm" | "varDesc" | "sender",
|
||||||
|
value: string,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVarColumns(
|
||||||
|
handlers: VarColumnHandlers,
|
||||||
|
): ColumnDef<NtftalkVarGridRow>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }: { table: Table<NtftalkVarGridRow> }) => (
|
||||||
|
<div
|
||||||
|
className="flex justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="전체 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: Row<NtftalkVarGridRow> }) => (
|
||||||
|
<div
|
||||||
|
className="flex justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="행 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "seq",
|
||||||
|
header: "순번",
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const pageIndex = table.getState().pagination.pageIndex;
|
||||||
|
const pageSize = table.getState().pagination.pageSize;
|
||||||
|
return pageIndex * pageSize + row.index + 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "varCd",
|
||||||
|
header: "변수코드",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
if (r.isDraft && r.clientId) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
className="h-[3rem] min-w-[10rem]"
|
||||||
|
value={r.varCd}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlers.onDraftFieldChange(r.clientId!, "varCd", e.target.value)
|
||||||
|
}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return r.varCd;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "varNm",
|
||||||
|
header: "변수명",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
if (r.isDraft && r.clientId) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
className="h-[3rem] min-w-[10rem]"
|
||||||
|
value={r.varNm}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlers.onDraftFieldChange(r.clientId!, "varNm", e.target.value)
|
||||||
|
}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return r.varNm;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "varDesc",
|
||||||
|
header: "설명",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
if (r.isDraft && r.clientId) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
className="h-[3rem] min-w-[16rem]"
|
||||||
|
value={r.varDesc ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlers.onDraftFieldChange(r.clientId!, "varDesc", e.target.value)
|
||||||
|
}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return r.varDesc ?? "—";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "regDt",
|
||||||
|
header: "등록일자",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "regr",
|
||||||
|
header: "등록자",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
LoadingWithAnimation,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
} from "@common/components";
|
||||||
|
import { NtftalkTempApi } from "@/entities/ntftalk";
|
||||||
|
import { ENDPOINT } from "@/shared/config";
|
||||||
|
import { NoData } from "@/shared/ui/no-data";
|
||||||
|
|
||||||
|
interface NtftalkTempDetailDialogProps {
|
||||||
|
templateCode: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NtftalkTempDetailDialog({
|
||||||
|
templateCode,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: NtftalkTempDetailDialogProps) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP, "detail", templateCode],
|
||||||
|
queryFn: () => NtftalkTempApi.getNtftalkTemp(templateCode!),
|
||||||
|
enabled: open && !!templateCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = query.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-h-[85vh] max-w-[96rem] overflow-y-auto"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>알림톡 템플릿 상세</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{query.isLoading && (
|
||||||
|
<div className="relative size-[5rem]">
|
||||||
|
<LoadingWithAnimation color="primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{query.isError && <NoData />}
|
||||||
|
{d && (
|
||||||
|
<Table variant="secondary">
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>메시지 유형</TableHead>
|
||||||
|
<TableCell>{d.templateMessageType}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>@채널</TableHead>
|
||||||
|
<TableCell>{d.senderKey}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>템플릿코드</TableHead>
|
||||||
|
<TableCell>{d.templateCode}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>템플릿명</TableHead>
|
||||||
|
<TableCell>{d.templateName}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>강조유형</TableHead>
|
||||||
|
<TableCell>{d.templateEmphasizeType}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>템플릿내용</TableHead>
|
||||||
|
<TableCell>{d.templateContent}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>검수상태</TableHead>
|
||||||
|
<TableCell>{d.inspectionStatus}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/* <TableRow>
|
||||||
|
<TableHead>보안템플릿 여부</TableHead>
|
||||||
|
<TableCell>{d.templateEmphasizeType}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>비즈톡센터 사용여부</TableHead>
|
||||||
|
<TableCell>{d.status}</TableCell>
|
||||||
|
</TableRow> */}
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>등록일</TableHead>
|
||||||
|
<TableCell>{d.createdAt}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>템플릿 상태</TableHead>
|
||||||
|
<TableCell>{d.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NtftalkTempDetailDialog };
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||||
|
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
LoadingWithAnimation,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@common/components";
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
NtftalkBiztalkApi,
|
||||||
|
NtftalkTempApi,
|
||||||
|
NtftalkVarApi,
|
||||||
|
type NtftalkBiztalkInspectionRequestItem,
|
||||||
|
type NtftalkTemp,
|
||||||
|
type NtftalkTempSearchParams,
|
||||||
|
type NtftalkVarSearchParams,
|
||||||
|
} from "@/entities/ntftalk";
|
||||||
|
import { AUTH_USER_ID, ENDPOINT } from "@/shared/config";
|
||||||
|
import {
|
||||||
|
getCookie,
|
||||||
|
useAlertStore,
|
||||||
|
useDataTable,
|
||||||
|
useMutationWrapper,
|
||||||
|
} from "@/shared/lib";
|
||||||
|
import { NoData } from "@/shared/ui/no-data";
|
||||||
|
import { createTemplateColumns } from "../model/template-table-columns";
|
||||||
|
import {
|
||||||
|
createEmptyVarDraft,
|
||||||
|
type NtftalkVarGridRow,
|
||||||
|
} from "../model/ntftalk-var-grid";
|
||||||
|
import { createVarColumns } from "../model/var-table-columns";
|
||||||
|
import { NtftalkTempDetailDialog } from "./ntftalk-temp-detail-dialog";
|
||||||
|
import { NtftalkTempRegistDialog } from "./ntftalk-temp-regist-dialog";
|
||||||
|
|
||||||
|
const ALL = "__ALL__";
|
||||||
|
|
||||||
|
const varDraftSaveSchema = z.object({
|
||||||
|
varCd: z.string().min(1),
|
||||||
|
varNm: z.string().min(1),
|
||||||
|
varDesc: z.string().optional(),
|
||||||
|
sender: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface VarDraftFormValues {
|
||||||
|
draftVars: NtftalkVarGridRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function NtftalkTempPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const alert = useAlertStore((s) => s.setMessage);
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
|
const [templateCodeInput, setTemplateCodeInput] = useState("");
|
||||||
|
const [templateNameInput, setTemplateNameInput] = useState("");
|
||||||
|
const [templateContentInput, setTemplateContentInput] = useState("");
|
||||||
|
const [senderKeyFilter, setSenderKeyFilter] = useState(ALL);
|
||||||
|
const [inspectionFilter, setInspectionFilter] = useState(ALL);
|
||||||
|
const [messageTypeFilter, setMessageTypeFilter] = useState(ALL);
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useState<NtftalkTempSearchParams>({
|
||||||
|
sort: "createdAt,desc",
|
||||||
|
});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
|
const [varPagination, setVarPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const [varSearchParams] = useState<NtftalkVarSearchParams>({
|
||||||
|
sort: "regDt,desc",
|
||||||
|
});
|
||||||
|
const [varRowSelection, setVarRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const varDraftForm = useForm<VarDraftFormValues>({
|
||||||
|
defaultValues: { draftVars: [] },
|
||||||
|
});
|
||||||
|
const { control, setValue } = varDraftForm;
|
||||||
|
const { append, remove, replace } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "draftVars",
|
||||||
|
});
|
||||||
|
const draftVars = useWatch({ control, name: "draftVars" }) ?? [];
|
||||||
|
|
||||||
|
const [detailCode, setDetailCode] = useState<string | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [registOpen, setRegistOpen] = useState(false);
|
||||||
|
|
||||||
|
const tempQuery = useQuery({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP, searchParams, pagination],
|
||||||
|
queryFn: () =>
|
||||||
|
NtftalkTempApi.getNtftalkTempPage({
|
||||||
|
...searchParams,
|
||||||
|
page: pagination.pageIndex,
|
||||||
|
size: pagination.pageSize,
|
||||||
|
}),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const varQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR,
|
||||||
|
varSearchParams,
|
||||||
|
varPagination,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
NtftalkVarApi.getNtftalkVarPage({
|
||||||
|
...varSearchParams,
|
||||||
|
page: varPagination.pageIndex,
|
||||||
|
size: varPagination.pageSize,
|
||||||
|
}),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const senderQuery = useQuery({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"],
|
||||||
|
queryFn: () => NtftalkBiztalkApi.getSender(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const senderKeyOptions = useMemo(() => {
|
||||||
|
if (senderQuery.data) return [senderQuery.data];
|
||||||
|
return [];
|
||||||
|
}, [senderQuery.data]);
|
||||||
|
|
||||||
|
const inspectionOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
(tempQuery.data?.content ?? []).forEach((r) => {
|
||||||
|
if (r.inspectionStatus) set.add(r.inspectionStatus);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [tempQuery.data]);
|
||||||
|
|
||||||
|
const messageTypeOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
(tempQuery.data?.content ?? []).forEach((r) => {
|
||||||
|
if (r.templateMessageType) set.add(r.templateMessageType);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [tempQuery.data]);
|
||||||
|
|
||||||
|
const templateColumns = useMemo(() => createTemplateColumns(), []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
tableComponent: templateTableComponent,
|
||||||
|
tableInstance: templateTable,
|
||||||
|
} = useDataTable({
|
||||||
|
query: tempQuery,
|
||||||
|
tableOptions: {
|
||||||
|
columns: templateColumns,
|
||||||
|
state: { pagination, rowSelection },
|
||||||
|
manualPagination: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
},
|
||||||
|
dataTableOptions: {
|
||||||
|
viewPagination: true,
|
||||||
|
onRowClick: (row: NtftalkTemp) => {
|
||||||
|
setDetailCode(row.templateCode);
|
||||||
|
setDetailOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftIndexByClientId = useMemo(() => {
|
||||||
|
const indexByClientId = new Map<string, number>();
|
||||||
|
draftVars.forEach((row, index) => {
|
||||||
|
if (row.clientId) indexByClientId.set(row.clientId, index);
|
||||||
|
});
|
||||||
|
return indexByClientId;
|
||||||
|
}, [draftVars]);
|
||||||
|
|
||||||
|
const onDraftFieldChange = useCallback(
|
||||||
|
(
|
||||||
|
clientId: string,
|
||||||
|
field: "varCd" | "varNm" | "varDesc" | "sender",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const draftIndex = draftIndexByClientId.get(clientId);
|
||||||
|
if (draftIndex === undefined) return;
|
||||||
|
setValue(`draftVars.${draftIndex}.${field}`, value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[draftIndexByClientId, setValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const varColumns = useMemo(
|
||||||
|
() => createVarColumns({ onDraftFieldChange }),
|
||||||
|
[onDraftFieldChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const varTableRows = useMemo((): NtftalkVarGridRow[] => {
|
||||||
|
const server = varQuery.data?.content ?? [];
|
||||||
|
if (varPagination.pageIndex === 0) return [...server, ...draftVars];
|
||||||
|
return server;
|
||||||
|
}, [varQuery.data, draftVars, varPagination.pageIndex]);
|
||||||
|
|
||||||
|
const varTable = useReactTable({
|
||||||
|
data: varTableRows,
|
||||||
|
columns: varColumns,
|
||||||
|
state: { pagination: varPagination, rowSelection: varRowSelection },
|
||||||
|
manualPagination: true,
|
||||||
|
rowCount: varQuery.data?.totalElements ?? 0,
|
||||||
|
pageCount: varQuery.data?.totalPages ?? 0,
|
||||||
|
enableRowSelection: true,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
onPaginationChange: setVarPagination,
|
||||||
|
onRowSelectionChange: setVarRowSelection,
|
||||||
|
getRowId: (row) =>
|
||||||
|
row.isDraft && row.clientId
|
||||||
|
? `draft:${row.clientId}`
|
||||||
|
: `srv:${row.varCd}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSearchParamsFromInputs = (): NtftalkTempSearchParams => ({
|
||||||
|
sort: searchParams.sort ?? "createdAt,desc",
|
||||||
|
templateCode: templateCodeInput.trim() || undefined,
|
||||||
|
templateName: templateNameInput.trim() || undefined,
|
||||||
|
templateContent: templateContentInput.trim() || undefined,
|
||||||
|
senderKey: senderKeyFilter === ALL ? undefined : senderKeyFilter,
|
||||||
|
inspectionStatus: inspectionFilter === ALL ? undefined : inspectionFilter,
|
||||||
|
templateMessageType:
|
||||||
|
messageTypeFilter === ALL ? undefined : messageTypeFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
setSearchParams(buildSearchParamsFromInputs());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setTemplateCodeInput("");
|
||||||
|
setTemplateNameInput("");
|
||||||
|
setTemplateContentInput("");
|
||||||
|
setSenderKeyFilter(ALL);
|
||||||
|
setInspectionFilter(ALL);
|
||||||
|
setMessageTypeFilter(ALL);
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
setSearchParams({ sort: "createdAt,desc" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncTemplatesMutation = useMutationWrapper({
|
||||||
|
mutationFn: () => NtftalkBiztalkApi.syncTemplates({ senderKeyType: "S" }),
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
alert(data.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefreshTemplates = () => {
|
||||||
|
syncTemplatesMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTemplatesMutation = useMutationWrapper({
|
||||||
|
mutationFn: async (codes: string[]) => {
|
||||||
|
for (const code of codes) {
|
||||||
|
await NtftalkTempApi.deleteNtftalkTemp(code);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
|
||||||
|
});
|
||||||
|
setRowSelection({});
|
||||||
|
alert("삭제되었습니다.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteSelectedTemplates = async () => {
|
||||||
|
const rows = templateTable.getSelectedRowModel().rows;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
alert("삭제할 템플릿을 선택해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await confirm(
|
||||||
|
`선택한 ${rows.length}건의 템플릿을 삭제(비표시) 처리하시겠습니까?`,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
deleteTemplatesMutation.mutate(rows.map((r) => r.original.templateCode));
|
||||||
|
};
|
||||||
|
|
||||||
|
const inspectionRequestMutation = useMutationWrapper({
|
||||||
|
mutationFn: (requestList: NtftalkBiztalkInspectionRequestItem[]) =>
|
||||||
|
NtftalkBiztalkApi.requestInspection(requestList),
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
|
||||||
|
});
|
||||||
|
setRowSelection({});
|
||||||
|
alert(data.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInspectionRequest = async () => {
|
||||||
|
const rows = templateTable.getSelectedRowModel().rows;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
alert("검수요청할 템플릿을 선택해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestList: NtftalkBiztalkInspectionRequestItem[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const { senderKey, templateCode, senderKeyType } = row.original;
|
||||||
|
if (!senderKey || !templateCode) {
|
||||||
|
alert("선택 항목에 발신키 또는 템플릿코드가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestList.push({
|
||||||
|
senderKey,
|
||||||
|
templateCode,
|
||||||
|
senderKeyType: senderKeyType || "S",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(await confirm(`선택한 ${requestList.length}건을 검수요청하시겠습니까?`))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
inspectionRequestMutation.mutate(requestList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTemplateGrid = () => {
|
||||||
|
alert("템플릿 그리드 셀 편집·일괄 저장은 별도 구현 시 연동합니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddVarRow = () => {
|
||||||
|
append(createEmptyVarDraft(crypto.randomUUID()));
|
||||||
|
setVarPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteVarsMutation = useMutationWrapper({
|
||||||
|
mutationFn: async (codes: string[]) => {
|
||||||
|
for (const code of codes) {
|
||||||
|
await NtftalkVarApi.deleteNtftalkVar(code);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR],
|
||||||
|
});
|
||||||
|
setVarRowSelection({});
|
||||||
|
alert("삭제되었습니다.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteSelectedVars = async () => {
|
||||||
|
const selected = varTable.getSelectedRowModel().rows;
|
||||||
|
if (selected.length === 0) {
|
||||||
|
alert("삭제할 변수를 선택해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await confirm(`선택한 ${selected.length}건을 삭제하시겠습니까?`)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const serverCodes: string[] = [];
|
||||||
|
const draftIndexes: number[] = [];
|
||||||
|
selected.forEach((r) => {
|
||||||
|
const o = r.original;
|
||||||
|
if (o.isDraft && o.clientId) {
|
||||||
|
const draftIndex = draftIndexByClientId.get(o.clientId);
|
||||||
|
if (draftIndex !== undefined) draftIndexes.push(draftIndex);
|
||||||
|
} else if (o.varCd) serverCodes.push(o.varCd);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (draftIndexes.length)
|
||||||
|
remove([...new Set(draftIndexes)].sort((a, b) => b - a));
|
||||||
|
|
||||||
|
if (serverCodes.length) deleteVarsMutation.mutate(serverCodes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveVarsMutation = useMutationWrapper({
|
||||||
|
mutationFn: async (drafts: NtftalkVarGridRow[]) => {
|
||||||
|
const regr = getCookie(AUTH_USER_ID) ?? undefined;
|
||||||
|
for (const d of drafts) {
|
||||||
|
const parsed = varDraftSaveSchema.safeParse({
|
||||||
|
varCd: d.varCd,
|
||||||
|
varNm: d.varNm,
|
||||||
|
varDesc: d.varDesc || undefined,
|
||||||
|
sender: d.sender || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success)
|
||||||
|
throw new Error(
|
||||||
|
parsed.error.issues[0]?.message ?? "유효하지 않은 입력입니다.",
|
||||||
|
);
|
||||||
|
await NtftalkVarApi.saveNtftalkVar({
|
||||||
|
varCd: parsed.data.varCd,
|
||||||
|
varNm: parsed.data.varNm,
|
||||||
|
varDesc: parsed.data.varDesc,
|
||||||
|
sender: parsed.data.sender,
|
||||||
|
regr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR],
|
||||||
|
});
|
||||||
|
replace([]);
|
||||||
|
setVarRowSelection({});
|
||||||
|
alert("저장되었습니다.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveVars = () => {
|
||||||
|
if (draftVars.length === 0) {
|
||||||
|
alert("저장할 신규 변수 행이 없습니다. 변수추가로 행을 추가하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveVarsMutation.mutate(draftVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
const varTableComponent = (() => {
|
||||||
|
if (varQuery.isLoading)
|
||||||
|
return (
|
||||||
|
<div className="relative size-[5rem]">
|
||||||
|
<LoadingWithAnimation color="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (varQuery.isError) return <NoData />;
|
||||||
|
return <DataTable table={varTable} viewPagination />;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-border !mt-6 flex flex-col gap-4">
|
||||||
|
<div className="card card-border">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSearch}
|
||||||
|
className="flex items-end justify-between p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex w-full gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-[6rem] whitespace-nowrap text-label">
|
||||||
|
템플릿코드
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="w-[20rem]"
|
||||||
|
value={templateCodeInput}
|
||||||
|
onChange={(e) => setTemplateCodeInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-[6rem] whitespace-nowrap text-label">
|
||||||
|
템플릿명
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="w-[20rem]"
|
||||||
|
value={templateNameInput}
|
||||||
|
onChange={(e) => setTemplateNameInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-[6rem] whitespace-nowrap text-label">
|
||||||
|
템플릿내용
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="w-[20rem]"
|
||||||
|
value={templateContentInput}
|
||||||
|
onChange={(e) => setTemplateContentInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-[6rem] whitespace-nowrap text-label">
|
||||||
|
채널
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={senderKeyFilter}
|
||||||
|
onValueChange={setSenderKeyFilter}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[20rem]">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>전체</SelectItem>
|
||||||
|
{senderKeyOptions?.map((k) => (
|
||||||
|
<SelectItem key={k.senderKey} value={k.senderKey}>
|
||||||
|
{k.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-[6rem] whitespace-nowrap text-label">
|
||||||
|
검수상태
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={inspectionFilter}
|
||||||
|
onValueChange={setInspectionFilter}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[20rem]">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>전체</SelectItem>
|
||||||
|
{inspectionOptions.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{k}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-[6rem] whitespace-nowrap text-label">
|
||||||
|
메시지유형
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={messageTypeFilter}
|
||||||
|
onValueChange={setMessageTypeFilter}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[20rem]">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>전체</SelectItem>
|
||||||
|
{messageTypeOptions.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{k}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="h-[3.2rem]" type="submit">
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="white"
|
||||||
|
className="h-[3.2rem]"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card card-border">
|
||||||
|
<div className="card-header flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<h3 className="text-2xl font-medium text-label">
|
||||||
|
알림톡 템플릿 관리
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="white"
|
||||||
|
onClick={handleRefreshTemplates}
|
||||||
|
disabled={syncTemplatesMutation.isPending}
|
||||||
|
>
|
||||||
|
템플릿갱신
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="green"
|
||||||
|
onClick={() => setRegistOpen(true)}
|
||||||
|
>
|
||||||
|
템플릿등록
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleInspectionRequest}
|
||||||
|
disabled={inspectionRequestMutation.isPending}
|
||||||
|
>
|
||||||
|
선택 검수요청
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="red"
|
||||||
|
onClick={handleDeleteSelectedTemplates}
|
||||||
|
disabled={deleteTemplatesMutation.isPending}
|
||||||
|
>
|
||||||
|
선택삭제
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleSaveTemplateGrid}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card !m-[1.2rem] !p-0">{templateTableComponent}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card card-border">
|
||||||
|
<div className="card-header flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<h3 className="text-2xl font-medium text-label">변수 관리</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="green"
|
||||||
|
className="h-[3.2rem]"
|
||||||
|
onClick={handleAddVarRow}
|
||||||
|
>
|
||||||
|
변수추가
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="red"
|
||||||
|
className="h-[3.2rem]"
|
||||||
|
onClick={handleDeleteSelectedVars}
|
||||||
|
disabled={deleteVarsMutation.isPending}
|
||||||
|
>
|
||||||
|
선택삭제
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-[3.2rem]"
|
||||||
|
onClick={handleSaveVars}
|
||||||
|
disabled={saveVarsMutation.isPending}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card !m-[1.2rem] !p-0">{varTableComponent}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NtftalkTempDetailDialog
|
||||||
|
templateCode={detailCode}
|
||||||
|
open={detailOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
setDetailOpen(o);
|
||||||
|
if (!o) setDetailCode(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NtftalkTempRegistDialog open={registOpen} onOpenChange={setRegistOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NtftalkTempPage };
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type RowSelectionState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
LoadingWithAnimation,
|
||||||
|
RadioGroup,
|
||||||
|
RadioGroupItem,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
Textarea,
|
||||||
|
TableRow,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@common/components";
|
||||||
|
import {
|
||||||
|
NtftalkBiztalkApi,
|
||||||
|
NtftalkTempApi,
|
||||||
|
NtftalkVarApi,
|
||||||
|
type NtftalkVarSearchParams,
|
||||||
|
} from "@/entities/ntftalk";
|
||||||
|
import { ENDPOINT } from "@/shared/config";
|
||||||
|
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
|
||||||
|
import { NoData } from "@/shared/ui/no-data";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import {
|
||||||
|
ntftalkTempSaveFormSchema,
|
||||||
|
type NtftalkTempSaveFormData,
|
||||||
|
} from "../model/ntftalk-temp-save-schema";
|
||||||
|
import { createVarColumns } from "../model/var-table-columns";
|
||||||
|
|
||||||
|
interface NtftalkTempRegistDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_MESSAGE_TYPE_BASIC = "BA";
|
||||||
|
const SECURITY_TEMPLATE_EMPHASIZE_TYPE = {
|
||||||
|
enabled: "SECURITY",
|
||||||
|
disabled: "NONE",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function NtftalkTempRegistDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: NtftalkTempRegistDialogProps) {
|
||||||
|
const [isVarDialogOpen, setIsVarDialogOpen] = useState(false);
|
||||||
|
const [varPagination, setVarPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const [varRowSelection, setVarRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [varSearchParams] = useState<NtftalkVarSearchParams>({
|
||||||
|
sort: "regDt,desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const alert = useAlertStore((s) => s.setMessage);
|
||||||
|
const senderQuery = useQuery({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"],
|
||||||
|
queryFn: () => NtftalkBiztalkApi.getSender(),
|
||||||
|
});
|
||||||
|
const varQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR,
|
||||||
|
varSearchParams,
|
||||||
|
varPagination,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
NtftalkVarApi.getNtftalkVarPage({
|
||||||
|
...varSearchParams,
|
||||||
|
page: varPagination.pageIndex,
|
||||||
|
size: varPagination.pageSize,
|
||||||
|
}),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
enabled: isVarDialogOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const varColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
createVarColumns({
|
||||||
|
onDraftFieldChange: () => {},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { tableComponent, tableInstance } = useDataTable({
|
||||||
|
query: varQuery,
|
||||||
|
tableOptions: {
|
||||||
|
columns: varColumns,
|
||||||
|
state: { pagination: varPagination, rowSelection: varRowSelection },
|
||||||
|
manualPagination: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
onPaginationChange: setVarPagination,
|
||||||
|
onRowSelectionChange: setVarRowSelection,
|
||||||
|
getRowId: (row) => `srv:${row.varCd}`,
|
||||||
|
},
|
||||||
|
dataTableOptions: {
|
||||||
|
viewPagination: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<NtftalkTempSaveFormData>({
|
||||||
|
resolver: zodResolver(ntftalkTempSaveFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
templateCode: "",
|
||||||
|
senderKey: "",
|
||||||
|
senderKeyType: "S",
|
||||||
|
templateName: "",
|
||||||
|
templateContent: "",
|
||||||
|
templateMessageType: TEMPLATE_MESSAGE_TYPE_BASIC,
|
||||||
|
templateEmphasizeType: SECURITY_TEMPLATE_EMPHASIZE_TYPE.enabled,
|
||||||
|
templateImageUrl: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
form.reset();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const { mutateAsync: saveTemp, isPending } = useMutationWrapper({
|
||||||
|
mutationFn: (data: NtftalkTempSaveFormData) =>
|
||||||
|
NtftalkTempApi.saveNtftalkTemp({
|
||||||
|
...data,
|
||||||
|
templateImageUrl: data.templateImageUrl || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP],
|
||||||
|
});
|
||||||
|
alert("등록되었습니다.");
|
||||||
|
form.reset();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((data) =>
|
||||||
|
saveTemp({
|
||||||
|
...data,
|
||||||
|
senderKeyType: "S",
|
||||||
|
templateMessageType: TEMPLATE_MESSAGE_TYPE_BASIC,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!senderQuery.data) return;
|
||||||
|
if (form.getValues("senderKey")) return;
|
||||||
|
form.setValue("senderKey", senderQuery.data.senderKey);
|
||||||
|
}, [senderQuery.data, form]);
|
||||||
|
|
||||||
|
function handleInsertVariable() {
|
||||||
|
setIsVarDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplySelectedVariables() {
|
||||||
|
const rows = tableInstance.getSelectedRowModel().rows;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
alert("추가할 변수를 선택해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const varCodes = rows.map((row) => row.original.varCd?.trim() ?? "");
|
||||||
|
if (varCodes.some((varCd) => !varCd)) {
|
||||||
|
alert("선택 항목 중 변수코드가 없는 항목이 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = form.getValues("templateContent");
|
||||||
|
const tokens = varCodes.map((varCd) => `${varCd}`);
|
||||||
|
const appendValue = tokens.join("\n");
|
||||||
|
const nextValue = currentValue
|
||||||
|
? `${currentValue}\n${appendValue}`
|
||||||
|
: appendValue;
|
||||||
|
|
||||||
|
form.setValue("templateContent", nextValue, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
setVarRowSelection({});
|
||||||
|
setIsVarDialogOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const varTableComponent = (() => {
|
||||||
|
if (varQuery.isLoading)
|
||||||
|
return (
|
||||||
|
<div className="relative size-[5rem]">
|
||||||
|
<LoadingWithAnimation color="primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (varQuery.isError) return <NoData />;
|
||||||
|
return tableComponent;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-h-[85vh] max-w-[64rem] overflow-y-auto"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>알림 템플릿 등록</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form id="ntftalk-temp-regist-form" onSubmit={onSubmit}>
|
||||||
|
<Table variant="secondary" className="w-full border">
|
||||||
|
<colgroup>
|
||||||
|
<col width="22%" />
|
||||||
|
<col width="78%" />
|
||||||
|
</colgroup>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-3 py-2 text-left text-label">
|
||||||
|
메시지 유형
|
||||||
|
</TableHead>
|
||||||
|
<TableCell>기본형</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-3 py-2 text-left text-label">
|
||||||
|
채널
|
||||||
|
</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="senderKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="채널을 선택하세요." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{senderQuery.data && (
|
||||||
|
<SelectItem
|
||||||
|
value={senderQuery.data.senderKey}
|
||||||
|
>
|
||||||
|
{senderQuery.data.uuid}
|
||||||
|
{senderQuery.data
|
||||||
|
? `(${senderQuery.data.name})`
|
||||||
|
: ""}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-[1.2rem]" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-3 py-2 text-left text-label">
|
||||||
|
템플릿코드
|
||||||
|
</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="templateCode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-[1.2rem]" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-3 py-2 text-left text-label">
|
||||||
|
템플릿명
|
||||||
|
</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="templateName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-[1.2rem]" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-3 py-2 text-left text-label">
|
||||||
|
보안템플릿여부
|
||||||
|
</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="templateEmphasizeType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
className="flex items-center gap-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={
|
||||||
|
SECURITY_TEMPLATE_EMPHASIZE_TYPE.enabled
|
||||||
|
}
|
||||||
|
id="security-enabled"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="security-enabled">설정</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={
|
||||||
|
SECURITY_TEMPLATE_EMPHASIZE_TYPE.disabled
|
||||||
|
}
|
||||||
|
id="security-disabled"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="security-disabled">
|
||||||
|
미설정
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-[1.2rem]" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-3 py-2 text-left align-top text-label">
|
||||||
|
템플릿내용
|
||||||
|
</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="templateContent"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="white"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInsertVariable}
|
||||||
|
>
|
||||||
|
+ 변수추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[20rem] resize-none !text-[1.3rem]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-[1.2rem]" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="ntftalk-temp-regist-form"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
<Dialog open={isVarDialogOpen} onOpenChange={setIsVarDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-[96rem] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>변수 목록</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{varTableComponent}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="white"
|
||||||
|
onClick={() => setIsVarDialogOpen(false)}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleApplySelectedVariables}>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NtftalkTempRegistDialog };
|
||||||
@@ -69,6 +69,7 @@ export const ENDPOINT = {
|
|||||||
NTFTALK_SERVICE: {
|
NTFTALK_SERVICE: {
|
||||||
NTFTALK_VAR: "/ntftalk-service/api/v1/ntftalk-var",
|
NTFTALK_VAR: "/ntftalk-service/api/v1/ntftalk-var",
|
||||||
NTFTALK_TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
|
NTFTALK_TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
|
||||||
|
NTFTALK_BIZTALK: "/ntftalk-service/api/v1/ntftalk-biztalk",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+7
-4
@@ -104,10 +104,10 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.39.4(jiti@1.21.7)
|
version: 9.39.4(jiti@2.6.1)
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 15.3.8
|
specifier: 15.3.8
|
||||||
version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.4.49
|
specifier: ^8.4.49
|
||||||
version: 8.5.9
|
version: 8.5.9
|
||||||
@@ -533,10 +533,13 @@ importers:
|
|||||||
version: 5.28.5
|
version: 5.28.5
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.39.4(jiti@2.6.1)
|
version: 9.39.4(jiti@1.21.7)
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 15.3.8
|
specifier: 15.3.8
|
||||||
version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||||
|
eslint-plugin-react-hooks:
|
||||||
|
specifier: ^5.2.0
|
||||||
|
version: 5.2.0(eslint@9.39.4(jiti@1.21.7))
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.4.49
|
specifier: ^8.4.49
|
||||||
version: 8.5.9
|
version: 8.5.9
|
||||||
|
|||||||
Reference in New Issue
Block a user