This commit is contained in:
2026-04-15 13:31:23 +09:00
9 changed files with 252 additions and 175 deletions
@@ -11,7 +11,7 @@ export interface NtftalkVar {
sender: string;
varNm: string;
varDesc: string;
regDt: string;
regDt?: string;
regr: string;
}
@@ -0,0 +1,17 @@
import { z } from "zod";
export const ntftalkVarAdditionalSchema = z.object({
draftVars: z.array(
z.object({
varCd: z.string().min(1, "변수코드를 입력하세요."),
varNm: z.string().min(1, "변수명을 입력하세요."),
varDesc: z.string().default(""),
sender: z.string().default(""),
regr: z.string().default(""),
}),
),
});
export type NtftalkVarAdditionalFormData = z.infer<
typeof ntftalkVarAdditionalSchema
>;
@@ -12,15 +12,21 @@ export const varDraftRowSchema = z.object({
});
export function buildNtftalkVarBatchPayload(input: {
draftVars: NtftalkVarGridRow[];
pendingDeleteVarCds: ReadonlySet<string>;
regr: string | undefined;
draftVars?: NtftalkVarGridRow[];
regr?: string;
}): NtftalkVarSavePayload[] {
const { draftVars, pendingDeleteVarCds, regr } = input;
const payloads: NtftalkVarSavePayload[] = [];
if (!draftVars) {
for (const varCd of pendingDeleteVarCds) {
payloads.push({ mode: "D", varCd });
}
return payloads;
}
for (const d of draftVars) {
if (!d.isDraft) continue;
const parsed = varDraftRowSchema.safeParse({
varCd: d.varCd,
varNm: d.varNm,
@@ -42,9 +48,5 @@ export function buildNtftalkVarBatchPayload(input: {
});
}
for (const varCd of pendingDeleteVarCds) {
payloads.push({ mode: "D", varCd });
}
return payloads;
}
@@ -2,17 +2,11 @@ import type { NtftalkVar } from "@/entities/ntftalk";
export interface NtftalkVarGridRow extends NtftalkVar {
clientId?: string;
isDraft?: boolean;
}
export interface VarDraftFormValues {
draftVars: NtftalkVarGridRow[];
}
export function createEmptyVarDraft(clientId: string): NtftalkVarGridRow {
return {
clientId,
isDraft: true,
varCd: "",
varNm: "",
varDesc: "",
@@ -1,6 +1,6 @@
import type { ColumnDef, Row, Table } from "@tanstack/react-table";
import { Checkbox, Input } from "@common/components";
import { Checkbox } from "@common/components";
import type { NtftalkVarGridRow } from "./ntftalk-var-grid";
@@ -12,9 +12,7 @@ export interface VarColumnHandlers {
) => void;
}
export function createVarColumns(
handlers: VarColumnHandlers,
): ColumnDef<NtftalkVarGridRow>[] {
export function createVarColumns(): ColumnDef<NtftalkVarGridRow>[] {
return [
{
id: "select",
@@ -65,74 +63,14 @@ export function createVarColumns(
{
accessorKey: "varCd",
header: "변수코드",
cell: ({ row }) => {
const r = row.original;
if (r.isDraft && r.clientId) {
return (
<Input
className="h-[3rem] min-w-[10rem]"
value={r.varCd}
onChange={(e) =>
handlers.onDraftFieldChange(
r.clientId!,
"varCd",
e.target.value,
)
}
onClick={(e) => e.stopPropagation()}
/>
);
}
return r.varCd;
},
},
{
accessorKey: "varNm",
header: "변수명",
cell: ({ row }) => {
const r = row.original;
if (r.isDraft && r.clientId) {
return (
<Input
className="h-[3rem] min-w-[10rem]"
value={r.varNm}
onChange={(e) =>
handlers.onDraftFieldChange(
r.clientId!,
"varNm",
e.target.value,
)
}
onClick={(e) => e.stopPropagation()}
/>
);
}
return r.varNm;
},
},
{
accessorKey: "varDesc",
header: "설명",
cell: ({ row }) => {
const r = row.original;
if (r.isDraft && r.clientId) {
return (
<Input
className="h-[3rem] min-w-[16rem]"
value={r.varDesc ?? ""}
onChange={(e) =>
handlers.onDraftFieldChange(
r.clientId!,
"varDesc",
e.target.value,
)
}
onClick={(e) => e.stopPropagation()}
/>
);
}
return r.varDesc ?? "—";
},
},
{
accessorKey: "regDt",
@@ -109,17 +109,10 @@ function NtftalkTempRegistDialog({
enabled: isVarDialogOpen,
});
const varColumns = useMemo(
() =>
createVarColumns({
onDraftFieldChange: () => {},
}),
[],
);
const { tableComponent, tableInstance } = useDataTable({
query: varQuery,
tableOptions: {
columns: varColumns,
columns: createVarColumns(),
state: { pagination: varPagination, rowSelection: varRowSelection },
manualPagination: true,
enableRowSelection: true,
@@ -60,8 +60,10 @@ function NtftalkTempTablePanel({
}: NtftalkTempTablePanelProps) {
if (query.isLoading) {
return (
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
<div className="flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
);
}
@@ -217,8 +219,7 @@ function NtftalkTempTemplateSection() {
alert("삭제할 템플릿을 선택해 주세요.");
return;
}
if (!(await confirm(`선택한 ${rows.length}건을 삭제하시겠습니까?`)))
return;
if (!(await confirm(`선택한 ${rows.length}건을 삭제하시겠습니까?`))) return;
const codes = rows.map((r) => r.original.templateCode);
setPendingDeleteTemplateCodes((prev) => {
const next = new Set(prev);
@@ -420,10 +421,7 @@ function NtftalkTempTemplateSection() {
<Button
type="button"
onClick={handleSaveTemplateGrid}
disabled={
deleteMultiTemplatesMutation.isPending ||
pendingDeleteTemplateCodes.size === 0
}
disabled={deleteMultiTemplatesMutation.isPending}
>
저장
</Button>
@@ -1,7 +1,6 @@
"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";
@@ -19,7 +18,7 @@ import {
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper, useUserStore } from "@/shared/lib";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { NoData } from "@/shared/ui/no-data";
import {
NtftalkVarApi,
@@ -30,13 +29,10 @@ import {
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 { type NtftalkVarGridRow } from "../model/ntftalk-var-grid";
import { createVarColumns } from "../model/var-table-columns";
import { NtftalkVarAddDialog } from "./ntftalk-var-add-dialog";
import { NtftalkVarEditDialog } from "./ntftalk-var-edit-dialog";
interface NtftalkVarTablePanelProps {
@@ -67,7 +63,6 @@ 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,
@@ -81,16 +76,7 @@ function NtftalkVarSection() {
() => 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 [addVarDialogOpen, setAddVarDialogOpen] = useState(false);
const varQuery = useQuery({
queryKey: [
@@ -107,48 +93,15 @@ function NtftalkVarSection() {
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(
return (varQuery.data?.content ?? []).filter(
(row) => !pendingDeleteVarCds.has(row.varCd),
);
if (varPagination.pageIndex === 0) return [...server, ...draftVars];
return server;
}, [varQuery.data, draftVars, varPagination.pageIndex, pendingDeleteVarCds]);
}, [varQuery.data, pendingDeleteVarCds]);
const varTable = useReactTable({
data: varTableRows,
columns: varColumns,
columns: createVarColumns(),
state: { pagination: varPagination, rowSelection: varRowSelection },
manualPagination: true,
rowCount: varQuery.data?.totalElements ?? 0,
@@ -158,17 +111,9 @@ function NtftalkVarSection() {
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setVarPagination,
onRowSelectionChange: setVarRowSelection,
getRowId: (row) =>
row.isDraft && row.clientId
? `draft:${row.clientId}`
: `srv:${row.varCd}`,
getRowId: (row) => `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) {
@@ -178,18 +123,9 @@ function NtftalkVarSection() {
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));
const serverCodes = selected
.map((r) => r.original.varCd)
.filter((varCd): varCd is string => Boolean(varCd));
if (serverCodes.length) {
setPendingDeleteVarCds((prev) => {
@@ -208,29 +144,26 @@ function NtftalkVarSection() {
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR],
});
replace([]);
setPendingDeleteVarCds(new Set());
setVarRowSelection({});
setAddVarDialogOpen(false);
alert("저장되었습니다.");
},
});
const handleVarRowClick = useCallback((row: NtftalkVarGridRow) => {
if (row.isDraft) return;
setEditTargetVarCd(row.varCd);
}, []);
const handleSaveVars = () => {
if (draftVars.length === 0 && pendingDeleteVarCds.size === 0) {
if (pendingDeleteVarCds.size === 0) {
alert("저장할 변경 사항이 없습니다.");
return;
}
let payload: NtftalkVarSavePayload[];
try {
payload = buildNtftalkVarBatchPayload({
draftVars,
pendingDeleteVarCds,
regr: userInfo?.userName ?? "",
});
} catch (e) {
alert(e instanceof Error ? e.message : "유효하지 않은 입력입니다.");
@@ -252,7 +185,7 @@ function NtftalkVarSection() {
type="button"
color="green"
className="h-[3.2rem]"
onClick={handleAddVarRow}
onClick={() => setAddVarDialogOpen(true)}
>
변수추가
</Button>
@@ -282,6 +215,11 @@ function NtftalkVarSection() {
onRowClick={handleVarRowClick}
/>
</div>
<NtftalkVarAddDialog
open={addVarDialogOpen}
onOpenChange={setAddVarDialogOpen}
onSave={saveVarsMutation.mutate}
/>
<NtftalkVarEditDialog
varCd={editTargetVarCd}
open={editTargetVarCd !== null}
@@ -0,0 +1,197 @@
"use client";
import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import type { NtftalkVarSavePayload } from "@/entities/ntftalk";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import {
type NtftalkVarAdditionalFormData,
ntftalkVarAdditionalSchema,
} from "../model/ntftalk-var-additional-schema";
import { buildNtftalkVarBatchPayload } from "../model/ntftalk-var-batch-payload";
import { createEmptyVarDraft } from "../model/ntftalk-var-grid";
interface NtftalkVarAddDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (payload: NtftalkVarSavePayload[]) => void;
}
function NtftalkVarAddDialog({
open,
onOpenChange,
onSave,
}: NtftalkVarAddDialogProps) {
const alert = useAlertStore((s) => s.setMessage);
const userInfo = useUserStore((s) => s.userInfo);
const form = useForm<NtftalkVarAdditionalFormData>({
resolver: zodResolver(ntftalkVarAdditionalSchema),
defaultValues: { draftVars: [] },
});
const { control, reset } = form;
const { fields, append } = useFieldArray({
control,
name: "draftVars",
});
useEffect(() => {
if (!open) return;
reset({
draftVars: [createEmptyVarDraft(crypto.randomUUID())],
});
}, [open, reset]);
const onSubmit = form.handleSubmit((data) => {
const draftVars = data.draftVars ?? [];
let payload: NtftalkVarSavePayload[];
try {
payload = buildNtftalkVarBatchPayload({
draftVars,
pendingDeleteVarCds: new Set(),
regr: userInfo?.userName,
});
} catch (e) {
alert(e instanceof Error ? e.message : "유효하지 않은 입력입니다.");
return;
}
if (payload.length === 0) {
alert("저장할 변경 사항이 없습니다.");
return;
}
onSave(payload);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] max-w-[72rem] overflow-y-auto"
aria-describedby={undefined}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>변수 추가</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Form {...form}>
<form id="ntftalk-var-add-form" onSubmit={onSubmit}>
<Table variant="secondary" className="w-full border">
<TableHeader>
<TableRow>
<TableHead className="min-w-[12rem] text-left text-label">
변수코드
</TableHead>
<TableHead className="min-w-[12rem] text-left text-label">
변수명
</TableHead>
<TableHead className="min-w-[16rem] text-left text-label">
설명
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell className="align-top">
<FormField
control={control}
name={`draftVars.${index}.varCd`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
<TableCell className="align-top">
<FormField
control={control}
name={`draftVars.${index}.varNm`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
<TableCell className="align-top">
<FormField
control={control}
name={`draftVars.${index}.varDesc`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</form>
</Form>
<div className="flex justify-start">
<Button
type="button"
color="white"
size="sm"
onClick={() => append(createEmptyVarDraft(crypto.randomUUID()))}
>
+ 행추가
</Button>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button
type="button"
color="white"
onClick={() => onOpenChange(false)}
>
취소
</Button>
<Button type="submit" form="ntftalk-var-add-form">
저장
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { NtftalkVarAddDialog };