Files
Groupware/Project/frontend/src/pages/UserList.tsx
backuppc c9b5d756e1 feat: React 프론트엔드 기능 대폭 확장
- 월별근무표: 휴일/근무일 관리, 자동 초기화
- 메일양식: 템플릿 CRUD, To/CC/BCC 설정
- 그룹정보: 부서 관리, 비트 연산 기반 권한 설정
- 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정
- 웹소켓 메시지 type 충돌 버그 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 17:25:31 +09:00

556 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { Search, RefreshCw, Users, Check, X, User, Save } from 'lucide-react';
import { clsx } from 'clsx';
import { comms } from '@/communication';
import { GroupUser, UserLevelInfo, UserFullData } from '@/types';
// 사용자 상세 다이얼로그 Props
interface UserDetailDialogProps {
user: GroupUser;
levelInfo: UserLevelInfo | null;
onClose: () => void;
onSave: () => void;
}
// 사용자 상세 다이얼로그 컴포넌트
function UserDetailDialog({ user, levelInfo, onClose, onSave }: UserDetailDialogProps) {
const [formData, setFormData] = useState<UserFullData>({
id: user.id,
name: user.name || '',
nameE: user.nameE || '',
grade: user.grade || '',
email: user.email || '',
tel: user.tel || '',
hp: user.hp || '',
indate: user.indate || '',
outdate: user.outdate || '',
memo: user.memo || '',
processs: user.processs || '',
state: user.state || '',
level: user.level || 1,
useUserState: user.useUserState || false,
useJobReport: user.useJobReport || false,
exceptHoly: user.exceptHoly || false,
});
const [saving, setSaving] = useState(false);
// 편집 가능 여부: 관리자(level >= 5) 또는 본인
const isSelf = levelInfo?.CurrentUserId === user.id;
const canEdit = levelInfo?.CanEdit || isSelf;
const canEditAdmin = levelInfo?.CanEdit || false; // 관리자 전용 필드
const handleChange = (field: keyof UserFullData, value: string | number | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!canEdit) return;
setSaving(true);
try {
const result = await comms.saveUserFull(formData);
if (result.Success) {
onSave();
onClose();
} else {
alert(result.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
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-2xl max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-white/70" />
<h2 className="text-lg font-semibold text-white">
{canEdit ? '' : '(읽기 전용)'}
</h2>
</div>
<button
onClick={onClose}
className="text-white/60 hover:text-white transition-colors text-xl"
>
×
</button>
</div>
{/* 내용 */}
<div className="p-4 overflow-auto max-h-[calc(90vh-120px)]">
<div className="grid grid-cols-3 gap-4">
{/* 사번 (읽기 전용) */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.id}
disabled
className="w-full px-3 py-2 bg-white/5 border border-white/20 rounded-lg text-white/50"
/>
</div>
{/* 성명 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 영문명 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.nameE}
onChange={(e) => handleChange('nameE', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 직책 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.grade}
onChange={(e) => handleChange('grade', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 공정 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.processs}
onChange={(e) => handleChange('processs', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 상태 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 이메일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 전화 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.tel}
onChange={(e) => handleChange('tel', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 입사일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.indate}
onChange={(e) => handleChange('indate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 퇴사일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.outdate}
onChange={(e) => handleChange('outdate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 레벨 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<select
value={formData.level}
onChange={(e) => handleChange('level', parseInt(e.target.value) || 0)}
disabled={!canEditAdmin}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEditAdmin ? "bg-white/10" : "bg-white/5 text-white/50"
)}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((lv) => (
<option key={lv} value={lv} className="bg-gray-800 text-white">
{lv}
</option>
))}
</select>
</div>
{/* 메모 */}
<div className="col-span-3">
<label className="block text-sm text-white/70 mb-1"></label>
<textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
disabled={!canEdit}
rows={2}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white resize-none",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 관리자 전용 설정 */}
<div className="col-span-3 border-t border-white/10 pt-4 mt-2">
<h3 className="text-sm font-medium text-white/80 mb-3">
{!canEditAdmin && '(관리자만 수정 가능)'}
</h3>
<div className="flex items-center gap-6">
{/* 계정 사용 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.useUserState}
onChange={(e) => handleChange('useUserState', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
{/* 일지 사용 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.useJobReport}
onChange={(e) => handleChange('useJobReport', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
{/* 휴가 제외 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.exceptHoly}
onChange={(e) => handleChange('exceptHoly', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="p-4 border-t border-white/10 flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
>
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-white transition-colors"
>
<Save className="w-4 h-4" />
{saving ? '저장 중...' : '저장'}
</button>
)}
</div>
</div>
</div>
);
}
export function UserListPage() {
const [users, setUsers] = useState<GroupUser[]>([]);
const [process, setProcess] = useState('%');
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState('');
const [levelInfo, setLevelInfo] = useState<UserLevelInfo | null>(null);
const [selectedUser, setSelectedUser] = useState<GroupUser | null>(null);
useEffect(() => {
loadLevelInfo();
loadUsers();
}, []);
const loadLevelInfo = async () => {
try {
const result = await comms.getCurrentUserLevel();
if (result.Success && result.Data) {
setLevelInfo(result.Data);
}
} catch (error) {
console.error('권한 정보 로드 실패:', error);
}
};
const loadUsers = async () => {
setLoading(true);
try {
const result = await comms.getUserList(process);
if (Array.isArray(result)) {
setUsers(result);
} else if (result && typeof result === 'object') {
const r = result as { Success?: boolean; Message?: string };
if (r.Success === false) {
console.error('사용자 목록 조회 실패:', r.Message);
}
setUsers([]);
} else {
console.error('사용자 목록 응답이 배열이 아님:', result);
setUsers([]);
}
} catch (error) {
console.error('사용자 목록 로드 실패:', error);
setUsers([]);
} finally {
setLoading(false);
}
};
const handleRefresh = () => {
loadUsers();
};
const handleRowClick = (user: GroupUser) => {
setSelectedUser(user);
};
const filteredUsers = users.filter(
(u) =>
(u.id ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(u.name ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(u.email ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(u.tel ?? '').toLowerCase().includes(filter.toLowerCase())
);
return (
<div className="h-full flex flex-col">
{/* 헤더 */}
<div className="glass-effect rounded-xl p-4 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-white/70"></label>
<input
type="text"
value={process}
onChange={(e) => setProcess(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRefresh()}
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white w-24 text-center"
/>
</div>
<button
onClick={handleRefresh}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="검색..."
className="pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-48"
/>
</div>
</div>
</div>
{/* 테이블 */}
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
<div className="p-4 border-b border-white/10 flex items-center gap-2">
<Users className="w-5 h-5 text-white/70" />
<h2 className="text-lg font-semibold text-white"> </h2>
<span className="text-sm text-white/50">({filteredUsers.length})</span>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-white/5 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th>
<th className="px-3 py-2 text-left font-medium text-white/70"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-28"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-12">Lv</th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-16"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredUsers.map((user) => (
<tr
key={user.id}
onClick={() => handleRowClick(user)}
className={clsx(
'hover:bg-white/5 transition-colors cursor-pointer',
!user.useUserState && 'opacity-50'
)}
>
<td className="px-3 py-2 text-white font-mono">{user.id}</td>
<td className="px-3 py-2 text-white font-medium">{user.name}</td>
<td className="px-3 py-2 text-white/70">{user.grade}</td>
<td className="px-3 py-2">
{user.email ? (
<a
href={`mailto:${user.email}`}
className="text-blue-400 hover:text-blue-300 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{user.email}
</a>
) : (
<span className="text-white/70">-</span>
)}
</td>
<td className="px-3 py-2 text-white/70">{user.tel}</td>
<td className="px-3 py-2 text-white/70">{user.processs}</td>
<td className="px-3 py-2 text-white/70">{user.state}</td>
<td className="px-3 py-2 text-white text-center">{user.level}</td>
<td className="px-3 py-2 text-center">
{user.useUserState ? (
<Check className="w-4 h-4 text-green-400 mx-auto" />
) : (
<X className="w-4 h-4 text-red-400 mx-auto" />
)}
</td>
<td className="px-3 py-2 text-center">
{user.useJobReport ? (
<Check className="w-4 h-4 text-green-400 mx-auto" />
) : (
<X className="w-4 h-4 text-red-400 mx-auto" />
)}
</td>
</tr>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-white/50">
{users.length === 0 ? '공정을 입력하고 새로고침하세요.' : '검색 결과가 없습니다.'}
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
{/* 사용자 상세 다이얼로그 */}
{selectedUser && (
<UserDetailDialog
user={selectedUser}
levelInfo={levelInfo}
onClose={() => setSelectedUser(null)}
onSave={() => loadUsers()}
/>
)}
</div>
);
}