ERP-node/frontend/components/admin/CodeFormModal.tsx

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>
);
}