[cs] WIP: 알림톡템플릿관리

This commit is contained in:
2026-04-13 10:58:41 +09:00
parent 207268b7fc
commit 9d18ef7527
23 changed files with 1751 additions and 18 deletions
@@ -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<typeof LwpoCodeFormSchema>;
@@ -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(),
@@ -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;
},
};
+9
View File
@@ -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,
@@ -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 {
templateCode?: string;
templateName?: string;
/** 백엔드 스펙 확정 시 전달 */
templateContent?: string;
inspectionStatus?: string;
senderKey?: string;
status?: string;
/** 백엔드 스펙 확정 시 전달 */
templateMessageType?: string;
}
export interface NtftalkBtnTemp {
@@ -103,7 +103,6 @@ const LwpoCodeManagementDialog = memo(
prntLwpoCd: detailData?.prntLwpoCd ?? "",
useYn: detailData?.useYn ?? "Y",
sortSeq: detailData?.sortSeq ?? 0,
delYn: "N",
},
});
+9 -1
View File
@@ -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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -160,6 +167,7 @@ const UpCodeManagementDialog = memo(
placeholder="상위코드명을 입력해주세요"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
+1
View File
@@ -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 };
+1
View File
@@ -69,6 +69,7 @@ export const ENDPOINT = {
NTFTALK_SERVICE: {
NTFTALK_VAR: "/ntftalk-service/api/v1/ntftalk-var",
NTFTALK_TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
NTFTALK_BIZTALK: "/ntftalk-service/api/v1/ntftalk-biztalk",
},
};