867 lines
34 KiB
TypeScript
867 lines
34 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
FileText,
|
||
Search,
|
||
RefreshCw,
|
||
Copy,
|
||
Info,
|
||
Plus,
|
||
Calendar,
|
||
AlertTriangle,
|
||
X,
|
||
} from 'lucide-react';
|
||
import { comms } from '@/communication';
|
||
import { JobReportItem, JobReportUser } from '@/types';
|
||
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
|
||
import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog';
|
||
import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal';
|
||
|
||
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 [showDayReportModal, setShowDayReportModal] = useState(false);
|
||
const [showTypeReportModal, setShowTypeReportModal] = useState(false);
|
||
const [editingItem, setEditingItem] = useState<JobReportItem | null>(null);
|
||
const [formData, setFormData] = useState<JobreportFormData>(initialFormData);
|
||
|
||
// 페이징 상태
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const pageSize = 10;
|
||
|
||
// 권한 상태
|
||
const [canViewOT, setCanViewOT] = useState(false);
|
||
|
||
// 오늘 근무시간 상태
|
||
const [todayWork, setTodayWork] = useState({ hrs: 0, ot: 0 });
|
||
|
||
// 미등록 업무일지 상태
|
||
const [unregisteredJobReportCount, setUnregisteredJobReportCount] = useState(0);
|
||
const [unregisteredJobReportDays, setUnregisteredJobReportDays] = useState<{ date: string; hrs: number }[]>([]);
|
||
const [showUnregisteredModal, setShowUnregisteredModal] = useState(false);
|
||
|
||
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
|
||
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 loadUnregisteredJobReports = useCallback(async (userId: string) => {
|
||
try {
|
||
const now = new Date();
|
||
const todayStr = formatDateLocal(now);
|
||
|
||
// 15일 전 날짜 계산
|
||
const fifteenDaysAgoDate = new Date(now);
|
||
fifteenDaysAgoDate.setDate(now.getDate() - 15);
|
||
const fifteenDaysAgoStr = formatDateLocal(fifteenDaysAgoDate);
|
||
|
||
const response = await comms.getJobReportList(fifteenDaysAgoStr, todayStr, userId, '');
|
||
|
||
if (response.Success && response.Data) {
|
||
const dailyWork: { [key: string]: number } = {};
|
||
|
||
// 날짜별 시간 합계 계산
|
||
response.Data.forEach((item: JobReportItem) => {
|
||
if (item.pdate) {
|
||
const date = item.pdate.substring(0, 10);
|
||
dailyWork[date] = (dailyWork[date] || 0) + (item.hrs || 0);
|
||
}
|
||
});
|
||
|
||
const insufficientDays: { date: string; hrs: number }[] = [];
|
||
|
||
// 어제부터 15일 전까지 확인 (오늘은 제외)
|
||
for (let i = 1; i <= 15; i++) {
|
||
const d = new Date(now);
|
||
d.setDate(now.getDate() - i);
|
||
const dStr = formatDateLocal(d);
|
||
|
||
// 주말(토:6, 일:0) 제외
|
||
if (d.getDay() === 0 || d.getDay() === 6) continue;
|
||
|
||
const hrs = dailyWork[dStr] || 0;
|
||
if (hrs < 8) {
|
||
insufficientDays.push({ date: dStr, hrs });
|
||
}
|
||
}
|
||
|
||
setUnregisteredJobReportCount(insufficientDays.length);
|
||
setUnregisteredJobReportDays(insufficientDays);
|
||
}
|
||
} 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();
|
||
}, []);
|
||
|
||
// 초기화 완료 후 조회 실행 (최초 1회만)
|
||
useEffect(() => {
|
||
if (initialized && startDate && endDate && selectedUser) {
|
||
handleSearchAndLoadToday();
|
||
}
|
||
}, [initialized]); // startDate, endDate, selectedUser 의존성 제거 (날짜 변경 시 자동 조회 방지)
|
||
|
||
// 검색 + 오늘 근무시간 로드 (순차 실행)
|
||
const handleSearchAndLoadToday = async () => {
|
||
await handleSearch();
|
||
loadTodayWork(selectedUser);
|
||
loadUnregisteredJobReports(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,
|
||
pdate: formatDateLocal(new Date())
|
||
});
|
||
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: formatDateLocal(new Date()), // 오늘 날짜 (로컬 시간 기준)
|
||
projectName: data.projectName || '',
|
||
pidx: data.pidx ?? null, // pidx도 복사
|
||
requestpart: data.requestpart || '',
|
||
package: data.package || '',
|
||
type: data.type || '',
|
||
process: data.process || '',
|
||
status: data.status || '진행 완료',
|
||
description: data.description || '',
|
||
hrs: 0, // 시간 초기화
|
||
ot: 0, // OT 초기화
|
||
otStart: data.otStart ? data.otStart.substring(11, 16) : '18:00',
|
||
otEnd: data.otEnd ? data.otEnd.substring(11, 16) : '20:00',
|
||
jobgrp: data.jobgrp || '',
|
||
tag: data.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 || '',
|
||
pidx: data.pidx ?? null,
|
||
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,
|
||
otStart: data.otStart ? data.otStart.substring(11, 16) : '18:00',
|
||
otEnd: data.otEnd ? data.otEnd.substring(11, 16) : '20:00',
|
||
jobgrp: data.jobgrp || '',
|
||
tag: data.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.pidx,
|
||
formData.requestpart || '',
|
||
formData.package || '',
|
||
formData.type || '',
|
||
formData.process || '',
|
||
formData.status || '진행 완료',
|
||
formData.description || '',
|
||
formData.hrs || 0,
|
||
formData.ot || 0,
|
||
formData.jobgrp || '',
|
||
formData.tag || '',
|
||
formData.otStart || '18:00',
|
||
formData.otEnd || '20:00'
|
||
);
|
||
} else {
|
||
response = await comms.addJobReport(
|
||
formData.pdate || '',
|
||
formData.projectName || '',
|
||
formData.pidx,
|
||
formData.requestpart || '',
|
||
formData.package || '',
|
||
formData.type || '',
|
||
formData.process || '',
|
||
formData.status || '진행 완료',
|
||
formData.description || '',
|
||
formData.hrs || 0,
|
||
formData.ot || 0,
|
||
formData.jobgrp || '',
|
||
formData.tag || '',
|
||
formData.otStart || '18:00',
|
||
formData.otEnd || '20:00'
|
||
);
|
||
}
|
||
|
||
if (response.Success) {
|
||
setShowModal(false);
|
||
loadData();
|
||
loadTodayWork(selectedUser);
|
||
loadUnregisteredJobReports(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);
|
||
loadUnregisteredJobReports(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();
|
||
};
|
||
|
||
// 빠른 날짜 선택 함수들
|
||
const setToday = () => {
|
||
const today = new Date();
|
||
setStartDate(formatDateLocal(today));
|
||
};
|
||
|
||
const setThisMonth = () => {
|
||
const now = new Date();
|
||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||
setStartDate(formatDateLocal(startOfMonth));
|
||
setEndDate(formatDateLocal(endOfMonth));
|
||
};
|
||
|
||
const setYesterday = () => {
|
||
const yesterday = new Date();
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
setStartDate(formatDateLocal(yesterday));
|
||
};
|
||
|
||
const setLastMonth = () => {
|
||
const now = new Date();
|
||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
|
||
setStartDate(formatDateLocal(lastMonthStart));
|
||
setEndDate(formatDateLocal(lastMonthEnd));
|
||
};
|
||
|
||
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">
|
||
{/* 빠른 날짜 선택 버튼 - 2x2 그리드 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button
|
||
onClick={setToday}
|
||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||
title="오늘 날짜로 설정"
|
||
>
|
||
오늘
|
||
</button>
|
||
<button
|
||
onClick={setYesterday}
|
||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||
title="어제 날짜로 설정"
|
||
>
|
||
어제
|
||
</button>
|
||
<button
|
||
onClick={setThisMonth}
|
||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||
title="이번 달 1일부터 말일까지"
|
||
>
|
||
이번달
|
||
</button>
|
||
<button
|
||
onClick={setLastMonth}
|
||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||
title="저번달 1일부터 말일까지"
|
||
>
|
||
저번달
|
||
</button>
|
||
</div>
|
||
|
||
{/* 필터 입력 영역: 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="flex flex-col gap-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 flex flex-col gap-3 justify-center">
|
||
<button
|
||
onClick={() => setShowDayReportModal(true)}
|
||
className="h-10 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
||
>
|
||
<Calendar className="w-4 h-4 mr-2" />
|
||
일별 집계
|
||
</button>
|
||
<button
|
||
onClick={() => setShowTypeReportModal(true)}
|
||
className="h-10 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
||
>
|
||
<FileText className="w-4 h-4 mr-2" />
|
||
업무형태별 집계
|
||
</button>
|
||
</div>
|
||
|
||
{/* 미등록 업무일지 카드 */}
|
||
<div className="flex-shrink-0 w-40">
|
||
<div
|
||
className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center cursor-pointer hover:bg-white/20 transition-colors"
|
||
onClick={() => setShowUnregisteredModal(true)}
|
||
>
|
||
<div className="text-white/70 text-sm font-medium mb-2 text-center flex items-center justify-center gap-2">
|
||
<AlertTriangle className="w-4 h-4 text-danger-400" />
|
||
미등록
|
||
</div>
|
||
<div className="text-center">
|
||
<span className="text-3xl font-bold text-danger-400">{unregisteredJobReportCount}</span>
|
||
<span className="text-white/70 text-lg ml-1">건</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 우측: 오늘 근무시간 */}
|
||
<div className="flex-shrink-0 w-40">
|
||
<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.toFixed(1)}</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.toFixed(1)}시간</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-2 py-3 text-left text-xs font-medium text-white/70 uppercase w-24">날짜</th>
|
||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase" style={{ width: '35%' }}>프로젝트</th>
|
||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">업무형태</th>
|
||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">상태</th>
|
||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">시간</th>
|
||
{canViewOT && <th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>}
|
||
<th className="px-2 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"
|
||
>
|
||
<td
|
||
className="px-2 py-3 text-center cursor-pointer hover:bg-primary-500/10 transition-colors"
|
||
onClick={(e) => openCopyModal(item, e)}
|
||
title="복사하여 새로 작성"
|
||
>
|
||
<Copy className="w-4 h-4 mx-auto text-white/40" />
|
||
</td>
|
||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>{formatDate(item.pdate)}</td>
|
||
<td className={`px-4 py-3 text-sm font-medium ${item.pidx && item.pidx > 0 ? 'text-white' : 'text-white/50'}`}>
|
||
<div className="flex items-center space-x-2">
|
||
{item.pidx && item.pidx > 0 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
window.open(`#/project-detail/${item.pidx}`, '_blank');
|
||
}}
|
||
className="text-primary-400 hover:text-primary-300 transition-colors flex-shrink-0"
|
||
title="프로젝트 정보 보기"
|
||
>
|
||
<Info className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
<span className="truncate cursor-pointer" onClick={() => openEditModal(item)} title={item.projectName}>
|
||
{item.projectName || '-'}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>{item.type || '-'}</td>
|
||
<td className="px-4 py-3 text-sm cursor-pointer" onClick={() => openEditModal(item)}>
|
||
<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 cursor-pointer" onClick={() => openEditModal(item)}>
|
||
{item.hrs || 0}
|
||
</td>
|
||
{canViewOT && (
|
||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>
|
||
{item.ot ? <span className="text-warning-400">{item.ot}</span> : '-'}
|
||
</td>
|
||
)}
|
||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>{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);
|
||
}}
|
||
/>
|
||
|
||
{/* 일별 집계 모달 */}
|
||
<JobReportDayDialog
|
||
isOpen={showDayReportModal}
|
||
onClose={() => setShowDayReportModal(false)}
|
||
initialMonth={startDate.substring(0, 7)}
|
||
/>
|
||
|
||
{/* 업무형태별 집계 모달 */}
|
||
<JobreportTypeModal
|
||
isOpen={showTypeReportModal}
|
||
onClose={() => setShowTypeReportModal(false)}
|
||
startDate={startDate}
|
||
endDate={endDate}
|
||
userId={selectedUser}
|
||
/>
|
||
|
||
{/* 업무일지 미등록 상세 모달 */}
|
||
{showUnregisteredModal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-scale-in">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||
<AlertTriangle className="w-5 h-5 text-danger-400" />
|
||
업무일지 미등록 내역
|
||
</h3>
|
||
<button
|
||
onClick={() => setShowUnregisteredModal(false)}
|
||
className="text-white/50 hover:text-white transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||
<p className="text-white/70 text-sm mb-4">
|
||
최근 15일(평일 기준) 중 8시간 미만 등록된 날짜입니다.
|
||
</p>
|
||
|
||
{unregisteredJobReportDays.length === 0 ? (
|
||
<div className="text-center py-8 text-white/50">
|
||
미등록 내역이 없습니다.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{unregisteredJobReportDays.map((day, index) => (
|
||
<div key={index} className="flex items-center justify-between p-3 bg-white/5 rounded-lg border border-white/5">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 rounded-full bg-danger-500/20 flex items-center justify-center text-danger-400 text-xs font-bold">
|
||
{index + 1}
|
||
</div>
|
||
<span className="text-white font-medium">{day.date}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className={`font-bold ${day.hrs === 0 ? 'text-danger-400' : 'text-warning-400'}`}>
|
||
{day.hrs}시간
|
||
</span>
|
||
<span className="text-white/40 text-xs">/ 8시간</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end">
|
||
<button
|
||
onClick={() => setShowUnregisteredModal(false)}
|
||
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm font-medium"
|
||
>
|
||
닫기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|