nr 구매 제한 기능 추가

- 트리거를 이용하여 기존 프로그램 사용자도 오류가 발생하도록 함
This commit is contained in:
backuppc
2025-12-12 11:06:13 +09:00
parent 77f1ddab80
commit 890e6edab4
20 changed files with 1787 additions and 216 deletions

View File

@@ -367,6 +367,7 @@
<Compile Include="MessageWindow.cs" />
<Compile Include="MethodExtentions.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.HolidayRequest.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Login.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Dashboard.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Todo.cs" />

View File

@@ -9,7 +9,7 @@
<ErrorReportUrlHistory />
<FallbackCulture>ko-KR</FallbackCulture>
<VerifyUploadedFiles>false</VerifyUploadedFiles>
<ProjectView>ShowAllFiles</ProjectView>
<ProjectView>ProjectFiles</ProjectView>
</PropertyGroup>
<PropertyGroup>
<EnableSecurityDebugging>false</EnableSecurityDebugging>

View File

@@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// 모든 값을 지정하거나 아래와 같이 '*'를 사용하여 빌드 번호 및 수정 번호가 자동으로
// 지정되도록 할 수 있습니다.
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("25.11.11.1030")]
[assembly: AssemblyFileVersion("25.11.11.1030")]
[assembly: AssemblyVersion("25.12.12.1050")]
[assembly: AssemblyFileVersion("25.12.12.1050")]

View File

