Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend
This commit is contained in:
@@ -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 };
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user