338 lines
12 KiB
TypeScript
338 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 { useCreateCode, useUpdateCode } from "@/hooks/queries/useCodes";
|
|
import { useCheckCodeDuplicate } from "@/hooks/queries/useValidation";
|
|
import { createCodeSchema, updateCodeSchema, type CreateCodeData, type UpdateCodeData } from "@/lib/schemas/commonCode";
|
|
import type { CodeInfo } from "@/types/commonCode";
|
|
import type { FieldError } from "react-hook-form";
|
|
|
|
interface CodeFormModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
categoryCode: string;
|
|
editingCode?: CodeInfo | null;
|
|
codes: CodeInfo[];
|
|
defaultParentCode?: string; // 하위 코드 추가 시 기본 부모 코드
|
|
}
|
|
|
|
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
|
|
const getErrorMessage = (error: FieldError | undefined): string => {
|
|
if (!error) return "";
|
|
if (typeof error === "string") return error;
|
|
return error.message || "";
|
|
};
|
|
|
|
// 코드값 자동 생성 함수 (UUID 기반 짧은 코드)
|
|
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,
|
|
categoryCode,
|
|
editingCode,
|
|
codes,
|
|
defaultParentCode,
|
|
}: CodeFormModalProps) {
|
|
const createCodeMutation = useCreateCode();
|
|
const updateCodeMutation = useUpdateCode();
|
|
|
|
const isEditing = !!editingCode;
|
|
|
|
// 검증 상태 관리 (코드명만 중복 검사)
|
|
const [validationStates, setValidationStates] = useState({
|
|
codeName: { enabled: false, value: "" },
|
|
});
|
|
|
|
// 코드명 중복 검사
|
|
const codeNameCheck = useCheckCodeDuplicate(
|
|
categoryCode,
|
|
"codeName",
|
|
validationStates.codeName.value,
|
|
isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
|
|
validationStates.codeName.enabled,
|
|
);
|
|
|
|
// 중복 검사 결과 확인
|
|
const hasDuplicateErrors = codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled;
|
|
|
|
// 중복 검사 로딩 중인지 확인
|
|
const isDuplicateChecking = codeNameCheck.isLoading;
|
|
|
|
// 폼 스키마 선택 (생성/수정에 따라)
|
|
const schema = isEditing ? updateCodeSchema : createCodeSchema;
|
|
|
|
const form = useForm({
|
|
resolver: zodResolver(schema),
|
|
mode: "onChange", // 실시간 검증 활성화
|
|
defaultValues: {
|
|
codeValue: "",
|
|
codeName: "",
|
|
codeNameEng: "",
|
|
description: "",
|
|
sortOrder: 1,
|
|
parentCodeValue: "" as string | undefined,
|
|
...(isEditing && { isActive: "Y" as const }),
|
|
},
|
|
});
|
|
|
|
// 편집 모드일 때 기존 데이터 로드
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (isEditing && editingCode) {
|
|
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
|
|
const parentValue = editingCode.parentCodeValue || editingCode.parent_code_value || "";
|
|
|
|
form.reset({
|
|
codeName: editingCode.codeName || editingCode.code_name,
|
|
codeNameEng: editingCode.codeNameEng || editingCode.code_name_eng || "",
|
|
description: editingCode.description || "",
|
|
sortOrder: editingCode.sortOrder || editingCode.sort_order,
|
|
isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N",
|
|
parentCodeValue: parentValue,
|
|
});
|
|
|
|
// codeValue는 별도로 설정 (표시용)
|
|
form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value);
|
|
} else {
|
|
// 새 코드 모드: 자동 순서 계산
|
|
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order || 0)) : 0;
|
|
|
|
// 기본 부모 코드가 있으면 설정 (하위 코드 추가 시)
|
|
const parentValue = defaultParentCode || "";
|
|
|
|
// 코드값 자동 생성
|
|
const autoCodeValue = generateCodeValue();
|
|
|
|
form.reset({
|
|
codeValue: autoCodeValue,
|
|
codeName: "",
|
|
codeNameEng: "",
|
|
description: "",
|
|
sortOrder: maxSortOrder + 1,
|
|
parentCodeValue: parentValue,
|
|
});
|
|
}
|
|
}
|
|
}, [isOpen, isEditing, editingCode, codes, defaultParentCode]);
|
|
|
|
const handleSubmit = form.handleSubmit(async (data) => {
|
|
try {
|
|
if (isEditing && editingCode) {
|
|
// 수정
|
|
await updateCodeMutation.mutateAsync({
|
|
categoryCode,
|
|
codeValue: editingCode.codeValue || editingCode.code_value || "",
|
|
data: data as UpdateCodeData,
|
|
});
|
|
} else {
|
|
// 생성
|
|
await createCodeMutation.mutateAsync({
|
|
categoryCode,
|
|
data: data as CreateCodeData,
|
|
});
|
|
}
|
|
|
|
onClose();
|
|
form.reset();
|
|
} catch (error) {
|
|
console.error("코드 저장 실패:", error);
|
|
}
|
|
});
|
|
|
|
const isLoading = createCodeMutation.isPending || updateCodeMutation.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 ? "코드 수정" : defaultParentCode ? "하위 코드 추가" : "새 코드"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
|
{/* 코드값 (자동 생성, 수정 시에만 표시) */}
|
|
{isEditing && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs sm:text-sm">코드값</Label>
|
|
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
|
|
{form.watch("codeValue")}
|
|
</div>
|
|
<p className="text-muted-foreground text-[10px] sm:text-xs">코드값은 변경할 수 없습니다</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 코드명 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="codeName" className="text-xs sm:text-sm">
|
|
코드명 *
|
|
</Label>
|
|
<Input
|
|
id="codeName"
|
|
{...form.register("codeName")}
|
|
disabled={isLoading}
|
|
placeholder="코드명을 입력하세요"
|
|
className={
|
|
form.formState.errors.codeName
|
|
? "border-destructive h-8 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) {
|
|
setValidationStates((prev) => ({
|
|
...prev,
|
|
codeName: { enabled: true, value },
|
|
}));
|
|
}
|
|
}}
|
|
/>
|
|
{form.formState.errors.codeName && (
|
|
<p className="text-destructive text-[10px] sm:text-xs">
|
|
{getErrorMessage(form.formState.errors.codeName)}
|
|
</p>
|
|
)}
|
|
{!form.formState.errors.codeName && (
|
|
<ValidationMessage
|
|
message={codeNameCheck.data?.message}
|
|
isValid={!codeNameCheck.data?.isDuplicate}
|
|
isLoading={codeNameCheck.isLoading}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 영문명 (선택) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="codeNameEng" className="text-xs sm:text-sm">
|
|
코드 영문명
|
|
</Label>
|
|
<Input
|
|
id="codeNameEng"
|
|
{...form.register("codeNameEng")}
|
|
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>
|
|
|
|
{/* 부모 코드 표시 (하위 코드 추가 시에만 표시, 읽기 전용) */}
|
|
{defaultParentCode && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs sm:text-sm">상위 코드</Label>
|
|
<div className="bg-muted h-8 rounded-md border px-3 py-1.5 text-xs sm:h-10 sm:py-2 sm:text-sm">
|
|
{(() => {
|
|
const parentCode = codes.find((c) => (c.codeValue || c.code_value) === defaultParentCode);
|
|
return parentCode
|
|
? `${parentCode.codeName || parentCode.code_name} (${defaultParentCode})`
|
|
: defaultParentCode;
|
|
})()}
|
|
</div>
|
|
<p className="text-muted-foreground text-[10px] sm:text-xs">이 코드는 위 코드의 하위로 추가됩니다</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 정렬 순서 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="sortOrder" className="text-xs sm:text-sm">
|
|
정렬 순서
|
|
</Label>
|
|
<Input
|
|
id="sortOrder"
|
|
type="number"
|
|
{...form.register("sortOrder", { valueAsNumber: true })}
|
|
disabled={isLoading}
|
|
min={1}
|
|
className={
|
|
form.formState.errors.sortOrder
|
|
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
|
|
: "h-8 text-xs sm:h-10 sm:text-sm"
|
|
}
|
|
/>
|
|
{form.formState.errors.sortOrder && (
|
|
<p className="text-destructive text-[10px] sm:text-xs">
|
|
{getErrorMessage(form.formState.errors.sortOrder)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 활성 상태 (수정 시에만) */}
|
|
{isEditing && (
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="isActive"
|
|
checked={form.watch("isActive") === "Y"}
|
|
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
|
|
disabled={isLoading}
|
|
aria-label="활성 상태"
|
|
/>
|
|
<Label htmlFor="isActive" className="text-xs sm:text-sm">
|
|
{form.watch("isActive") === "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>
|
|
);
|
|
}
|