From 6b53cb414c9356f569f63d2e65b0aad05e8018d8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 17:28:12 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=A5=BC=20alert=EC=97=90?= =?UTF-8?q?=EC=84=9C=20modal=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/departmentController.ts | 66 +++++- backend-node/src/routes/departmentRoutes.ts | 3 + .../admin/department/DepartmentManagement.tsx | 20 +- .../admin/department/DepartmentMembers.tsx | 209 +++++++++++++++--- .../admin/department/DepartmentStructure.tsx | 91 +++++++- frontend/lib/api/department.ts | 32 ++- 6 files changed, 380 insertions(+), 41 deletions(-) diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index deb729ae..93b37475 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -114,6 +114,22 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) return; } + // 같은 회사 내 중복 부서명 확인 + const duplicate = await queryOne(` + SELECT dept_code, dept_name + FROM dept_info + WHERE company_code = $1 AND dept_name = $2 + `, [companyCode, dept_name.trim()]); + + if (duplicate) { + res.status(409).json({ + success: false, + message: `"${dept_name}" 부서가 이미 존재합니다.`, + isDuplicate: true, + }); + return; + } + // 회사 이름 조회 const company = await queryOne(` SELECT company_name FROM company_mng WHERE company_code = $1 @@ -322,6 +338,53 @@ export async function getDepartmentMembers(req: AuthenticatedRequest, res: Respo } } +/** + * 사용자 검색 (부서원 추가용) + */ +export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const { search } = req.query; + + if (!search || typeof search !== 'string') { + res.status(400).json({ + success: false, + message: "검색어를 입력해주세요.", + }); + return; + } + + // 사용자 검색 (ID 또는 이름) + const users = await query(` + SELECT + user_id, + user_name, + email, + position_name, + company_code + FROM user_info + WHERE company_code = $1 + AND ( + user_id ILIKE $2 OR + user_name ILIKE $2 + ) + ORDER BY user_name + LIMIT 20 + `, [companyCode, `%${search}%`]); + + res.status(200).json({ + success: true, + data: users, + }); + } catch (error) { + logger.error("사용자 검색 실패", error); + res.status(500).json({ + success: false, + message: "사용자 검색 중 오류가 발생했습니다.", + }); + } +} + /** * 부서원 추가 */ @@ -361,9 +424,10 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon `, [user_id, deptCode]); if (existing) { - res.status(400).json({ + res.status(409).json({ success: false, message: "이미 해당 부서의 부서원입니다.", + isDuplicate: true, }); return; } diff --git a/backend-node/src/routes/departmentRoutes.ts b/backend-node/src/routes/departmentRoutes.ts index 2f06dd3c..52cc309e 100644 --- a/backend-node/src/routes/departmentRoutes.ts +++ b/backend-node/src/routes/departmentRoutes.ts @@ -30,6 +30,9 @@ router.delete("/:deptCode", departmentController.deleteDepartment); // 부서원 목록 조회 router.get("/:deptCode/members", departmentController.getDepartmentMembers); +// 사용자 검색 (부서원 추가용) +router.get("/companies/:companyCode/users/search", departmentController.searchUsers); + // 부서원 추가 router.post("/:deptCode/members", departmentController.addDepartmentMember); diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx index abad5fe6..e82be525 100644 --- a/frontend/components/admin/department/DepartmentManagement.tsx +++ b/frontend/components/admin/department/DepartmentManagement.tsx @@ -23,6 +23,12 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) const [selectedDepartment, setSelectedDepartment] = useState(null); const [activeTab, setActiveTab] = useState("structure"); const [companyName, setCompanyName] = useState(""); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + // 부서원 변경 시 부서 구조 새로고침 + const handleMemberChange = () => { + setRefreshTrigger((prev) => prev + 1); + }; // 회사 정보 로드 useEffect(() => { @@ -71,11 +77,16 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) companyCode={companyCode} selectedDepartment={selectedDepartment} onSelectDepartment={setSelectedDepartment} + refreshTrigger={refreshTrigger} /> - + @@ -88,12 +99,17 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) companyCode={companyCode} selectedDepartment={selectedDepartment} onSelectDepartment={setSelectedDepartment} + refreshTrigger={refreshTrigger} /> {/* 우측: 부서 인원 (80%) */}
- +
diff --git a/frontend/components/admin/department/DepartmentMembers.tsx b/frontend/components/admin/department/DepartmentMembers.tsx index a1a79f92..6fb398ec 100644 --- a/frontend/components/admin/department/DepartmentMembers.tsx +++ b/frontend/components/admin/department/DepartmentMembers.tsx @@ -19,16 +19,28 @@ import * as departmentAPI from "@/lib/api/department"; interface DepartmentMembersProps { companyCode: string; selectedDepartment: Department | null; + onMemberChange?: () => void; } /** * 부서 인원 관리 컴포넌트 */ -export function DepartmentMembers({ companyCode, selectedDepartment }: DepartmentMembersProps) { +export function DepartmentMembers({ + companyCode, + selectedDepartment, + onMemberChange, +}: DepartmentMembersProps) { const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [searchUserId, setSearchUserId] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [duplicateMessage, setDuplicateMessage] = useState(null); + + // 부서원 삭제 확인 모달 + const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); + const [memberToRemove, setMemberToRemove] = useState<{ userId: string; name: string } | null>(null); // 부서원 목록 로드 useEffect(() => { @@ -57,22 +69,51 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen } }; + // 사용자 검색 + const handleSearch = async () => { + if (!searchQuery.trim()) { + setSearchResults([]); + return; + } + + setIsSearching(true); + try { + const response = await departmentAPI.searchUsers(companyCode, searchQuery); + if (response.success && response.data) { + setSearchResults(response.data); + } else { + setSearchResults([]); + } + } catch (error) { + console.error("사용자 검색 실패:", error); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }; + // 부서원 추가 - const handleAddMember = async () => { - if (!searchUserId.trim() || !selectedDepartment) return; + const handleAddMember = async (userId: string) => { + if (!selectedDepartment) return; try { const response = await departmentAPI.addDepartmentMember( selectedDepartment.dept_code, - searchUserId + userId ); if (response.success) { setIsAddModalOpen(false); - setSearchUserId(""); + setSearchQuery(""); + setSearchResults([]); loadMembers(); + onMemberChange?.(); // 부서 구조 새로고침 } else { - alert(response.error || "부서원 추가에 실패했습니다."); + if ((response as any).isDuplicate) { + setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다."); + } else { + alert(response.error || "부서원 추가에 실패했습니다."); + } } } catch (error) { console.error("부서원 추가 실패:", error); @@ -80,19 +121,27 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen } }; - // 부서원 제거 - const handleRemoveMember = async (userId: string) => { - if (!selectedDepartment) return; - if (!confirm("이 부서에서 사용자를 제외하시겠습니까?")) return; + // 부서원 제거 확인 요청 + const handleRemoveMemberRequest = (userId: string, userName: string) => { + setMemberToRemove({ userId, name: userName }); + setRemoveConfirmOpen(true); + }; + + // 부서원 제거 실행 + const handleRemoveMemberConfirm = async () => { + if (!selectedDepartment || !memberToRemove) return; try { const response = await departmentAPI.removeDepartmentMember( selectedDepartment.dept_code, - userId + memberToRemove.userId ); if (response.success) { + setRemoveConfirmOpen(false); + setMemberToRemove(null); loadMembers(); + onMemberChange?.(); // 부서 구조 새로고침 } else { alert(response.error || "부서원 제거에 실패했습니다."); } @@ -195,7 +244,7 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen variant="ghost" size="icon" className="h-8 w-8 text-destructive" - onClick={() => handleRemoveMember(member.user_id)} + onClick={() => handleRemoveMemberRequest(member.user_id, member.user_name)} > @@ -208,35 +257,139 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen {/* 부서원 추가 모달 */} - + - 부서원 추가 + 부서원 추가 -
+
+ {/* 검색 입력 */}
-
+ + {/* 검색 결과 */} + {isSearching ? ( +
검색 중...
+ ) : searchResults.length > 0 ? ( +
+ {searchResults.map((user) => ( +
handleAddMember(user.user_id)} + > +
+
+ {user.user_name} + ({user.user_id}) +
+
+ {user.position_name && {user.position_name}} + {user.email && {user.email}} +
+
+ +
+ ))} +
+ ) : searchQuery && !isSearching ? ( +
+ 검색 결과가 없습니다. +
+ ) : null}
+ + + + +
+ + {/* 중복 알림 모달 */} + setDuplicateMessage(null)}> + + + 중복 알림 + +
+

