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

382 lines
13 KiB
TypeScript

"use client";
import { useEffect, useMemo, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
import { useCreateCodeDetail, useUpdateCodeDetail } from "@/hooks/queries/useCodeDetail";
import { useCheckCodeDetailDuplicate } from "@/hooks/queries/useValidation";
import {
createCodeDetailSchema,
updateCodeDetailSchema,
type CreateCodeDetailData,
type UpdateCodeDetailData,
} from "@/lib/schemas/commonCode";
import type { CodeDetail } from "@/types/commonCode";
interface CodeFormModalProps {
isOpen: boolean;
onClose: () => void;
codeInfo: string;
editingRow?: CodeDetail | null;
allRows: CodeDetail[];
defaultParentDetailId?: number | null;
}
const PARENT_NONE = "__NONE__";
/** code_value 자동 생성 (timestamp+random) */
const generateCodeValue = (): string => {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `${timestamp}${random}`;
};
export function CodeFormModal({
isOpen,
onClose,
codeInfo,
editingRow,
allRows,
defaultParentDetailId,
}: CodeFormModalProps) {
const createMutation = useCreateCodeDetail();
const updateMutation = useUpdateCodeDetail();
const isEditing = !!editingRow;
const schema = isEditing ? updateCodeDetailSchema : createCodeDetailSchema;
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange",
defaultValues: {
code_info: codeInfo,
parent_detail_id: null as number | null,
code_value: "",
code_name: "",
code_name_eng: "",
description: "",
sort_order: 10,
...(isEditing && { is_active: "Y" as const }),
},
});
// 부모 후보: 자기 자신 + 자기 자손은 제외
const parentOptions = useMemo(() => {
if (!isEditing) return allRows;
if (!editingRow) return allRows;
const selfId = String(editingRow.code_detail_id);
const descendants = new Set<string>([selfId]);
let changed = true;
while (changed) {
changed = false;
for (const row of allRows) {
const parentKey =
row.parent_detail_id == null ? "" : String(row.parent_detail_id);
if (
descendants.has(parentKey) &&
!descendants.has(String(row.code_detail_id))
) {
descendants.add(String(row.code_detail_id));
changed = true;
}
}
}
return allRows.filter((r) => !descendants.has(String(r.code_detail_id)));
}, [allRows, editingRow, isEditing]);
// 중복 검사 (code_name)
const [duplicateState, setDuplicateState] = useState<{ enabled: boolean; value: string }>({
enabled: false,
value: "",
});
const nameCheck = useCheckCodeDetailDuplicate(
codeInfo,
"code_name",
duplicateState.value,
isEditing ? editingRow?.code_detail_id : undefined,
duplicateState.enabled,
);
const hasDuplicateErrors = !!nameCheck.data?.isDuplicate && duplicateState.enabled;
const isDuplicateChecking = nameCheck.isLoading;
useEffect(() => {
if (!isOpen) return;
if (isEditing && editingRow) {
form.reset({
code_info: editingRow.code_info || codeInfo,
parent_detail_id: editingRow.parent_detail_id ?? null,
code_value: editingRow.code_value || "",
code_name: editingRow.code_name || "",
code_name_eng: editingRow.code_name_eng || "",
description: editingRow.description || "",
sort_order: editingRow.sort_order ?? 10,
is_active: ((editingRow.is_active as "Y" | "N") || "Y") as any,
} as any);
} else {
// 형제 중 최대 sort_order + 10
const siblings = allRows.filter((r) => {
const pid = r.parent_detail_id ?? null;
return pid === (defaultParentDetailId ?? null);
});
const maxSort =
siblings.length > 0
? Math.max(...siblings.map((r) => r.sort_order ?? 0))
: 0;
form.reset({
code_info: codeInfo,
parent_detail_id: defaultParentDetailId ?? null,
code_value: generateCodeValue(),
code_name: "",
code_name_eng: "",
description: "",
sort_order: maxSort + 10,
});
}
setDuplicateState({ enabled: false, value: "" });
}, [isOpen, isEditing, editingRow, codeInfo, defaultParentDetailId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = form.handleSubmit(async (data) => {
try {
if (isEditing && editingRow) {
await updateMutation.mutateAsync({
codeDetailId: editingRow.code_detail_id,
data: data as UpdateCodeDetailData,
});
} else {
await createMutation.mutateAsync(data as CreateCodeDetailData);
}
onClose();
form.reset();
} catch (error) {
console.error("디테일 저장 실패:", error);
}
});
const isLoading = createMutation.isPending || updateMutation.isPending;
const watchedParent = form.watch("parent_detail_id");
const parentSelectValue =
watchedParent == null || watchedParent === undefined
? PARENT_NONE
: String(watchedParent);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isEditing
? "디테일 수정"
: defaultParentDetailId != null
? "하위 코드 추가"
: "새 코드"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* code_value */}
<div className="space-y-2">
<Label htmlFor="code_value" className="text-xs sm:text-sm">
*
</Label>
<Input
id="code_value"
{...form.register("code_value")}
disabled={isLoading || isEditing}
placeholder="코드값"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
{isEditing && (
<p className="text-[10px] sm:text-xs text-muted-foreground">
</p>
)}
{form.formState.errors.code_value && (
<p className="text-[10px] sm:text-xs text-destructive">
{form.formState.errors.code_value.message as string}
</p>
)}
</div>
{/* 부모 선택 */}
<div className="space-y-2">
<Label htmlFor="parent_detail_id" className="text-xs sm:text-sm">
</Label>
<Select
value={parentSelectValue}
onValueChange={(v) =>
form.setValue("parent_detail_id", v === PARENT_NONE ? null : Number(v), {
shouldValidate: true,
shouldDirty: true,
})
}
disabled={isLoading}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="상위 코드를 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value={PARENT_NONE}> (2 )</SelectItem>
{parentOptions.map((opt) => (
<SelectItem
key={String(opt.code_detail_id)}
value={String(opt.code_detail_id)}
>
{" ".repeat(Math.max(0, (opt.depth ?? 2) - 2))}
{opt.code_name || opt.code_value}{" "}
<span className="ml-1 text-muted-foreground">({opt.code_value})</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] sm:text-xs text-muted-foreground">
(2 )
</p>
</div>
{/* 코드명 */}
<div className="space-y-2">
<Label htmlFor="code_name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="code_name"
{...form.register("code_name")}
disabled={isLoading}
placeholder="코드명"
className={
form.formState.errors.code_name
? "h-8 border-destructive text-xs sm:h-10 sm:text-sm"
: "h-8 text-xs sm:h-10 sm:text-sm"
}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) setDuplicateState({ enabled: true, value });
}}
/>
{form.formState.errors.code_name && (
<p className="text-[10px] sm:text-xs text-destructive">
{form.formState.errors.code_name.message as string}
</p>
)}
{!form.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" className="text-xs sm:text-sm">
</Label>
<Input
id="code_name_eng"
{...form.register("code_name_eng")}
disabled={isLoading}
placeholder="영문명 (선택)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
{...form.register("description")}
disabled={isLoading}
placeholder="설명 (선택)"
rows={2}
className="text-xs sm:text-sm"
/>
</div>
{/* 정렬 */}
<div className="space-y-2">
<Label htmlFor="sort_order" className="text-xs sm:text-sm">
</Label>
<Input
id="sort_order"
type="number"
{...form.register("sort_order", { valueAsNumber: true })}
disabled={isLoading}
min={0}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 활성 (수정) */}
{isEditing && (
<div className="flex items-center gap-2">
<Switch
id="is_active"
checked={form.watch("is_active" as any) === "Y"}
onCheckedChange={(checked) =>
form.setValue("is_active" as any, checked ? "Y" : "N")
}
disabled={isLoading}
aria-label="활성 상태"
/>
<Label htmlFor="is_active" className="text-xs sm:text-sm">
{form.watch("is_active" as any) === "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 ||
!form.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>
);
}