From 9d18ef75272929de1a79bccbf19d30ff62e09561 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 13 Apr 2026 10:58:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[cs]=20WIP:=20=EC=95=8C=EB=A6=BC=ED=86=A1?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-base-layout)/ntftalk-temp/page.tsx | 3 + apps/cs/docs/spec/ntftalk/SPEC.md | 121 +++- apps/cs/eslint.config.mjs | 1 + apps/cs/package.json | 3 +- .../entities/code/model/lwpocode-interface.ts | 1 - .../entities/code/model/upcode-interface.ts | 7 +- .../ntftalk/api/ntftalk-biztalk-service.ts | 64 ++ apps/cs/src/entities/ntftalk/index.ts | 9 + .../model/ntftalk-biztalk-interface.ts | 48 ++ .../ntftalk/model/ntftalk-temp-interface.ts | 4 + apps/cs/src/pages/code/ui/lwpocode-dialog.tsx | 1 - apps/cs/src/pages/code/ui/upcode-dialog.tsx | 10 +- apps/cs/src/pages/ntftalk-temp/index.ts | 1 + .../model/ntftalk-temp-save-schema.ts | 14 + .../ntftalk-temp/model/ntftalk-var-grid.ts | 19 + .../model/template-table-columns.tsx | 99 +++ .../ntftalk-temp/model/var-table-columns.tsx | 130 ++++ apps/cs/src/pages/ntftalk-temp/page.tsx | 0 .../ui/ntftalk-temp-detail-dialog.tsx | 109 +++ .../ntftalk-temp/ui/ntftalk-temp-page.tsx | 674 ++++++++++++++++++ .../ui/ntftalk-temp-regist-dialog.tsx | 439 ++++++++++++ apps/cs/src/shared/config/api.ts | 1 + pnpm-lock.yaml | 11 +- 23 files changed, 1751 insertions(+), 18 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx create mode 100644 apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts create mode 100644 apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/index.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx delete mode 100644 apps/cs/src/pages/ntftalk-temp/page.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx diff --git a/apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx b/apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx new file mode 100644 index 000000000..04ff3524a --- /dev/null +++ b/apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx @@ -0,0 +1,3 @@ +import { NtftalkTempPage } from "@/pages/ntftalk-temp"; + +export default NtftalkTempPage; diff --git a/apps/cs/docs/spec/ntftalk/SPEC.md b/apps/cs/docs/spec/ntftalk/SPEC.md index ec8a7455e..0b84ace4a 100644 --- a/apps/cs/docs/spec/ntftalk/SPEC.md +++ b/apps/cs/docs/spec/ntftalk/SPEC.md @@ -46,14 +46,14 @@ ### 4-1. 필드 정의 -| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | -| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------------------------- | -| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | 부분/완전일치 여부는 백엔드 정책 확인 필요 | -| 템플릿명 | 텍스트 | N | 공백 | `templateName` | 부분/완전일치 여부는 백엔드 정책 확인 필요 | -| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) | -| 채널 | 드롭다운 | N | 전체 | `senderKey` | 화면명 `@채널`과 `senderKey` 매핑 | -| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | 예: 승인/반려/요청 등 코드값 | -| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) | +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------- | +| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | | +| 템플릿명 | 텍스트 | N | 공백 | `templateName` | | +| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의 | +| 채널 | 드롭다운 | N | 전체 | `senderKey` | | +| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | | +| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의 | ### 4-2. 버튼 @@ -155,3 +155,108 @@ Base: `ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR`(`/ntftalk-service/api/v1/ntftalk-va | 변수 등록 | POST | `/ntftalk-var` | `NtftalkVarSavePayload` | `NtftalkVar` | `saveNtftalkVar` | | 변수 수정 | PUT | `/ntftalk-var/{varCd}` | Path: `varCd`, Body: `NtftalkVarUpdatePayload` | `NtftalkVar` | `updateNtftalkVar` | | 변수 삭제 | 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 +``` diff --git a/apps/cs/eslint.config.mjs b/apps/cs/eslint.config.mjs index 9109635e9..e11052b52 100644 --- a/apps/cs/eslint.config.mjs +++ b/apps/cs/eslint.config.mjs @@ -14,6 +14,7 @@ const eslintConfig = [ { rules: { "react-hooks/exhaustive-deps": "off", + "@typescript-eslint/no-unused-vars": "warn", }, }, ]; diff --git a/apps/cs/package.json b/apps/cs/package.json index 9343e5dfc..5bf3b53e7 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -9,8 +9,8 @@ "lint": "next lint" }, "dependencies": { - "@common/auth": "workspace:*", "@common/assets": "workspace:*", + "@common/auth": "workspace:*", "@common/components": "workspace:*", "@common/config": "workspace:*", "@fontsource-variable/noto-sans-kr": "^5.2.5", @@ -54,6 +54,7 @@ "@types/webpack": "^5.28.5", "eslint": "^9.0.0", "eslint-config-next": "15.3.8", + "eslint-plugin-react-hooks": "^5.2.0", "postcss": "^8.4.49", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/apps/cs/src/entities/code/model/lwpocode-interface.ts b/apps/cs/src/entities/code/model/lwpocode-interface.ts index 2538c1c30..d44a89ef2 100644 --- a/apps/cs/src/entities/code/model/lwpocode-interface.ts +++ b/apps/cs/src/entities/code/model/lwpocode-interface.ts @@ -44,7 +44,6 @@ export const LwpoCodeFormSchema = z.object({ prntLwpoCd: z.string().max(10).nullish(), useYn: z.string(), sortSeq: z.number(), - delYn: z.string(), }); export type LwpoCodeFormData = z.infer; diff --git a/apps/cs/src/entities/code/model/upcode-interface.ts b/apps/cs/src/entities/code/model/upcode-interface.ts index 73bf4f3ae..c63eccc0a 100644 --- a/apps/cs/src/entities/code/model/upcode-interface.ts +++ b/apps/cs/src/entities/code/model/upcode-interface.ts @@ -32,8 +32,11 @@ export interface UpCodeDetail extends UpCodeFormData { } export const upCodeFormSchema = z.object({ - upCd: z.string().max(10).nonempty(), - upCdNm: z.string().max(100).nonempty(), + upCd: z + .string() + .max(30, "최대 30자까지 입력할 수 있습니다.") + .nonempty("최소 1자 이상 입력해주세요."), + upCdNm: z.string().max(100).nonempty("최소 1자 이상 입력해주세요."), upCdExpln: z.string().max(1000).nullish(), cdSe: z.string().max(10).nonempty(), siteId: z.string().max(2).nullish(), diff --git a/apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts b/apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts new file mode 100644 index 000000000..85f585360 --- /dev/null +++ b/apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sender`, + ); + return response.data; + }, + + async syncTemplates( + params?: NtftalkBiztalkSyncTemplatesParams, + ): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sync-templates`, + undefined, + { + params: { + senderKeyType: params?.senderKeyType, + }, + }, + ); + return response.data; + }, + + async requestInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/request-inspection`, + requestList, + ); + return response.data; + }, + + async cancelInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/cancel-inspection`, + requestList, + ); + return response.data; + }, + + async checkInspection( + templateCode: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/check-inspection/${encodeURIComponent(templateCode)}`, + ); + return response.data; + }, +}; diff --git a/apps/cs/src/entities/ntftalk/index.ts b/apps/cs/src/entities/ntftalk/index.ts index 87c3dfe2c..048eaad9e 100644 --- a/apps/cs/src/entities/ntftalk/index.ts +++ b/apps/cs/src/entities/ntftalk/index.ts @@ -1,5 +1,6 @@ export { NtftalkVarApi } from "./api/ntftalk-var-service"; export { NtftalkTempApi } from "./api/ntftalk-temp-service"; +export { NtftalkBiztalkApi } from "./api/ntftalk-biztalk-service"; export type { NtftalkVar, NtftalkVarPageList, @@ -7,6 +8,14 @@ export type { NtftalkVarSearchParams, NtftalkVarUpdatePayload, } from "./model/ntftalk-var-interface"; +export type { + NtftalkBiztalkCheckInspectionResponse, + NtftalkBiztalkInspectionRequestItem, + NtftalkBiztalkInspectionResponse, + NtftalkBiztalkSender, + NtftalkBiztalkSyncTemplatesParams, + NtftalkBiztalkSyncTemplatesResponse, +} from "./model/ntftalk-biztalk-interface"; export type { NtftalkBtnTemp, NtftalkBtnTempSavePayload, diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts new file mode 100644 index 000000000..824eb00d5 --- /dev/null +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts @@ -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; + syncTemplates( + params?: NtftalkBiztalkSyncTemplatesParams, + ): Promise; + requestInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise; + cancelInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise; + checkInspection( + templateCode: string, + ): Promise; +} diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts index 79d537b28..7e7ab317d 100644 --- a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts @@ -3,9 +3,13 @@ import type { PageableList, PageableParams } from "@/shared/model"; export interface NtftalkTempSearchParams extends PageableParams { templateCode?: string; templateName?: string; + /** 백엔드 스펙 확정 시 전달 */ + templateContent?: string; inspectionStatus?: string; senderKey?: string; status?: string; + /** 백엔드 스펙 확정 시 전달 */ + templateMessageType?: string; } export interface NtftalkBtnTemp { diff --git a/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx b/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx index 3179303dd..69dd31aa2 100644 --- a/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx +++ b/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx @@ -103,7 +103,6 @@ const LwpoCodeManagementDialog = memo( prntLwpoCd: detailData?.prntLwpoCd ?? "", useYn: detailData?.useYn ?? "Y", sortSeq: detailData?.sortSeq ?? 0, - delYn: "N", }, }); diff --git a/apps/cs/src/pages/code/ui/upcode-dialog.tsx b/apps/cs/src/pages/code/ui/upcode-dialog.tsx index 6774bd17e..d54030cca 100644 --- a/apps/cs/src/pages/code/ui/upcode-dialog.tsx +++ b/apps/cs/src/pages/code/ui/upcode-dialog.tsx @@ -31,7 +31,13 @@ import { import { SiteSelect } from "@/entities/site"; import { ENDPOINT } from "@/shared/config"; 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 = { upCd?: string; @@ -139,6 +145,7 @@ const UpCodeManagementDialog = memo( disabled={!isRegistration} /> + )} /> @@ -160,6 +167,7 @@ const UpCodeManagementDialog = memo( placeholder="상위코드명을 입력해주세요" /> + )} /> diff --git a/apps/cs/src/pages/ntftalk-temp/index.ts b/apps/cs/src/pages/ntftalk-temp/index.ts new file mode 100644 index 000000000..a15c01db3 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/index.ts @@ -0,0 +1 @@ +export { NtftalkTempPage } from "./ui/ntftalk-temp-page"; diff --git a/apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts new file mode 100644 index 000000000..911c4a277 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts @@ -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; diff --git a/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts new file mode 100644 index 000000000..aa4892b1f --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts @@ -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: "", + }; +} diff --git a/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx b/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx new file mode 100644 index 000000000..29df948ad --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx @@ -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[] { + return [ + { + id: "select", + header: ({ table }: { table: Table }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + table.toggleAllPageRowsSelected(!!value)} + aria-label="전체 선택" + /> +
+ ), + cell: ({ row }: { row: Row }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + row.toggleSelected(!!value)} + aria-label="행 선택" + /> +
+ ), + 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 ( + + {v} + + ); + }, + }, + { + accessorKey: "templateEmphasizeType", + header: "강조유형", + }, + { + id: "buttons", + header: "버튼정보", + cell: ({ row }) => formatButtons(row.original), + }, + { + accessorKey: "inspectionStatus", + header: "검수상태", + }, + { + accessorKey: "status", + header: "상태", + }, + { + accessorKey: "createdAt", + header: "등록일자", + }, + ]; +} diff --git a/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx b/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx new file mode 100644 index 000000000..3b0d39ced --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx @@ -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[] { + return [ + { + id: "select", + header: ({ table }: { table: Table }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + table.toggleAllPageRowsSelected(!!value)} + aria-label="전체 선택" + /> +
+ ), + cell: ({ row }: { row: Row }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + row.toggleSelected(!!value)} + aria-label="행 선택" + /> +
+ ), + 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 ( + + 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 ( + + 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 ( + + handlers.onDraftFieldChange(r.clientId!, "varDesc", e.target.value) + } + onClick={(e) => e.stopPropagation()} + /> + ); + } + return r.varDesc ?? "—"; + }, + }, + { + accessorKey: "regDt", + header: "등록일자", + }, + { + accessorKey: "regr", + header: "등록자", + }, + ]; +} diff --git a/apps/cs/src/pages/ntftalk-temp/page.tsx b/apps/cs/src/pages/ntftalk-temp/page.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx new file mode 100644 index 000000000..f196df4e3 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx @@ -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 ( + + + + 알림톡 템플릿 상세 + + {query.isLoading && ( +
+ +
+ )} + {query.isError && } + {d && ( + + + + 메시지 유형 + {d.templateMessageType} + + + @채널 + {d.senderKey} + + + 템플릿코드 + {d.templateCode} + + + 템플릿명 + {d.templateName} + + + 강조유형 + {d.templateEmphasizeType} + + + 템플릿내용 + {d.templateContent} + + + 검수상태 + {d.inspectionStatus} + + {/* + 보안템플릿 여부 + {d.templateEmphasizeType} + + + 비즈톡센터 사용여부 + {d.status} + */} + + 등록일 + {d.createdAt} + + + 템플릿 상태 + {d.status} + + +
+ )} +
+
+ ); +} + +export { NtftalkTempDetailDialog }; diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx new file mode 100644 index 000000000..8cdd4e338 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx @@ -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({ + sort: "createdAt,desc", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [rowSelection, setRowSelection] = useState({}); + + const [varPagination, setVarPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [varSearchParams] = useState({ + sort: "regDt,desc", + }); + const [varRowSelection, setVarRowSelection] = useState({}); + const varDraftForm = useForm({ + defaultValues: { draftVars: [] }, + }); + const { control, setValue } = varDraftForm; + const { append, remove, replace } = useFieldArray({ + control, + name: "draftVars", + }); + const draftVars = useWatch({ control, name: "draftVars" }) ?? []; + + const [detailCode, setDetailCode] = useState(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(); + (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(); + (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(); + 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 ( +
+ +
+ ); + if (varQuery.isError) return ; + return ; + })(); + + return ( +
+
+
+
+
+
+ + setTemplateCodeInput(e.target.value)} + /> +
+
+ + setTemplateNameInput(e.target.value)} + /> +
+
+ + setTemplateContentInput(e.target.value)} + /> +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+

+ 알림톡 템플릿 관리 +

+
+ + + + + +
+
+
{templateTableComponent}
+
+ +
+
+

변수 관리

+
+ + + +
+
+
{varTableComponent}
+
+ + { + setDetailOpen(o); + if (!o) setDetailCode(null); + }} + /> + +
+ ); +} + +export { NtftalkTempPage }; diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx new file mode 100644 index 000000000..6a8937073 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx @@ -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({ + pageIndex: 0, + pageSize: 10, + }); + const [varRowSelection, setVarRowSelection] = useState({}); + const [varSearchParams] = useState({ + 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({ + 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 ( +
+ +
+ ); + if (varQuery.isError) return ; + return tableComponent; + })(); + + return ( + + + + 알림 템플릿 등록 + +
+ + + + + + + + + + 메시지 유형 + + 기본형 + + + + 채널 + + + ( + + + + + + + )} + /> + + + + + 템플릿코드 + + + ( + + + + + + + )} + /> + + + + + 템플릿명 + + + ( + + + + + + + )} + /> + + + + + 보안템플릿여부 + + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+ + + 템플릿내용 + + + ( + +
+ +
+ +