{duplicateMessage}

+
- + +
+
+ + {/* 부서원 제거 확인 모달 */} + + + + 부서원 제거 확인 + +
+

+ {memberToRemove?.name} 님을 이 부서에서 제외하시겠습니까? +

+

+ 다른 부서에는 영향을 주지 않습니다. +

+
+ + -
diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx index 1e8b83a9..49df54bc 100644 --- a/frontend/components/admin/department/DepartmentStructure.tsx +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -19,6 +19,7 @@ interface DepartmentStructureProps { companyCode: string; selectedDepartment: Department | null; onSelectDepartment: (department: Department | null) => void; + refreshTrigger?: number; } /** @@ -28,6 +29,7 @@ export function DepartmentStructure({ companyCode, selectedDepartment, onSelectDepartment, + refreshTrigger, }: DepartmentStructureProps) { const [departments, setDepartments] = useState([]); const [expandedDepts, setExpandedDepts] = useState>(new Set()); @@ -37,11 +39,16 @@ export function DepartmentStructure({ const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [parentDeptForAdd, setParentDeptForAdd] = useState(null); const [newDeptName, setNewDeptName] = useState(""); + const [duplicateMessage, setDuplicateMessage] = useState(null); + + // 부서 삭제 확인 모달 + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null); // 부서 목록 로드 useEffect(() => { loadDepartments(); - }, [companyCode]); + }, [companyCode, refreshTrigger]); const loadDepartments = async () => { setIsLoading(true); @@ -87,9 +94,15 @@ export function DepartmentStructure({ if (response.success) { setIsAddModalOpen(false); + setNewDeptName(""); + setParentDeptForAdd(null); loadDepartments(); } else { - alert(response.error || "부서 추가에 실패했습니다."); + if ((response as any).isDuplicate) { + setDuplicateMessage(response.error || "이미 존재하는 부서명입니다."); + } else { + alert(response.error || "부서 추가에 실패했습니다."); + } } } catch (error) { console.error("부서 추가 실패:", error); @@ -97,14 +110,22 @@ export function DepartmentStructure({ } }; - // 부서 삭제 - const handleDeleteDepartment = async (deptCode: string) => { - if (!confirm("이 부서를 삭제하시겠습니까?")) return; + // 부서 삭제 확인 요청 + const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => { + setDeptToDelete({ code: deptCode, name: deptName }); + setDeleteConfirmOpen(true); + }; + + // 부서 삭제 실행 + const handleDeleteDepartmentConfirm = async () => { + if (!deptToDelete) return; try { - const response = await departmentAPI.deleteDepartment(deptCode); + const response = await departmentAPI.deleteDepartment(deptToDelete.code); if (response.success) { + setDeleteConfirmOpen(false); + setDeptToDelete(null); loadDepartments(); } else { alert(response.error || "부서 삭제에 실패했습니다."); @@ -196,7 +217,7 @@ export function DepartmentStructure({ className="h-6 w-6 text-destructive" onClick={(e) => { e.stopPropagation(); - handleDeleteDepartment(dept.dept_code); + handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name); }} > @@ -269,6 +290,62 @@ export function DepartmentStructure({
+ + {/* 중복 알림 모달 */} + setDuplicateMessage(null)}> + + + 중복 알림 + +
+

