diff --git a/apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts b/apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts index 190e9964a..91ccf6561 100644 --- a/apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts +++ b/apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts @@ -1,7 +1,9 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; + import { type NtftalkTemp, + type NtftalkTempDeleteMultiPayload, type NtftalkTempPageList, type NtftalkTempSavePayload, type NtftalkTempSearchParams, @@ -51,6 +53,12 @@ class NtftalkTempApiService implements NtftalkTempService { `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`, ); } + + async deleteMultiNtftalkTemp( + payload: NtftalkTempDeleteMultiPayload, + ): Promise { + await client.post(`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/delmulti`, payload); + } } export const NtftalkTempApi = new NtftalkTempApiService(); diff --git a/apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts b/apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts index 972ca0208..88adfd477 100644 --- a/apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts +++ b/apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts @@ -1,5 +1,6 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; + import { type NtftalkVar, type NtftalkVarPageList, @@ -27,8 +28,10 @@ class NtftalkVarApiService implements NtftalkVarService { return response.data; } - async saveNtftalkVar(payload: NtftalkVarSavePayload): Promise { - const response = await client.post( + async saveNtftalkVar( + payload: NtftalkVarSavePayload[], + ): Promise { + const response = await client.post( `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`, payload, ); 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 7e7ab317d..34ed20803 100644 --- a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts @@ -40,10 +40,14 @@ export interface NtftalkTemp { templateName: string; templateContent: string; templateMessageType: string; + templateMessageTypeNm: string; templateEmphasizeType: string; + templateEmphasizeTypeNm: string; templateImageUrl: string; inspectionStatus: string; + inspectionStatusNm: string; status: string; + statusNm: string; createdAt: string; modifiedAt: string; buttons?: NtftalkBtnTemp[]; @@ -94,6 +98,10 @@ export interface NtftalkTempUpdatePayload { quickReplies?: NtftalkQuickTempSavePayload[]; } +export interface NtftalkTempDeleteMultiPayload { + templateCodes: string[]; +} + export type NtftalkTempPageList = PageableList; export interface NtftalkTempService { @@ -107,4 +115,5 @@ export interface NtftalkTempService { payload: NtftalkTempUpdatePayload, ): Promise; deleteNtftalkTemp(templateCode: string): Promise; + deleteMultiNtftalkTemp(payload: NtftalkTempDeleteMultiPayload): Promise; } diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts index 7a52a7a89..8367ea87e 100644 --- a/apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts @@ -15,13 +15,25 @@ export interface NtftalkVar { regr: string; } -export interface NtftalkVarSavePayload { - varCd: string; - sender?: string; - varNm: string; - varDesc?: string; - regr?: string; -} +export type NtftalkVarSavePayload = + | { + mode: "C"; + varCd: string; + varNm: string; + sender?: string; + varDesc?: string; + regr?: string; + } + | { + mode: "U"; + varCd: string; + varNm: string; + varDesc?: string; + } + | { + mode: "D"; + varCd: string; + }; export interface NtftalkVarUpdatePayload { varNm: string; @@ -31,9 +43,11 @@ export interface NtftalkVarUpdatePayload { export type NtftalkVarPageList = PageableList; export interface NtftalkVarService { - getNtftalkVarPage(params: NtftalkVarSearchParams): Promise; + getNtftalkVarPage( + params: NtftalkVarSearchParams, + ): Promise; getNtftalkVar(varCd: string): Promise; - saveNtftalkVar(payload: NtftalkVarSavePayload): Promise; + saveNtftalkVar(payload: NtftalkVarSavePayload[]): Promise; updateNtftalkVar( varCd: string, payload: NtftalkVarUpdatePayload, diff --git a/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-batch-payload.ts b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-batch-payload.ts new file mode 100644 index 000000000..23af045f6 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-batch-payload.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +import type { NtftalkVarSavePayload } from "@/entities/ntftalk"; + +import type { NtftalkVarGridRow } from "./ntftalk-var-grid"; + +export const varDraftRowSchema = z.object({ + varCd: z.string().min(1), + varNm: z.string().min(1), + varDesc: z.string().optional(), + sender: z.string().optional(), +}); + +export function buildNtftalkVarBatchPayload(input: { + draftVars: NtftalkVarGridRow[]; + pendingDeleteVarCds: ReadonlySet; + regr: string | undefined; +}): NtftalkVarSavePayload[] { + const { draftVars, pendingDeleteVarCds, regr } = input; + const payloads: NtftalkVarSavePayload[] = []; + + for (const d of draftVars) { + if (!d.isDraft) continue; + const parsed = varDraftRowSchema.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 ?? "유효하지 않은 입력입니다.", + ); + } + payloads.push({ + mode: "C", + varCd: parsed.data.varCd, + varNm: parsed.data.varNm, + varDesc: parsed.data.varDesc, + sender: parsed.data.sender, + regr, + }); + } + + for (const varCd of pendingDeleteVarCds) { + payloads.push({ mode: "D", varCd }); + } + + return payloads; +} 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 index aa4892b1f..b946305a5 100644 --- a/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts +++ b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts @@ -5,6 +5,10 @@ export interface NtftalkVarGridRow extends NtftalkVar { isDraft?: boolean; } +export interface VarDraftFormValues { + draftVars: NtftalkVarGridRow[]; +} + export function createEmptyVarDraft(clientId: string): NtftalkVarGridRow { return { clientId, 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 index 29df948ad..e5d8a00fe 100644 --- a/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx +++ b/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx @@ -1,13 +1,18 @@ +import { TooltipContent } from "@radix-ui/react-tooltip"; import type { ColumnDef, Row, Table } from "@tanstack/react-table"; -import { Checkbox } from "@common/components"; -import type { NtftalkTemp } from "@/entities/ntftalk"; + +import { InfoTooltip } from "@/shared/ui/info-tooltip"; +import type { NtftalkBiztalkSender, NtftalkTemp } from "@/entities/ntftalk"; +import { Checkbox, Tooltip, TooltipTrigger } from "@common/components"; function formatButtons(row: NtftalkTemp): string { if (!row.buttons?.length) return "—"; return row.buttons.map((b) => b.name).join(", "); } -export function createTemplateColumns(): ColumnDef[] { +export function createTemplateColumns( + senderData: NtftalkBiztalkSender[], +): ColumnDef[] { return [ { id: "select", @@ -23,7 +28,9 @@ export function createTemplateColumns(): ColumnDef[] { table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate") } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + onCheckedChange={(value) => + table.toggleAllPageRowsSelected(!!value) + } aria-label="전체 선택" /> @@ -48,6 +55,12 @@ export function createTemplateColumns(): ColumnDef[] { { accessorKey: "senderKey", header: "@채널", + cell: ({ row }) => { + const sender = senderData.find( + (s) => s.senderKey === row.original.senderKey, + ); + return sender ? sender.name : "—"; + }, }, { accessorKey: "templateCode", @@ -58,7 +71,7 @@ export function createTemplateColumns(): ColumnDef[] { header: "템플릿명", }, { - accessorKey: "templateMessageType", + accessorKey: "templateMessageTypeNm", header: "메시지유형", }, { @@ -68,14 +81,30 @@ export function createTemplateColumns(): ColumnDef[] { const v = getValue() as string; if (!v) return "—"; return ( - - {v} - +
+ + +

+ {v} +

+
+ +

+ {v.split("\n").map((line, idx) => ( + + {line} + {idx !== v.split("\n").length - 1 &&
} +
+ ))} +

+
+
+
); }, }, { - accessorKey: "templateEmphasizeType", + accessorKey: "templateEmphasizeTypeNm", header: "강조유형", }, { @@ -84,11 +113,11 @@ export function createTemplateColumns(): ColumnDef[] { cell: ({ row }) => formatButtons(row.original), }, { - accessorKey: "inspectionStatus", + accessorKey: "inspectionStatusNm", header: "검수상태", }, { - accessorKey: "status", + accessorKey: "statusNm", 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 index 3b0d39ced..3514bd7eb 100644 --- a/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx +++ b/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx @@ -1,5 +1,7 @@ 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 { @@ -28,7 +30,9 @@ export function createVarColumns( table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate") } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + onCheckedChange={(value) => + table.toggleAllPageRowsSelected(!!value) + } aria-label="전체 선택" /> @@ -69,7 +73,11 @@ export function createVarColumns( className="h-[3rem] min-w-[10rem]" value={r.varCd} onChange={(e) => - handlers.onDraftFieldChange(r.clientId!, "varCd", e.target.value) + handlers.onDraftFieldChange( + r.clientId!, + "varCd", + e.target.value, + ) } onClick={(e) => e.stopPropagation()} /> @@ -89,7 +97,11 @@ export function createVarColumns( className="h-[3rem] min-w-[10rem]" value={r.varNm} onChange={(e) => - handlers.onDraftFieldChange(r.clientId!, "varNm", e.target.value) + handlers.onDraftFieldChange( + r.clientId!, + "varNm", + e.target.value, + ) } onClick={(e) => e.stopPropagation()} /> @@ -109,7 +121,11 @@ export function createVarColumns( className="h-[3rem] min-w-[16rem]" value={r.varDesc ?? ""} onChange={(e) => - handlers.onDraftFieldChange(r.clientId!, "varDesc", e.target.value) + handlers.onDraftFieldChange( + r.clientId!, + "varDesc", + e.target.value, + ) } onClick={(e) => e.stopPropagation()} /> 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 index 787f33f93..ac2cbfc6f 100644 --- a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx @@ -1,676 +1,13 @@ "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 { - getCoreRowModel, - type PaginationState, - type RowSelectionState, - useReactTable, -} from "@tanstack/react-table"; -import { z } from "zod"; - -import { AUTH_USER_ID, ENDPOINT } from "@/shared/config"; -import { - getCookie, - useAlertStore, - useDataTable, - useMutationWrapper, -} from "@/shared/lib"; -import { NoData } from "@/shared/ui/no-data"; -import { - NtftalkBiztalkApi, - type NtftalkBiztalkInspectionRequestItem, - type NtftalkTemp, - NtftalkTempApi, - type NtftalkTempSearchParams, - NtftalkVarApi, - type NtftalkVarSearchParams, -} from "@/entities/ntftalk"; -import { - Button, - DataTable, - Input, - Label, - LoadingWithAnimation, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@common/components"; - -import { - createEmptyVarDraft, - type NtftalkVarGridRow, -} from "../model/ntftalk-var-grid"; -import { createTemplateColumns } from "../model/template-table-columns"; -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[]; -} +import { NtftalkTempTemplateSection } from "./ntftalk-temp-template-section"; +import { NtftalkVarSection } from "./ntftalk-temp-var-section"; 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.SubmitEvent) => { - 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); - }} - /> - + +
); } diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-template-section.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-template-section.tsx new file mode 100644 index 000000000..4903a5f73 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-template-section.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + keepPreviousData, + useQuery, + useQueryClient, + type UseQueryResult, +} from "@tanstack/react-query"; +import { + getCoreRowModel, + type PaginationState, + type RowSelectionState, + type Table, + useReactTable, +} from "@tanstack/react-table"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { NoData } from "@/shared/ui/no-data"; +import { CodeSelect } from "@/entities/code"; +import { + NtftalkBiztalkApi, + type NtftalkBiztalkInspectionRequestItem, + type NtftalkTemp, + NtftalkTempApi, + type NtftalkTempPageList, + type NtftalkTempSearchParams, +} from "@/entities/ntftalk"; +import { + Button, + DataTable, + Input, + Label, + LoadingWithAnimation, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +import { createTemplateColumns } from "../model/template-table-columns"; + +import { NtftalkTempDetailDialog } from "./ntftalk-temp-detail-dialog"; +import { NtftalkTempRegistDialog } from "./ntftalk-temp-regist-dialog"; + +interface NtftalkTempTablePanelProps { + query: UseQueryResult; + table: Table; + onRowClick: (row: NtftalkTemp) => void; +} + +function NtftalkTempTablePanel({ + query, + table, + onRowClick, +}: NtftalkTempTablePanelProps) { + if (query.isLoading) { + return ( +
+ +
+ ); + } + if (query.isError) return ; + return ; +} + +function NtftalkTempTemplateSection() { + 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(""); + const [inspectionFilter, setInspectionFilter] = useState(""); + const [messageTypeFilter, setMessageTypeFilter] = useState(""); + + const [searchParams, setSearchParams] = useState({ + sort: "createdAt,desc", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [rowSelection, setRowSelection] = useState({}); + const [pendingDeleteTemplateCodes, setPendingDeleteTemplateCodes] = useState( + () => new Set(), + ); + + 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 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 templateColumns = useMemo( + () => createTemplateColumns(senderQuery?.data ? [senderQuery.data] : []), + [senderQuery.data], + ); + + const visibleTemplates = useMemo( + () => + (tempQuery.data?.content ?? []).filter( + (row) => !pendingDeleteTemplateCodes.has(row.templateCode), + ), + [tempQuery.data, pendingDeleteTemplateCodes], + ); + + const templateTable = useReactTable({ + data: visibleTemplates, + columns: templateColumns, + state: { pagination, rowSelection }, + manualPagination: true, + rowCount: tempQuery.data?.totalElements ?? 0, + pageCount: tempQuery.data?.totalPages ?? 0, + enableRowSelection: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + getRowId: (row) => row.templateCode, + }); + + const buildSearchParamsFromInputs = (): NtftalkTempSearchParams => ({ + sort: searchParams.sort ?? "createdAt,desc", + templateCode: templateCodeInput.trim() || undefined, + templateName: templateNameInput.trim() || undefined, + templateContent: templateContentInput.trim() || undefined, + senderKey: senderKeyFilter, + inspectionStatus: inspectionFilter, + templateMessageType: messageTypeFilter, + }); + + const handleSearch = (e: React.SubmitEvent) => { + e.preventDefault(); + setPendingDeleteTemplateCodes(new Set()); + setRowSelection({}); + setPagination((p) => ({ ...p, pageIndex: 0 })); + setSearchParams(buildSearchParamsFromInputs()); + }; + + const handleReset = () => { + setTemplateCodeInput(""); + setTemplateNameInput(""); + setTemplateContentInput(""); + setSenderKeyFilter(""); + setInspectionFilter(""); + setMessageTypeFilter(""); + setPendingDeleteTemplateCodes(new Set()); + setRowSelection({}); + 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 = () => { + setPendingDeleteTemplateCodes(new Set()); + setRowSelection({}); + syncTemplatesMutation.mutate(); + }; + + const deleteMultiTemplatesMutation = useMutationWrapper({ + mutationFn: (templateCodes: string[]) => + NtftalkTempApi.deleteMultiNtftalkTemp({ templateCodes }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP], + }); + setPendingDeleteTemplateCodes(new Set()); + setRowSelection({}); + alert("저장되었습니다."); + }, + }); + + const handleDeleteSelectedTemplates = async () => { + const rows = templateTable.getSelectedRowModel().rows; + if (rows.length === 0) { + alert("삭제할 템플릿을 선택해 주세요."); + return; + } + if (!(await confirm(`선택한 ${rows.length}건을 삭제하시겠습니까?`))) + return; + const codes = rows.map((r) => r.original.templateCode); + setPendingDeleteTemplateCodes((prev) => { + const next = new Set(prev); + for (const code of codes) next.add(code); + return next; + }); + setRowSelection({}); + }; + + 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; + 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 = () => { + if (pendingDeleteTemplateCodes.size === 0) { + alert("저장할 변경 사항이 없습니다."); + return; + } + deleteMultiTemplatesMutation.mutate([...pendingDeleteTemplateCodes]); + }; + + return ( + <> +
+
+
+
+
+ + setTemplateCodeInput(e.target.value)} + /> +
+
+ + setTemplateNameInput(e.target.value)} + /> +
+
+ + setTemplateContentInput(e.target.value)} + /> +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+

+ 알림톡 템플릿 관리 +

+
+ + + + + +
+
+
+ { + setDetailCode(row.templateCode); + setDetailOpen(true); + }} + /> +
+
+ + { + setDetailOpen(o); + if (!o) setDetailCode(null); + }} + /> + + + ); +} + +export { NtftalkTempTemplateSection }; diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-var-section.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-var-section.tsx new file mode 100644 index 000000000..7e517afc7 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-var-section.tsx @@ -0,0 +1,296 @@ +"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 type { UseQueryResult } from "@tanstack/react-query"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + getCoreRowModel, + type PaginationState, + type RowSelectionState, + type Table, + useReactTable, +} from "@tanstack/react-table"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib"; +import { NoData } from "@/shared/ui/no-data"; +import { + NtftalkVarApi, + type NtftalkVarPageList, + type NtftalkVarSavePayload, + type NtftalkVarSearchParams, +} from "@/entities/ntftalk"; +import { Button, DataTable, LoadingWithAnimation } from "@common/components"; + +import { buildNtftalkVarBatchPayload } from "../model/ntftalk-var-batch-payload"; +import { + createEmptyVarDraft, + type NtftalkVarGridRow, + type VarDraftFormValues, +} from "../model/ntftalk-var-grid"; +import { createVarColumns } from "../model/var-table-columns"; + +import { NtftalkVarEditDialog } from "./ntftalk-var-edit-dialog"; + +interface NtftalkVarTablePanelProps { + query: UseQueryResult; + table: Table; + onRowClick?: (row: NtftalkVarGridRow) => void; +} + +function NtftalkVarTablePanel({ + query, + table, + onRowClick, +}: NtftalkVarTablePanelProps) { + if (query.isLoading) { + return ( +
+
+ +
+
+ ); + } + if (query.isError) return ; + return ; +} + +function NtftalkVarSection() { + const queryClient = useQueryClient(); + const alert = useAlertStore((s) => s.setMessage); + const { confirm } = useConfirm(); + const userInfo = useUserStore((s) => s.userInfo); + + const [varPagination, setVarPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [varSearchParams] = useState({ + sort: "regDt,desc", + }); + const [varRowSelection, setVarRowSelection] = useState({}); + const [pendingDeleteVarCds, setPendingDeleteVarCds] = useState( + () => new Set(), + ); + const [editTargetVarCd, setEditTargetVarCd] = useState(null); + + const varDraftForm = useForm({ + defaultValues: { draftVars: [] }, + }); + const { control, setValue, getValues } = varDraftForm; + const { append, remove, replace } = useFieldArray({ + control, + name: "draftVars", + }); + const draftVars = useWatch({ control, name: "draftVars" }) ?? []; + + const varQuery = useQuery({ + queryKey: [ + ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR, + varSearchParams, + varPagination, + ], + queryFn: () => + NtftalkVarApi.getNtftalkVarPage({ + ...varSearchParams, + page: varPagination.pageIndex, + size: varPagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + 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 drafts = getValues("draftVars") ?? []; + const draftIndex = drafts.findIndex( + (r) => r.isDraft && r.clientId === clientId, + ); + if (draftIndex === -1) return; + setValue(`draftVars.${draftIndex}.${field}`, value, { + shouldDirty: true, + }); + }, + [getValues, setValue], + ); + + const varColumns = useMemo( + () => createVarColumns({ onDraftFieldChange }), + [onDraftFieldChange], + ); + + const varTableRows = useMemo((): NtftalkVarGridRow[] => { + const server = (varQuery.data?.content ?? []).filter( + (row) => !pendingDeleteVarCds.has(row.varCd), + ); + if (varPagination.pageIndex === 0) return [...server, ...draftVars]; + return server; + }, [varQuery.data, draftVars, varPagination.pageIndex, pendingDeleteVarCds]); + + 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 handleAddVarRow = () => { + append(createEmptyVarDraft(crypto.randomUUID())); + setVarPagination((p) => ({ ...p, pageIndex: 0 })); + }; + + 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) { + setPendingDeleteVarCds((prev) => { + const next = new Set(prev); + for (const code of serverCodes) next.add(code); + return next; + }); + } + setVarRowSelection({}); + }; + + const saveVarsMutation = useMutationWrapper({ + mutationFn: (payload: NtftalkVarSavePayload[]) => + NtftalkVarApi.saveNtftalkVar(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR], + }); + replace([]); + setPendingDeleteVarCds(new Set()); + setVarRowSelection({}); + alert("저장되었습니다."); + }, + }); + + const handleVarRowClick = useCallback((row: NtftalkVarGridRow) => { + if (row.isDraft) return; + setEditTargetVarCd(row.varCd); + }, []); + + const handleSaveVars = () => { + if (draftVars.length === 0 && pendingDeleteVarCds.size === 0) { + alert("저장할 변경 사항이 없습니다."); + return; + } + let payload: NtftalkVarSavePayload[]; + try { + payload = buildNtftalkVarBatchPayload({ + draftVars, + pendingDeleteVarCds, + regr: userInfo?.userName ?? "", + }); + } catch (e) { + alert(e instanceof Error ? e.message : "유효하지 않은 입력입니다."); + return; + } + if (payload.length === 0) { + alert("저장할 변경 사항이 없습니다."); + return; + } + saveVarsMutation.mutate(payload); + }; + + return ( +
+
+

변수 관리

+
+ + + +
+
+
+ +
+ { + if (!next) setEditTargetVarCd(null); + }} + /> +
+ ); +} + +export { NtftalkVarSection }; diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-var-edit-dialog.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-var-edit-dialog.tsx new file mode 100644 index 000000000..4d1917674 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-var-edit-dialog.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/ui/form"; +import { NoData } from "@/shared/ui/no-data"; +import { NtftalkVarApi } from "@/entities/ntftalk"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + LoadingWithAnimation, + Textarea, +} from "@common/components"; + +const ntftalkVarEditFormSchema = z.object({ + varCd: z.string().min(1, "변수코드를 입력해 주세요."), + varNm: z.string().min(1, "변수명을 입력해 주세요."), + varDesc: z.string().optional(), +}); + +type NtftalkVarEditFormValues = z.infer; + +interface NtftalkVarEditDialogProps { + varCd: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function NtftalkVarEditDialog({ + varCd, + open, + onOpenChange, +}: NtftalkVarEditDialogProps) { + const queryClient = useQueryClient(); + const alert = useAlertStore((s) => s.setMessage); + + const detailQuery = useQuery({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR, "detail", varCd], + queryFn: () => NtftalkVarApi.getNtftalkVar(varCd!), + enabled: open && !!varCd, + }); + + const form = useForm({ + resolver: zodResolver(ntftalkVarEditFormSchema), + defaultValues: { varCd: "", varNm: "", varDesc: "" }, + }); + + useEffect(() => { + const d = detailQuery.data; + if (!d) return; + form.reset({ + varCd: d.varCd, + varNm: d.varNm, + varDesc: d.varDesc || "", + }); + }, [detailQuery.data, form]); + + const saveMutation = useMutationWrapper({ + mutationFn: () => { + const values = form.getValues(); + return NtftalkVarApi.saveNtftalkVar([ + { + mode: "U", + varCd: values.varCd, + varNm: values.varNm, + varDesc: values.varDesc || undefined, + }, + ]); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR], + }); + alert("저장되었습니다."); + onOpenChange(false); + }, + }); + + const handleSubmit = form.handleSubmit(() => saveMutation.mutate()); + + return ( + + e.preventDefault()} + > + + 변수 수정 + + {detailQuery.isLoading && ( +
+
+ +
+
+ )} + {detailQuery.isError && } + {detailQuery.data && ( +
+ +
+ + +
+ ( + + 변수명 + + + + + + )} + /> + ( + + 설명 + +