근태(Holyday) API 추가 및 일별/업무형태별 집계 다이얼로그 구현, OT 시작/종료시간 필드 추가
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import { MachineBridgeInterface, ApiResponse, TodoModel, PurchaseCount, HolyUser, HolyRequestUser, PurchaseItem, KuntaeModel, LoginStatusResponse, LoginResult, UserGroup, PreviousLoginInfo, UserInfoDetail, GroupUser, UserLevelInfo, UserFullData, JobReportItem, JobReportUser, CommonCodeGroup, CommonCode, ItemInfo, ItemDetail, SupplierStaff, PurchaseHistoryItem, JobReportPermission, AppVersionInfo, JobTypeItem, HolidayItem, MailFormItem, UserGroupItem, PermissionInfo, AuthItem, AuthFieldInfo, CheckAuthResponse, MyAuthInfo, AuthType, ProjectSearchItem } from './types';
|
||||
// WebView2 환경 감지
|
||||
const isWebView = typeof window !== 'undefined' &&
|
||||
window.chrome?.webview?.hostObjects !== undefined;
|
||||
|
||||
// WebView2 환경인지 체크
|
||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||
|
||||
// 비동기 프록시 캐싱 (한 번만 초기화)
|
||||
const machine: MachineBridgeInterface | null = isWebView
|
||||
? window.chrome!.webview!.hostObjects.machine
|
||||
: null;
|
||||
@@ -308,6 +306,50 @@ class CommunicationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Holyday (근태) API =====
|
||||
|
||||
public async getHolydayList(sd: string, ed: string, uid: string = '%'): Promise<ApiResponse<KuntaeModel[]>> {
|
||||
return this.wsRequest<ApiResponse<KuntaeModel[]>>('HOLYDAY_GET_LIST', 'HOLYDAY_LIST_DATA', { sd, ed, uid });
|
||||
}
|
||||
|
||||
public async getHolydayDetail(idx: number): Promise<ApiResponse<KuntaeModel>> {
|
||||
return this.wsRequest<ApiResponse<KuntaeModel>>('HOLYDAY_GET_DETAIL', 'HOLYDAY_DETAIL_DATA', { idx });
|
||||
}
|
||||
|
||||
public async addHolyday(
|
||||
cate: string, sdate: string, edate: string, term: number, crtime: number,
|
||||
termDr: number, drTime: number, contents: string, uid: string
|
||||
): Promise<ApiResponse> {
|
||||
return this.wsRequest<ApiResponse>('HOLYDAY_ADD', 'HOLYDAY_ADDED', {
|
||||
cate, sdate, edate, term, crtime, termDr, drTime, contents, uid
|
||||
});
|
||||
}
|
||||
|
||||
public async editHolyday(
|
||||
idx: number, cate: string, sdate: string, edate: string, term: number, crtime: number,
|
||||
termDr: number, drTime: number, contents: string
|
||||
): Promise<ApiResponse> {
|
||||
return this.wsRequest<ApiResponse>('HOLYDAY_EDIT', 'HOLYDAY_EDITED', {
|
||||
idx, cate, sdate, edate, term, crtime, termDr, drTime, contents
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteHolyday(idx: number): Promise<ApiResponse> {
|
||||
return this.wsRequest<ApiResponse>('HOLYDAY_DELETE', 'HOLYDAY_DELETED', { idx });
|
||||
}
|
||||
|
||||
public async getHolydayUserList(sd: string, ed: string): Promise<ApiResponse> {
|
||||
return this.wsRequest<ApiResponse>('HOLYDAY_GET_USERLIST', 'HOLYDAY_USERLIST_DATA', { sd, ed });
|
||||
}
|
||||
|
||||
public async getHolydayPermission(): Promise<ApiResponse> {
|
||||
return this.wsRequest<ApiResponse>('HOLYDAY_GET_PERMISSION', 'HOLYDAY_PERMISSION_DATA');
|
||||
}
|
||||
|
||||
public async getHolydayBalance(year: string, uid: string): Promise<ApiResponse<HolydayBalance[]>> {
|
||||
return this.wsRequest<ApiResponse<HolydayBalance[]>>('HOLYDAY_GET_BALANCE', 'HOLYDAY_BALANCE_DATA', { year, uid });
|
||||
}
|
||||
|
||||
// ===== Favorite API =====
|
||||
|
||||
public async getFavoriteList(): Promise<ApiResponse> {
|
||||
@@ -701,14 +743,14 @@ class CommunicationLayer {
|
||||
public async addJobReport(
|
||||
pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string,
|
||||
type: string, process: string, status: string, description: string,
|
||||
hrs: number, ot: number, jobgrp: string, tag: string
|
||||
hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_Add(pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
|
||||
const result = await machine.Jobreport_Add(pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('JOBREPORT_ADD', 'JOBREPORT_ADDED', {
|
||||
pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag
|
||||
pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -716,14 +758,14 @@ class CommunicationLayer {
|
||||
public async editJobReport(
|
||||
idx: number, pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string,
|
||||
type: string, process: string, status: string, description: string,
|
||||
hrs: number, ot: number, jobgrp: string, tag: string
|
||||
hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_Edit(idx, pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
|
||||
const result = await machine.Jobreport_Edit(idx, pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('JOBREPORT_EDIT', 'JOBREPORT_EDITED', {
|
||||
idx, pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag
|
||||
idx, pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -746,6 +788,15 @@ class CommunicationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
public async getJobReportTypeList(sd: string, ed: string, uid: string = ''): Promise<ApiResponse<JobReportTypeItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_GetTypeList(sd, ed, uid);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<JobReportTypeItem[]>>('JOBREPORT_GET_TYPE_LIST', 'JOBREPORT_TYPE_LIST_DATA', { sd, ed, uid });
|
||||
}
|
||||
}
|
||||
|
||||
public async getJobTypes(process: string = ''): Promise<ApiResponse<JobTypeItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_GetJobTypes(process);
|
||||
@@ -1012,12 +1063,12 @@ class CommunicationLayer {
|
||||
* 현재 사용자의 프로젝트 목록 조회 (업무일지 콤보박스용)
|
||||
* @returns ApiResponse<{idx: number, name: string, status: string}[]>
|
||||
*/
|
||||
public async getUserProjects(): Promise<ApiResponse<{idx: number, name: string, status: string}[]>> {
|
||||
public async getUserProjects(): Promise<ApiResponse<{ idx: number, name: string, status: string }[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Project_GetUserProjects();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<{idx: number, name: string, status: string}[]>>('PROJECT_GET_USER_PROJECTS', 'PROJECT_USER_PROJECTS_DATA');
|
||||
return this.wsRequest<ApiResponse<{ idx: number, name: string, status: string }[]>>('PROJECT_GET_USER_PROJECTS', 'PROJECT_USER_PROJECTS_DATA');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Plus,
|
||||
Calendar,
|
||||
} 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[]>([]);
|
||||
@@ -24,6 +27,8 @@ export function Jobreport() {
|
||||
|
||||
// 모달 상태
|
||||
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);
|
||||
|
||||
@@ -200,6 +205,8 @@ export function Jobreport() {
|
||||
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: '',
|
||||
tag: '',
|
||||
});
|
||||
@@ -230,6 +237,8 @@ export function Jobreport() {
|
||||
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 || '',
|
||||
});
|
||||
@@ -276,7 +285,9 @@ export function Jobreport() {
|
||||
formData.hrs || 0,
|
||||
formData.ot || 0,
|
||||
formData.jobgrp || '',
|
||||
formData.tag || ''
|
||||
formData.tag || '',
|
||||
formData.otStart || '18:00',
|
||||
formData.otEnd || '20:00'
|
||||
);
|
||||
} else {
|
||||
response = await comms.addJobReport(
|
||||
@@ -292,7 +303,9 @@ export function Jobreport() {
|
||||
formData.hrs || 0,
|
||||
formData.ot || 0,
|
||||
formData.jobgrp || '',
|
||||
formData.tag || ''
|
||||
formData.tag || '',
|
||||
formData.otStart || '18:00',
|
||||
formData.otEnd || '20:00'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -419,7 +432,7 @@ export function Jobreport() {
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역: 우측 수직 배치 */}
|
||||
<div className="grid grid-rows-2 gap-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handleSearchWithReset}
|
||||
disabled={loading}
|
||||
@@ -443,6 +456,24 @@ export function Jobreport() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 집계 메뉴 */}
|
||||
<div className="flex-shrink-0 flex flex-col gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => setShowDayReportModal(true)}
|
||||
className="h-12 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-12 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-48">
|
||||
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center">
|
||||
@@ -523,9 +554,8 @@ export function Jobreport() {
|
||||
</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'
|
||||
}`}>
|
||||
<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>
|
||||
@@ -602,6 +632,22 @@ export function Jobreport() {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Search,
|
||||
@@ -8,9 +8,17 @@ import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Edit,
|
||||
Copy,
|
||||
User,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { KuntaeModel } from '@/types';
|
||||
import { KuntaeModel, HolydayPermission, HolydayUser, HolydayBalance } from '@/types';
|
||||
import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal';
|
||||
|
||||
export function Kuntae() {
|
||||
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
|
||||
@@ -20,66 +28,132 @@ export function Kuntae() {
|
||||
// 검색 조건
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState('%');
|
||||
const [filterText, setFilterText] = useState(''); // 클라이언트 필터
|
||||
|
||||
// 통계
|
||||
const [stats, setStats] = useState({
|
||||
holyUsed: 0,
|
||||
alternateUsed: 0,
|
||||
holyRemain: 0,
|
||||
alternateRemain: 0,
|
||||
});
|
||||
// 권한 및 사용자 목록
|
||||
const [permission, setPermission] = useState<HolydayPermission | null>(null);
|
||||
const [userList, setUserList] = useState<HolydayUser[]>([]);
|
||||
|
||||
// 날짜 초기화 (현재 월)
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'add' | 'edit' | 'copy'>('add');
|
||||
const [selectedItem, setSelectedItem] = useState<KuntaeModel | null>(null);
|
||||
|
||||
// 통계 (잔량 정보)
|
||||
const [balances, setBalances] = useState<HolydayBalance[]>([]);
|
||||
|
||||
// 초기화
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const init = async () => {
|
||||
// 날짜 초기화 (현재 월)
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
setStartDate(startOfMonth.toISOString().split('T')[0]);
|
||||
setEndDate(endOfMonth.toISOString().split('T')[0]);
|
||||
const sd = startOfMonth.toISOString().split('T')[0];
|
||||
const ed = endOfMonth.toISOString().split('T')[0];
|
||||
|
||||
setStartDate(sd);
|
||||
setEndDate(ed);
|
||||
|
||||
// 권한 조회
|
||||
try {
|
||||
const permResponse = await comms.getHolydayPermission();
|
||||
if (permResponse.Success && permResponse.Data) {
|
||||
// @ts-ignore - API 응답 타입 불일치 해결
|
||||
setPermission(permResponse.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 조회 오류:', error);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
// 사용자 목록 로드 (기간 변경 시)
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
if (!startDate || !endDate) return;
|
||||
try {
|
||||
const response = await comms.getHolydayUserList(startDate, endDate);
|
||||
// @ts-ignore - API 응답이 배열로 옴
|
||||
if (Array.isArray(response)) {
|
||||
setUserList(response);
|
||||
} else if (response.Success && response.Data) {
|
||||
// @ts-ignore
|
||||
setUserList(response.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
loadUsers();
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 데이터 및 잔량 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getKuntaeList(startDate, endDate);
|
||||
if (response.Success && response.Data) {
|
||||
setKuntaeList(response.Data);
|
||||
updateStats(response.Data);
|
||||
// 1. 목록 조회
|
||||
const listResponse = await comms.getHolydayList(startDate, endDate, selectedUser);
|
||||
if (listResponse.Success && listResponse.Data) {
|
||||
setKuntaeList(listResponse.Data);
|
||||
} else {
|
||||
setKuntaeList([]);
|
||||
}
|
||||
|
||||
// 2. 잔량 조회 (연도 기준)
|
||||
const year = startDate.substring(0, 4);
|
||||
// 전체 사용자(%) 선택 시에는 로그인한 사용자 기준 잔량을 보여주거나, 비워두는 게 맞음
|
||||
// 여기서는 선택된 사용자가 있으면 그 사용자, 없으면 본인(권한 없으면 에러나겠지만 API에서 처리)
|
||||
const targetUid = selectedUser === '%' ? (permission?.CurrentUserId || '') : selectedUser;
|
||||
|
||||
if (targetUid) {
|
||||
const balanceResponse = await comms.getHolydayBalance(year, targetUid);
|
||||
if (balanceResponse.Success && balanceResponse.Data) {
|
||||
setBalances(balanceResponse.Data);
|
||||
} else {
|
||||
setBalances([]);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('근태 목록 로드 오류:', error);
|
||||
console.error('데이터 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
}, [startDate, endDate, selectedUser, permission]);
|
||||
|
||||
// 통계 업데이트
|
||||
const updateStats = (data: KuntaeModel[]) => {
|
||||
const holyUsed = data.filter(item => item.cate === '연차' || item.cate === '휴가').length;
|
||||
const alternateUsed = data.filter(item => item.cate === '대체').length;
|
||||
|
||||
setStats({
|
||||
holyUsed,
|
||||
alternateUsed,
|
||||
holyRemain: 15 - holyUsed, // 예시 값
|
||||
alternateRemain: 5 - alternateUsed, // 예시 값
|
||||
});
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = () => {
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작일은 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
// 초기 로드 및 검색 조건 변경 시 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 필터링된 목록
|
||||
const filteredList = useMemo(() => {
|
||||
if (!filterText) return kuntaeList;
|
||||
const lowerText = filterText.toLowerCase();
|
||||
return kuntaeList.filter(item =>
|
||||
(item.cate && item.cate.toLowerCase().includes(lowerText)) ||
|
||||
(item.contents && item.contents.toLowerCase().includes(lowerText)) ||
|
||||
(item.UserName && item.UserName.toLowerCase().includes(lowerText))
|
||||
);
|
||||
}, [kuntaeList, filterText]);
|
||||
|
||||
// 월 이동
|
||||
const moveMonth = (offset: number) => {
|
||||
const current = new Date(startDate);
|
||||
current.setMonth(current.getMonth() + offset);
|
||||
|
||||
const startOfMonth = new Date(current.getFullYear(), current.getMonth(), 1);
|
||||
const endOfMonth = new Date(current.getFullYear(), current.getMonth() + 1, 0);
|
||||
|
||||
setStartDate(startOfMonth.toISOString().split('T')[0]);
|
||||
setEndDate(endOfMonth.toISOString().split('T')[0]);
|
||||
};
|
||||
|
||||
// 삭제
|
||||
@@ -88,7 +162,7 @@ export function Kuntae() {
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteKuntae(id);
|
||||
const response = await comms.deleteHolyday(id);
|
||||
if (response.Success) {
|
||||
alert('삭제되었습니다.');
|
||||
loadData();
|
||||
@@ -103,93 +177,218 @@ export function Kuntae() {
|
||||
}
|
||||
};
|
||||
|
||||
// 모달 열기
|
||||
const openModal = (mode: 'add' | 'edit' | 'copy', item?: KuntaeModel) => {
|
||||
setModalMode(mode);
|
||||
setSelectedItem(item || null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = async (formData: KuntaeFormData) => {
|
||||
try {
|
||||
let response;
|
||||
if (modalMode === 'add' || modalMode === 'copy') {
|
||||
response = await comms.addHolyday(
|
||||
formData.cate,
|
||||
formData.sdate,
|
||||
formData.edate,
|
||||
formData.term,
|
||||
formData.crtime,
|
||||
formData.termDr,
|
||||
formData.drTime,
|
||||
formData.contents,
|
||||
formData.uid || (permission?.CurrentUserId || '')
|
||||
);
|
||||
} else {
|
||||
if (!formData.idx) return;
|
||||
response = await comms.editHolyday(
|
||||
formData.idx,
|
||||
formData.cate,
|
||||
formData.sdate,
|
||||
formData.edate,
|
||||
formData.term,
|
||||
formData.crtime,
|
||||
formData.termDr,
|
||||
formData.drTime,
|
||||
formData.contents
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
alert(response.Message || '저장되었습니다.');
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR');
|
||||
return dateStr.split('T')[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 개발중 경고 */}
|
||||
<div className="bg-warning-500/20 border border-warning-500/30 rounded-xl p-4 flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 text-warning-400 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-white font-medium">개발중인 기능입니다</p>
|
||||
<p className="text-white/60 text-sm">일부 기능이 정상적으로 동작하지 않을 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
{/* 상단 컨트롤 바 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(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 className="flex flex-col space-y-4">
|
||||
|
||||
{/* 1행: 날짜, 사용자, 조회/등록 */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
|
||||
{/* 날짜 선택 및 월 이동 */}
|
||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||
<button
|
||||
onClick={() => moveMonth(-1)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => moveMonth(1)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 사용자 선택 (관리자용) */}
|
||||
{permission?.CanManage && (
|
||||
<div className="w-full md:w-64">
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<select
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 appearance-none"
|
||||
>
|
||||
<option value="%" className="bg-[#1e1e2e]">전체 사용자</option>
|
||||
{userList.map(user => (
|
||||
<option key={user.uid} value={user.uid} className="bg-[#1e1e2e]">
|
||||
{user.UserName} ({user.uid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조회 및 등록 버튼 */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<button
|
||||
onClick={() => loadData()}
|
||||
disabled={loading}
|
||||
className="flex-1 md:flex-none bg-white/10 hover:bg-white/20 text-white px-4 py-2 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={() => openModal('add')}
|
||||
className="flex-1 md:flex-none bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(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 className="flex items-end">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 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" />
|
||||
|
||||
{/* 2행: 검색 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="구분, 내용, 성명으로 검색..."
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm placeholder-white/30"
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
onClick={() => setFilterText('')}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{/* 통계 카드 (잔량 정보) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="휴가 사용"
|
||||
value={stats.holyUsed}
|
||||
icon={<Calendar className="w-6 h-6 text-primary-400" />}
|
||||
color="text-primary-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="대체 사용"
|
||||
value={stats.alternateUsed}
|
||||
icon={<CheckCircle className="w-6 h-6 text-success-400" />}
|
||||
color="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="잔량 (연차)"
|
||||
value={stats.holyRemain}
|
||||
icon={<Clock className="w-6 h-6 text-warning-400" />}
|
||||
color="text-warning-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="잔량 (대체)"
|
||||
value={stats.alternateRemain}
|
||||
icon={<XCircle className="w-6 h-6 text-danger-400" />}
|
||||
color="text-danger-400"
|
||||
/>
|
||||
{balances.length > 0 ? (
|
||||
balances.map((bal, idx) => {
|
||||
// 잔량 계산
|
||||
const remainDays = bal.TotalGenDays - bal.TotalUseDays;
|
||||
const remainHours = bal.TotalGenHours - bal.TotalUseHours;
|
||||
|
||||
// 아이콘 및 색상 결정
|
||||
let icon = <Clock className="w-6 h-6" />;
|
||||
let color = "text-white";
|
||||
|
||||
if (bal.cate === '연차') {
|
||||
icon = <Calendar className="w-6 h-6 text-primary-400" />;
|
||||
color = "text-primary-400";
|
||||
} else if (bal.cate === '대체') {
|
||||
icon = <RefreshCw className="w-6 h-6 text-success-400" />;
|
||||
color = "text-success-400";
|
||||
} else if (bal.cate === '휴가') {
|
||||
icon = <CheckCircle className="w-6 h-6 text-warning-400" />;
|
||||
color = "text-warning-400";
|
||||
}
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
key={idx}
|
||||
title={`${bal.cate} 잔량`}
|
||||
value={`${remainDays}일 ${remainHours > 0 ? `(${remainHours}h)` : ''}`}
|
||||
subValue={`발생: ${bal.TotalGenDays} / 사용: ${bal.TotalUseDays}`}
|
||||
icon={icon}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 데이터 없을 때 기본 카드 표시
|
||||
<>
|
||||
<StatCard title="연차 잔량" value="-" icon={<Calendar className="w-6 h-6 text-white/30" />} color="text-white/30" />
|
||||
<StatCard title="대체 잔량" value="-" icon={<RefreshCw className="w-6 h-6 text-white/30" />} color="text-white/30" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-white">근태 상세 내역</h3>
|
||||
<span className="text-white/50 text-sm">
|
||||
총 {filteredList.length}건
|
||||
{kuntaeList.length !== filteredList.length && ` (전체 ${kuntaeList.length}건 중)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -199,56 +398,80 @@ export function Kuntae() {
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center">
|
||||
<td colSpan={9} 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>
|
||||
) : kuntaeList.length === 0 ? (
|
||||
) : filteredList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center text-white/50">
|
||||
조회된 데이터가 없습니다.
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-white/50">
|
||||
{filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
kuntaeList.map((item) => (
|
||||
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-white text-sm">{item.cate || '-'}</td>
|
||||
filteredList.map((item) => (
|
||||
<tr key={item.idx} className={`hover:bg-white/5 transition-colors ${item.extidx ? 'bg-black/20' : ''}`}>
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
<span className={`px-2 py-1 rounded text-xs ${item.cate === '연차' ? 'bg-primary-500/20 text-primary-300' :
|
||||
item.cate === '대체' ? 'bg-success-500/20 text-success-300' :
|
||||
'bg-white/10 text-white/70'
|
||||
}`}>
|
||||
{item.cate || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.sdate)}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.edate)}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.uid || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.uname || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.term || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.term > 0 ? item.term : '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.crtime > 0 ? item.crtime : '-'}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}>
|
||||
{item.contents || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.extcate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.wuid || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.wdate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
{item.UserName || item.uname || item.uid}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white/50 text-xs">
|
||||
{item.extcate ? `${item.extcate}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handleDelete(item.idx)}
|
||||
disabled={processing}
|
||||
className="text-danger-400 hover:text-danger-300 transition-colors disabled:opacity-50"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => openModal('edit', item)}
|
||||
className={`text-primary-400 hover:text-primary-300 transition-colors ${item.extidx ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={item.extidx ? "외부 연동 데이터는 수정할 수 없습니다" : "수정"}
|
||||
disabled={!!item.extidx}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('copy', item)}
|
||||
className="text-success-400 hover:text-success-300 transition-colors"
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.idx)}
|
||||
className={`text-danger-400 hover:text-danger-300 transition-colors ${item.extidx ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={item.extidx ? "외부 연동 데이터는 삭제할 수 없습니다" : "삭제"}
|
||||
disabled={!!item.extidx}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@@ -257,6 +480,15 @@ export function Kuntae() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<KuntaeEditModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
initialData={selectedItem}
|
||||
mode={modalMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -264,12 +496,13 @@ export function Kuntae() {
|
||||
// 통계 카드 컴포넌트
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
value: string | number;
|
||||
subValue?: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color }: StatCardProps) {
|
||||
function StatCard({ title, value, subValue, icon, color }: StatCardProps) {
|
||||
return (
|
||||
<div className="glass-effect rounded-xl p-4 card-hover">
|
||||
<div className="flex items-center">
|
||||
@@ -278,7 +511,8 @@ function StatCard({ title, value, icon, color }: StatCardProps) {
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/70">{title}</p>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
<p className={`text-xl font-bold ${color}`}>{value}</p>
|
||||
{subValue && <p className="text-xs text-white/40 mt-1">{subValue}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,24 +90,47 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// 근태 관련 타입
|
||||
// 근태 관련 타입 (Holyday 테이블)
|
||||
export interface KuntaeModel {
|
||||
idx: number;
|
||||
gcode: string;
|
||||
cate: string; // 구분 (연차, 대체, 휴가 등)
|
||||
sdate: string; // 시작일
|
||||
edate: string; // 종료일
|
||||
term: number; // 사용(일)
|
||||
crtime: number; // 사용(시간)
|
||||
termDr: number; // 발생(일)
|
||||
DrTime: number; // 발생(시간)
|
||||
contents: string; // 내용
|
||||
uid: string; // 사용자 ID
|
||||
UserName?: string; // 사용자 이름 (조인)
|
||||
wuid: string; // 등록자 ID
|
||||
wdate: string; // 등록일
|
||||
extcate?: string; // 외부 소스 카테고리
|
||||
extidx?: number; // 외부 소스 인덱스
|
||||
}
|
||||
|
||||
// 근태 권한 정보 타입
|
||||
export interface HolydayPermission {
|
||||
Success: boolean;
|
||||
CurrentUserId: string;
|
||||
Level: number;
|
||||
CanManage: boolean;
|
||||
}
|
||||
|
||||
// 근태 사용자 목록 타입
|
||||
export interface HolydayUser {
|
||||
uid: string;
|
||||
uname: string;
|
||||
UserName: string;
|
||||
}
|
||||
|
||||
// 근태 잔량 정보 타입
|
||||
export interface HolydayBalance {
|
||||
cate: string;
|
||||
sdate: string | null;
|
||||
edate: string | null;
|
||||
term: number;
|
||||
termdr: number;
|
||||
drtime: number;
|
||||
crtime: number;
|
||||
contents: string;
|
||||
tag: string;
|
||||
extcate: string;
|
||||
wuid: string;
|
||||
wdate: string;
|
||||
TotalGenDays: number;
|
||||
TotalGenHours: number;
|
||||
TotalUseDays: number;
|
||||
TotalUseHours: number;
|
||||
}
|
||||
|
||||
// 업무일지 관련 타입 (기존 - 사용 안함)
|
||||
@@ -134,6 +157,8 @@ export interface JobReportItem {
|
||||
svalue: string; // 업무형태 표시값
|
||||
hrs: number;
|
||||
ot: number;
|
||||
otStart?: string; // 초과근무 시작시간
|
||||
otEnd?: string; // 초과근무 종료시간
|
||||
requestpart: string;
|
||||
package: string;
|
||||
userprocess: string; // 사용자 공정
|
||||
@@ -333,8 +358,8 @@ export interface MachineBridgeInterface {
|
||||
Jobreport_GetList(sd: string, ed: string, uid: string, cate: string, searchKey: string): Promise<string>;
|
||||
Jobreport_GetUsers(): Promise<string>;
|
||||
Jobreport_GetDetail(id: number): Promise<string>;
|
||||
Jobreport_Add(pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
|
||||
Jobreport_Edit(idx: number, pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
|
||||
Jobreport_Add(pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string): Promise<string>;
|
||||
Jobreport_Edit(idx: number, pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string): Promise<string>;
|
||||
Jobreport_Delete(id: number): Promise<string>;
|
||||
Jobreport_GetPermission(targetUserId: string): Promise<string>;
|
||||
Jobreport_GetJobTypes(process: string): Promise<string>;
|
||||
@@ -741,3 +766,28 @@ export interface ProjectDailyMemo {
|
||||
wdate?: string;
|
||||
wname?: string;
|
||||
}
|
||||
|
||||
// 일별 업무일지 집계 타입
|
||||
export interface JobReportDayItem {
|
||||
uid: string;
|
||||
uname: string;
|
||||
pdate: string;
|
||||
hrs: number;
|
||||
ot: number;
|
||||
processs: string;
|
||||
jobtype: string;
|
||||
}
|
||||
|
||||
// 업무형태별 집계 타입
|
||||
export interface JobReportTypeItem {
|
||||
type: string;
|
||||
hrs: number;
|
||||
ot: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface JobReportDayData {
|
||||
items: JobReportDayItem[];
|
||||
holidays: HolidayItem[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user