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

338 lines
12 KiB
TypeScript
Raw Normal View History

2025-09-02 13:18:46 +09:00
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
2025-12-05 10:46:10 +09:00
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
2025-09-02 13:18:46 +09:00
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 13:18:46 +09:00
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
2025-09-04 10:00:38 +09:00
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";
2025-09-02 13:18:46 +09:00
interface CodeFormModalProps {
isOpen: boolean;
onClose: () => void;
categoryCode: string;
editingCode?: CodeInfo | null;
2025-09-04 10:00:38 +09:00
codes: CodeInfo[];
2025-12-23 09:31:18 +09:00
defaultParentCode?: string; // 하위 코드 추가 시 기본 부모 코드
2025-09-02 13:18:46 +09:00
}
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
const getErrorMessage = (error: FieldError | undefined): string => {
if (!error) return "";
if (typeof error === "string") return error;
return error.message || "";
};
2025-12-23 09:31:18 +09:00
// 코드값 자동 생성 함수 (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;
2025-12-23 09:31:18 +09:00
// 검증 상태 관리 (코드명만 중복 검사)
const [validationStates, setValidationStates] = useState({
codeName: { enabled: false, value: "" },
});
2025-12-23 09:31:18 +09:00
// 코드명 중복 검사
const codeNameCheck = useCheckCodeDuplicate(
categoryCode,
"codeName",
validationStates.codeName.value,
2025-09-30 14:28:40 +09:00
isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
validationStates.codeName.enabled,
);
// 중복 검사 결과 확인
2025-12-23 09:31:18 +09:00
const hasDuplicateErrors = codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled;
// 중복 검사 로딩 중인지 확인
2025-12-23 09:31:18 +09:00
const isDuplicateChecking = codeNameCheck.isLoading;
// 폼 스키마 선택 (생성/수정에 따라)
const schema = isEditing ? updateCodeSchema : createCodeSchema;
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange", // 실시간 검증 활성화
defaultValues: {
codeValue: "",
codeName: "",
codeNameEng: "",
description: "",
sortOrder: 1,
2025-12-23 09:31:18 +09:00
parentCodeValue: "" as string | undefined,
...(isEditing && { isActive: "Y" as const }),
},
2025-09-02 13:18:46 +09:00
});
// 편집 모드일 때 기존 데이터 로드
2025-09-02 13:18:46 +09:00
useEffect(() => {
if (isOpen) {
if (isEditing && editingCode) {
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
2025-12-23 09:31:18 +09:00
const parentValue = editingCode.parentCodeValue || editingCode.parent_code_value || "";
form.reset({
2025-09-30 14:28:40 +09:00
codeName: editingCode.codeName || editingCode.code_name,
codeNameEng: editingCode.codeNameEng || editingCode.code_name_eng || "",
description: editingCode.description || "",
2025-09-30 14:28:40 +09:00
sortOrder: editingCode.sortOrder || editingCode.sort_order,
2025-12-23 09:31:18 +09:00
isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N",
parentCodeValue: parentValue,
});
// codeValue는 별도로 설정 (표시용)
2025-09-30 14:28:40 +09:00
form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value);
} else {
// 새 코드 모드: 자동 순서 계산
2025-12-23 09:31:18 +09:00
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({
2025-12-23 09:31:18 +09:00
codeValue: autoCodeValue,
codeName: "",
codeNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
2025-12-23 09:31:18 +09:00
parentCodeValue: parentValue,
2025-09-02 13:18:46 +09:00
});
}
}
2025-12-23 09:31:18 +09:00
}, [isOpen, isEditing, editingCode, codes, defaultParentCode]);
const handleSubmit = form.handleSubmit(async (data) => {
2025-09-02 13:18:46 +09:00
try {
if (isEditing && editingCode) {
2025-09-02 13:18:46 +09:00
// 수정
await updateCodeMutation.mutateAsync({
categoryCode,
2025-12-23 09:31:18 +09:00
codeValue: editingCode.codeValue || editingCode.code_value || "",
data: data as UpdateCodeData,
2025-09-02 13:18:46 +09:00
});
} else {
// 생성
await createCodeMutation.mutateAsync({
categoryCode,
data: data as CreateCodeData,
2025-09-02 13:18:46 +09:00
});
}
onClose();
form.reset();
2025-09-02 13:18:46 +09:00
} catch (error) {
console.error("코드 저장 실패:", error);
2025-09-02 13:18:46 +09:00
}
});
2025-09-02 13:18:46 +09:00
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
2025-09-02 13:18:46 +09:00
return (
2025-12-05 10:46:10 +09:00
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
2025-12-23 09:31:18 +09:00
<DialogTitle className="text-base sm:text-lg">
{isEditing ? "코드 수정" : defaultParentCode ? "하위 코드 추가" : "새 코드"}
</DialogTitle>
2025-12-05 10:46:10 +09:00
</DialogHeader>
2025-09-02 13:18:46 +09:00
2025-10-22 14:52:13 +09:00
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
2025-12-23 09:31:18 +09:00
{/* 코드값 (자동 생성, 수정 시에만 표시) */}
{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>
)}
2025-09-02 13:18:46 +09:00
{/* 코드명 */}
<div className="space-y-2">
2025-12-23 09:31:18 +09:00
<Label htmlFor="codeName" className="text-xs sm:text-sm">
*
</Label>
2025-09-02 13:18:46 +09:00
<Input
id="codeName"
{...form.register("codeName")}
disabled={isLoading}
placeholder="코드명을 입력하세요"
2025-12-23 09:31:18 +09:00
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 },
}));
}
}}
2025-09-02 13:18:46 +09:00
/>
{form.formState.errors.codeName && (
2025-12-23 09:31:18 +09:00
<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}
/>
)}
2025-09-02 13:18:46 +09:00
</div>
2025-12-23 09:31:18 +09:00
{/* 영문명 (선택) */}
2025-09-02 13:18:46 +09:00
<div className="space-y-2">
2025-12-23 09:31:18 +09:00
<Label htmlFor="codeNameEng" className="text-xs sm:text-sm">
</Label>
2025-09-02 13:18:46 +09:00
<Input
id="codeNameEng"
{...form.register("codeNameEng")}
disabled={isLoading}
2025-12-23 09:31:18 +09:00
placeholder="코드 영문명을 입력하세요 (선택사항)"
className="h-8 text-xs sm:h-10 sm:text-sm"
2025-09-02 13:18:46 +09:00
/>
</div>
2025-12-23 09:31:18 +09:00
{/* 설명 (선택) */}
2025-09-02 13:18:46 +09:00
<div className="space-y-2">
2025-12-23 09:31:18 +09:00
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
2025-09-02 13:18:46 +09:00
<Textarea
id="description"
{...form.register("description")}
disabled={isLoading}
2025-12-23 09:31:18 +09:00
placeholder="설명을 입력하세요 (선택사항)"
rows={2}
className="text-xs sm:text-sm"
2025-09-02 13:18:46 +09:00
/>
</div>
2025-12-23 09:31:18 +09:00
{/* 부모 코드 표시 (하위 코드 추가 시에만 표시, 읽기 전용) */}
{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>
)}
2025-09-02 13:18:46 +09:00
{/* 정렬 순서 */}
<div className="space-y-2">
2025-12-23 09:31:18 +09:00
<Label htmlFor="sortOrder" className="text-xs sm:text-sm">
</Label>
2025-09-02 13:18:46 +09:00
<Input
id="sortOrder"
type="number"
{...form.register("sortOrder", { valueAsNumber: true })}
disabled={isLoading}
min={1}
2025-12-23 09:31:18 +09:00
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"
}
2025-09-02 13:18:46 +09:00
/>
{form.formState.errors.sortOrder && (
2025-12-23 09:31:18 +09:00
<p className="text-destructive text-[10px] sm:text-xs">
{getErrorMessage(form.formState.errors.sortOrder)}
</p>
)}
2025-09-02 13:18:46 +09:00
</div>
{/* 활성 상태 (수정 시에만) */}
{isEditing && (
2025-10-22 14:52:13 +09:00
<div className="flex items-center gap-2">
2025-09-02 13:18:46 +09:00
<Switch
id="isActive"
checked={form.watch("isActive") === "Y"}
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
disabled={isLoading}
2025-10-22 14:52:13 +09:00
aria-label="활성 상태"
2025-09-02 13:18:46 +09:00
/>
2025-12-23 09:31:18 +09:00
<Label htmlFor="isActive" className="text-xs sm:text-sm">
{form.watch("isActive") === "Y" ? "활성" : "비활성"}
</Label>
2025-09-02 13:18:46 +09:00
</div>
)}
{/* 버튼 */}
2025-10-22 14:52:13 +09:00
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
2025-12-23 09:31:18 +09:00
<Button
type="button"
variant="outline"
onClick={onClose}
2025-10-22 14:52:13 +09:00
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
2025-09-02 13:18:46 +09:00
</Button>
<Button
type="submit"
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
2025-10-22 14:52:13 +09:00
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? (
2025-09-02 13:18:46 +09:00
<>
<LoadingSpinner size="sm" className="mr-2" />
{isEditing ? "수정 중..." : "저장 중..."}
2025-09-02 13:18:46 +09:00
</>
) : isEditing ? (
"코드 수정"
2025-09-02 13:18:46 +09:00
) : (
"코드 저장"
2025-09-02 13:18:46 +09:00
)}
</Button>
</div>
</form>
2025-12-05 10:46:10 +09:00
</DialogContent>
</Dialog>
2025-09-02 13:18:46 +09:00
);
}