feat: 품목정보 상세 패널 추가 및 프로젝트/근태/권한 기능 확장
- Items: 우측에 이미지, 담당자, 입고/발주내역 패널 추가 (fItems 윈폼 동일) - Project: 목록 및 상세 다이얼로그 구현 - Kuntae: 오류검사/수정 기능 추가 - UserAuth: 사용자 권한 관리 페이지 추가 - UserGroup: 그룹정보 다이얼로그로 전환 - Header: 사용자 메뉴 서브메뉴 방향 수정, 즐겨찾기 기능 - Backend API: Items 상세/담당자/구매내역, 근태 오류검사, 프로젝트 목록 등 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
530
Project/frontend/src/components/user/UserGroupDialog.tsx
Normal file
530
Project/frontend/src/components/user/UserGroupDialog.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { UserGroupItem, PermissionInfo } from '@/types';
|
||||
|
||||
interface UserGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const initialFormData: Partial<UserGroupItem> = {
|
||||
dept: '',
|
||||
path_kj: '',
|
||||
permission: 0,
|
||||
advpurchase: false,
|
||||
advkisul: false,
|
||||
managerinfo: '',
|
||||
devinfo: '',
|
||||
usemail: false,
|
||||
};
|
||||
|
||||
// 비트 연산 헬퍼 함수
|
||||
const getBit = (value: number, index: number): boolean => {
|
||||
return ((value >> index) & 1) === 1;
|
||||
};
|
||||
|
||||
const setBit = (value: number, index: number, flag: boolean): number => {
|
||||
if (flag) {
|
||||
return value | (1 << index);
|
||||
} else {
|
||||
return value & ~(1 << index);
|
||||
}
|
||||
};
|
||||
|
||||
export function UserGroupDialog({ isOpen, onClose }: UserGroupDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState<UserGroupItem[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showPermissionModal, setShowPermissionModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<UserGroupItem | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<UserGroupItem>>(initialFormData);
|
||||
const [permissionInfo, setPermissionInfo] = useState<PermissionInfo[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [groupsRes, permRes] = await Promise.all([
|
||||
comms.getUserGroupList(),
|
||||
comms.getPermissionInfo()
|
||||
]);
|
||||
|
||||
if (groupsRes.Success && groupsRes.Data) {
|
||||
setGroups(groupsRes.Data);
|
||||
}
|
||||
if (permRes.Success && permRes.Data) {
|
||||
setPermissionInfo(permRes.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, loadData]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const filteredItems = groups.filter(item =>
|
||||
!searchKey ||
|
||||
item.dept?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.managerinfo?.toLowerCase().includes(searchKey.toLowerCase())
|
||||
);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item: UserGroupItem) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
dept: item.dept || '',
|
||||
path_kj: item.path_kj || '',
|
||||
permission: item.permission || 0,
|
||||
advpurchase: item.advpurchase || false,
|
||||
advkisul: item.advkisul || false,
|
||||
managerinfo: item.managerinfo || '',
|
||||
devinfo: item.devinfo || '',
|
||||
usemail: item.usemail || false,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openPermissionModal = (item: UserGroupItem) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...formData,
|
||||
dept: item.dept,
|
||||
permission: item.permission || 0,
|
||||
});
|
||||
setShowPermissionModal(true);
|
||||
};
|
||||
|
||||
const handlePermissionChange = (index: number, checked: boolean) => {
|
||||
const newPermission = setBit(formData.permission || 0, index, checked);
|
||||
setFormData({ ...formData, permission: newPermission });
|
||||
};
|
||||
|
||||
const handleSavePermission = async () => {
|
||||
if (!editingItem) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await comms.editUserGroup(
|
||||
editingItem.dept,
|
||||
editingItem.dept,
|
||||
editingItem.path_kj || '',
|
||||
formData.permission || 0,
|
||||
editingItem.advpurchase || false,
|
||||
editingItem.advkisul || false,
|
||||
editingItem.managerinfo || '',
|
||||
editingItem.devinfo || '',
|
||||
editingItem.usemail || false
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
setShowPermissionModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.dept?.trim()) {
|
||||
alert('부서명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingItem) {
|
||||
response = await comms.editUserGroup(
|
||||
editingItem.dept,
|
||||
formData.dept || '',
|
||||
formData.path_kj || '',
|
||||
formData.permission || 0,
|
||||
formData.advpurchase || false,
|
||||
formData.advkisul || false,
|
||||
formData.managerinfo || '',
|
||||
formData.devinfo || '',
|
||||
formData.usemail || false
|
||||
);
|
||||
} else {
|
||||
response = await comms.addUserGroup(
|
||||
formData.dept || '',
|
||||
formData.path_kj || '',
|
||||
formData.permission || 0,
|
||||
formData.advpurchase || false,
|
||||
formData.advkisul || false,
|
||||
formData.managerinfo || '',
|
||||
formData.devinfo || '',
|
||||
formData.usemail || false
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: UserGroupItem) => {
|
||||
if (!confirm(`"${item.dept}" 그룹을 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const response = await comms.deleteUserGroup(item.dept);
|
||||
if (response.Success) {
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionCount = (permission: number): number => {
|
||||
let count = 0;
|
||||
for (let i = 0; i < 11; i++) {
|
||||
if (getBit(permission, i)) count++;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-gray-900/95 border border-white/10 rounded-2xl shadow-2xl w-[1000px] max-h-[85vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||
<Users className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">그룹정보</h2>
|
||||
<p className="text-white/50 text-sm">부서/그룹 및 권한 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="부서명 검색..."
|
||||
className="pl-10 pr-4 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 w-40"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>새 그룹</span>
|
||||
</button>
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors ml-2">
|
||||
<X className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-white/50">
|
||||
<Users className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>등록된 그룹이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">부서명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">경로</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-20">권한</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">구매</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">기술</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">메일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">관리자정보</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-24">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item, index) => (
|
||||
<tr key={`${item.dept}-${index}`} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-2 text-white font-medium">{item.dept}</td>
|
||||
<td className="px-4 py-2 text-white/70 text-xs">{item.path_kj || '-'}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={() => openPermissionModal(item)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>{getPermissionCount(item.permission || 0)}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{item.advpurchase && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{item.advkisul && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{item.usemail && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white/70 text-xs truncate max-w-40">{item.managerinfo || '-'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => openEditModal(item)}
|
||||
className="p-1.5 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="p-1.5 hover:bg-danger-500/20 rounded text-white/70 hover:text-danger-400 transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-white/10 shrink-0 bg-white/5">
|
||||
<span className="text-sm text-white/50">{filteredItems.length}개 그룹</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 편집 모달 */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-gray-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{editingItem ? '그룹 수정' : '새 그룹'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">부서명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dept || ''}
|
||||
onChange={(e) => setFormData({ ...formData, dept: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">경로 (path_kj)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.path_kj || ''}
|
||||
onChange={(e) => setFormData({ ...formData, path_kj: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<label className="flex items-center space-x-2 text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.advpurchase || false}
|
||||
onChange={(e) => setFormData({ ...formData, advpurchase: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span>구매고급</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.advkisul || false}
|
||||
onChange={(e) => setFormData({ ...formData, advkisul: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span>기술고급</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.usemail || false}
|
||||
onChange={(e) => setFormData({ ...formData, usemail: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span>메일 사용</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">관리자 정보</label>
|
||||
<textarea
|
||||
value={formData.managerinfo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, managerinfo: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">개발자 정보</label>
|
||||
<textarea
|
||||
value={formData.devinfo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, devinfo: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 권한 설정 모달 */}
|
||||
{showPermissionModal && editingItem && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-gray-800 rounded-2xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">권한 설정</h2>
|
||||
<p className="text-white/60 text-sm">{editingItem.dept}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPermissionModal(false)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{permissionInfo.map((perm) => (
|
||||
<label
|
||||
key={perm.index}
|
||||
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-white/5 cursor-pointer"
|
||||
title={perm.description}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={getBit(formData.permission || 0, perm.index)}
|
||||
onChange={(e) => handlePermissionChange(perm.index, e.target.checked)}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-white/80 text-sm">{perm.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setShowPermissionModal(false)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePermission}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,17 @@ function PasswordDialog({ isOpen, onClose, onConfirm }: PasswordDialogProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!newPassword) {
|
||||
setError('새 비밀번호를 입력하세요.');
|
||||
@@ -141,6 +152,17 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
||||
}
|
||||
}, [isOpen, userId]);
|
||||
|
||||
// ESC 키로 닫기 (비밀번호 다이얼로그가 열려있지 않을 때만)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !showPasswordDialog) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, showPasswordDialog]);
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
212
Project/frontend/src/components/user/UserSearchDialog.tsx
Normal file
212
Project/frontend/src/components/user/UserSearchDialog.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, X, Users, Check } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { GroupUser } from '@/types';
|
||||
|
||||
interface UserSearchDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (user: GroupUser) => void;
|
||||
title?: string;
|
||||
excludeUsers?: string[]; // 제외할 사용자 ID 목록
|
||||
initialSearchKey?: string; // 초기 검색어
|
||||
}
|
||||
|
||||
export function UserSearchDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
title = '사용자 검색',
|
||||
excludeUsers = [],
|
||||
initialSearchKey = '',
|
||||
}: UserSearchDialogProps) {
|
||||
const [users, setUsers] = useState<GroupUser[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<GroupUser[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<GroupUser | null>(null);
|
||||
|
||||
// 사용자 목록 로드
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getUserList('%');
|
||||
if (Array.isArray(result)) {
|
||||
// 제외 목록에 없고, 계정 사용 중이고, 퇴사하지 않은 사용자만 표시
|
||||
const filtered = result.filter(
|
||||
(u: GroupUser) =>
|
||||
u.useUserState &&
|
||||
!excludeUsers.includes(u.id) &&
|
||||
!u.outdate // 퇴사일이 없는 사용자만 (재직 중)
|
||||
);
|
||||
setUsers(filtered);
|
||||
setFilteredUsers(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [excludeUsers]);
|
||||
|
||||
// 다이얼로그 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadUsers();
|
||||
setSearchKey(initialSearchKey); // 초기 검색어 설정
|
||||
setSelectedUser(null);
|
||||
}
|
||||
}, [isOpen, loadUsers, initialSearchKey]);
|
||||
|
||||
// 검색 필터링
|
||||
useEffect(() => {
|
||||
if (!searchKey.trim()) {
|
||||
setFilteredUsers(users);
|
||||
} else {
|
||||
const key = searchKey.toLowerCase();
|
||||
setFilteredUsers(
|
||||
users.filter(
|
||||
(u) =>
|
||||
u.id.toLowerCase().includes(key) ||
|
||||
(u.name || '').toLowerCase().includes(key) ||
|
||||
(u.email || '').toLowerCase().includes(key)
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [searchKey, users]);
|
||||
|
||||
// 선택 확정
|
||||
const handleConfirm = () => {
|
||||
if (selectedUser) {
|
||||
onSelect(selectedUser);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-effect rounded-xl w-full max-w-lg max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="p-4 border-b border-white/10 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="사번, 이름, 이메일로 검색..."
|
||||
autoFocus
|
||||
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-400 mx-auto mb-2"></div>
|
||||
<p className="text-white/50">로딩 중...</p>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/50">
|
||||
{searchKey ? '검색 결과가 없습니다' : '사용자가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredUsers.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => setSelectedUser(user)}
|
||||
onDoubleClick={() => {
|
||||
setSelectedUser(user);
|
||||
onSelect(user);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||
selectedUser?.id === user.id
|
||||
? 'bg-primary-500/30 border border-primary-400/50'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-white/80 text-sm">{user.id}</span>
|
||||
<span className="font-medium text-white">{user.name}</span>
|
||||
{user.grade && (
|
||||
<span className="text-xs text-white/50 bg-white/10 px-1.5 py-0.5 rounded">
|
||||
{user.grade}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-white/50 mt-0.5 truncate">
|
||||
{user.email || '-'} {user.processs && `| ${user.processs}`}
|
||||
</div>
|
||||
</div>
|
||||
{selectedUser?.id === user.id && (
|
||||
<Check className="w-5 h-5 text-primary-400 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-white/10 flex items-center justify-between shrink-0">
|
||||
<span className="text-sm text-white/50">
|
||||
{filteredUsers.length}명 {selectedUser && `| 선택: ${selectedUser.id} (${selectedUser.name})`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedUser}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white transition-colors"
|
||||
>
|
||||
선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { UserInfoDialog } from './UserInfoDialog';
|
||||
export { UserSearchDialog } from './UserSearchDialog';
|
||||
|
||||
Reference in New Issue
Block a user