@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using Newtonsoft.Json;
using FCOMMON;
namespace Project.Web
{
public partial class MachineBridge
{
#region HolidayRequest API (/ )
/// <summary>
/// 휴가/외출 신청 목록 조회
/// </summary>
public string HolidayRequest_GetList(string startDate, string endDate, string userId, int userLevel)
{
try
{
// 권한에 따른 uid 필터링
// userLevel < 5: 본인만 조회
// userLevel >= 5: userId가 '%'이면 전체, 특정 uid면 해당 사용자만
var uidFilter = userLevel < 5 ? info.Login.no : (string.IsNullOrEmpty(userId) || userId == "%" ? "%" : userId);
var sql = @"
SELECT
hr.idx, hr.gcode, hr.uid, hr.cate, hr.sdate, hr.edate, hr.Remark, hr.wuid, hr.wdate,
u.dept, u.name, u.grade, u.tel, u.processs,
hr.Response, hr.conf, hr.HolyReason, hr.HolyBackup, hr.HolyLocation,
hr.HolyDays, hr.HolyTimes, hr.sendmail, hr.stime, hr.etime, hr.conf_id, hr.conf_time
FROM EETGW_HolydayRequest hr WITH (nolock)
LEFT OUTER JOIN vGroupUser u ON hr.uid = u.id AND hr.gcode = u.gcode
WHERE hr.gcode = @gcode
AND hr.sdate >= @startDate
AND hr.sdate <= @endDate
AND hr.uid LIKE @uid
ORDER BY hr.conf, hr.sdate DESC";
var cs = Properties.Settings.Default.gwcs;
using (var cn = new SqlConnection(cs))
using (var cmd = new SqlCommand(sql, cn))
{
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
cmd.Parameters.AddWithValue("@startDate", startDate);
cmd.Parameters.AddWithValue("@endDate", endDate);
cmd.Parameters.AddWithValue("@uid", uidFilter);
using (var da = new SqlDataAdapter(cmd))
{
var dt = new DataTable();
da.Fill(dt);
// 승인/미승인 합계 계산
decimal sumApprovedDays = 0;
decimal sumApprovedTimes = 0;
decimal sumPendingDays = 0;
decimal sumPendingTimes = 0;
foreach (DataRow row in dt.Rows)
{
var conf = Convert.ToInt32(row["conf"]);
var days = row["HolyDays"] != DBNull.Value ? Convert.ToDecimal(row["HolyDays"]) : 0;
var times = row["HolyTimes"] != DBNull.Value ? Convert.ToDecimal(row["HolyTimes"]) : 0;
if (conf == 1)
{
sumApprovedDays += days;
sumApprovedTimes += times;
}
else
{
sumPendingDays += days;
sumPendingTimes += times;
}
}
return JsonConvert.SerializeObject(new
{
Success = true,
Data = dt,
Summary = new
{
ApprovedDays = sumApprovedDays,
ApprovedTimes = sumApprovedTimes,
PendingDays = sumPendingDays,
PendingTimes = sumPendingTimes
}
});
}
}
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
/// <summary>
/// 휴가/외출 신청 저장
/// </summary>
public string HolidayRequest_Save(int idx, string uid, string cate, string sdate, string edate,
string remark, string response, int conf, string holyReason, string holyBackup,
string holyLocation, decimal holyDays, decimal holyTimes, string stime, string etime)
{
try
{
var cs = Properties.Settings.Default.gwcs;
using (var cn = new SqlConnection(cs))
{
cn.Open();
var sql = "";
if (idx == 0) // INSERT
{
sql = @"INSERT INTO EETGW_HolydayRequest
(gcode, uid, cate, sdate, edate, conf, Remark, wuid, wdate, Response,
HolyReason, HolyBackup, HolyLocation, HolyDays, HolyTimes, stime, etime)
VALUES
(@gcode, @uid, @cate, @sdate, @edate, @conf, @remark, @wuid, GETDATE(), @response,
@holyReason, @holyBackup, @holyLocation, @holyDays, @holyTimes, @stime, @etime)";
}
else // UPDATE
{
sql = @"UPDATE EETGW_HolydayRequest
SET uid = @uid, cate = @cate, sdate = @sdate, edate = @edate, conf = @conf,
Remark = @remark, Response = @response, HolyReason = @holyReason,
HolyBackup = @holyBackup, HolyLocation = @holyLocation,
HolyDays = @holyDays, HolyTimes = @holyTimes, stime = @stime, etime = @etime
WHERE idx = @idx AND gcode = @gcode";
}
using (var cmd = new SqlCommand(sql, cn))
{
cmd.Parameters.AddWithValue("@idx", idx);
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
cmd.Parameters.AddWithValue("@uid", uid);
cmd.Parameters.AddWithValue("@cate", cate);
cmd.Parameters.AddWithValue("@sdate", sdate);
cmd.Parameters.AddWithValue("@edate", edate);
cmd.Parameters.AddWithValue("@conf", conf);
cmd.Parameters.AddWithValue("@remark", remark ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no); // 작성자
cmd.Parameters.AddWithValue("@response", response ?? "");
cmd.Parameters.AddWithValue("@holyReason", holyReason ?? "");
cmd.Parameters.AddWithValue("@holyBackup", holyBackup ?? "");
cmd.Parameters.AddWithValue("@holyLocation", holyLocation ?? "");
cmd.Parameters.AddWithValue("@holyDays", holyDays);
cmd.Parameters.AddWithValue("@holyTimes", holyTimes);
cmd.Parameters.AddWithValue("@stime", stime ?? "");
cmd.Parameters.AddWithValue("@etime", etime ?? "");
cmd.ExecuteNonQuery();
}
}
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다." });
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
#endregion
}
}

View File

@@ -599,6 +599,7 @@ namespace Project.Web
}
break;
// ===== JobReport API (JobReport 뷰/테이블) =====
case "JOBREPORT_GET_LIST":
{
@@ -607,9 +608,10 @@ namespace Project.Web
string uid = json.uid ?? "";
string cate = json.cate ?? ""; // 사용안함 (호환성)
string searchKey = json.searchKey ?? "";
string requestId = json.requestId;
Console.WriteLine($"[WS] JOBREPORT_GET_LIST: sd={sd}, ed={ed}, uid={uid}, searchKey={searchKey}");
string result = _bridge.Jobreport_GetList(sd, ed, uid, cate, searchKey);
var response = new { type = "JOBREPORT_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
var response = new { type = "JOBREPORT_LIST_DATA", data = JsonConvert.DeserializeObject(result), requestId = requestId };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
@@ -1199,6 +1201,43 @@ namespace Project.Web
}
break;
// ===== HolidayRequest API (휴가/외출 신청) =====
case "HOLIDAY_REQUEST_GET_LIST":
{
string startDate = json.startDate ?? "";
string endDate = json.endDate ?? "";
string userId = json.userId ?? "";
int userLevel = json.userLevel ?? 0;
string result = _bridge.HolidayRequest_GetList(startDate, endDate, userId, userLevel);
var response = new { type = "HOLIDAY_REQUEST_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLIDAY_REQUEST_SAVE":
{
int idx = json.idx ?? 0;
string uid = json.uid ?? "";
string cate = json.cate ?? "";
string sdate = json.sdate ?? "";
string edate = json.edate ?? "";
string remark = json.remark ?? "";
string responseMsg = json.response ?? "";
int conf = json.conf ?? 0;
string holyReason = json.holyReason ?? "";
string holyBackup = json.holyBackup ?? "";
string holyLocation = json.holyLocation ?? "";
decimal holyDays = json.holyDays ?? 0;
decimal holyTimes = json.holyTimes ?? 0;
string stime = json.stime ?? "";
string etime = json.etime ?? "";
string result = _bridge.HolidayRequest_Save(idx, uid, cate, sdate, edate, remark, responseMsg, conf, holyReason, holyBackup, holyLocation, holyDays, holyTimes, stime, etime);
var response = new { type = "HOLIDAY_REQUEST_SAVED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
// ===== MailForm API (메일양식) =====
case "MAILFORM_GET_LIST":
{

View File

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

View File

@@ -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[]>> {

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

View File

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

View File

@@ -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'); // 상태

View File

@@ -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: '휴가/외출 신청' },
],
},
{

View File

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

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

View File

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

View File

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