ERP-node/frontend/components/admin/department/DepartmentMembers.tsx

448 lines
15 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Plus, X, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import type { Department, DepartmentMember } from "@/types/department";
import * as departmentAPI from "@/lib/api/department";
interface DepartmentMembersProps {
companyCode: string;
selectedDepartment: Department | null;
onMemberChange?: () => void;
}
/**
* 부서 인원 관리 컴포넌트
*/
export function DepartmentMembers({
companyCode,
selectedDepartment,
onMemberChange,
}: DepartmentMembersProps) {
const { toast } = useToast();
const [members, setMembers] = useState<DepartmentMember[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
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(() => {
if (selectedDepartment) {
loadMembers();
}
}, [selectedDepartment]);
const loadMembers = async () => {
if (!selectedDepartment) return;
setIsLoading(true);
try {
const response = await departmentAPI.getDepartmentMembers(selectedDepartment.dept_code);
if (response.success && response.data) {
setMembers(response.data);
} else {
console.error("부서원 목록 로드 실패:", response.error);
setMembers([]);
}
} catch (error) {
console.error("부서원 목록 로드 실패:", error);
setMembers([]);
} finally {
setIsLoading(false);
}
};
// 사용자 검색
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 (userId: string) => {
if (!selectedDepartment) return;
try {
const response = await departmentAPI.addDepartmentMember(
selectedDepartment.dept_code,
userId
);
if (response.success) {
setIsAddModalOpen(false);
setSearchQuery("");
setSearchResults([]);
loadMembers();
onMemberChange?.(); // 부서 구조 새로고침
// 성공 Toast 표시
toast({
title: "부서원 추가 완료",
description: "부서원이 추가되었습니다.",
variant: "default",
});
} else {
if ((response as any).isDuplicate) {
setDuplicateMessage(response.error || "이미 해당 부서의 부서원입니다.");
} else {
toast({
title: "부서원 추가 실패",
description: response.error || "부서원 추가에 실패했습니다.",
variant: "destructive",
});
}
}
} catch (error) {
console.error("부서원 추가 실패:", error);
toast({
title: "부서원 추가 실패",
description: "부서원 추가 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서원 제거 확인 요청
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,
memberToRemove.userId
);
if (response.success) {
setRemoveConfirmOpen(false);
setMemberToRemove(null);
loadMembers();
onMemberChange?.(); // 부서 구조 새로고침
// 성공 Toast 표시
toast({
title: "부서원 제거 완료",
description: `${memberToRemove.name} 님이 부서에서 제외되었습니다.`,
variant: "default",
});
} else {
toast({
title: "부서원 제거 실패",
description: response.error || "부서원 제거에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("부서원 제거 실패:", error);
toast({
title: "부서원 제거 실패",
description: "부서원 제거 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 주 부서 설정
const handleSetPrimaryDepartment = async (userId: string) => {
if (!selectedDepartment) return;
try {
const response = await departmentAPI.setPrimaryDepartment(
selectedDepartment.dept_code,
userId
);
if (response.success) {
loadMembers();
// 성공 Toast 표시
toast({
title: "주 부서 설정 완료",
description: "주 부서가 변경되었습니다.",
variant: "default",
});
} else {
toast({
title: "주 부서 설정 실패",
description: response.error || "주 부서 설정에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("주 부서 설정 실패:", error);
toast({
title: "주 부서 설정 실패",
description: "주 부서 설정 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
if (!selectedDepartment) {
return (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card p-8 shadow-sm">
<p className="text-sm text-muted-foreground"> </p>
</div>
);
}
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{selectedDepartment.dept_name}</h3>
<p className="text-sm text-muted-foreground"> {members.length}</p>
</div>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => setIsAddModalOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 부서원 목록 */}
<div className="space-y-2 rounded-lg border bg-card p-4 shadow-sm">
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground"> ...</div>
) : members.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
. .
</div>
) : (
<div className="space-y-2">
{members.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{member.user_name}</span>
<span className="text-sm text-muted-foreground">({member.user_id})</span>
{member.is_primary && (
<Badge variant="default" className="h-5 gap-1 text-xs">
<Star className="h-3 w-3" />
</Badge>
)}
</div>
<div className="mt-1 flex gap-4 text-xs text-muted-foreground">
{member.position_name && <span>: {member.position_name}</span>}
{member.email && <span>: {member.email}</span>}
{member.phone && <span>: {member.phone}</span>}
{member.cell_phone && <span>: {member.cell_phone}</span>}
</div>
</div>
<div className="flex gap-2">
{!member.is_primary && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={() => handleSetPrimaryDepartment(member.user_id)}
>
<Star className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemoveMemberRequest(member.user_id, member.user_name)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 부서원 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 검색 입력 */}
<div className="space-y-2">
<Label htmlFor="search" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<Input
id="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
placeholder="이름 또는 ID로 검색"
className="h-8 text-xs sm:h-10 sm:text-sm"
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>
</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>
<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>
<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
variant="destructive"
onClick={handleRemoveMemberConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}