{duplicateMessage}

+
+ + + +
+
+ + {/* 부서 삭제 확인 모달 */} + + + + 부서 삭제 확인 + +
+

+ {deptToDelete?.name} 부서를 삭제하시겠습니까? +

+

+ 이 작업은 되돌릴 수 없습니다. +

+
+ + + + +
+
); } diff --git a/frontend/lib/api/department.ts b/frontend/lib/api/department.ts index a3f13962..2486d11f 100644 --- a/frontend/lib/api/department.ts +++ b/frontend/lib/api/department.ts @@ -44,7 +44,12 @@ export async function createDepartment(companyCode: string, data: DepartmentForm return response.data; } catch (error: any) { console.error("부서 생성 실패:", error); - return { success: false, error: error.message }; + const isDuplicate = error.response?.status === 409; + return { + success: false, + error: error.response?.data?.message || error.message, + isDuplicate, + }; } } @@ -89,18 +94,39 @@ export async function getDepartmentMembers(deptCode: string) { } } +/** + * 사용자 검색 (부서원 추가용) + */ +export async function searchUsers(companyCode: string, search: string) { + try { + const response = await apiClient.get<{ success: boolean; data: any[] }>( + `/departments/companies/${companyCode}/users/search`, + { params: { search } }, + ); + return response.data; + } catch (error: any) { + console.error("사용자 검색 실패:", error); + return { success: false, error: error.message }; + } +} + /** * 부서원 추가 */ export async function addDepartmentMember(deptCode: string, userId: string) { try { - const response = await apiClient.post<{ success: boolean }>(`/departments/${deptCode}/members`, { + const response = await apiClient.post<{ success: boolean; message?: string }>(`/departments/${deptCode}/members`, { user_id: userId, }); return response.data; } catch (error: any) { console.error("부서원 추가 실패:", error); - return { success: false, error: error.message }; + const isDuplicate = error.response?.status === 409; + return { + success: false, + error: error.response?.data?.message || error.message, + isDuplicate, + }; } }