nr 구매 제한 기능 추가
- 트리거를 이용하여 기존 프로그램 사용자도 오류가 발생하도록 함
This commit is contained in:
@@ -8,6 +8,7 @@ import { MailList } from '@/pages/MailList';
|
||||
import { Customs } from '@/pages/Customs';
|
||||
import { LicenseList } from '@/components/license/LicenseList';
|
||||
import { PartList } from '@/pages/PartList';
|
||||
import HolidayRequest from '@/pages/HolidayRequest';
|
||||
import { comms } from '@/communication';
|
||||
import { UserInfo } from '@/types';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
@@ -90,6 +91,7 @@ export default function App() {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/todo" element={<Todo />} />
|
||||
<Route path="/kuntae" element={<Kuntae />} />
|
||||
<Route path="/holiday-request" element={<HolidayRequest />} />
|
||||
<Route path="/jobreport" element={<Jobreport />} />
|
||||
<Route path="/project" element={<Project />} />
|
||||
<Route path="/common" element={<CommonCodePage />} />
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
CustomItem,
|
||||
LicenseItem,
|
||||
PartListItem,
|
||||
HolidayRequest,
|
||||
} from '@/types';
|
||||
|
||||
// WebView2 환경 감지
|
||||
@@ -137,21 +138,28 @@ class CommunicationLayer {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const requestId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
reject(new Error(`${requestType} timeout`));
|
||||
}, 10000);
|
||||
|
||||
const handler = (data: unknown) => {
|
||||
const msg = data as { type: string; data?: T; Success?: boolean; Message?: string };
|
||||
const msg = data as { type: string; data?: T; Success?: boolean; Message?: string; requestId?: string };
|
||||
if (msg.type === responseType) {
|
||||
// requestId가 있는 경우 일치 여부 확인 (백엔드가 지원하는 경우)
|
||||
if (msg.requestId && msg.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
resolve(msg.data as T);
|
||||
}
|
||||
};
|
||||
this.listeners.push(handler);
|
||||
this.ws?.send(JSON.stringify({ ...params, type: requestType }));
|
||||
this.ws?.send(JSON.stringify({ ...params, type: requestType, requestId }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -916,6 +924,39 @@ class CommunicationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HolidayRequest API (휴가/외출 신청) =====
|
||||
|
||||
public async getHolidayRequestList(startDate: string, endDate: string, userId: string, userLevel: number): Promise<ApiResponse<HolidayRequest[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.HolidayRequest_GetList(startDate, endDate, userId, userLevel);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<HolidayRequest[]>>('HOLIDAY_REQUEST_GET_LIST', 'HOLIDAY_REQUEST_LIST_DATA', { startDate, endDate, userId, userLevel });
|
||||
}
|
||||
}
|
||||
|
||||
public async saveHolidayRequest(data: HolidayRequest): Promise<ApiResponse> {
|
||||
const { idx, uid, cate, sdate, edate, Remark, Response, conf, HolyReason, HolyBackup, HolyLocation, HolyDays, HolyTimes, stime, etime } = data;
|
||||
const remark = Remark || '';
|
||||
const response = Response || '';
|
||||
const holyReason = HolyReason || '';
|
||||
const holyBackup = HolyBackup || '';
|
||||
const holyLocation = HolyLocation || '';
|
||||
const holyDays = HolyDays || 0;
|
||||
const holyTimes = HolyTimes || 0;
|
||||
const sTime = stime || '';
|
||||
const eTime = etime || '';
|
||||
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.HolidayRequest_Save(idx, uid, cate, sdate, edate, remark, response, conf, holyReason, holyBackup, holyLocation, holyDays, holyTimes, sTime, eTime);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('HOLIDAY_REQUEST_SAVE', 'HOLIDAY_REQUEST_SAVED', {
|
||||
idx, uid, cate, sdate, edate, remark, response, conf, holyReason, holyBackup, holyLocation, holyDays, holyTimes, stime: sTime, etime: eTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MailForm API (메일양식) =====
|
||||
|
||||
public async getMailFormList(): Promise<ApiResponse<MailFormItem[]>> {
|
||||
|
||||
507
Project/frontend/src/components/holiday/HolidayRequestDialog.tsx
Normal file
507
Project/frontend/src/components/holiday/HolidayRequestDialog.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Calendar, Clock, MapPin, User, FileText, AlertCircle } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { HolidayRequest, CommonCode } from '@/types';
|
||||
|
||||
interface HolidayRequestDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
initialData?: HolidayRequest | null;
|
||||
userLevel: number;
|
||||
currentUserId: string;
|
||||
currentUserName: string;
|
||||
}
|
||||
|
||||
export function HolidayRequestDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialData,
|
||||
userLevel,
|
||||
currentUserId,
|
||||
// currentUserName
|
||||
}: HolidayRequestDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [codes, setCodes] = useState<{
|
||||
cate: CommonCode[];
|
||||
reason: CommonCode[];
|
||||
location: CommonCode[];
|
||||
backup: CommonCode[];
|
||||
}>({
|
||||
cate: [],
|
||||
reason: [],
|
||||
location: [],
|
||||
backup: []
|
||||
});
|
||||
const [users, setUsers] = useState<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState<HolidayRequest>({
|
||||
idx: 0,
|
||||
gcode: '',
|
||||
uid: currentUserId,
|
||||
cate: '',
|
||||
sdate: new Date().toISOString().split('T')[0],
|
||||
edate: new Date().toISOString().split('T')[0],
|
||||
Remark: '',
|
||||
wuid: currentUserId,
|
||||
wdate: '',
|
||||
Response: '',
|
||||
conf: 0,
|
||||
HolyReason: '',
|
||||
HolyBackup: '',
|
||||
HolyLocation: '',
|
||||
HolyDays: 0,
|
||||
HolyTimes: 0,
|
||||
stime: '09:00',
|
||||
etime: '18:00',
|
||||
sendmail: false
|
||||
});
|
||||
|
||||
const [requestType, setRequestType] = useState<'day' | 'time' | 'out'>('day'); // day: 휴가, time: 대체, out: 외출
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadCodes();
|
||||
if (userLevel >= 5) {
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
if (initialData) {
|
||||
setFormData({ ...initialData });
|
||||
// Determine request type based on data
|
||||
if (initialData.cate === '외출') {
|
||||
setRequestType('out');
|
||||
} else if (initialData.cate === '대체') {
|
||||
setRequestType('time');
|
||||
} else {
|
||||
setRequestType('day');
|
||||
}
|
||||
} else {
|
||||
// Reset form for new entry
|
||||
setFormData({
|
||||
idx: 0,
|
||||
gcode: '',
|
||||
uid: currentUserId,
|
||||
cate: '',
|
||||
sdate: new Date().toISOString().split('T')[0],
|
||||
edate: new Date().toISOString().split('T')[0],
|
||||
Remark: '',
|
||||
wuid: currentUserId,
|
||||
wdate: '',
|
||||
Response: '',
|
||||
conf: 0,
|
||||
HolyReason: '',
|
||||
HolyBackup: '',
|
||||
HolyLocation: '',
|
||||
HolyDays: 0,
|
||||
HolyTimes: 0,
|
||||
stime: '09:00',
|
||||
etime: '18:00',
|
||||
sendmail: false
|
||||
});
|
||||
setRequestType('day');
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialData, currentUserId]);
|
||||
|
||||
const loadCodes = async () => {
|
||||
try {
|
||||
const [cateRes, reasonRes, locationRes, backupRes] = await Promise.all([
|
||||
comms.getCommonList('50'),
|
||||
comms.getCommonList('51'),
|
||||
comms.getCommonList('52'),
|
||||
comms.getCommonList('53')
|
||||
]);
|
||||
setCodes({
|
||||
cate: cateRes || [],
|
||||
reason: reasonRes || [],
|
||||
location: locationRes || [],
|
||||
backup: backupRes || []
|
||||
});
|
||||
|
||||
// Set default category if new
|
||||
if (!initialData && cateRes && cateRes.length > 0) {
|
||||
setFormData(prev => ({ ...prev, cate: cateRes[0].svalue }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load codes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const userList = await comms.getUserList('');
|
||||
if (userList && userList.length > 0) {
|
||||
const mappedUsers = userList.map((u: any) => ({
|
||||
id: u.id || u.Id,
|
||||
name: u.name || u.NameK || u.id
|
||||
}));
|
||||
setUsers(mappedUsers);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validation
|
||||
if (!formData.cate && requestType === 'day') {
|
||||
alert('구분을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
if (formData.sdate > formData.edate) {
|
||||
alert('종료일이 시작일보다 빠를 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data based on type
|
||||
const dataToSave = { ...formData };
|
||||
if (requestType === 'out') {
|
||||
dataToSave.cate = '외출';
|
||||
dataToSave.HolyDays = 0;
|
||||
// Calculate times if needed, or rely on user input?
|
||||
// WinForms doesn't seem to auto-calc times for 'out', just saves stime/etime.
|
||||
} else if (requestType === 'time') {
|
||||
dataToSave.cate = '대체';
|
||||
dataToSave.HolyDays = 0;
|
||||
} else {
|
||||
// Day
|
||||
dataToSave.HolyTimes = 0;
|
||||
dataToSave.stime = '';
|
||||
dataToSave.etime = '';
|
||||
|
||||
// Calculate days
|
||||
const start = new Date(dataToSave.sdate);
|
||||
const end = new Date(dataToSave.edate);
|
||||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
dataToSave.HolyDays = diffDays;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.saveHolidayRequest(dataToSave);
|
||||
if (response.Success) {
|
||||
onSave();
|
||||
onClose();
|
||||
} else {
|
||||
alert('저장 실패: ' + response.Message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
{formData.idx === 0 ? '휴가/외출 신청' : '신청 내역 수정'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Request Type */}
|
||||
<div className="flex gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="type"
|
||||
checked={requestType === 'day'}
|
||||
onChange={() => setRequestType('day')}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
disabled={formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출'}
|
||||
/>
|
||||
<span className="font-medium text-gray-700">일반휴가</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="type"
|
||||
checked={requestType === 'time'}
|
||||
onChange={() => setRequestType('time')}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
disabled={formData.idx > 0 && initialData?.cate !== '대체'}
|
||||
/>
|
||||
<span className="font-medium text-gray-700">대체휴가(시간)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="type"
|
||||
checked={requestType === 'out'}
|
||||
onChange={() => setRequestType('out')}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
disabled={formData.idx > 0 && initialData?.cate !== '외출'}
|
||||
/>
|
||||
<span className="font-medium text-gray-700">외출</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* User Selection (Admin only) */}
|
||||
{userLevel >= 5 && (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<User className="w-4 h-4" /> 신청자
|
||||
</label>
|
||||
<select
|
||||
value={formData.uid}
|
||||
onChange={(e) => setFormData({ ...formData, uid: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={formData.idx > 0}
|
||||
>
|
||||
{users.map(user => (
|
||||
<option key={user.id} value={user.id}>{user.name} ({user.id})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date & Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" /> 시작일
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.sdate}
|
||||
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" /> 종료일
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.edate}
|
||||
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(requestType === 'time' || requestType === 'out') && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" /> 시작 시간
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.stime}
|
||||
onChange={(e) => setFormData({ ...formData, stime: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" /> 종료 시간
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.etime}
|
||||
onChange={(e) => setFormData({ ...formData, etime: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category & Reason */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" /> 구분
|
||||
</label>
|
||||
{requestType === 'day' ? (
|
||||
<select
|
||||
value={formData.cate}
|
||||
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{codes.cate.map(code => (
|
||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={requestType === 'out' ? '외출' : '대체'}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" /> 사유
|
||||
</label>
|
||||
<select
|
||||
value={formData.HolyReason}
|
||||
onChange={(e) => setFormData({ ...formData, HolyReason: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{codes.reason.map(code => (
|
||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location & Backup */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" /> 행선지
|
||||
</label>
|
||||
<select
|
||||
value={formData.HolyLocation}
|
||||
onChange={(e) => setFormData({ ...formData, HolyLocation: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{codes.location.map(code => (
|
||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<User className="w-4 h-4" /> 업무대행
|
||||
</label>
|
||||
<select
|
||||
value={formData.HolyBackup}
|
||||
onChange={(e) => setFormData({ ...formData, HolyBackup: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{codes.backup.map(code => (
|
||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days & Times (Manual Override) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">일수</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={formData.HolyDays}
|
||||
onChange={(e) => setFormData({ ...formData, HolyDays: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={requestType !== 'day'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">시간</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={formData.HolyTimes}
|
||||
onChange={(e) => setFormData({ ...formData, HolyTimes: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={requestType === 'day'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remark */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">비고</label>
|
||||
<textarea
|
||||
value={formData.Remark}
|
||||
onChange={(e) => setFormData({ ...formData, Remark: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent h-20 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Admin Response & Confirmation */}
|
||||
{userLevel >= 5 && (
|
||||
<div className="p-4 bg-blue-50 rounded-lg space-y-4 border border-blue-100">
|
||||
<h3 className="font-semibold text-blue-800">관리자 승인</h3>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="conf"
|
||||
checked={formData.conf === 0}
|
||||
onChange={() => setFormData({ ...formData, conf: 0 })}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<span className="text-gray-700">미승인</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="conf"
|
||||
checked={formData.conf === 1}
|
||||
onChange={() => setFormData({ ...formData, conf: 1 })}
|
||||
className="w-4 h-4 text-green-600"
|
||||
/>
|
||||
<span className="text-gray-700">승인</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="conf"
|
||||
checked={formData.conf === 2}
|
||||
onChange={() => setFormData({ ...formData, conf: 2 })}
|
||||
className="w-4 h-4 text-red-600"
|
||||
/>
|
||||
<span className="text-gray-700">반려</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">관리자 메모</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.Response}
|
||||
onChange={(e) => setFormData({ ...formData, Response: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50 rounded-b-xl">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-lg shadow-blue-500/30 transition-all font-medium disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{loading ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -319,7 +319,7 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
|
||||
);
|
||||
})}
|
||||
<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})
|
||||
{row.totalHrs.toFixed(1)}+{row.totalOt.toFixed(1)}(*{row.totalHolidayOt.toFixed(1)})
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -95,12 +95,15 @@ export function JobreportEditModal({
|
||||
try {
|
||||
// WebSocket 모드에서는 같은 응답타입을 사용하므로 순차적으로 로드
|
||||
const requestPart = await comms.getCommonList('13'); // 요청부서
|
||||
if (requestPart) requestPart.sort((a, b) => (a.memo || a.svalue || '').localeCompare(b.memo || b.svalue || ''));
|
||||
setRequestPartList(requestPart || []);
|
||||
|
||||
const packages = await comms.getCommonList('14'); // 패키지
|
||||
if (packages) packages.sort((a, b) => (a.memo || a.svalue || '').localeCompare(b.memo || b.svalue || ''));
|
||||
setPackageList(packages || []);
|
||||
|
||||
const processes = await comms.getCommonList('16'); // 공정(프로세스)
|
||||
if (processes) processes.sort((a, b) => (a.memo || a.svalue || '').localeCompare(b.memo || b.svalue || ''));
|
||||
setProcessList(processes || []);
|
||||
|
||||
const statuses = await comms.getCommonList('12'); // 상태
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
User,
|
||||
Users,
|
||||
CalendarDays,
|
||||
Calendar,
|
||||
Mail,
|
||||
Shield,
|
||||
List,
|
||||
@@ -81,6 +82,7 @@ const leftDropdownMenus: DropdownMenuConfig[] = [
|
||||
items: [
|
||||
{ type: 'link', path: '/kuntae', icon: List, label: '목록' },
|
||||
{ type: 'action', icon: AlertTriangle, label: '오류검사', action: 'kuntaeErrorCheck' },
|
||||
{ type: 'link', path: '/holiday-request', icon: Calendar, label: '휴가/외출 신청' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,6 +61,8 @@ export function Dashboard() {
|
||||
const [purchaseCR, setPurchaseCR] = useState(0);
|
||||
const [todoCount, setTodoCount] = useState(0);
|
||||
const [todayWorkHrs, setTodayWorkHrs] = useState(0);
|
||||
const [unregisteredJobReportCount, setUnregisteredJobReportCount] = useState(0);
|
||||
const [unregisteredJobReportDays, setUnregisteredJobReportDays] = useState<{ date: string; hrs: number }[]>([]);
|
||||
|
||||
// 목록 데이터
|
||||
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
|
||||
@@ -73,6 +75,7 @@ export function Dashboard() {
|
||||
// 모달 상태
|
||||
const [showNRModal, setShowNRModal] = useState(false);
|
||||
const [showCRModal, setShowCRModal] = useState(false);
|
||||
const [showUnregisteredModal, setShowUnregisteredModal] = useState(false);
|
||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||
const [showNoteEditModal, setShowNoteEditModal] = useState(false);
|
||||
const [showNoteAddModal, setShowNoteAddModal] = useState(false);
|
||||
@@ -125,6 +128,11 @@ export function Dashboard() {
|
||||
const now = new Date();
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 15일 전 날짜 계산
|
||||
const fifteenDaysAgoDate = new Date(now);
|
||||
fifteenDaysAgoDate.setDate(now.getDate() - 15);
|
||||
const fifteenDaysAgoStr = `${fifteenDaysAgoDate.getFullYear()}-${String(fifteenDaysAgoDate.getMonth() + 1).padStart(2, '0')}-${String(fifteenDaysAgoDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 현재 로그인 사용자 ID 가져오기
|
||||
let currentUserId = '';
|
||||
try {
|
||||
@@ -142,12 +150,14 @@ export function Dashboard() {
|
||||
urgentTodosResponse,
|
||||
allTodosResponse,
|
||||
jobreportResponse,
|
||||
jobreportHistoryResponse,
|
||||
notesResponse,
|
||||
] = await Promise.all([
|
||||
comms.getPurchaseWaitCount(),
|
||||
comms.getUrgentTodos(),
|
||||
comms.getTodos(),
|
||||
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
|
||||
comms.getJobReportList(fifteenDaysAgoStr, todayStr, currentUserId, ''),
|
||||
comms.getNoteList('2000-01-01', todayStr, ''),
|
||||
]);
|
||||
|
||||
@@ -174,6 +184,39 @@ export function Dashboard() {
|
||||
setTodayWorkHrs(0);
|
||||
}
|
||||
|
||||
// 최근 15일간 업무일지 미등록(8시간 미만) 확인
|
||||
if (jobreportHistoryResponse.Success && jobreportHistoryResponse.Data) {
|
||||
const dailyWork: { [key: string]: number } = {};
|
||||
|
||||
// 날짜별 시간 합계 계산
|
||||
jobreportHistoryResponse.Data.forEach((item: JobReportItem) => {
|
||||
if (item.pdate) {
|
||||
const date = item.pdate.substring(0, 10);
|
||||
dailyWork[date] = (dailyWork[date] || 0) + (item.hrs || 0);
|
||||
}
|
||||
});
|
||||
|
||||
const insufficientDays: { date: string; hrs: number }[] = [];
|
||||
|
||||
// 어제부터 15일 전까지 확인 (오늘은 제외)
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() - i);
|
||||
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 주말(토:6, 일:0) 제외
|
||||
if (d.getDay() === 0 || d.getDay() === 6) continue;
|
||||
|
||||
const hrs = dailyWork[dStr] || 0;
|
||||
if (hrs < 8) {
|
||||
insufficientDays.push({ date: dStr, hrs });
|
||||
}
|
||||
}
|
||||
|
||||
setUnregisteredJobReportCount(insufficientDays.length);
|
||||
setUnregisteredJobReportDays(insufficientDays);
|
||||
}
|
||||
|
||||
// 최근 메모 목록 (최대 10개)
|
||||
if (notesResponse.Success && notesResponse.Data) {
|
||||
setRecentNotes(notesResponse.Data.slice(0, 10));
|
||||
@@ -525,7 +568,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||||
<StatCard
|
||||
title="구매요청 (NR)"
|
||||
value={purchaseNR}
|
||||
@@ -547,6 +590,13 @@ export function Dashboard() {
|
||||
color="text-warning-400"
|
||||
onClick={() => navigate('/todo')}
|
||||
/>
|
||||
<StatCard
|
||||
title="업무일지 미등록"
|
||||
value={`${unregisteredJobReportCount}건`}
|
||||
icon={<AlertTriangle className="w-6 h-6 text-danger-400" />}
|
||||
color="text-danger-400"
|
||||
onClick={() => setShowUnregisteredModal(true)}
|
||||
/>
|
||||
<StatCard
|
||||
title="금일 업무일지"
|
||||
value={`${todayWorkHrs}시간`}
|
||||
@@ -656,6 +706,69 @@ export function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 업무일지 미등록 상세 모달 */}
|
||||
{showUnregisteredModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-scale-in">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-danger-400" />
|
||||
업무일지 미등록 내역
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowUnregisteredModal(false)}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<p className="text-white/70 text-sm mb-4">
|
||||
최근 15일(평일 기준) 중 8시간 미만 등록된 날짜입니다.
|
||||
</p>
|
||||
|
||||
{unregisteredJobReportDays.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/50">
|
||||
미등록 내역이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{unregisteredJobReportDays.map((day, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-danger-500/20 flex items-center justify-center text-danger-400 text-xs font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-white font-medium">{day.date}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold ${day.hrs === 0 ? 'text-danger-400' : 'text-warning-400'}`}>
|
||||
{day.hrs}시간
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">/ 8시간</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUnregisteredModal(false);
|
||||
navigate('/jobreport');
|
||||
}}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
업무일지 작성하러 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NR 모달 */}
|
||||
{showNRModal && (
|
||||
<Modal title="구매요청 (NR) 목록" onClose={() => setShowNRModal(false)}>
|
||||
|
||||
374
Project/frontend/src/pages/HolidayRequest.tsx
Normal file
374
Project/frontend/src/pages/HolidayRequest.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Calendar, Search, User, RefreshCw, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||
import { comms } from '../communication';
|
||||
import { HolidayRequest, HolidayRequestSummary } from '../types';
|
||||
import { HolidayRequestDialog } from '../components/holiday/HolidayRequestDialog';
|
||||
|
||||
export default function HolidayRequestPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requests, setRequests] = useState<HolidayRequest[]>([]);
|
||||
const [summary, setSummary] = useState<HolidayRequestSummary>({
|
||||
ApprovedDays: 0,
|
||||
ApprovedTimes: 0,
|
||||
PendingDays: 0,
|
||||
PendingTimes: 0
|
||||
});
|
||||
|
||||
// 필터 상태
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState('%');
|
||||
const [userLevel, setUserLevel] = useState(0);
|
||||
const [currentUserId, setCurrentUserId] = useState('');
|
||||
const [currentUserName, setCurrentUserName] = useState('');
|
||||
|
||||
// 사용자 목록
|
||||
const [users, setUsers] = useState<Array<{id: string, name: string}>>([]);
|
||||
|
||||
// Dialog State
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [selectedRequest, setSelectedRequest] = useState<HolidayRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 날짜 설정: 1개월 전 ~ 1개월 후
|
||||
const today = new Date();
|
||||
const oneMonthAgo = new Date(today);
|
||||
oneMonthAgo.setMonth(today.getMonth() - 1);
|
||||
const oneMonthLater = new Date(today);
|
||||
oneMonthLater.setMonth(today.getMonth() + 1);
|
||||
|
||||
setStartDate(formatDate(oneMonthAgo));
|
||||
setEndDate(formatDate(oneMonthLater));
|
||||
|
||||
// 사용자 정보 로드
|
||||
loadUserInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (startDate && endDate && currentUserId) {
|
||||
loadData();
|
||||
}
|
||||
}, [startDate, endDate, selectedUserId, currentUserId]);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const formatDateShort = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
const d = dateStr.substring(0, 10);
|
||||
if (d.length < 10) return d;
|
||||
return `${d.substring(2, 4)}-${d.substring(5, 7)}-${d.substring(8, 10)}`;
|
||||
};
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
// 사용자 정보 조회
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
const user = loginStatus.User as { Level?: number; Id?: string; NameK?: string; Name?: string };
|
||||
setCurrentUserId(user.Id || '');
|
||||
setCurrentUserName(user.NameK || user.Name || '');
|
||||
setUserLevel(user.Level || 0);
|
||||
|
||||
// 사용자 목록 로드
|
||||
loadUsers(user.Level || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async (level: number) => {
|
||||
try {
|
||||
// 레벨 5 이상만 사용자 목록 조회 가능
|
||||
if (level >= 5) {
|
||||
const userList = await comms.getUserList('');
|
||||
if (userList && userList.length > 0) {
|
||||
const mappedUsers = userList.map((u: any) => ({
|
||||
id: u.id || u.Id,
|
||||
name: u.name || u.NameK || u.id
|
||||
}));
|
||||
setUsers([{ id: '%', name: '전체' }, ...mappedUsers]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const userId = userLevel < 5 ? currentUserId : selectedUserId;
|
||||
const response = await comms.getHolidayRequestList(startDate, endDate, userId, userLevel);
|
||||
|
||||
if (response.Success && response.Data) {
|
||||
setRequests(response.Data);
|
||||
// Summary는 별도 필드로 올 수 있음
|
||||
const data = response as any;
|
||||
if (data.Summary) {
|
||||
setSummary(data.Summary);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load holiday requests:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startDate, endDate, selectedUserId, userLevel, currentUserId]);
|
||||
|
||||
// 월 이동
|
||||
const moveMonth = (offset: number) => {
|
||||
const current = new Date(startDate);
|
||||
current.setMonth(current.getMonth() + offset);
|
||||
const year = current.getFullYear();
|
||||
const month = current.getMonth();
|
||||
|
||||
const newStart = new Date(year, month, 1);
|
||||
const newEnd = new Date(year, month + 1, 0);
|
||||
|
||||
setStartDate(formatDate(newStart));
|
||||
setEndDate(formatDate(newEnd));
|
||||
};
|
||||
|
||||
const getCategoryName = (cate: string) => {
|
||||
const categories: { [key: string]: string } = {
|
||||
'1': '연차',
|
||||
'2': '반차',
|
||||
'3': '병가',
|
||||
'4': '경조사',
|
||||
'5': '외출',
|
||||
'6': '기타'
|
||||
};
|
||||
return categories[cate] || cate;
|
||||
};
|
||||
|
||||
const getConfirmStatusText = (conf: number) => {
|
||||
return conf === 1 ? '승인' : '미승인';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect-solid border-b border-white/20">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-lg">
|
||||
<Calendar className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">휴가/외출 신청</h1>
|
||||
<p className="text-sm text-white/70">Holiday Request Management</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRequest(null);
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all shadow-lg hover:shadow-blue-500/30"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">신청</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 glass-effect-solid hover:bg-white/20 rounded-lg transition-all text-white disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm font-medium">새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="glass-effect-solid border-b border-white/20">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{/* 월 이동 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => moveMonth(-1)}
|
||||
className="p-2 glass-effect hover:bg-white/30 rounded-lg transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-white/80" />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="px-3 py-2 glass-effect text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-400/50 focus:outline-none"
|
||||
/>
|
||||
<span className="text-white/60">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="px-3 py-2 glass-effect text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-400/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => moveMonth(1)}
|
||||
className="p-2 glass-effect hover:bg-white/30 rounded-lg transition-all"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 담당자 선택 (레벨 5 이상만) */}
|
||||
{userLevel >= 5 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-white/80" />
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
className="px-3 py-2 glass-effect text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-400/50 focus:outline-none"
|
||||
>
|
||||
{users.map(user => (
|
||||
<option key={user.id} value={user.id}>{user.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조회 버튼 */}
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white text-sm font-medium rounded-lg shadow-lg transition-all disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 합계 표시 */}
|
||||
<div className="mt-4 flex gap-6 text-sm">
|
||||
<div className="glass-effect px-4 py-2 rounded-lg">
|
||||
<span className="font-medium text-white/90">합계(일) = </span>
|
||||
<span className="text-blue-300 font-semibold">승인: {summary.ApprovedDays}</span>
|
||||
<span className="mx-1 text-white/60">/</span>
|
||||
<span className={summary.PendingDays === 0 ? 'text-white/90' : 'text-red-400 font-semibold'}>
|
||||
미승인: {summary.PendingDays}
|
||||
</span>
|
||||
</div>
|
||||
<div className="glass-effect px-4 py-2 rounded-lg">
|
||||
<span className="font-medium text-white/90">합계(시간) = </span>
|
||||
<span className="text-blue-300 font-semibold">승인: {summary.ApprovedTimes}</span>
|
||||
<span className="mx-1 text-white/60">/</span>
|
||||
<span className={summary.PendingTimes === 0 ? 'text-white/90' : 'text-red-400 font-semibold'}>
|
||||
미승인: {summary.PendingTimes}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
<div className="glass-effect-solid rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">No</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">신청일</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">부서</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">신청자</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">종류</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">시작일</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">종료일</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-white/90">일수</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-white/90">시간</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-white/90">사유</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-white/90">승인</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-12 text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<RefreshCw className="w-5 h-5 text-blue-400 animate-spin" />
|
||||
<span className="text-white/60">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : requests.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Calendar className="w-12 h-12 text-white/30" />
|
||||
<span className="text-white/60">조회된 데이터가 없습니다.</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
requests.map((req, index) => (
|
||||
<tr
|
||||
key={req.idx}
|
||||
className="border-b border-white/5 hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedRequest(req);
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-3 text-white/70">{index + 1}</td>
|
||||
<td className="px-4 py-3 text-white/90">{formatDateShort(req.wdate)}</td>
|
||||
<td className="px-4 py-3 text-white/80">{req.dept || ''}</td>
|
||||
<td className="px-4 py-3 text-white font-medium">{req.name || ''}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-300 rounded text-xs font-medium">
|
||||
{getCategoryName(req.cate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white/90">{formatDateShort(req.sdate)}</td>
|
||||
<td className="px-4 py-3 text-white/90">{formatDateShort(req.edate)}</td>
|
||||
<td className="px-4 py-3 text-center text-white/90">{req.HolyDays || 0}</td>
|
||||
<td className="px-4 py-3 text-center text-white/90">{req.HolyTimes || 0}</td>
|
||||
<td className="px-4 py-3 text-white/70 max-w-xs truncate" title={req.HolyReason || ''}>
|
||||
{req.HolyReason || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||
req.conf === 1
|
||||
? 'bg-green-500/20 text-green-300'
|
||||
: 'bg-red-500/20 text-red-300'
|
||||
}`}>
|
||||
{getConfirmStatusText(req.conf)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다이얼로그 */}
|
||||
<HolidayRequestDialog
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
onSave={() => {
|
||||
setIsDialogOpen(false);
|
||||
loadData();
|
||||
}}
|
||||
initialData={selectedRequest}
|
||||
currentUserName={currentUserName}
|
||||
currentUserId={currentUserId}
|
||||
userLevel={userLevel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
Info,
|
||||
Plus,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { JobReportItem, JobReportUser } from '@/types';
|
||||
@@ -43,6 +45,11 @@ export function Jobreport() {
|
||||
// 오늘 근무시간 상태
|
||||
const [todayWork, setTodayWork] = useState({ hrs: 0, ot: 0 });
|
||||
|
||||
// 미등록 업무일지 상태
|
||||
const [unregisteredJobReportCount, setUnregisteredJobReportCount] = useState(0);
|
||||
const [unregisteredJobReportDays, setUnregisteredJobReportDays] = useState<{ date: string; hrs: number }[]>([]);
|
||||
const [showUnregisteredModal, setShowUnregisteredModal] = useState(false);
|
||||
|
||||
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
|
||||
const formatDateLocal = (date: Date) => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
@@ -70,6 +77,55 @@ export function Jobreport() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 미등록 업무일지 로드
|
||||
const loadUnregisteredJobReports = useCallback(async (userId: string) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const todayStr = formatDateLocal(now);
|
||||
|
||||
// 15일 전 날짜 계산
|
||||
const fifteenDaysAgoDate = new Date(now);
|
||||
fifteenDaysAgoDate.setDate(now.getDate() - 15);
|
||||
const fifteenDaysAgoStr = formatDateLocal(fifteenDaysAgoDate);
|
||||
|
||||
const response = await comms.getJobReportList(fifteenDaysAgoStr, todayStr, userId, '');
|
||||
|
||||
if (response.Success && response.Data) {
|
||||
const dailyWork: { [key: string]: number } = {};
|
||||
|
||||
// 날짜별 시간 합계 계산
|
||||
response.Data.forEach((item: JobReportItem) => {
|
||||
if (item.pdate) {
|
||||
const date = item.pdate.substring(0, 10);
|
||||
dailyWork[date] = (dailyWork[date] || 0) + (item.hrs || 0);
|
||||
}
|
||||
});
|
||||
|
||||
const insufficientDays: { date: string; hrs: number }[] = [];
|
||||
|
||||
// 어제부터 15일 전까지 확인 (오늘은 제외)
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() - i);
|
||||
const dStr = formatDateLocal(d);
|
||||
|
||||
// 주말(토:6, 일:0) 제외
|
||||
if (d.getDay() === 0 || d.getDay() === 6) continue;
|
||||
|
||||
const hrs = dailyWork[dStr] || 0;
|
||||
if (hrs < 8) {
|
||||
insufficientDays.push({ date: dStr, hrs });
|
||||
}
|
||||
}
|
||||
|
||||
setUnregisteredJobReportCount(insufficientDays.length);
|
||||
setUnregisteredJobReportDays(insufficientDays);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('미등록 업무일지 로드 오류:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기화 완료 플래그
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
@@ -129,6 +185,7 @@ export function Jobreport() {
|
||||
const handleSearchAndLoadToday = async () => {
|
||||
await handleSearch();
|
||||
loadTodayWork(selectedUser);
|
||||
loadUnregisteredJobReports(selectedUser);
|
||||
};
|
||||
|
||||
// 사용자 목록 로드
|
||||
@@ -182,7 +239,10 @@ export function Jobreport() {
|
||||
// 새 업무일지 추가 모달
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setFormData({
|
||||
...initialFormData,
|
||||
pdate: formatDateLocal(new Date())
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@@ -195,7 +255,7 @@ export function Jobreport() {
|
||||
const data = response.Data;
|
||||
setEditingItem(null); // 새로 추가하는 것이므로 null
|
||||
setFormData({
|
||||
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
||||
pdate: formatDateLocal(new Date()), // 오늘 날짜 (로컬 시간 기준)
|
||||
projectName: data.projectName || '',
|
||||
pidx: data.pidx ?? null, // pidx도 복사
|
||||
requestpart: data.requestpart || '',
|
||||
@@ -314,6 +374,7 @@ export function Jobreport() {
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
loadTodayWork(selectedUser);
|
||||
loadUnregisteredJobReports(selectedUser);
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
@@ -336,6 +397,7 @@ export function Jobreport() {
|
||||
alert('삭제되었습니다.');
|
||||
loadData();
|
||||
loadTodayWork(selectedUser);
|
||||
loadUnregisteredJobReports(selectedUser);
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
@@ -535,17 +597,34 @@ export function Jobreport() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 미등록 업무일지 카드 */}
|
||||
<div className="flex-shrink-0 w-40">
|
||||
<div
|
||||
className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={() => setShowUnregisteredModal(true)}
|
||||
>
|
||||
<div className="text-white/70 text-sm font-medium mb-2 text-center flex items-center justify-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-danger-400" />
|
||||
미등록
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl font-bold text-danger-400">{unregisteredJobReportCount}</span>
|
||||
<span className="text-white/70 text-lg ml-1">건</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 오늘 근무시간 */}
|
||||
<div className="flex-shrink-0 w-48">
|
||||
<div className="flex-shrink-0 w-40">
|
||||
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center">
|
||||
<div className="text-white/70 text-sm font-medium mb-2 text-center">오늘 근무시간</div>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl font-bold text-white">{todayWork.hrs}</span>
|
||||
<span className="text-3xl font-bold text-white">{todayWork.hrs.toFixed(1)}</span>
|
||||
<span className="text-white/70 text-lg ml-1">시간</span>
|
||||
</div>
|
||||
{todayWork.ot > 0 && (
|
||||
<div className="text-center mt-1">
|
||||
<span className="text-warning-400 text-sm">OT: {todayWork.ot}시간</span>
|
||||
<span className="text-warning-400 text-sm">OT: {todayWork.ot.toFixed(1)}시간</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -568,13 +647,13 @@ export function Jobreport() {
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-center text-xs font-medium text-white/70 uppercase w-10"></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-24">날짜</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase" style={{ width: '35%' }}>프로젝트</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>
|
||||
{canViewOT && <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">담당자</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase w-24">날짜</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase" style={{ width: '35%' }}>프로젝트</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">업무형태</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">상태</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">시간</th>
|
||||
{canViewOT && <th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>}
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
@@ -722,6 +801,66 @@ export function Jobreport() {
|
||||
endDate={endDate}
|
||||
userId={selectedUser}
|
||||
/>
|
||||
|
||||
{/* 업무일지 미등록 상세 모달 */}
|
||||
{showUnregisteredModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-scale-in">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-danger-400" />
|
||||
업무일지 미등록 내역
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowUnregisteredModal(false)}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<p className="text-white/70 text-sm mb-4">
|
||||
최근 15일(평일 기준) 중 8시간 미만 등록된 날짜입니다.
|
||||
</p>
|
||||
|
||||
{unregisteredJobReportDays.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/50">
|
||||
미등록 내역이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{unregisteredJobReportDays.map((day, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-danger-500/20 flex items-center justify-center text-danger-400 text-xs font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-white font-medium">{day.date}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold ${day.hrs === 0 ? 'text-danger-400' : 'text-warning-400'}`}>
|
||||
{day.hrs}시간
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">/ 8시간</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowUnregisteredModal(false)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,6 +488,10 @@ export interface MachineBridgeInterface {
|
||||
PartList_GetList(projectIdx: number): Promise<string>;
|
||||
PartList_Save(idx: number, projectIdx: number, itemgroup: string, itemname: string, item: string, itemmodel: string, itemscale: string, itemunit: string, qty: number, price: number, itemsupply: string, itemsupplyidx: number, itemmanu: string, itemsid: string, option1: string, remark: string, no: number, qtybuy: number): Promise<string>;
|
||||
PartList_Delete(idx: number): Promise<string>;
|
||||
|
||||
// HolidayRequest API (휴가/외출 신청)
|
||||
HolidayRequest_GetList(startDate: string, endDate: string, userId: string, userLevel: number): Promise<string>;
|
||||
HolidayRequest_Save(idx: number, uid: string, cate: string, sdate: string, edate: string, remark: string, response: string, conf: number, holyReason: string, holyBackup: string, holyLocation: string, holyDays: number, holyTimes: number, stime: string, etime: string): Promise<string>;
|
||||
}
|
||||
|
||||
// 사용자 권한 정보 타입
|
||||
@@ -846,6 +850,44 @@ export interface JobReportTypeItem {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// 휴가/외출 신청 타입
|
||||
export interface HolidayRequest {
|
||||
idx: number;
|
||||
gcode: string;
|
||||
uid: string;
|
||||
cate: string;
|
||||
sdate: string;
|
||||
edate: string;
|
||||
Remark?: string;
|
||||
wuid?: string;
|
||||
wdate?: string;
|
||||
dept?: string;
|
||||
name?: string;
|
||||
grade?: string;
|
||||
tel?: string;
|
||||
processs?: string;
|
||||
Response?: string;
|
||||
conf: number;
|
||||
HolyReason?: string;
|
||||
HolyBackup?: string;
|
||||
HolyLocation?: string;
|
||||
HolyDays?: number;
|
||||
HolyTimes?: number;
|
||||
sendmail?: boolean;
|
||||
stime?: string;
|
||||
etime?: string;
|
||||
conf_id?: string;
|
||||
conf_time?: string;
|
||||
}
|
||||
|
||||
// 휴가/외출 신청 합계 타입
|
||||
export interface HolidayRequestSummary {
|
||||
ApprovedDays: number;
|
||||
ApprovedTimes: number;
|
||||
PendingDays: number;
|
||||
PendingTimes: number;
|
||||
}
|
||||
|
||||
export interface JobReportDayData {
|
||||
items: JobReportDayItem[];
|
||||
holidays: HolidayItem[];
|
||||
|
||||
Reference in New Issue
Block a user