387 lines
15 KiB
TypeScript
387 lines
15 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
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[];
|
|
}
|
|
|
|
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
|
|
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, codes }: CodeFormModalProps) {
|
|
const createCodeMutation = useCreateCode();
|
|
const updateCodeMutation = useUpdateCode();
|
|
|
|
const isEditing = !!editingCode;
|
|
|
|
// 검증 상태 관리
|
|
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?.codeValue || editingCode?.code_value : undefined,
|
|
validationStates.codeValue.enabled,
|
|
);
|
|
|
|
const codeNameCheck = useCheckCodeDuplicate(
|
|
categoryCode,
|
|
"codeName",
|
|
validationStates.codeName.value,
|
|
isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
|
|
validationStates.codeName.enabled,
|
|
);
|
|
|
|
const codeNameEngCheck = useCheckCodeDuplicate(
|
|
categoryCode,
|
|
"codeNameEng",
|
|
validationStates.codeNameEng.value,
|
|
isEditing ? editingCode?.codeValue || 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;
|
|
|
|
// 폼 스키마 선택 (생성/수정에 따라)
|
|
const schema = isEditing ? updateCodeSchema : createCodeSchema;
|
|
|
|
// 부모 코드 선택 상태
|
|
const [parentCodeValue, setParentCodeValue] = useState<string>("");
|
|
|
|
const form = useForm({
|
|
resolver: zodResolver(schema),
|
|
mode: "onChange", // 실시간 검증 활성화
|
|
defaultValues: {
|
|
codeValue: "",
|
|
codeName: "",
|
|
codeNameEng: "",
|
|
description: "",
|
|
sortOrder: 1,
|
|
...(isEditing && { isActive: "Y" as const }),
|
|
},
|
|
});
|
|
|
|
// 편집 모드일 때 기존 데이터 로드
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (isEditing && editingCode) {
|
|
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
|
|
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", // 타입 캐스팅
|
|
});
|
|
|
|
// codeValue는 별도로 설정 (표시용)
|
|
form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value);
|
|
|
|
// 부모 코드 설정
|
|
setParentCodeValue(editingCode.parentCodeValue || editingCode.parent_code_value || "");
|
|
} else {
|
|
// 새 코드 모드: 자동 순서 계산
|
|
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order)) : 0;
|
|
|
|
form.reset({
|
|
codeValue: "",
|
|
codeName: "",
|
|
codeNameEng: "",
|
|
description: "",
|
|
sortOrder: maxSortOrder + 1,
|
|
});
|
|
setParentCodeValue("");
|
|
}
|
|
}
|
|
}, [isOpen, isEditing, editingCode, codes]);
|
|
|
|
const handleSubmit = form.handleSubmit(async (data) => {
|
|
try {
|
|
if (isEditing && editingCode) {
|
|
// 수정 - 부모 코드 포함
|
|
await updateCodeMutation.mutateAsync({
|
|
categoryCode,
|
|
codeValue: editingCode.codeValue || editingCode.code_value,
|
|
data: {
|
|
...data,
|
|
parentCodeValue: parentCodeValue || undefined,
|
|
} as UpdateCodeData,
|
|
});
|
|
} else {
|
|
// 생성 - 부모 코드 포함
|
|
await createCodeMutation.mutateAsync({
|
|
categoryCode,
|
|
data: {
|
|
...data,
|
|
parentCodeValue: parentCodeValue || undefined,
|
|
} as CreateCodeData,
|
|
});
|
|
}
|
|
|
|
onClose();
|
|
form.reset();
|
|
setParentCodeValue("");
|
|
} 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 ? "코드 수정" : "새 코드"}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
|
{/* 코드값 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="codeValue" className="text-xs sm:text-sm">코드값 *</Label>
|
|
<Input
|
|
id="codeValue"
|
|
{...form.register("codeValue")}
|
|
disabled={isLoading || isEditing}
|
|
placeholder="코드값을 입력하세요"
|
|
className={(form.formState.errors as any)?.codeValue ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
|
|
onBlur={(e) => {
|
|
const value = e.target.value.trim();
|
|
if (value && !isEditing) {
|
|
setValidationStates((prev) => ({
|
|
...prev,
|
|
codeValue: { enabled: true, value },
|
|
}));
|
|
}
|
|
}}
|
|
/>
|
|
{(form.formState.errors as any)?.codeValue && (
|
|
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
|
|
)}
|
|
{!isEditing && !(form.formState.errors as any)?.codeValue && (
|
|
<ValidationMessage
|
|
message={codeValueCheck.data?.message}
|
|
isValid={!codeValueCheck.data?.isDuplicate}
|
|
isLoading={codeValueCheck.isLoading}
|
|
/>
|
|
)}
|
|
</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 ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "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-[10px] sm:text-xs text-destructive">{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={form.formState.errors.codeNameEng ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
|
|
onBlur={(e) => {
|
|
const value = e.target.value.trim();
|
|
if (value) {
|
|
setValidationStates((prev) => ({
|
|
...prev,
|
|
codeNameEng: { enabled: true, value },
|
|
}));
|
|
}
|
|
}}
|
|
/>
|
|
{form.formState.errors.codeNameEng && (
|
|
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
|
|
)}
|
|
{!form.formState.errors.codeNameEng && (
|
|
<ValidationMessage
|
|
message={codeNameEngCheck.data?.message}
|
|
isValid={!codeNameEngCheck.data?.isDuplicate}
|
|
isLoading={codeNameEngCheck.isLoading}
|
|
/>
|
|
)}
|
|
</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={3}
|
|
className={form.formState.errors.description ? "text-xs sm:text-sm border-destructive" : "text-xs sm:text-sm"}
|
|
/>
|
|
{form.formState.errors.description && (
|
|
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage(form.formState.errors.description)}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 부모 코드 (계층 구조) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="parentCodeValue" className="text-xs sm:text-sm">부모 코드 (선택)</Label>
|
|
<Select
|
|
value={parentCodeValue || "_none_"}
|
|
onValueChange={(val) => setParentCodeValue(val === "_none_" ? "" : val)}
|
|
disabled={isLoading}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="부모 코드 선택 (최상위면 비워두세요)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_none_">없음 (최상위)</SelectItem>
|
|
{codes
|
|
.filter((c) => {
|
|
// 자기 자신은 제외
|
|
const currentCodeValue = editingCode?.codeValue || editingCode?.code_value;
|
|
const codeValue = c.codeValue || c.code_value;
|
|
return codeValue !== currentCodeValue;
|
|
})
|
|
.map((c) => {
|
|
const codeValue = c.codeValue || c.code_value;
|
|
if (!codeValue) return null;
|
|
return (
|
|
<SelectItem key={codeValue} value={codeValue}>
|
|
{c.depth && c.depth > 1 ? "└".repeat(c.depth - 1) + " " : ""}
|
|
{c.codeName || c.code_name} ({codeValue})
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
|
계층 구조가 필요한 경우 부모 코드를 선택하세요.
|
|
</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 ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
|
|
/>
|
|
{form.formState.errors.sortOrder && (
|
|
<p className="text-[10px] sm:text-xs text-destructive">{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>
|
|
);
|
|
}
|