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

701 lines
25 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Eye, EyeOff } from "lucide-react";
import { userAPI } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
// 알림 모달 컴포넌트
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
type?: "success" | "error" | "info";
}
function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertModalProps) {
const getTypeColor = () => {
switch (type) {
case "success":
return "text-green-600";
case "error":
return "text-destructive";
default:
return "text-primary";
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-muted-foreground text-sm">{message}</p>
</div>
<div className="flex justify-end">
<Button onClick={onClose} className="w-20">
</Button>
</div>
</DialogContent>
</Dialog>
);
}
interface UserFormModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
editingUser?: any | null;
}
interface CompanyOption {
company_code: string;
company_name: string;
[key: string]: any; // 기타 필드들
}
interface DepartmentOption {
deptCode?: string;
deptName?: string;
parentDeptCode?: string;
masterSabun?: string;
masterUserId?: string;
location?: string;
locationName?: string;
regdate?: string;
dataType?: string;
status?: string;
salesYn?: string;
companyName?: string;
children?: DepartmentOption[];
// 기존 호환성을 위한 필드들
CODE?: string;
NAME?: string;
DEPT_CODE?: string;
DEPT_NAME?: string;
[key: string]: any; // 기타 필드들
}
export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserFormModalProps) {
// 현재 로그인한 사용자 정보
const { user: currentUser } = useAuth();
// 최고 관리자 여부 (company_code === '*' && userType === 'SUPER_ADMIN')
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 수정 모드 여부
const isEditMode = !!editingUser;
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [companies, setCompanies] = useState<CompanyOption[]>([]);
const [departments, setDepartments] = useState<DepartmentOption[]>([]);
// 알림 모달 상태
const [alertModal, setAlertModal] = useState({
isOpen: false,
title: "",
message: "",
type: "info" as "success" | "error" | "info",
});
// 알림 모달 표시 함수 (useCallback 유지 - 다른 useCallback의 의존성으로 사용됨)
const showAlert = useCallback((title: string, message: string, type: "success" | "error" | "info" = "info") => {
setAlertModal({
isOpen: true,
title,
message,
type,
});
}, []);
// 알림 모달 닫기 함수 (useCallback 제거 - 의존성이 없고 성능상 이점 없음)
const closeAlert = () => {
setAlertModal((prev) => ({ ...prev, isOpen: false }));
};
const [formData, setFormData] = useState({
userId: "",
userPassword: "",
userName: "",
email: "",
tel: "",
cellPhone: "",
positionName: "",
companyCode: "",
deptCode: "",
userType: "USER", // 기본값: 일반 사용자
sabun: null, // 항상 null로 설정
});
// ID 중복체크 상태 관리
const [isUserIdChecked, setIsUserIdChecked] = useState(false);
const [lastCheckedUserId, setLastCheckedUserId] = useState("");
const [duplicateCheckMessage, setDuplicateCheckMessage] = useState("");
const [duplicateCheckType, setDuplicateCheckType] = useState<"success" | "error" | "">("");
// 필수 필드 검증 (실시간)
const isFormValid = useMemo(() => {
// 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력)
const requiredFields = isEditMode
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode]
: [
formData.userId.trim(),
formData.userPassword.trim(),
formData.userName.trim(),
formData.companyCode,
formData.deptCode,
];
// 모든 필수 필드가 입력되었는지 확인
const allFieldsFilled = requiredFields.every((field) => field);
// 수정 모드: ID 중복체크 불필요 (이미 존재하는 사용자)
// 등록 모드: ID 중복체크 필수
const duplicateCheckValid = isEditMode || (isUserIdChecked && lastCheckedUserId === formData.userId);
return allFieldsFilled && duplicateCheckValid;
}, [formData, isUserIdChecked, lastCheckedUserId, isEditMode]);
// 회사 목록 로드
const loadCompanies = useCallback(async () => {
try {
const companyList = await userAPI.getCompanyList();
setCompanies(companyList);
} catch (error) {
console.error("회사 목록 조회 오류:", error);
showAlert("오류 발생", "회사 목록을 불러오는데 실패했습니다.", "error");
}
}, [showAlert]);
// 부서 목록 로드
const loadDepartments = useCallback(
async (companyCode?: string) => {
try {
const departmentList = await userAPI.getDepartmentList(companyCode);
setDepartments(departmentList);
} catch (error) {
console.error("부서 목록 조회 오류:", error);
showAlert("오류 발생", "부서 목록을 불러오는데 실패했습니다.", "error");
}
},
[showAlert],
);
// 모달이 열릴 때 회사 목록 및 부서 목록 로드, 수정 모드면 데이터 로드
useEffect(() => {
if (isOpen) {
loadCompanies();
loadDepartments(); // 전체 부서 목록 로드
// 수정 모드: 기존 사용자 정보 로드
if (isEditMode && editingUser) {
setFormData({
userId: editingUser.userId || "",
userPassword: "", // 수정 시 비밀번호는 비워둠 (변경 원할 경우만 입력)
userName: editingUser.userName || "",
email: editingUser.email || "",
tel: editingUser.tel || "",
cellPhone: editingUser.cellPhone || "",
positionName: editingUser.positionName || "",
companyCode: editingUser.companyCode || "",
deptCode: editingUser.deptCode || "",
userType: editingUser.userType || "USER",
sabun: editingUser.sabun || null,
});
// 수정 모드에서는 ID 중복체크 불필요
setIsUserIdChecked(true);
setLastCheckedUserId(editingUser.userId);
} else {
// 등록 모드: 폼 초기화
setFormData({
userId: "",
userPassword: "",
userName: "",
email: "",
tel: "",
cellPhone: "",
positionName: "",
companyCode: "",
deptCode: "",
userType: "USER",
sabun: null,
});
setIsUserIdChecked(false);
setLastCheckedUserId("");
setDuplicateCheckMessage("");
setDuplicateCheckType("");
}
}
}, [isOpen, isEditMode, editingUser, loadCompanies, loadDepartments]);
// 회사 선택 시 부서 목록 업데이트
useEffect(() => {
if (formData.companyCode) {
loadDepartments(formData.companyCode);
// 회사 변경 시 부서 선택 초기화
setFormData((prev) => ({ ...prev, deptCode: "" }));
}
}, [formData.companyCode, loadDepartments]);
// 폼 데이터 변경 핸들러 (useCallback 제거 - 의존성이 없고 성능상 이점 없음)
const handleInputChange = (field: string, value: string) => {
// userId가 변경되면 중복체크 상태 초기화
if (field === "userId") {
setIsUserIdChecked(false);
setLastCheckedUserId("");
setDuplicateCheckMessage("");
setDuplicateCheckType("");
}
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// 사용자 ID 중복 체크
const checkUserIdDuplicate = async () => {
if (!formData.userId.trim()) {
setDuplicateCheckMessage("사용자 ID를 입력해주세요.");
setDuplicateCheckType("error");
return;
}
try {
const response = await userAPI.checkDuplicateId(formData.userId);
if (response.success && response.data) {
// 백엔드 API 응답 구조: { isDuplicate: boolean, message: string }
const isDuplicate = response.data.isDuplicate;
const message = response.data.message;
if (!isDuplicate) {
// 중복되지 않음 (사용 가능)
setIsUserIdChecked(true);
setLastCheckedUserId(formData.userId);
setDuplicateCheckMessage(message || "사용 가능한 사용자 ID입니다.");
setDuplicateCheckType("success");
} else {
// 중복됨 (사용 불가)
setIsUserIdChecked(false);
setLastCheckedUserId("");
setDuplicateCheckMessage(message || "이미 사용 중인 사용자 ID입니다.");
setDuplicateCheckType("error");
}
}
} catch (error) {
console.error("ID 중복 체크 오류:", error);
setIsUserIdChecked(false);
setLastCheckedUserId("");
setDuplicateCheckMessage("ID 중복 체크 중 오류가 발생했습니다.");
setDuplicateCheckType("error");
}
};
// 유효성 검사
const validateForm = useCallback(() => {
if (!formData.userId.trim()) {
showAlert("입력 오류", "사용자 ID를 입력해주세요.", "error");
return false;
}
// ID 중복체크 필수 검증
if (!isUserIdChecked || lastCheckedUserId !== formData.userId) {
showAlert("중복체크 필요", "사용자 ID 중복체크를 먼저 진행해주세요.", "error");
return false;
}
if (!formData.userPassword.trim()) {
showAlert("입력 오류", "비밀번호를 입력해주세요.", "error");
return false;
}
if (!formData.userName.trim()) {
showAlert("입력 오류", "사용자명을 입력해주세요.", "error");
return false;
}
if (!formData.companyCode) {
showAlert("입력 오류", "회사를 선택해주세요.", "error");
return false;
}
if (!formData.deptCode) {
showAlert("입력 오류", "부서를 선택해주세요.", "error");
return false;
}
// 이메일 형식 검사 (입력된 경우만)
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
showAlert("입력 오류", "올바른 이메일 형식을 입력해주세요.", "error");
return false;
}
return true;
}, [formData, isUserIdChecked, lastCheckedUserId, showAlert]);
// 사용자 등록
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
const userDataToSend = {
userId: formData.userId,
userPassword: formData.userPassword,
userName: formData.userName,
email: formData.email || null,
tel: formData.tel || null,
cellPhone: formData.cellPhone || null,
positionName: formData.positionName || null,
companyCode: formData.companyCode,
deptCode: formData.deptCode || null,
userType: formData.userType, // 권한 타입 추가
sabun: null, // 항상 null (테이블 1번 컬럼)
status: "active", // 기본값 (테이블 18번 컬럼)
};
let response;
if (isEditMode) {
// 수정 모드: 비밀번호 필드 제외 (비밀번호 초기화 기능 별도 제공)
const updateData = { ...userDataToSend };
delete updateData.userPassword;
response = await userAPI.update(updateData);
} else {
// 등록 모드
response = await userAPI.create(userDataToSend);
}
if (response.success) {
showAlert(
isEditMode ? "수정 완료" : "등록 완료",
isEditMode ? "사용자 정보가 성공적으로 수정되었습니다." : "사용자가 성공적으로 등록되었습니다.",
"success",
);
// 성공 시 모달을 바로 닫지 않고 사용자가 확인 후 닫도록 수정
setTimeout(() => {
onClose();
onSuccess?.();
}, 1500); // 1.5초 후 자동으로 모달 닫기
} else {
showAlert(
isEditMode ? "수정 실패" : "등록 실패",
response.message || (isEditMode ? "사용자 정보 수정에 실패했습니다." : "사용자 등록에 실패했습니다."),
"error",
);
}
} catch (error) {
console.error(isEditMode ? "사용자 수정 오류:" : "사용자 등록 오류:", error);
showAlert(
"오류 발생",
isEditMode ? "사용자 정보 수정 중 오류가 발생했습니다." : "사용자 등록 중 오류가 발생했습니다.",
"error",
);
} finally {
setIsLoading(false);
}
}, [formData, validateForm, onSuccess, onClose, showAlert, isEditMode]);
// 모달 닫기
const handleClose = useCallback(() => {
setFormData({
userId: "",
userPassword: "",
userName: "",
email: "",
tel: "",
cellPhone: "",
positionName: "",
companyCode: "",
deptCode: "",
sabun: null,
});
setShowPassword(false);
// ID 중복체크 상태 초기화
setIsUserIdChecked(false);
setLastCheckedUserId("");
setDuplicateCheckMessage("");
setDuplicateCheckType("");
onClose();
}, [onClose]);
// Enter 키 처리
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading) {
handleSubmit();
}
},
[handleSubmit, isLoading],
);
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
<DialogHeader>
<DialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 기본 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="userId" className="text-sm font-medium">
ID <span className="text-red-500">*</span>
</Label>
{isEditMode ? (
<Input id="userId" value={formData.userId} disabled className="bg-muted cursor-not-allowed" />
) : (
<>
<div className="flex gap-2">
<Input
id="userId"
placeholder="사용자 ID 입력"
value={formData.userId}
onChange={(e) => handleInputChange("userId", e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1"
/>
<Button
type="button"
variant={isUserIdChecked && lastCheckedUserId === formData.userId ? "default" : "outline"}
onClick={checkUserIdDuplicate}
disabled={!formData.userId.trim() || isLoading}
className="whitespace-nowrap"
>
{isUserIdChecked && lastCheckedUserId === formData.userId ? "확인완료" : "중복확인"}
</Button>
</div>
{/* 중복확인 결과 메시지 */}
{duplicateCheckMessage && (
<div
className={`mt-1 text-sm ${duplicateCheckType === "success" ? "text-green-600" : "text-destructive"}`}
>
{duplicateCheckMessage}
</div>
)}
</>
)}
</div>
<div className="space-y-2">
<Label htmlFor="userName" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<Input
id="userName"
placeholder="사용자명 입력"
value={formData.userName}
onChange={(e) => handleInputChange("userName", e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
{/* 비밀번호 - 등록 모드에만 표시 */}
{!isEditMode && (
<div className="space-y-2">
<Label htmlFor="userPassword" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
id="userPassword"
type={showPassword ? "text" : "password"}
placeholder="비밀번호 입력"
value={formData.userPassword}
onChange={(e) => handleInputChange("userPassword", e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 회사 선택 */}
<div className="space-y-2">
<Label htmlFor="companyCode" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
{isSuperAdmin ? (
<>
<Select
value={formData.companyCode}
onValueChange={(value) => handleInputChange("companyCode", value)}
>
<SelectTrigger>
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</>
) : (
<>
<Input
id="companyCode"
value={
companies.find((c) => c.company_code === formData.companyCode)?.company_name ||
formData.companyCode
}
disabled
className="bg-muted cursor-not-allowed"
/>
<p className="text-muted-foreground text-xs"> .</p>
</>
)}
</div>
{/* 부서 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="deptCode" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.deptCode} onValueChange={(value) => handleInputChange("deptCode", value)}>
<SelectTrigger>
<SelectValue placeholder="부서 선택" />
</SelectTrigger>
<SelectContent>
{Array.isArray(departments) && departments.length > 0 ? (
departments
.filter((department) => {
const deptCode = department.deptCode || department.CODE || department.DEPT_CODE;
return deptCode && deptCode.trim() !== "";
})
.map((department) => {
const deptCode = department.deptCode || department.CODE || department.DEPT_CODE || "";
const deptName =
department.deptName || department.NAME || department.DEPT_NAME || "Unknown Department";
return (
<SelectItem key={deptCode} value={deptCode}>
{deptName}
</SelectItem>
);
})
) : (
<SelectItem value="no-data" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 연락처 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
</Label>
<Input
id="email"
type="email"
placeholder="이메일 입력 (선택사항)"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tel" className="text-sm font-medium">
</Label>
<Input
id="tel"
placeholder="전화번호 입력 (선택사항)"
value={formData.tel}
onChange={(e) => handleInputChange("tel", e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
{/* 추가 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cellPhone" className="text-sm font-medium">
</Label>
<Input
id="cellPhone"
placeholder="휴대폰 번호 입력 (선택사항)"
value={formData.cellPhone}
onChange={(e) => handleInputChange("cellPhone", e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className="space-y-2">
<Label htmlFor="positionName" className="text-sm font-medium">
</Label>
<Input
id="positionName"
placeholder="직책명 입력 (선택사항)"
value={formData.positionName}
onChange={(e) => handleInputChange("positionName", e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
</div>
{/* 버튼 영역 */}
<div className="flex justify-end gap-3 border-t pt-4">
<Button type="button" variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button type="button" onClick={handleSubmit} disabled={isLoading || !isFormValid} className="min-w-[80px]">
{isLoading ? "처리중..." : isEditMode ? "수정" : "등록"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 알림 모달 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={closeAlert}
title={alertModal.title}
message={alertModal.message}
type={alertModal.type}
/>
</>
);
}