448 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|