삭제를 alert에서 modal로 변경

This commit is contained in:
dohyeons 2025-11-03 17:28:12 +09:00
parent 0d6b018ec4
commit 6b53cb414c
6 changed files with 380 additions and 41 deletions

View File

@ -114,6 +114,22 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response)
return; return;
} }
// 같은 회사 내 중복 부서명 확인
const duplicate = await queryOne<any>(`
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<any>(` const company = await queryOne<any>(`
SELECT company_name FROM company_mng WHERE company_code = $1 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<void> {
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<any>(`
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]); `, [user_id, deptCode]);
if (existing) { if (existing) {
res.status(400).json({ res.status(409).json({
success: false, success: false,
message: "이미 해당 부서의 부서원입니다.", message: "이미 해당 부서의 부서원입니다.",
isDuplicate: true,
}); });
return; return;
} }

View File

@ -30,6 +30,9 @@ router.delete("/:deptCode", departmentController.deleteDepartment);
// 부서원 목록 조회 // 부서원 목록 조회
router.get("/:deptCode/members", departmentController.getDepartmentMembers); router.get("/:deptCode/members", departmentController.getDepartmentMembers);
// 사용자 검색 (부서원 추가용)
router.get("/companies/:companyCode/users/search", departmentController.searchUsers);
// 부서원 추가 // 부서원 추가
router.post("/:deptCode/members", departmentController.addDepartmentMember); router.post("/:deptCode/members", departmentController.addDepartmentMember);

View File

@ -23,6 +23,12 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps)
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null); const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure"); const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>(""); const [companyName, setCompanyName] = useState<string>("");
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 부서원 변경 시 부서 구조 새로고침
const handleMemberChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
// 회사 정보 로드 // 회사 정보 로드
useEffect(() => { useEffect(() => {
@ -71,11 +77,16 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps)
companyCode={companyCode} companyCode={companyCode}
selectedDepartment={selectedDepartment} selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment} onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/> />
</TabsContent> </TabsContent>
<TabsContent value="members" className="mt-4"> <TabsContent value="members" className="mt-4">
<DepartmentMembers companyCode={companyCode} selectedDepartment={selectedDepartment} /> <DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@ -88,12 +99,17 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps)
companyCode={companyCode} companyCode={companyCode}
selectedDepartment={selectedDepartment} selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment} onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/> />
</div> </div>
{/* 우측: 부서 인원 (80%) */} {/* 우측: 부서 인원 (80%) */}
<div className="w-[80%] pl-0"> <div className="w-[80%] pl-0">
<DepartmentMembers companyCode={companyCode} selectedDepartment={selectedDepartment} /> <DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,16 +19,28 @@ import * as departmentAPI from "@/lib/api/department";
interface DepartmentMembersProps { interface DepartmentMembersProps {
companyCode: string; companyCode: string;
selectedDepartment: Department | null; selectedDepartment: Department | null;
onMemberChange?: () => void;
} }
/** /**
* *
*/ */
export function DepartmentMembers({ companyCode, selectedDepartment }: DepartmentMembersProps) { export function DepartmentMembers({
companyCode,
selectedDepartment,
onMemberChange,
}: DepartmentMembersProps) {
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);
const [searchUserId, setSearchUserId] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
// 부서원 삭제 확인 모달
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; name: string } | null>(null);
// 부서원 목록 로드 // 부서원 목록 로드
useEffect(() => { useEffect(() => {
@ -57,42 +69,79 @@ 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 () => { const handleAddMember = async (userId: string) => {
if (!searchUserId.trim() || !selectedDepartment) return; if (!selectedDepartment) return;
try { try {
const response = await departmentAPI.addDepartmentMember( const response = await departmentAPI.addDepartmentMember(
selectedDepartment.dept_code, selectedDepartment.dept_code,
searchUserId userId
); );
if (response.success) { if (response.success) {
setIsAddModalOpen(false); setIsAddModalOpen(false);
setSearchUserId(""); setSearchQuery("");
setSearchResults([]);
loadMembers(); loadMembers();
onMemberChange?.(); // 부서 구조 새로고침
} else {
if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다.");
} else { } else {
alert(response.error || "부서원 추가에 실패했습니다."); alert(response.error || "부서원 추가에 실패했습니다.");
} }
}
} catch (error) { } catch (error) {
console.error("부서원 추가 실패:", error); console.error("부서원 추가 실패:", error);
alert("부서원 추가 중 오류가 발생했습니다."); alert("부서원 추가 중 오류가 발생했습니다.");
} }
}; };
// 부서원 제거 // 부서원 제거 확인 요청
const handleRemoveMember = async (userId: string) => { const handleRemoveMemberRequest = (userId: string, userName: string) => {
if (!selectedDepartment) return; setMemberToRemove({ userId, name: userName });
if (!confirm("이 부서에서 사용자를 제외하시겠습니까?")) return; setRemoveConfirmOpen(true);
};
// 부서원 제거 실행
const handleRemoveMemberConfirm = async () => {
if (!selectedDepartment || !memberToRemove) return;
try { try {
const response = await departmentAPI.removeDepartmentMember( const response = await departmentAPI.removeDepartmentMember(
selectedDepartment.dept_code, selectedDepartment.dept_code,
userId memberToRemove.userId
); );
if (response.success) { if (response.success) {
setRemoveConfirmOpen(false);
setMemberToRemove(null);
loadMembers(); loadMembers();
onMemberChange?.(); // 부서 구조 새로고침
} else { } else {
alert(response.error || "부서원 제거에 실패했습니다."); alert(response.error || "부서원 제거에 실패했습니다.");
} }
@ -195,7 +244,7 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-destructive" className="h-8 w-8 text-destructive"
onClick={() => handleRemoveMember(member.user_id)} onClick={() => handleRemoveMemberRequest(member.user_id, member.user_name)}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@ -208,35 +257,139 @@ export function DepartmentMembers({ companyCode, selectedDepartment }: Departmen
{/* 부서원 추가 모달 */} {/* 부서원 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}> <Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle> </DialogTitle> <DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4">
{/* 검색 입력 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="user_id"> <Label htmlFor="search" className="text-xs sm:text-sm">
ID <span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<div className="flex gap-2">
<Input <Input
id="user_id" id="search"
value={searchUserId} value={searchQuery}
onChange={(e) => setSearchUserId(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="사용자 ID를 입력하세요" onKeyPress={(e) => e.key === "Enter" && handleSearch()}
placeholder="이름 또는 ID로 검색"
className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus autoFocus
/> />
<Button
onClick={handleSearch}
disabled={!searchQuery.trim() || isSearching}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
. . . .
</p> </p>
</div> </div>
{/* 검색 결과 */}
{isSearching ? (
<div className="py-8 text-center text-sm text-muted-foreground"> ...</div>
) : searchResults.length > 0 ? (
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border p-2">
{searchResults.map((user) => (
<div
key={user.user_id}
className="flex cursor-pointer items-center justify-between rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
onClick={() => handleAddMember(user.user_id)}
>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{user.user_name}</span>
<span className="text-sm text-muted-foreground">({user.user_id})</span>
</div>
<div className="mt-1 flex gap-3 text-xs text-muted-foreground">
{user.position_name && <span>{user.position_name}</span>}
{user.email && <span>{user.email}</span>}
</div>
</div>
<Plus className="h-4 w-4 text-muted-foreground" />
</div>
))}
</div>
) : searchQuery && !isSearching ? (
<div className="py-8 text-center text-sm text-muted-foreground">
.
</div>
) : null}
</div> </div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setIsAddModalOpen(false);
setSearchQuery("");
setSearchResults([]);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 중복 알림 모달 */}
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(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">{duplicateMessage}</p>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}> <Button
onClick={() => setDuplicateMessage(null)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 부서원 제거 확인 모달 */}
<Dialog open={removeConfirmOpen} onOpenChange={setRemoveConfirmOpen}>
<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">
<span className="font-semibold">{memberToRemove?.name}</span> ?
</p>
<p className="mt-2 text-xs text-muted-foreground">
.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setRemoveConfirmOpen(false);
setMemberToRemove(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button> </Button>
<Button onClick={handleAddMember} disabled={!searchUserId.trim()}> <Button
variant="destructive"
onClick={handleRemoveMemberConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -19,6 +19,7 @@ interface DepartmentStructureProps {
companyCode: string; companyCode: string;
selectedDepartment: Department | null; selectedDepartment: Department | null;
onSelectDepartment: (department: Department | null) => void; onSelectDepartment: (department: Department | null) => void;
refreshTrigger?: number;
} }
/** /**
@ -28,6 +29,7 @@ export function DepartmentStructure({
companyCode, companyCode,
selectedDepartment, selectedDepartment,
onSelectDepartment, onSelectDepartment,
refreshTrigger,
}: DepartmentStructureProps) { }: DepartmentStructureProps) {
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());
@ -37,11 +39,16 @@ export function DepartmentStructure({
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
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 [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
// 부서 목록 로드 // 부서 목록 로드
useEffect(() => { useEffect(() => {
loadDepartments(); loadDepartments();
}, [companyCode]); }, [companyCode, refreshTrigger]);
const loadDepartments = async () => { const loadDepartments = async () => {
setIsLoading(true); setIsLoading(true);
@ -87,24 +94,38 @@ export function DepartmentStructure({
if (response.success) { if (response.success) {
setIsAddModalOpen(false); setIsAddModalOpen(false);
setNewDeptName("");
setParentDeptForAdd(null);
loadDepartments(); loadDepartments();
} else {
if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 존재하는 부서명입니다.");
} else { } else {
alert(response.error || "부서 추가에 실패했습니다."); alert(response.error || "부서 추가에 실패했습니다.");
} }
}
} catch (error) { } catch (error) {
console.error("부서 추가 실패:", error); console.error("부서 추가 실패:", error);
alert("부서 추가 중 오류가 발생했습니다."); alert("부서 추가 중 오류가 발생했습니다.");
} }
}; };
// 부서 삭제 // 부서 삭제 확인 요청
const handleDeleteDepartment = async (deptCode: string) => { const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => {
if (!confirm("이 부서를 삭제하시겠습니까?")) return; setDeptToDelete({ code: deptCode, name: deptName });
setDeleteConfirmOpen(true);
};
// 부서 삭제 실행
const handleDeleteDepartmentConfirm = async () => {
if (!deptToDelete) return;
try { try {
const response = await departmentAPI.deleteDepartment(deptCode); const response = await departmentAPI.deleteDepartment(deptToDelete.code);
if (response.success) { if (response.success) {
setDeleteConfirmOpen(false);
setDeptToDelete(null);
loadDepartments(); loadDepartments();
} else { } else {
alert(response.error || "부서 삭제에 실패했습니다."); alert(response.error || "부서 삭제에 실패했습니다.");
@ -196,7 +217,7 @@ export function DepartmentStructure({
className="h-6 w-6 text-destructive" className="h-6 w-6 text-destructive"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteDepartment(dept.dept_code); handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}} }}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@ -269,6 +290,62 @@ export function DepartmentStructure({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 중복 알림 모달 */}
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(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">{duplicateMessage}</p>
</div>
<DialogFooter>
<Button
onClick={() => setDuplicateMessage(null)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 부서 삭제 확인 모달 */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<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">
<span className="font-semibold">{deptToDelete?.name}</span> ?
</p>
<p className="mt-2 text-xs text-muted-foreground">
.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setDeleteConfirmOpen(false);
setDeptToDelete(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteDepartmentConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -44,7 +44,12 @@ export async function createDepartment(companyCode: string, data: DepartmentForm
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("부서 생성 실패:", error); 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) { export async function addDepartmentMember(deptCode: string, userId: string) {
try { 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, user_id: userId,
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("부서원 추가 실패:", error); 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,
};
} }
} }