This commit is contained in:
2026-04-14 17:25:35 +09:00
13 changed files with 1097 additions and 694 deletions
@@ -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<void> {
await client.post(`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/delmulti`, payload);
}
}
export const NtftalkTempApi = new NtftalkTempApiService();
@@ -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<NtftalkVar> {
const response = await client.post<NtftalkVar>(
async saveNtftalkVar(
payload: NtftalkVarSavePayload[],
): Promise<NtftalkVar[]> {
const response = await client.post<NtftalkVar[]>(
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`,
payload,
);
@@ -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<NtftalkTemp>;
export interface NtftalkTempService {
@@ -107,4 +115,5 @@ export interface NtftalkTempService {
payload: NtftalkTempUpdatePayload,
): Promise<NtftalkTemp>;
deleteNtftalkTemp(templateCode: string): Promise<void>;
deleteMultiNtftalkTemp(payload: NtftalkTempDeleteMultiPayload): Promise<void>;
}
@@ -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<NtftalkVar>;
export interface NtftalkVarService {
getNtftalkVarPage(params: NtftalkVarSearchParams): Promise<NtftalkVarPageList>;
getNtftalkVarPage(
params: NtftalkVarSearchParams,
): Promise<NtftalkVarPageList>;
getNtftalkVar(varCd: string): Promise<NtftalkVar>;
saveNtftalkVar(payload: NtftalkVarSavePayload): Promise<NtftalkVar>;
saveNtftalkVar(payload: NtftalkVarSavePayload[]): Promise<NtftalkVar[]>;
updateNtftalkVar(
varCd: string,
payload: NtftalkVarUpdatePayload,
@@ -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<string>;
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;
}
@@ -5,6 +5,10 @@ export interface NtftalkVarGridRow extends NtftalkVar {
isDraft?: boolean;
}
export interface VarDraftFormValues {
draftVars: NtftalkVarGridRow[];
}
export function createEmptyVarDraft(clientId: string): NtftalkVarGridRow {
return {
clientId,
@@ -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<NtftalkTemp>[] {
export function createTemplateColumns(
senderData: NtftalkBiztalkSender[],
): ColumnDef<NtftalkTemp>[] {
return [
{
id: "select",
@@ -23,7 +28,9 @@ export function createTemplateColumns(): ColumnDef<NtftalkTemp>[] {
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="전체 선택"
/>
</div>
@@ -48,6 +55,12 @@ export function createTemplateColumns(): ColumnDef<NtftalkTemp>[] {
{
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<NtftalkTemp>[] {
header: "템플릿명",
},
{
accessorKey: "templateMessageType",
accessorKey: "templateMessageTypeNm",
header: "메시지유형",
},
{
@@ -68,14 +81,30 @@ export function createTemplateColumns(): ColumnDef<NtftalkTemp>[] {
const v = getValue() as string;
if (!v) return "—";
return (
<span className="line-clamp-2 max-w-[28rem] text-left" title={v}>
{v}
</span>
<div className="relative flex max-w-[32rem] items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<p className="line-clamp-2 flex-1 text-left" title={v}>
{v}
</p>
</TooltipTrigger>
<TooltipContent className="absolute -top-1 left-1/2 z-10 flex min-h-[4rem] w-[36rem] -translate-x-1/2 translate-y-[-100%] items-center justify-center rounded-sm border border-border bg-background p-2">
<p>
{v.split("\n").map((line, idx) => (
<span key={idx}>
{line}
{idx !== v.split("\n").length - 1 && <br />}
</span>
))}
</p>
</TooltipContent>
</Tooltip>
</div>
);
},
},
{
accessorKey: "templateEmphasizeType",
accessorKey: "templateEmphasizeTypeNm",
header: "강조유형",
},
{
@@ -84,11 +113,11 @@ export function createTemplateColumns(): ColumnDef<NtftalkTemp>[] {
cell: ({ row }) => formatButtons(row.original),
},
{
accessorKey: "inspectionStatus",
accessorKey: "inspectionStatusNm",
header: "검수상태",
},
{
accessorKey: "status",
accessorKey: "statusNm",
header: "상태",
},
{
@@ -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="전체 선택"
/>
</div>
@@ -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()}
/>
@@ -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<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.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 (
<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} />
<NtftalkTempTemplateSection />
<NtftalkVarSection />
</div>
);
}
@@ -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<NtftalkTempPageList>;
table: Table<NtftalkTemp>;
onRowClick: (row: NtftalkTemp) => void;
}
function NtftalkTempTablePanel({
query,
table,
onRowClick,
}: NtftalkTempTablePanelProps) {
if (query.isLoading) {
return (
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
);
}
if (query.isError) return <NoData />;
return <DataTable table={table} viewPagination onRowClick={onRowClick} />;
}
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<NtftalkTempSearchParams>({
sort: "createdAt,desc",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pendingDeleteTemplateCodes, setPendingDeleteTemplateCodes] = useState(
() => new Set<string>(),
);
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 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 (
<>
<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>
{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>
<CodeSelect
upCd="NTFINSPSTS"
value={inspectionFilter}
onValueChange={setInspectionFilter}
placeholder="전체"
className="w-[20rem]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="w-[6rem] whitespace-nowrap text-label">
메시지유형
</Label>
<CodeSelect
upCd="NTFMSGTYPE"
value={messageTypeFilter}
onValueChange={setMessageTypeFilter}
placeholder="전체"
className="w-[20rem]"
/>
</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 ||
Object.keys(rowSelection).length === 0
}
>
선택 검수요청
</Button>
<Button
type="button"
color="red"
onClick={handleDeleteSelectedTemplates}
disabled={
deleteMultiTemplatesMutation.isPending ||
Object.keys(rowSelection).length === 0
}
>
선택삭제
</Button>
<Button
type="button"
onClick={handleSaveTemplateGrid}
disabled={
deleteMultiTemplatesMutation.isPending ||
pendingDeleteTemplateCodes.size === 0
}
>
저장
</Button>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">
<NtftalkTempTablePanel
query={tempQuery}
table={templateTable}
onRowClick={(row) => {
setDetailCode(row.templateCode);
setDetailOpen(true);
}}
/>
</div>
</div>
<NtftalkTempDetailDialog
templateCode={detailCode}
open={detailOpen}
onOpenChange={(o) => {
setDetailOpen(o);
if (!o) setDetailCode(null);
}}
/>
<NtftalkTempRegistDialog open={registOpen} onOpenChange={setRegistOpen} />
</>
);
}
export { NtftalkTempTemplateSection };
@@ -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<NtftalkVarPageList>;
table: Table<NtftalkVarGridRow>;
onRowClick?: (row: NtftalkVarGridRow) => void;
}
function NtftalkVarTablePanel({
query,
table,
onRowClick,
}: NtftalkVarTablePanelProps) {
if (query.isLoading) {
return (
<div className="flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
);
}
if (query.isError) return <NoData />;
return <DataTable table={table} viewPagination onRowClick={onRowClick} />;
}
function NtftalkVarSection() {
const queryClient = useQueryClient();
const alert = useAlertStore((s) => s.setMessage);
const { confirm } = useConfirm();
const userInfo = useUserStore((s) => s.userInfo);
const [varPagination, setVarPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [varSearchParams] = useState<NtftalkVarSearchParams>({
sort: "regDt,desc",
});
const [varRowSelection, setVarRowSelection] = useState<RowSelectionState>({});
const [pendingDeleteVarCds, setPendingDeleteVarCds] = useState(
() => new Set<string>(),
);
const [editTargetVarCd, setEditTargetVarCd] = useState<string | null>(null);
const varDraftForm = useForm<VarDraftFormValues>({
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<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 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 (
<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={Object.keys(varRowSelection).length === 0}
>
선택삭제
</Button>
<Button
type="button"
className="h-[3.2rem]"
onClick={handleSaveVars}
disabled={saveVarsMutation.isPending}
>
저장
</Button>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">
<NtftalkVarTablePanel
query={varQuery}
table={varTable}
onRowClick={handleVarRowClick}
/>
</div>
<NtftalkVarEditDialog
varCd={editTargetVarCd}
open={editTargetVarCd !== null}
onOpenChange={(next) => {
if (!next) setEditTargetVarCd(null);
}}
/>
</div>
);
}
export { NtftalkVarSection };
@@ -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<typeof ntftalkVarEditFormSchema>;
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<NtftalkVarEditFormValues>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-[56rem]"
aria-describedby={undefined}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>변수 수정</DialogTitle>
</DialogHeader>
{detailQuery.isLoading && (
<div className="relative flex justify-center py-8">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{detailQuery.isError && <NoData />}
{detailQuery.data && (
<Form {...form}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid gap-2">
<Label>변수코드</Label>
<Input
value={detailQuery.data.varCd}
disabled
className="bg-muted"
/>
</div>
<FormField
control={form.control}
name="varNm"
render={({ field }) => (
<FormItem>
<FormLabel>변수명</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="varDesc"
render={({ field }) => (
<FormItem>
<FormLabel>설명</FormLabel>
<FormControl>
<Textarea
{...field}
rows={4}
className="min-h-[8rem] !text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2 sm:gap-2">
<Button
type="button"
color="white"
onClick={() => onOpenChange(false)}
>
취소
</Button>
<Button type="submit" disabled={saveMutation.isPending}>
저장
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
}
export { NtftalkVarEditDialog };
+1 -1
View File
@@ -6,7 +6,7 @@ function InfoTooltip({ text }: { text: string }) {
<TooltipTrigger asChild>
<i className="diveicon di-info-circle" />
</TooltipTrigger>
<TooltipContent>
<TooltipContent className="border border-border p-2">
<p>
{text.split("\n").map((line, idx) => (
<span key={idx}>