삭제 후엔 부서 선택 해제

This commit is contained in:
dohyeons 2025-11-03 17:42:46 +09:00
parent 6b53cb414c
commit c50c8d01df
3 changed files with 137 additions and 59 deletions

View File

@ -250,25 +250,19 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response)
if (parseInt(hasChildren?.count || "0") > 0) { if (parseInt(hasChildren?.count || "0") > 0) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: "하위 부서가 있는 부서는 삭제할 수 없습니다.", message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.",
}); });
return; return;
} }
// 부서원 확인 // 부서원 삭제 (부서 삭제 전에 먼저 삭제)
const hasMembers = await queryOne<any>(` const deletedMembers = await query<any>(`
SELECT COUNT(*) as count DELETE FROM user_dept
FROM user_dept
WHERE dept_code = $1 WHERE dept_code = $1
RETURNING user_id
`, [deptCode]); `, [deptCode]);
if (parseInt(hasMembers?.count || "0") > 0) { const memberCount = deletedMembers.length;
res.status(400).json({
success: false,
message: "부서원이 있는 부서는 삭제할 수 없습니다.",
});
return;
}
// 부서 삭제 // 부서 삭제
const result = await query<any>(` const result = await query<any>(`
@ -285,11 +279,17 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response)
return; return;
} }
logger.info("부서 삭제 성공", { deptCode }); logger.info("부서 삭제 성공", {
deptCode,
deptName: result[0].dept_name,
deletedMemberCount: memberCount
});
res.status(200).json({ res.status(200).json({
success: true, success: true,
message: "부서가 삭제되었습니다.", message: memberCount > 0
? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)`
: "부서가 삭제되었습니다.",
}); });
} catch (error) { } catch (error) {
logger.error("부서 삭제 실패", error); logger.error("부서 삭제 실패", error);

View File

@ -13,6 +13,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import type { Department, DepartmentMember } from "@/types/department"; import type { Department, DepartmentMember } from "@/types/department";
import * as departmentAPI from "@/lib/api/department"; import * as departmentAPI from "@/lib/api/department";
@ -30,6 +31,7 @@ export function DepartmentMembers({
selectedDepartment, selectedDepartment,
onMemberChange, onMemberChange,
}: DepartmentMembersProps) { }: DepartmentMembersProps) {
const { toast } = useToast();
const [members, setMembers] = useState<DepartmentMember[]>([]); const [members, setMembers] = useState<DepartmentMember[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@ -108,16 +110,31 @@ export function DepartmentMembers({
setSearchResults([]); setSearchResults([]);
loadMembers(); loadMembers();
onMemberChange?.(); // 부서 구조 새로고침 onMemberChange?.(); // 부서 구조 새로고침
// 성공 Toast 표시
toast({
title: "부서원 추가 완료",
description: "부서원이 추가되었습니다.",
variant: "default",
});
} else { } else {
if ((response as any).isDuplicate) { if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다."); setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다.");
} else { } else {
alert(response.error || "부서원 추가에 실패했습니다."); toast({
title: "부서원 추가 실패",
description: response.error || "부서원 추가에 실패했습니다.",
variant: "destructive",
});
} }
} }
} catch (error) { } catch (error) {
console.error("부서원 추가 실패:", error); console.error("부서원 추가 실패:", error);
alert("부서원 추가 중 오류가 발생했습니다."); toast({
title: "부서원 추가 실패",
description: "부서원 추가 중 오류가 발생했습니다.",
variant: "destructive",
});
} }
}; };
@ -142,12 +159,27 @@ export function DepartmentMembers({
setMemberToRemove(null); setMemberToRemove(null);
loadMembers(); loadMembers();
onMemberChange?.(); // 부서 구조 새로고침 onMemberChange?.(); // 부서 구조 새로고침
// 성공 Toast 표시
toast({
title: "부서원 제거 완료",
description: `${memberToRemove.name} 님이 부서에서 제외되었습니다.`,
variant: "default",
});
} else { } else {
alert(response.error || "부서원 제거에 실패했습니다."); toast({
title: "부서원 제거 실패",
description: response.error || "부서원 제거에 실패했습니다.",
variant: "destructive",
});
} }
} catch (error) { } catch (error) {
console.error("부서원 제거 실패:", error); console.error("부서원 제거 실패:", error);
alert("부서원 제거 중 오류가 발생했습니다."); toast({
title: "부서원 제거 실패",
description: "부서원 제거 중 오류가 발생했습니다.",
variant: "destructive",
});
} }
}; };
@ -163,12 +195,27 @@ export function DepartmentMembers({
if (response.success) { if (response.success) {
loadMembers(); loadMembers();
// 성공 Toast 표시
toast({
title: "주 부서 설정 완료",
description: "주 부서가 변경되었습니다.",
variant: "default",
});
} else { } else {
alert(response.error || "주 부서 설정에 실패했습니다."); toast({
title: "주 부서 설정 실패",
description: response.error || "주 부서 설정에 실패했습니다.",
variant: "destructive",
});
} }
} catch (error) { } catch (error) {
console.error("주 부서 설정 실패:", error); console.error("주 부서 설정 실패:", error);
alert("주 부서 설정 중 오류가 발생했습니다."); toast({
title: "주 부서 설정 실패",
description: "주 부서 설정 중 오류가 발생했습니다.",
variant: "destructive",
});
} }
}; };

View File

@ -3,16 +3,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { Department, DepartmentFormData } from "@/types/department"; import { useToast } from "@/hooks/use-toast";
import type { Department } from "@/types/department";
import * as departmentAPI from "@/lib/api/department"; import * as departmentAPI from "@/lib/api/department";
interface DepartmentStructureProps { interface DepartmentStructureProps {
@ -31,6 +26,7 @@ export function DepartmentStructure({
onSelectDepartment, onSelectDepartment,
refreshTrigger, refreshTrigger,
}: DepartmentStructureProps) { }: DepartmentStructureProps) {
const { toast } = useToast();
const [departments, setDepartments] = useState<Department[]>([]); const [departments, setDepartments] = useState<Department[]>([]);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set()); const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -40,10 +36,11 @@ export function DepartmentStructure({
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null); const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
const [newDeptName, setNewDeptName] = useState(""); const [newDeptName, setNewDeptName] = useState("");
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null); const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
// 부서 삭제 확인 모달 // 부서 삭제 확인 모달
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null); const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
// 부서 목록 로드 // 부서 목록 로드
useEffect(() => { useEffect(() => {
@ -97,16 +94,31 @@ export function DepartmentStructure({
setNewDeptName(""); setNewDeptName("");
setParentDeptForAdd(null); setParentDeptForAdd(null);
loadDepartments(); loadDepartments();
// 성공 Toast 표시
toast({
title: "부서 생성 완료",
description: `"${newDeptName}" 부서가 생성되었습니다.`,
variant: "default",
});
} else { } else {
if ((response as any).isDuplicate) { if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 존재하는 부서명입니다."); setDuplicateMessage(response.error || "이미 존재하는 부서명입니다.");
} else { } else {
alert(response.error || "부서 추가에 실패했습니다."); toast({
title: "부서 생성 실패",
description: response.error || "부서 추가에 실패했습니다.",
variant: "destructive",
});
} }
} }
} catch (error) { } catch (error) {
console.error("부서 추가 실패:", error); console.error("부서 추가 실패:", error);
alert("부서 추가 중 오류가 발생했습니다."); toast({
title: "부서 생성 실패",
description: "부서 추가 중 오류가 발생했습니다.",
variant: "destructive",
});
} }
}; };
@ -124,15 +136,32 @@ export function DepartmentStructure({
const response = await departmentAPI.deleteDepartment(deptToDelete.code); const response = await departmentAPI.deleteDepartment(deptToDelete.code);
if (response.success) { if (response.success) {
// 삭제된 부서가 선택되어 있었다면 선택 해제
if (selectedDepartment?.dept_code === deptToDelete.code) {
onSelectDepartment(null);
}
setDeleteConfirmOpen(false); setDeleteConfirmOpen(false);
setDeptToDelete(null); setDeptToDelete(null);
loadDepartments(); loadDepartments();
// 성공 메시지 Toast로 표시 (부서원 수 포함)
toast({
title: "부서 삭제 완료",
description: response.message || "부서가 삭제되었습니다.",
variant: "default",
});
} else { } else {
alert(response.error || "부서 삭제에 실패했습니다."); // 삭제 확인 모달을 닫고 에러 모달을 표시
setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage(response.error || "부서 삭제에 실패했습니다.");
} }
} catch (error) { } catch (error) {
console.error("부서 삭제 실패:", error); console.error("부서 삭제 실패:", error);
alert("부서 삭제 중 오류가 발생했습니다."); setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage("부서 삭제 중 오류가 발생했습니다.");
} }
}; };
@ -160,15 +189,12 @@ export function DepartmentStructure({
<div key={dept.dept_code}> <div key={dept.dept_code}>
{/* 부서 항목 */} {/* 부서 항목 */}
<div <div
className={`flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors hover:bg-muted ${ className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
isSelected ? "bg-primary/10 text-primary" : "" isSelected ? "bg-primary/10 text-primary" : ""
}`} }`}
style={{ marginLeft: `${level * 16}px` }} style={{ marginLeft: `${level * 16}px` }}
> >
<div <div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
className="flex flex-1 items-center gap-2"
onClick={() => onSelectDepartment(dept)}
>
{/* 확장/축소 아이콘 */} {/* 확장/축소 아이콘 */}
{hasChildren ? ( {hasChildren ? (
<button <button
@ -178,11 +204,7 @@ export function DepartmentStructure({
}} }}
className="h-4 w-4" className="h-4 w-4"
> >
{isExpanded ? ( {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button> </button>
) : ( ) : (
<div className="h-4 w-4" /> <div className="h-4 w-4" />
@ -192,7 +214,7 @@ export function DepartmentStructure({
<span className="font-medium">{dept.dept_name}</span> <span className="font-medium">{dept.dept_name}</span>
{/* 인원수 */} {/* 인원수 */}
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center gap-1 text-xs">
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
<span>{dept.memberCount || 0}</span> <span>{dept.memberCount || 0}</span>
</div> </div>
@ -214,7 +236,7 @@ export function DepartmentStructure({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 text-destructive" className="text-destructive h-6 w-6"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name); handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
@ -244,11 +266,11 @@ export function DepartmentStructure({
</div> </div>
{/* 부서 트리 */} {/* 부서 트리 */}
<div className="space-y-1 rounded-lg border bg-card p-4 shadow-sm"> <div className="bg-card space-y-1 rounded-lg border p-4 shadow-sm">
{isLoading ? ( {isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground"> ...</div> <div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : departments.length === 0 ? ( ) : departments.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="text-muted-foreground py-8 text-center text-sm">
. . . .
</div> </div>
) : ( ) : (
@ -260,9 +282,7 @@ export function DepartmentStructure({
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}> <Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}</DialogTitle>
{parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
@ -301,10 +321,7 @@ export function DepartmentStructure({
<p className="text-sm">{duplicateMessage}</p> <p className="text-sm">{duplicateMessage}</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button onClick={() => setDuplicateMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
onClick={() => setDuplicateMessage(null)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -321,9 +338,7 @@ export function DepartmentStructure({
<p className="text-sm"> <p className="text-sm">
<span className="font-semibold">{deptToDelete?.name}</span> ? <span className="font-semibold">{deptToDelete?.name}</span> ?
</p> </p>
<p className="mt-2 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-2 text-xs"> .</p>
.
</p>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button
@ -346,7 +361,23 @@ export function DepartmentStructure({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 부서 삭제 에러 모달 */}
<Dialog open={!!deleteErrorMessage} onOpenChange={() => setDeleteErrorMessage(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm">{deleteErrorMessage}</p>
</div>
<DialogFooter>
<Button onClick={() => setDeleteErrorMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }