2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (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>
358 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|