근태(Holyday) API 추가 및 일별/업무형태별 집계 다이얼로그 구현, OT 시작/종료시간 필드 추가
This commit is contained in:
350
Project/frontend/src/components/jobreport/JobReportDayDialog.tsx
Normal file
350
Project/frontend/src/components/jobreport/JobReportDayDialog.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { JobReportDayItem, HolidayItem } from '@/types';
|
||||
|
||||
interface JobReportDayDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialMonth?: string; // YYYY-MM format
|
||||
}
|
||||
|
||||
interface DayColumn {
|
||||
day: number;
|
||||
dayOfWeek: string;
|
||||
isHoliday: boolean;
|
||||
holidayMemo?: string;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
uid: string;
|
||||
uname: string;
|
||||
processs: string;
|
||||
dailyData: Map<number, { hrs: number; ot: number; jobtype: string }>;
|
||||
totalHrs: number;
|
||||
totalOt: number;
|
||||
totalHolidayOt: number;
|
||||
}
|
||||
|
||||
export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportDayDialogProps) {
|
||||
const [currentMonth, setCurrentMonth] = useState(initialMonth || new Date().toISOString().substring(0, 7));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dayColumns, setDayColumns] = useState<DayColumn[]>([]);
|
||||
const [userRows, setUserRows] = useState<UserRow[]>([]);
|
||||
const [currentUserId, setCurrentUserId] = useState<string>('');
|
||||
const [currentUserLevel, setCurrentUserLevel] = useState<number>(0);
|
||||
const [authLevel, setAuthLevel] = useState<number>(0);
|
||||
const [canViewAll, setCanViewAll] = useState<boolean>(false);
|
||||
|
||||
// 요일 배열
|
||||
const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
// 권한 및 사용자 정보 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadAuthAndUserInfo();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadAuthAndUserInfo = async () => {
|
||||
try {
|
||||
// 현재 로그인 사용자 정보 가져오기
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
const userId = loginStatus.User.Id;
|
||||
const userLevel = loginStatus.User.Level || 0;
|
||||
setCurrentUserId(userId);
|
||||
setCurrentUserLevel(userLevel);
|
||||
|
||||
// 업무일지(jobreport) 권한 가져오기
|
||||
const authResponse = await comms.checkAuth('jobreport', 5);
|
||||
const jobReportAuthLevel = authResponse.EffectiveLevel || 0;
|
||||
setAuthLevel(jobReportAuthLevel);
|
||||
|
||||
// 유효 권한 레벨 = Max(사용자레벨, 권한레벨)
|
||||
const effectiveLevel = Math.max(userLevel, jobReportAuthLevel);
|
||||
setCanViewAll(effectiveLevel >= 5);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 정보 로드 오류:', error);
|
||||
setCanViewAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (isOpen && currentUserId) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, currentMonth, currentUserId, canViewAll]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 현재는 기존 API를 사용하여 월별 데이터를 가져옴
|
||||
const startDate = `${currentMonth}-01`;
|
||||
const year = parseInt(currentMonth.substring(0, 4));
|
||||
const month = parseInt(currentMonth.substring(5, 7));
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const endDate = `${currentMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [jobReportResponse, holidayResponse] = await Promise.all([
|
||||
comms.getJobReportList(startDate, endDate, '', ''),
|
||||
comms.getHolidayList(currentMonth)
|
||||
]);
|
||||
|
||||
if (jobReportResponse.Success && jobReportResponse.Data) {
|
||||
processData(jobReportResponse.Data, holidayResponse.Data || [], lastDay);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processData = (items: any[], holidays: HolidayItem[], lastDay: number) => {
|
||||
// 날짜 컬럼 생성
|
||||
const columns: DayColumn[] = [];
|
||||
const year = parseInt(currentMonth.substring(0, 4));
|
||||
const month = parseInt(currentMonth.substring(5, 7));
|
||||
|
||||
for (let day = 1; day <= lastDay; day++) {
|
||||
const date = new Date(year, month - 1, day);
|
||||
const dayOfWeek = weekDays[date.getDay()];
|
||||
const dateStr = `${currentMonth}-${String(day).padStart(2, '0')}`;
|
||||
const holiday = holidays.find(h => h.pdate === dateStr);
|
||||
|
||||
columns.push({
|
||||
day,
|
||||
dayOfWeek,
|
||||
isHoliday: holiday?.free || false,
|
||||
holidayMemo: holiday?.memo
|
||||
});
|
||||
}
|
||||
setDayColumns(columns);
|
||||
|
||||
// 사용자별 데이터 집계
|
||||
const userMap = new Map<string, UserRow>();
|
||||
|
||||
items.forEach(item => {
|
||||
const uid = item.id || item.uid;
|
||||
const uname = item.name || item.uname || uid;
|
||||
const pdate = item.pdate?.substring(0, 10);
|
||||
|
||||
if (!pdate || !pdate.startsWith(currentMonth)) return;
|
||||
|
||||
// 권한 체크: 권한 레벨이 5 미만이고 본인이 아니면 스킵
|
||||
if (!canViewAll && uid !== currentUserId) return;
|
||||
|
||||
const day = parseInt(pdate.substring(8, 10));
|
||||
|
||||
if (!userMap.has(uid)) {
|
||||
userMap.set(uid, {
|
||||
uid,
|
||||
uname,
|
||||
processs: item.userprocess || item.process || '',
|
||||
dailyData: new Map(),
|
||||
totalHrs: 0,
|
||||
totalOt: 0,
|
||||
totalHolidayOt: 0
|
||||
});
|
||||
}
|
||||
|
||||
const userRow = userMap.get(uid)!;
|
||||
const existing = userRow.dailyData.get(day);
|
||||
const hrs = (existing?.hrs || 0) + (item.hrs || 0);
|
||||
const ot = (existing?.ot || 0) + (item.ot || 0);
|
||||
const jobtype = item.type || existing?.jobtype || '';
|
||||
|
||||
userRow.dailyData.set(day, { hrs, ot, jobtype });
|
||||
userRow.totalHrs += item.hrs || 0;
|
||||
|
||||
// 휴일 OT 계산
|
||||
const column = columns[day - 1];
|
||||
if (column?.isHoliday) {
|
||||
userRow.totalHolidayOt += item.ot || 0;
|
||||
} else {
|
||||
userRow.totalOt += item.ot || 0;
|
||||
}
|
||||
});
|
||||
|
||||
setUserRows(Array.from(userMap.values()).sort((a, b) => a.uname.localeCompare(b.uname)));
|
||||
};
|
||||
|
||||
// 월 변경
|
||||
const changeMonth = (delta: number) => {
|
||||
const [year, month] = currentMonth.split('-').map(Number);
|
||||
const newDate = new Date(year, month - 1 + delta, 1);
|
||||
setCurrentMonth(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
// 셀 색상 결정
|
||||
const getCellStyle = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => {
|
||||
if (!data) return 'text-gray-400';
|
||||
|
||||
if (data.jobtype === '휴가') return 'text-red-500 font-medium';
|
||||
if (isHoliday) return 'text-green-500 font-medium';
|
||||
|
||||
if (data.hrs > 8) return 'text-blue-500 font-medium';
|
||||
if (data.hrs < 8) return 'text-red-500';
|
||||
if (data.ot > 0) return 'text-purple-500 font-medium';
|
||||
|
||||
return 'text-white';
|
||||
};
|
||||
|
||||
// 셀 내용 포맷
|
||||
const formatCellContent = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => {
|
||||
if (!data) return '--';
|
||||
|
||||
if (data.jobtype === '휴가' && data.hrs + data.ot === 8) return '휴가';
|
||||
|
||||
const prefix = isHoliday ? '*' : '';
|
||||
return `${prefix}${data.hrs}+${data.ot}`;
|
||||
};
|
||||
|
||||
// 엑셀 내보내기 (간단한 CSV)
|
||||
const exportToExcel = () => {
|
||||
let csv = '사원명,' + dayColumns.map(c => `${c.day}(${c.dayOfWeek})`).join(',') + ',합계\n';
|
||||
|
||||
userRows.forEach(row => {
|
||||
const cells = dayColumns.map(col => {
|
||||
const data = row.dailyData.get(col.day);
|
||||
return formatCellContent(data, col.isHoliday);
|
||||
});
|
||||
const summary = `${row.totalHrs}+${row.totalOt}(*${row.totalHolidayOt})`;
|
||||
csv += `${row.uname},${cells.join(',')},${summary}\n`;
|
||||
});
|
||||
|
||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `업무일지_일별집계_${currentMonth}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-2xl shadow-2xl w-full max-w-7xl max-h-[90vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-2xl font-bold text-white">일별 근무시간 집계</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<span className="text-lg font-medium text-white min-w-[100px] text-center">
|
||||
{currentMonth}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => changeMonth(1)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
className="px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
내보내기
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-white/50">데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 bg-gray-800 z-10">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
|
||||
사원명
|
||||
</th>
|
||||
{dayColumns.map(col => (
|
||||
<th
|
||||
key={col.day}
|
||||
className={`px-2 py-2 text-center text-xs font-medium uppercase border border-white/10 ${col.isHoliday ? 'bg-green-900/30 text-green-400' :
|
||||
col.dayOfWeek === '일' ? 'bg-red-900/30 text-red-400' :
|
||||
col.dayOfWeek === '토' ? 'bg-blue-900/30 text-blue-400' :
|
||||
'bg-gray-800 text-white/70'
|
||||
}`}
|
||||
title={col.holidayMemo}
|
||||
>
|
||||
{col.day}<br />({col.dayOfWeek})
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-center text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
|
||||
합계
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={dayColumns.length + 2} className="px-4 py-8 text-center text-white/50">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
userRows.map(row => (
|
||||
<tr key={row.uid} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-3 py-2 text-sm text-white border border-white/10 whitespace-nowrap">
|
||||
{row.uname}
|
||||
</td>
|
||||
{dayColumns.map(col => {
|
||||
const data = row.dailyData.get(col.day);
|
||||
return (
|
||||
<td
|
||||
key={col.day}
|
||||
className={`px-2 py-2 text-center text-sm border border-white/10 ${getCellStyle(data, col.isHoliday)}`}
|
||||
>
|
||||
{formatCellContent(data, col.isHoliday)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-3 py-2 text-center text-sm text-white border border-white/10 font-medium whitespace-nowrap">
|
||||
{row.totalHrs}+{row.totalOt}(*{row.totalHolidayOt})
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-gray-800/50">
|
||||
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
||||
<div><span className="text-gray-400">--</span> : 자료없음</div>
|
||||
<div><span className="text-red-500">휴가</span> : 휴가</div>
|
||||
<div><span className="text-green-500">*8+2</span> : 휴일근무</div>
|
||||
<div><span className="text-blue-500">9+0</span> : 8시간 초과</div>
|
||||
<div><span className="text-red-500">7+0</span> : 8시간 미만</div>
|
||||
<div><span className="text-purple-500">8+2</span> : 8시간+OT</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface JobreportFormData {
|
||||
description: string;
|
||||
hrs: number;
|
||||
ot: number;
|
||||
otStart: string; // 초과근무 시작시간 (HH:mm 형식)
|
||||
otEnd: string; // 초과근무 종료시간 (HH:mm 형식)
|
||||
jobgrp: string;
|
||||
tag: string;
|
||||
}
|
||||
@@ -39,6 +41,8 @@ export const initialFormData: JobreportFormData = {
|
||||
description: '',
|
||||
hrs: 8,
|
||||
ot: 0,
|
||||
otStart: '18:00',
|
||||
otEnd: '20:00',
|
||||
jobgrp: '',
|
||||
tag: '',
|
||||
};
|
||||
@@ -339,11 +343,10 @@ export function JobreportEditModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowJobTypeModal(true)}
|
||||
className={`w-full border rounded-lg px-4 py-2 text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-primary-400 transition-colors ${
|
||||
formData.type
|
||||
? 'bg-white/20 border-white/30 text-white'
|
||||
: 'bg-pink-500/30 border-pink-400/50 text-pink-200'
|
||||
}`}
|
||||
className={`w-full border rounded-lg px-4 py-2 text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-primary-400 transition-colors ${formData.type
|
||||
? 'bg-white/20 border-white/30 text-white'
|
||||
: 'bg-pink-500/30 border-pink-400/50 text-pink-200'
|
||||
}`}
|
||||
>
|
||||
<span>{getJobTypeDisplayText()}</span>
|
||||
<ChevronDown className="w-4 h-4 text-white/50" />
|
||||
@@ -411,6 +414,34 @@ export function JobreportEditModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5행: 초과근무 시간대 (OT > 0일 때만 표시) */}
|
||||
{formData.ot > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
초과근무 시작시간
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.otStart}
|
||||
onChange={(e) => handleFieldChange('otStart', e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
초과근무 종료시간
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.otEnd}
|
||||
onChange={(e) => handleFieldChange('otEnd', e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업무내용 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
|
||||
140
Project/frontend/src/components/jobreport/JobreportTypeModal.tsx
Normal file
140
Project/frontend/src/components/jobreport/JobreportTypeModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, RefreshCw } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { JobReportTypeItem } from '@/types';
|
||||
|
||||
interface JobreportTypeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function JobreportTypeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
}: JobreportTypeModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<JobReportTypeItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && startDate && endDate) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, startDate, endDate, userId]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getJobReportTypeList(startDate, endDate, userId);
|
||||
if (response.Success && response.Data) {
|
||||
setItems(response.Data);
|
||||
} else {
|
||||
setItems([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무형태별 집계 로드 오류:', error);
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const totalHrs = items.reduce((acc, item) => acc + (item.hrs || 0), 0);
|
||||
const totalOt = items.reduce((acc, item) => acc + (item.ot || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-gray-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white">업무형태별 집계</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="p-6">
|
||||
<div className="mb-4 text-white/70 text-sm">
|
||||
기간: {startDate} ~ {endDate}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-white/10">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">업무형태</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">건수</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">시간</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">OT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} 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>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-white/50">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<tr key={index} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right">{item.count}</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right">{item.hrs}h</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right">
|
||||
{item.ot > 0 ? <span className="text-warning-400">{item.ot}h</span> : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 */}
|
||||
<tr className="bg-white/5 font-semibold">
|
||||
<td className="px-4 py-3 text-white text-sm">합계</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right">
|
||||
{items.reduce((acc, item) => acc + item.count, 0)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right">{totalHrs}h</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right">
|
||||
{totalOt > 0 ? <span className="text-warning-400">{totalOt}h</span> : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end bg-white/5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
Project/frontend/src/components/kuntae/KuntaeEditModal.tsx
Normal file
295
Project/frontend/src/components/kuntae/KuntaeEditModal.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, Calendar, Clock, FileText, User } from 'lucide-react';
|
||||
import { KuntaeModel } from '@/types';
|
||||
|
||||
interface KuntaeEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: KuntaeFormData) => Promise<void>;
|
||||
initialData?: KuntaeModel | null;
|
||||
mode: 'add' | 'edit' | 'copy';
|
||||
}
|
||||
|
||||
export interface KuntaeFormData {
|
||||
idx?: number;
|
||||
cate: string;
|
||||
sdate: string;
|
||||
edate: string;
|
||||
term: number;
|
||||
crtime: number;
|
||||
termDr: number;
|
||||
drTime: number;
|
||||
contents: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
const CATE_OPTIONS = ['연차', '대체', '공가', '경조', '병가', '오전반차', '오후반차', '조퇴', '외출', '지각', '결근', '휴직', '교육', '출장', '재택', '특근', '당직', '기타'];
|
||||
|
||||
export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }: KuntaeEditModalProps) {
|
||||
const [formData, setFormData] = useState<KuntaeFormData>({
|
||||
cate: '연차',
|
||||
sdate: new Date().toISOString().split('T')[0],
|
||||
edate: new Date().toISOString().split('T')[0],
|
||||
term: 1,
|
||||
crtime: 0,
|
||||
termDr: 0,
|
||||
drTime: 0,
|
||||
contents: '',
|
||||
uid: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialData) {
|
||||
setFormData({
|
||||
idx: mode === 'edit' ? initialData.idx : undefined,
|
||||
cate: initialData.cate || '연차',
|
||||
sdate: initialData.sdate || new Date().toISOString().split('T')[0],
|
||||
edate: initialData.edate || new Date().toISOString().split('T')[0],
|
||||
term: initialData.term || 0,
|
||||
crtime: initialData.crtime || 0,
|
||||
termDr: initialData.termDr || 0,
|
||||
drTime: initialData.DrTime || 0,
|
||||
contents: initialData.contents || '',
|
||||
uid: initialData.uid || '',
|
||||
});
|
||||
} else {
|
||||
// 초기화
|
||||
setFormData({
|
||||
cate: '연차',
|
||||
sdate: new Date().toISOString().split('T')[0],
|
||||
edate: new Date().toISOString().split('T')[0],
|
||||
term: 1,
|
||||
crtime: 0,
|
||||
termDr: 0,
|
||||
drTime: 0,
|
||||
contents: '',
|
||||
uid: '', // 상위 컴포넌트에서 현재 사용자 ID를 주입받거나 여기서 처리해야 함
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialData, mode]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: name === 'term' || name === 'crtime' || name === 'termDr' || name === 'drTime'
|
||||
? parseFloat(value) || 0
|
||||
: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 변경 시 기간 자동 계산 (단순 1일 차이)
|
||||
useEffect(() => {
|
||||
if (formData.sdate && formData.edate) {
|
||||
const start = new Date(formData.sdate);
|
||||
const end = new Date(formData.edate);
|
||||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
// 연차/휴가 등일 때만 자동 계산
|
||||
if (['연차', '공가', '경조', '병가', '교육', '출장'].includes(formData.cate)) {
|
||||
setFormData(prev => ({ ...prev, term: diffDays }));
|
||||
}
|
||||
}
|
||||
}, [formData.sdate, formData.edate, formData.cate]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const title = mode === 'add' ? '근태 등록' : mode === 'edit' ? '근태 수정' : '근태 복사 등록';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-lg border border-white/10 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||
<h2 className="text-xl font-bold text-white flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2 text-primary-400" />
|
||||
{title}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-white/50 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
|
||||
{/* 구분 및 사용자 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">구분</label>
|
||||
<select
|
||||
name="cate"
|
||||
value={formData.cate}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
{CATE_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt} className="bg-[#1e1e2e]">{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">사용자 ID</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
name="uid"
|
||||
value={formData.uid}
|
||||
onChange={handleChange}
|
||||
readOnly={mode === 'edit'} // 수정 시에는 사용자 변경 불가
|
||||
className={`w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 ${mode === 'edit' ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
placeholder="사번 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기간 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
name="sdate"
|
||||
value={formData.sdate}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
name="edate"
|
||||
value={formData.edate}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용량 (일/시간) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">사용 (일)</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="number"
|
||||
name="term"
|
||||
value={formData.term}
|
||||
onChange={handleChange}
|
||||
step="0.5"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">사용 (시간)</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="number"
|
||||
name="crtime"
|
||||
value={formData.crtime}
|
||||
onChange={handleChange}
|
||||
step="0.5"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발생량 (일/시간) - 대체근무 등 발생 시 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">발생 (일)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="termDr"
|
||||
value={formData.termDr}
|
||||
onChange={handleChange}
|
||||
step="0.5"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">발생 (시간)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="drTime"
|
||||
value={formData.drTime}
|
||||
onChange={handleChange}
|
||||
step="0.5"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-1">내용</label>
|
||||
<div className="relative">
|
||||
<FileText className="absolute left-3 top-3 w-4 h-4 text-white/50" />
|
||||
<textarea
|
||||
name="contents"
|
||||
value={formData.contents}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
|
||||
placeholder="근태 사유 또는 내용 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user