근태(Holyday) API 추가 및 일별/업무형태별 집계 다이얼로그 구현, OT 시작/종료시간 필드 추가

This commit is contained in:
backuppc
2025-12-02 08:26:24 +09:00
parent adcdc40169
commit aa956cf063
14 changed files with 2012 additions and 240 deletions

View File

@@ -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');
}
}
}

View 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>
);
}

View File

@@ -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">

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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[];
}