feat: React 프론트엔드 기능 대폭 확장

- 월별근무표: 휴일/근무일 관리, 자동 초기화
- 메일양식: 템플릿 CRUD, To/CC/BCC 설정
- 그룹정보: 부서 관리, 비트 연산 기반 권한 설정
- 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정
- 웹소켓 메시지 type 충돌 버그 수정

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
backuppc
2025-11-27 17:25:31 +09:00
parent b57af6dad7
commit c9b5d756e1
65 changed files with 14028 additions and 467 deletions

View File

@@ -0,0 +1,603 @@
import { useState, useEffect, useCallback } from 'react';
import {
FileText,
Search,
RefreshCw,
Copy,
Plus,
} from 'lucide-react';
import { comms } from '@/communication';
import { JobReportItem, JobReportUser } from '@/types';
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
export function Jobreport() {
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
const [users, setUsers] = useState<JobReportUser[]>([]);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
// 검색 조건
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedUser, setSelectedUser] = useState('');
const [searchKey, setSearchKey] = useState('');
// 모달 상태
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<JobReportItem | null>(null);
const [formData, setFormData] = useState<JobreportFormData>(initialFormData);
// 페이징 상태
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 15;
// 권한 상태
const [canViewOT, setCanViewOT] = useState(false);
// 오늘 근무시간 상태
const [todayWork, setTodayWork] = useState({ hrs: 0, ot: 0 });
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
const formatDateLocal = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
// 오늘 근무시간 로드
const loadTodayWork = useCallback(async (userId: string) => {
const todayStr = formatDateLocal(new Date());
try {
const response = await comms.getJobReportList(todayStr, todayStr, userId, '');
if (response.Success && response.Data) {
// 웹소켓 모드에서 응답 혼선 방지를 위해 오늘 날짜 데이터만 필터링
const todayData = response.Data.filter(item => {
const itemDate = item.pdate?.substring(0, 10);
return itemDate === todayStr;
});
const work = todayData.reduce((acc, item) => ({
hrs: acc.hrs + (item.hrs || 0),
ot: acc.ot + (item.ot || 0)
}), { hrs: 0, ot: 0 });
setTodayWork(work);
}
} catch (error) {
console.error('오늘 근무시간 로드 오류:', error);
}
}, []);
// 초기화 완료 플래그
const [initialized, setInitialized] = useState(false);
// 날짜 및 사용자 정보 초기화
useEffect(() => {
const initialize = async () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const sd = formatDateLocal(startOfMonth);
const ed = formatDateLocal(endOfMonth);
setStartDate(sd);
setEndDate(ed);
// 현재 로그인 사용자 정보 로드
let userId = '';
try {
const loginStatus = await comms.checkLoginStatus();
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
userId = loginStatus.User.Id;
setSelectedUser(userId);
}
} catch (error) {
console.error('로그인 정보 로드 오류:', error);
}
// 사용자 목록 로드
loadUsers();
// 권한 로드 (본인 조회이므로 canViewOT = true)
try {
const perm = await comms.getJobReportPermission(userId);
if (perm.Success) {
setCanViewOT(perm.CanViewOT);
}
} catch (error) {
console.error('권한 정보 로드 오류:', error);
}
// 초기화 완료 표시
setInitialized(true);
};
initialize();
}, []);
// 초기화 완료 후 조회 실행
useEffect(() => {
if (initialized && startDate && endDate && selectedUser) {
handleSearchAndLoadToday();
}
}, [initialized, startDate, endDate, selectedUser]);
// 검색 + 오늘 근무시간 로드 (순차 실행)
const handleSearchAndLoadToday = async () => {
await handleSearch();
loadTodayWork(selectedUser);
};
// 사용자 목록 로드
const loadUsers = async () => {
try {
const result = await comms.getJobReportUsers();
setUsers(result || []);
} catch (error) {
console.error('사용자 목록 로드 오류:', error);
}
};
// 데이터 로드
const loadData = useCallback(async () => {
if (!startDate || !endDate) return;
setLoading(true);
try {
const response = await comms.getJobReportList(startDate, endDate, selectedUser, searchKey);
if (response.Success && response.Data) {
setJobreportList(response.Data);
} else {
setJobreportList([]);
}
} catch (error) {
console.error('업무일지 목록 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, [startDate, endDate, selectedUser, searchKey]);
// 검색
const handleSearch = async () => {
if (new Date(startDate) > new Date(endDate)) {
alert('시작일은 종료일보다 늦을 수 없습니다.');
return;
}
// 선택된 담당자에 따라 권한 재확인
try {
const perm = await comms.getJobReportPermission(selectedUser);
if (perm.Success) {
setCanViewOT(perm.CanViewOT);
}
} catch (error) {
console.error('권한 정보 로드 오류:', error);
}
await loadData();
};
// 새 업무일지 추가 모달
const openAddModal = () => {
setEditingItem(null);
setFormData(initialFormData);
setShowModal(true);
};
// 복사하여 새 업무일지 생성 모달
const openCopyModal = async (item: JobReportItem, e: React.MouseEvent) => {
e.stopPropagation(); // 행 클릭 이벤트 방지
try {
const response = await comms.getJobReportDetail(item.idx);
if (response.Success && response.Data) {
const data = response.Data;
setEditingItem(null); // 새로 추가하는 것이므로 null
setFormData({
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
projectName: data.projectName || '',
requestpart: data.requestpart || '',
package: data.package || '',
type: data.type || '',
process: data.process || '',
status: data.status || '진행 완료',
description: data.description || '',
hrs: 0, // 시간 초기화
ot: 0, // OT 초기화
jobgrp: '',
tag: '',
});
setShowModal(true);
}
} catch (error) {
console.error('업무일지 조회 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
}
};
// 편집 모달
const openEditModal = async (item: JobReportItem) => {
try {
const response = await comms.getJobReportDetail(item.idx);
if (response.Success && response.Data) {
const data = response.Data;
setEditingItem(data);
setFormData({
pdate: data.pdate ? data.pdate.split('T')[0] : '',
projectName: data.projectName || '',
requestpart: data.requestpart || '',
package: data.package || '',
type: data.type || '',
process: data.process || '',
status: data.status || '진행 완료',
description: data.description || '',
hrs: data.hrs || 0,
ot: data.ot || 0,
jobgrp: '', // 뷰에 없는 필드
tag: '', // 뷰에 없는 필드
});
setShowModal(true);
}
} catch (error) {
console.error('업무일지 조회 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
}
};
// 저장
const handleSave = async () => {
if (!formData.pdate) {
alert('날짜를 입력해주세요.');
return;
}
if (!formData.projectName.trim()) {
alert('프로젝트명을 입력해주세요.');
return;
}
setProcessing(true);
try {
let response;
if (editingItem) {
const itemIdx = editingItem.idx ?? (editingItem as unknown as Record<string, unknown>)['Idx'] as number;
if (!itemIdx) {
alert('수정할 항목의 ID를 찾을 수 없습니다.');
setProcessing(false);
return;
}
response = await comms.editJobReport(
itemIdx,
formData.pdate || '',
formData.projectName || '',
formData.requestpart || '',
formData.package || '',
formData.type || '',
formData.process || '',
formData.status || '진행 완료',
formData.description || '',
formData.hrs || 0,
formData.ot || 0,
formData.jobgrp || '',
formData.tag || ''
);
} else {
response = await comms.addJobReport(
formData.pdate || '',
formData.projectName || '',
formData.requestpart || '',
formData.package || '',
formData.type || '',
formData.process || '',
formData.status || '진행 완료',
formData.description || '',
formData.hrs || 0,
formData.ot || 0,
formData.jobgrp || '',
formData.tag || ''
);
}
if (response.Success) {
setShowModal(false);
loadData();
loadTodayWork(selectedUser);
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
} finally {
setProcessing(false);
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm('정말로 이 업무일지를 삭제하시겠습니까?')) return;
setProcessing(true);
try {
const response = await comms.deleteJobReport(id);
if (response.Success) {
alert('삭제되었습니다.');
loadData();
loadTodayWork(selectedUser);
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
// 날짜 포맷 (YY.MM.DD)
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
const yy = String(date.getFullYear()).slice(-2);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yy}.${mm}.${dd}`;
} catch {
return dateStr;
}
};
// 페이징 계산
const totalPages = Math.ceil(jobreportList.length / pageSize);
const paginatedList = jobreportList.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
// 검색 시 페이지 초기화
const handleSearchWithReset = () => {
setCurrentPage(1);
handleSearch();
};
return (
<div className="space-y-6 animate-fade-in">
{/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex gap-6">
{/* 좌측: 필터 영역 */}
<div className="flex-1">
<div className="flex items-start gap-3">
{/* 필터 입력 영역: 2행 2열 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{/* 1행: 시작일, 담당자 */}
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<option value="" className="bg-gray-800"></option>
{users.map((user) => (
<option key={user.id} value={user.id} className="bg-gray-800">
{user.name}({user.id})
</option>
))}
</select>
</div>
{/* 2행: 종료일, 검색어 */}
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
placeholder="프로젝트, 내용 등"
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
{/* 버튼 영역: 우측 수직 배치 */}
<div className="grid grid-rows-2 gap-y-3">
<button
onClick={handleSearchWithReset}
disabled={loading}
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
</button>
<button
onClick={openAddModal}
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
>
<Plus className="w-4 h-4 mr-2" />
</button>
</div>
</div>
</div>
{/* 우측: 오늘 근무시간 */}
<div className="flex-shrink-0 w-48">
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center">
<div className="text-white/70 text-sm font-medium mb-2 text-center"> </div>
<div className="text-center">
<span className="text-3xl font-bold text-white">{todayWork.hrs}</span>
<span className="text-white/70 text-lg ml-1"></span>
</div>
{todayWork.ot > 0 && (
<div className="text-center mt-1">
<span className="text-warning-400 text-sm">OT: {todayWork.ot}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white flex items-center">
<FileText className="w-5 h-5 mr-2" />
</h3>
<span className="text-white/60 text-sm">{jobreportList.length}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-2 py-3 text-center text-xs font-medium text-white/70 uppercase w-10"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
{canViewOT && <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>}
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td colSpan={canViewOT ? 8 : 7} className="px-4 py-8 text-center">
<div className="flex items-center justify-center">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
<span className="text-white/50"> ...</span>
</div>
</td>
</tr>
) : jobreportList.length === 0 ? (
<tr>
<td colSpan={canViewOT ? 8 : 7} className="px-4 py-8 text-center text-white/50">
.
</td>
</tr>
) : (
paginatedList.map((item) => (
<tr
key={item.idx}
className={`hover:bg-white/5 transition-colors cursor-pointer ${item.type === '휴가' ? 'bg-gradient-to-r from-lime-400/30 via-emerald-400/20 to-teal-400/30' : ''}`}
onClick={() => openEditModal(item)}
>
<td className="px-2 py-3 text-center">
<button
onClick={(e) => openCopyModal(item, e)}
className="text-white/40 hover:text-primary-400 transition-colors"
title="복사하여 새로 작성"
>
<Copy className="w-4 h-4" />
</button>
</td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.pdate)}</td>
<td className={`px-4 py-3 text-sm font-medium max-w-xs truncate ${item.pidx && item.pidx > 0 ? 'text-white' : 'text-white/50'}`} title={item.projectName}>
{item.projectName || '-'}
</td>
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs ${
item.status?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70'
}`}>
{item.status || '-'}
</span>
</td>
<td className="px-4 py-3 text-white text-sm">
{item.hrs || 0}h
</td>
{canViewOT && (
<td className="px-4 py-3 text-white text-sm">
{item.ot ? <span className="text-warning-400">{item.ot}h</span> : '-'}
</td>
)}
<td className="px-4 py-3 text-white text-sm">{item.name || item.id || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 페이징 */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between">
<div className="text-white/50 text-sm">
{jobreportList.length} {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, jobreportList.length)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
«
</button>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<span className="text-white/70 px-3">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
»
</button>
</div>
</div>
)}
</div>
{/* 추가/수정 모달 */}
<JobreportEditModal
isOpen={showModal}
editingItem={editingItem}
formData={formData}
processing={processing}
onClose={() => setShowModal(false)}
onFormChange={setFormData}
onSave={handleSave}
onDelete={(idx) => {
handleDelete(idx);
setShowModal(false);
}}
/>
</div>
);
}