Files
DDD1542 2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename

신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:50:50 +09:00

358 lines
12 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
import { useCreateCodeInfo, useUpdateCodeInfo } from "@/hooks/queries/useCodeInfo";
import { useCheckCodeInfoDuplicate } from "@/hooks/queries/useValidation";
import type { CodeInfo } from "@/types/commonCode";
import {
createCodeInfoSchema,
updateCodeInfoSchema,
type CreateCodeInfoData,
type UpdateCodeInfoData,
} from "@/lib/schemas/commonCode";
interface CodeInfoFormModalProps {
isOpen: boolean;
onClose: () => void;
editingCodeInfo?: string;
existingRows: CodeInfo[];
}
export function CodeInfoFormModal({
isOpen,
onClose,
editingCodeInfo,
existingRows,
}: CodeInfoFormModalProps) {
const createMutation = useCreateCodeInfo();
const updateMutation = useUpdateCodeInfo();
const isEditing = !!editingCodeInfo;
const editingRow = existingRows.find((r) => r.code_info === editingCodeInfo);
const createForm = useForm<CreateCodeInfoData>({
resolver: zodResolver(createCodeInfoSchema),
mode: "onChange",
defaultValues: {
code_info: "",
code_name: "",
code_name_eng: "",
description: "",
sort_order: 0,
},
});
const updateForm = useForm<UpdateCodeInfoData>({
resolver: zodResolver(updateCodeInfoSchema),
mode: "onChange",
defaultValues: {
code_name: "",
code_name_eng: "",
description: "",
sort_order: 0,
is_active: "Y",
},
});
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
const handleFieldBlur = (name: string) => {
setValidatedFields((prev) => new Set(prev).add(name));
};
const codeCheck = useCheckCodeInfoDuplicate(
"code_info",
isEditing ? "" : createForm.watch("code_info"),
isEditing ? editingCodeInfo : undefined,
validatedFields.has("code_info"),
);
const nameCheck = useCheckCodeInfoDuplicate(
"code_name",
isEditing ? updateForm.watch("code_name") : createForm.watch("code_name"),
isEditing ? editingCodeInfo : undefined,
validatedFields.has("code_name"),
);
const nameEngCheck = useCheckCodeInfoDuplicate(
"code_name_eng",
isEditing
? updateForm.watch("code_name_eng") || ""
: createForm.watch("code_name_eng") || "",
isEditing ? editingCodeInfo : undefined,
validatedFields.has("code_name_eng"),
);
const hasDuplicateErrors =
(!isEditing && codeCheck.data?.isDuplicate && validatedFields.has("code_info")) ||
(nameCheck.data?.isDuplicate && validatedFields.has("code_name")) ||
(nameEngCheck.data?.isDuplicate && validatedFields.has("code_name_eng"));
const isDuplicateChecking =
(!isEditing && codeCheck.isLoading) || nameCheck.isLoading || nameEngCheck.isLoading;
useEffect(() => {
if (!isOpen) return;
setValidatedFields(new Set());
if (isEditing && editingRow) {
updateForm.reset({
code_name: editingRow.code_name,
code_name_eng: editingRow.code_name_eng || "",
description: editingRow.description || "",
sort_order: editingRow.sort_order ?? 0,
is_active: (editingRow.is_active as "Y" | "N") || "Y",
});
} else {
const maxSort =
existingRows.length > 0
? Math.max(...existingRows.map((r) => r.sort_order ?? 0))
: 0;
createForm.reset({
code_info: "",
code_name: "",
code_name_eng: "",
description: "",
sort_order: maxSort + 1,
});
}
}, [isOpen, isEditing, editingRow, existingRows]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = isEditing
? updateForm.handleSubmit(async (data) => {
try {
await updateMutation.mutateAsync({ codeInfo: editingCodeInfo!, data });
onClose();
updateForm.reset();
} catch (error) {
console.error("그룹 수정 실패:", error);
}
})
: createForm.handleSubmit(async (data) => {
try {
await createMutation.mutateAsync(data);
onClose();
createForm.reset();
} catch (error) {
console.error("그룹 생성 실패:", error);
}
});
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isEditing ? "그룹 수정" : "새 그룹"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* 그룹 코드 (code_info) */}
{!isEditing && (
<div className="space-y-2">
<Label htmlFor="code_info" className="text-xs sm:text-sm">
*
</Label>
<Input
id="code_info"
{...createForm.register("code_info")}
disabled={isLoading}
placeholder="예: STATUS, USER_ROLE"
className={
createForm.formState.errors.code_info
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
: "h-8 text-xs sm:h-10 sm:text-sm"
}
onBlur={() => handleFieldBlur("code_info")}
/>
{createForm.formState.errors.code_info && (
<p className="text-[10px] sm:text-xs text-destructive">
{createForm.formState.errors.code_info.message}
</p>
)}
{!createForm.formState.errors.code_info && (
<ValidationMessage
message={codeCheck.data?.message}
isValid={!codeCheck.data?.isDuplicate}
isLoading={codeCheck.isLoading}
/>
)}
</div>
)}
{isEditing && editingRow && (
<div className="space-y-2">
<Label htmlFor="code_info_display" className="text-xs sm:text-sm">
</Label>
<Input
id="code_info_display"
value={editingRow.code_info}
disabled
className="h-8 cursor-not-allowed bg-muted text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
.
</p>
</div>
)}
{/* 그룹명 */}
<div className="space-y-2">
<Label htmlFor="code_name"> *</Label>
<Input
id="code_name"
{...(isEditing
? updateForm.register("code_name")
: createForm.register("code_name"))}
disabled={isLoading}
placeholder="그룹명을 입력하세요"
className={
(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name)
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("code_name")}
/>
{(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name) && (
<p className="text-sm text-destructive">
{
(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name
)?.message
}
</p>
)}
{!(isEditing
? updateForm.formState.errors.code_name
: createForm.formState.errors.code_name) && (
<ValidationMessage
message={nameCheck.data?.message}
isValid={!nameCheck.data?.isDuplicate}
isLoading={nameCheck.isLoading}
/>
)}
</div>
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="code_name_eng"> </Label>
<Input
id="code_name_eng"
{...(isEditing
? updateForm.register("code_name_eng")
: createForm.register("code_name_eng"))}
disabled={isLoading}
placeholder="영문명을 입력하세요 (선택)"
onBlur={() => handleFieldBlur("code_name_eng")}
/>
{!(isEditing
? updateForm.formState.errors.code_name_eng
: createForm.formState.errors.code_name_eng) && (
<ValidationMessage
message={nameEngCheck.data?.message}
isValid={!nameEngCheck.data?.isDuplicate}
isLoading={nameEngCheck.isLoading}
/>
)}
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
{...(isEditing
? updateForm.register("description")
: createForm.register("description"))}
disabled={isLoading}
placeholder="설명을 입력하세요 (선택)"
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
{...(isEditing
? updateForm.register("sort_order", { valueAsNumber: true })
: createForm.register("sort_order", { valueAsNumber: true }))}
disabled={isLoading}
min={0}
/>
</div>
{/* 활성 상태 (수정 시) */}
{isEditing && (
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={updateForm.watch("is_active") === "Y"}
onCheckedChange={(checked) =>
updateForm.setValue("is_active", checked ? "Y" : "N")
}
disabled={isLoading}
/>
<Label htmlFor="is_active">
{updateForm.watch("is_active") === "Y" ? "활성" : "비활성"}
</Label>
</div>
)}
{/* 버튼 */}
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
type="submit"
disabled={
isLoading ||
!(isEditing ? updateForm.formState.isValid : createForm.formState.isValid) ||
hasDuplicateErrors ||
isDuplicateChecking
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : isEditing ? (
"그룹 수정"
) : (
"그룹 저장"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}