2025-09-02 13:18:46 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-09-03 11:20:43 +09:00
|
|
|
import { useEffect, useState } from "react";
|
2025-09-02 18:25:44 +09:00
|
|
|
import { useForm } from "react-hook-form";
|
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
2025-09-02 13:18:46 +09:00
|
|
|
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";
|
2025-09-02 18:25:44 +09:00
|
|
|
|
2025-09-02 13:18:46 +09:00
|
|
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
2025-09-03 11:20:43 +09:00
|
|
|
import { ValidationMessage } from "@/components/common/ValidationMessage";
|
2025-09-02 18:25:44 +09:00
|
|
|
import { useCodes, useCreateCode, useUpdateCode } from "@/hooks/queries/useCodes";
|
2025-09-03 11:20:43 +09:00
|
|
|
import { useCheckCodeDuplicate } from "@/hooks/queries/useValidation";
|
2025-09-02 18:25:44 +09:00
|
|
|
import { createCodeSchema, updateCodeSchema, type CreateCodeData, type UpdateCodeData } from "@/lib/schemas/commonCode";
|
|
|
|
|
import type { CodeInfo } from "@/types/commonCode";
|
|
|
|
|
import type { FieldError } from "react-hook-form";
|
2025-09-02 13:18:46 +09:00
|
|
|
|
|
|
|
|
interface CodeFormModalProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
categoryCode: string;
|
2025-09-02 18:25:44 +09:00
|
|
|
editingCode?: CodeInfo | null;
|
2025-09-02 13:18:46 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-02 18:25:44 +09:00
|
|
|
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
|
|
|
|
|
const getErrorMessage = (error: FieldError | undefined): string => {
|
|
|
|
|
if (!error) return "";
|
|
|
|
|
if (typeof error === "string") return error;
|
|
|
|
|
return error.message || "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode }: CodeFormModalProps) {
|
|
|
|
|
const { data: codes = [] } = useCodes(categoryCode);
|
|
|
|
|
const createCodeMutation = useCreateCode();
|
|
|
|
|
const updateCodeMutation = useUpdateCode();
|
|
|
|
|
|
|
|
|
|
const isEditing = !!editingCode;
|
|
|
|
|
|
2025-09-03 11:20:43 +09:00
|
|
|
// 검증 상태 관리
|
|
|
|
|
const [validationStates, setValidationStates] = useState({
|
|
|
|
|
codeValue: { enabled: false, value: "" },
|
|
|
|
|
codeName: { enabled: false, value: "" },
|
|
|
|
|
codeNameEng: { enabled: false, value: "" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 중복 검사 훅들
|
|
|
|
|
const codeValueCheck = useCheckCodeDuplicate(
|
|
|
|
|
categoryCode,
|
|
|
|
|
"codeValue",
|
|
|
|
|
validationStates.codeValue.value,
|
|
|
|
|
isEditing ? editingCode?.code_value : undefined,
|
|
|
|
|
validationStates.codeValue.enabled,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const codeNameCheck = useCheckCodeDuplicate(
|
|
|
|
|
categoryCode,
|
|
|
|
|
"codeName",
|
|
|
|
|
validationStates.codeName.value,
|
|
|
|
|
isEditing ? editingCode?.code_value : undefined,
|
|
|
|
|
validationStates.codeName.enabled,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const codeNameEngCheck = useCheckCodeDuplicate(
|
|
|
|
|
categoryCode,
|
|
|
|
|
"codeNameEng",
|
|
|
|
|
validationStates.codeNameEng.value,
|
|
|
|
|
isEditing ? editingCode?.code_value : undefined,
|
|
|
|
|
validationStates.codeNameEng.enabled,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 중복 검사 결과 확인
|
|
|
|
|
const hasDuplicateErrors =
|
|
|
|
|
(codeValueCheck.data?.isDuplicate && validationStates.codeValue.enabled) ||
|
|
|
|
|
(codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled) ||
|
|
|
|
|
(codeNameEngCheck.data?.isDuplicate && validationStates.codeNameEng.enabled);
|
|
|
|
|
|
|
|
|
|
// 중복 검사 로딩 중인지 확인
|
|
|
|
|
const isDuplicateChecking = codeValueCheck.isLoading || codeNameCheck.isLoading || codeNameEngCheck.isLoading;
|
|
|
|
|
|
2025-09-02 18:25:44 +09:00
|
|
|
// 폼 스키마 선택 (생성/수정에 따라)
|
|
|
|
|
const schema = isEditing ? updateCodeSchema : createCodeSchema;
|
|
|
|
|
|
|
|
|
|
const form = useForm({
|
|
|
|
|
resolver: zodResolver(schema),
|
|
|
|
|
mode: "onChange", // 실시간 검증 활성화
|
|
|
|
|
defaultValues: {
|
|
|
|
|
codeValue: "",
|
|
|
|
|
codeName: "",
|
|
|
|
|
codeNameEng: "",
|
|
|
|
|
description: "",
|
|
|
|
|
sortOrder: 1,
|
|
|
|
|
...(isEditing && { isActive: "Y" as const }),
|
|
|
|
|
},
|
2025-09-02 13:18:46 +09:00
|
|
|
});
|
|
|
|
|
|
2025-09-02 18:25:44 +09:00
|
|
|
// 편집 모드일 때 기존 데이터 로드
|
2025-09-02 13:18:46 +09:00
|
|
|
useEffect(() => {
|
2025-09-02 15:41:07 +09:00
|
|
|
if (isOpen) {
|
2025-09-02 18:25:44 +09:00
|
|
|
if (isEditing && editingCode) {
|
|
|
|
|
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
|
|
|
|
|
form.reset({
|
2025-09-02 15:41:07 +09:00
|
|
|
codeName: editingCode.code_name,
|
|
|
|
|
codeNameEng: editingCode.code_name_eng || "",
|
|
|
|
|
description: editingCode.description || "",
|
|
|
|
|
sortOrder: editingCode.sort_order,
|
2025-09-02 18:25:44 +09:00
|
|
|
isActive: editingCode.is_active as "Y" | "N", // 타입 캐스팅
|
2025-09-02 15:41:07 +09:00
|
|
|
});
|
2025-09-02 18:25:44 +09:00
|
|
|
|
|
|
|
|
// codeValue는 별도로 설정 (표시용)
|
|
|
|
|
form.setValue("codeValue" as any, editingCode.code_value);
|
2025-09-02 15:41:07 +09:00
|
|
|
} else {
|
2025-09-02 18:25:44 +09:00
|
|
|
// 새 코드 모드: 자동 순서 계산
|
2025-09-02 15:41:07 +09:00
|
|
|
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order)) : 0;
|
2025-09-02 18:25:44 +09:00
|
|
|
|
|
|
|
|
form.reset({
|
2025-09-02 15:41:07 +09:00
|
|
|
codeValue: "",
|
|
|
|
|
codeName: "",
|
|
|
|
|
codeNameEng: "",
|
|
|
|
|
description: "",
|
|
|
|
|
sortOrder: maxSortOrder + 1,
|
2025-09-02 13:18:46 +09:00
|
|
|
});
|
|
|
|
|
}
|
2025-09-02 13:57:53 +09:00
|
|
|
}
|
2025-09-02 18:25:44 +09:00
|
|
|
}, [isOpen, isEditing, editingCode, codes, form]);
|
2025-09-02 13:57:53 +09:00
|
|
|
|
2025-09-02 18:25:44 +09:00
|
|
|
const handleSubmit = form.handleSubmit(async (data) => {
|
2025-09-02 13:18:46 +09:00
|
|
|
try {
|
2025-09-02 18:25:44 +09:00
|
|
|
if (isEditing && editingCode) {
|
2025-09-02 13:18:46 +09:00
|
|
|
// 수정
|
2025-09-02 18:25:44 +09:00
|
|
|
await updateCodeMutation.mutateAsync({
|
|
|
|
|
categoryCode,
|
|
|
|
|
codeValue: editingCode.code_value,
|
|
|
|
|
data: data as UpdateCodeData,
|
2025-09-02 13:18:46 +09:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// 생성
|
2025-09-02 18:25:44 +09:00
|
|
|
await createCodeMutation.mutateAsync({
|
|
|
|
|
categoryCode,
|
|
|
|
|
data: data as CreateCodeData,
|
2025-09-02 13:18:46 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onClose();
|
2025-09-02 18:25:44 +09:00
|
|
|
form.reset();
|
2025-09-02 13:18:46 +09:00
|
|
|
} catch (error) {
|
2025-09-02 18:25:44 +09:00
|
|
|
console.error("코드 저장 실패:", error);
|
2025-09-02 13:18:46 +09:00
|
|
|
}
|
2025-09-02 18:25:44 +09:00
|
|
|
});
|
2025-09-02 13:18:46 +09:00
|
|
|
|
2025-09-02 18:25:44 +09:00
|
|
|
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
|
2025-09-02 13:18:46 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
|
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
|
|
|
<DialogHeader>
|
2025-09-02 18:25:44 +09:00
|
|
|
<DialogTitle>{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
|
2025-09-02 13:18:46 +09:00
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
|
|
|
{/* 코드값 */}
|
|
|
|
|
<div className="space-y-2">
|
2025-09-02 18:25:44 +09:00
|
|
|
<Label htmlFor="codeValue">코드값 *</Label>
|
2025-09-02 13:18:46 +09:00
|
|
|
<Input
|
|
|
|
|
id="codeValue"
|
2025-09-02 18:25:44 +09:00
|
|
|
{...form.register("codeValue")}
|
|
|
|
|
disabled={isLoading || isEditing} // 수정 시에는 비활성화
|
|
|
|
|
placeholder="코드값을 입력하세요"
|
|
|
|
|
className={(form.formState.errors as any)?.codeValue ? "border-red-500" : ""}
|
2025-09-03 11:20:43 +09:00
|
|
|
onBlur={(e) => {
|
|
|
|
|
const value = e.target.value.trim();
|
|
|
|
|
if (value && !isEditing) {
|
|
|
|
|
setValidationStates((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
codeValue: { enabled: true, value },
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-09-02 13:18:46 +09:00
|
|
|
/>
|
2025-09-02 18:25:44 +09:00
|
|
|
{(form.formState.errors as any)?.codeValue && (
|
|
|
|
|
<p className="text-sm text-red-600">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
|
|
|
|
|
)}
|
2025-09-03 11:20:43 +09:00
|
|
|
{!isEditing && !(form.formState.errors as any)?.codeValue && (
|
|
|
|
|
<ValidationMessage
|
|
|
|
|
message={codeValueCheck.data?.message}
|
|
|
|
|
isValid={!codeValueCheck.data?.isDuplicate}
|
|
|
|
|
isLoading={codeValueCheck.isLoading}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-09-02 13:18:46 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 코드명 */}
|
|
|
|
|
<div className="space-y-2">
|
2025-09-02 18:25:44 +09:00
|
|
|
<Label htmlFor="codeName">코드명 *</Label>
|
2025-09-02 13:18:46 +09:00
|
|
|
<Input
|
|
|
|
|
id="codeName"
|
2025-09-02 18:25:44 +09:00
|
|
|
{...form.register("codeName")}
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
placeholder="코드명을 입력하세요"
|
|
|
|
|
className={form.formState.errors.codeName ? "border-red-500" : ""}
|
2025-09-03 11:20:43 +09:00
|
|
|
onBlur={(e) => {
|
|
|
|
|
const value = e.target.value.trim();
|
|
|
|
|
if (value) {
|
|
|
|
|
setValidationStates((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
codeName: { enabled: true, value },
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-09-02 13:18:46 +09:00
|
|
|
/>
|
2025-09-02 18:25:44 +09:00
|
|
|
{form.formState.errors.codeName && (
|
|
|
|
|
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeName)}</p>
|
|
|
|
|
)}
|
2025-09-03 11:20:43 +09:00
|
|
|
{!form.formState.errors.codeName && (
|
|
|
|
|
<ValidationMessage
|
|
|
|
|
message={codeNameCheck.data?.message}
|
|
|
|
|
isValid={!codeNameCheck.data?.isDuplicate}
|
|
|
|
|
isLoading={codeNameCheck.isLoading}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-09-02 13:18:46 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 영문명 */}
|
|
|
|
|
<div className="space-y-2">
|
2025-09-02 18:25:44 +09:00
|
|
|
<Label htmlFor="codeNameEng">코드 영문명 *</Label>
|
2025-09-02 13:18:46 +09:00
|
|
|
<Input
|
|
|
|
|
id="codeNameEng"
|
2025-09-02 18:25:44 +09:00
|
|
|
{...form.register("codeNameEng")}
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
placeholder="코드 영문명을 입력하세요"
|
|
|
|
|
className={form.formState.errors.codeNameEng ? "border-red-500" : ""}
|
2025-09-03 11:20:43 +09:00
|
|
|
onBlur={(e) => {
|
|
|
|
|
const value = e.target.value.trim();
|
|
|
|
|
if (value) {
|
|
|
|
|
setValidationStates((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
codeNameEng: { enabled: true, value },
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-09-02 13:18:46 +09:00
|
|
|
/>
|
2025-09-02 18:25:44 +09:00
|
|
|
{form.formState.errors.codeNameEng && (
|
|
|
|
|
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
|
|
|
|
|
)}
|
2025-09-03 11:20:43 +09:00
|
|
|
{!form.formState.errors.codeNameEng && (
|
|
|
|
|
<ValidationMessage
|
|
|
|
|
message={codeNameEngCheck.data?.message}
|
|
|
|
|
isValid={!codeNameEngCheck.data?.isDuplicate}
|
|
|
|
|
isLoading={codeNameEngCheck.isLoading}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-09-02 13:18:46 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 설명 */}
|
|
|
|
|
<div className="space-y-2">
|
2025-09-02 18:25:44 +09:00
|
|
|
<Label htmlFor="description">설명 *</Label>
|
2025-09-02 13:18:46 +09:00
|
|
|
<Textarea
|
|
|
|
|
id="description"
|
2025-09-02 18:25:44 +09:00
|
|
|
{...form.register("description")}
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
placeholder="설명을 입력하세요"
|
2025-09-02 13:18:46 +09:00
|
|
|
rows={3}
|
2025-09-02 18:25:44 +09:00
|
|
|
className={form.formState.errors.description ? "border-red-500" : ""}
|
2025-09-02 13:18:46 +09:00
|
|
|
/>
|
2025-09-02 18:25:44 +09:00
|
|
|
{form.formState.errors.description && (
|
|
|
|
|
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.description)}</p>
|
|
|
|
|
)}
|
2025-09-02 13:18:46 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 정렬 순서 */}
|
|
|
|
|
<div className="space-y-2">
|
2025-09-02 18:25:44 +09:00
|
|
|
<Label htmlFor="sortOrder">정렬 순서</Label>
|
2025-09-02 13:18:46 +09:00
|
|
|
<Input
|
|
|
|
|
id="sortOrder"
|
|
|
|
|
type="number"
|
2025-09-02 18:25:44 +09:00
|
|
|
{...form.register("sortOrder", { valueAsNumber: true })}
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
min={1}
|
|
|
|
|
className={form.formState.errors.sortOrder ? "border-red-500" : ""}
|
2025-09-02 13:18:46 +09:00
|
|
|
/>
|
2025-09-02 18:25:44 +09:00
|
|
|
{form.formState.errors.sortOrder && (
|
|
|
|
|
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.sortOrder)}</p>
|
|
|
|
|
)}
|
2025-09-02 13:18:46 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 활성 상태 (수정 시에만) */}
|
2025-09-02 18:25:44 +09:00
|
|
|
{isEditing && (
|
2025-09-02 13:18:46 +09:00
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Switch
|
|
|
|
|
id="isActive"
|
2025-09-02 18:25:44 +09:00
|
|
|
checked={form.watch("isActive") === "Y"}
|
|
|
|
|
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
|
|
|
|
|
disabled={isLoading}
|
2025-09-02 13:18:46 +09:00
|
|
|
/>
|
2025-09-02 18:25:44 +09:00
|
|
|
<Label htmlFor="isActive">{form.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
|
2025-09-02 13:18:46 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 버튼 */}
|
|
|
|
|
<div className="flex justify-end space-x-2 pt-4">
|
2025-09-02 18:25:44 +09:00
|
|
|
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
|
|
|
|
취소
|
2025-09-02 13:18:46 +09:00
|
|
|
</Button>
|
2025-09-03 11:20:43 +09:00
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
|
|
|
|
|
>
|
2025-09-02 18:25:44 +09:00
|
|
|
{isLoading ? (
|
2025-09-02 13:18:46 +09:00
|
|
|
<>
|
|
|
|
|
<LoadingSpinner size="sm" className="mr-2" />
|
2025-09-02 18:25:44 +09:00
|
|
|
{isEditing ? "수정 중..." : "저장 중..."}
|
2025-09-02 13:18:46 +09:00
|
|
|
</>
|
2025-09-02 18:25:44 +09:00
|
|
|
) : isEditing ? (
|
2025-09-02 13:57:53 +09:00
|
|
|
"코드 수정"
|
2025-09-02 13:18:46 +09:00
|
|
|
) : (
|
2025-09-02 18:25:44 +09:00
|
|
|
"코드 저장"
|
2025-09-02 13:18:46 +09:00
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|