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>
382 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|