Files
Groupware/Project/frontend/src/pages/Jobreport.tsx

864 lines
34 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, useCallback } from 'react';
import {
FileText,
Search,
RefreshCw,
Copy,
Info,
Plus,
Calendar,
AlertTriangle,
X,
XCircle,
} from 'lucide-react';
import { comms } from '@/communication';
import { JobReportItem, GroupUser } from '@/types';
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog';
import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal';
import { DateRangePicker } from '@/components/DateRangePicker';
import { UserSelector } from '@/components/UserSelector';
export function Jobreport() {
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
const [users, setUsers] = useState<GroupUser[]>([]);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
// 검색 조건
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedUser, setSelectedUser] = useState('');
const [loginUserId, setLoginUserId] = 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 = 30;
// 권한 상태
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);
setLoginUserId(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.getUserList('');
if (result) {
const today = new Date().toISOString().split('T')[0];
const includeResigned = true; // 퇴사자 포함 여부 (조회용이므로 포함)
const minLevel = 1; // 최소 레벨
const filtered = result.filter(u => {
// 1. 레벨 체크
if ((u.level || 0) < minLevel) return false;
// 2. 업무일지 사용여부 체크 (목록에 표시하되 토마토색으로 구분하기 위해 필터 해제)
if (!u.useJobReport) return false;
// 3. 퇴사자 체크
if (!includeResigned && u.outdate && u.outdate < today) {
return false;
}
return true;
});
setUsers(filtered);
} else {
setUsers([]);
}
} 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 = () => {
// 본인이 아닌 경우 추가 불가
if (selectedUser !== loginUserId) {
alert('다른 사용자의 업무일지는 등록할 수 없습니다.');
return;
}
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();
};
return (
<div className="space-y-6 animate-fade-in">
{/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6 relative z-20">
<div className="flex gap-6">
{/* 좌측: 필터 영역 */}
<div className="flex-1">
<div className="flex items-stretch gap-4">
{/* 1. 기간 선택 (Vertical) */}
<div className="flex-shrink-0">
<DateRangePicker
startDate={startDate}
endDate={endDate}
onChange={(s, e) => {
setStartDate(s);
setEndDate(e);
}}
align="vertical"
/>
</div>
{/* 2. 입력 필드 그룹 (담당자, 검색어) - 2행 */}
<div className="flex flex-col justify-between gap-2">
{/* 담당자 */}
<div className="flex items-center gap-2">
<UserSelector
users={users.map(u => ({
id: u.id,
name: u.name,
process: u.processs,
level: u.level,
useJobReport: u.useJobReport,
outdate: u.outdate
}))}
includeResigned={true}
onlyJobReportUsers={true}
selectedIds={selectedUser ? [selectedUser] : []}
onChange={(ids) => setSelectedUser(ids[0] || '')}
className="w-48"
placeholder="전체"
/>
</div>
</div>
{/* 3. 버튼 그룹 (높이 채우기) */}
<div className="flex gap-2">
<button
onClick={handleSearchWithReset}
disabled={loading}
className="h-full min-h-[5.5rem] bg-primary-500 hover:bg-primary-600 border border-white/20 text-white px-6 rounded-lg transition-colors flex flex-col items-center justify-center gap-1 disabled:opacity-50 whitespace-nowrap"
>
{loading ? (
<RefreshCw className="w-5 h-5 animate-spin" />
) : (
<Search className="w-5 h-5" />
)}
<span className="text-sm"></span>
</button>
<button
onClick={openAddModal}
className="h-full min-h-[5.5rem] bg-success-500 hover:bg-success-600 border border-white/20 text-white px-6 rounded-lg transition-colors flex flex-col items-center justify-center gap-1 whitespace-nowrap"
>
<Plus className="w-5 h-5" />
<span className="text-sm"></span>
</button>
</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 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 shadow-2xl transition-all duration-300">
<div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-500/20 rounded-lg">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
</h3>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
{/* 검색 필터 */}
<div className="relative flex-1 md:w-80 group">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
placeholder="검색..."
className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
/>
{searchKey && (
<button
onClick={() => setSearchKey('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
<span className="text-[var(--text-primary)] font-bold text-sm">{jobreportList.length}</span>
<span className="text-[var(--text-secondary)] text-xs"></span>
</div>
</div>
</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)}
/>
{/* 리스트가 하단 패널에 가려지지 않도록 빈 공간 추가 */}
<div className="h-24"></div>
{/* 하단 고정 상태바 (출력물 메뉴) */}
<div className="fixed bottom-12 left-0 right-0 z-40 bg-black/30 backdrop-blur-xl border-t border-white/10 h-14 flex items-center justify-center gap-4 shadow-2xl animate-slide-up">
<button
onClick={() => setShowDayReportModal(true)}
className="h-9 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap text-sm shadow-lg border border-white/10"
>
<Calendar className="w-4 h-4 mr-2" />
</button>
<div className="w-px h-5 bg-white/20"></div>
<button
onClick={() => setShowTypeReportModal(true)}
className="h-9 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap text-sm shadow-lg border border-white/10"
>
<FileText className="w-4 h-4 mr-2" />
</button>
</div>
{/* 업무형태별 집계 모달 */}
<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>
);
}