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

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

View File

@@ -361,6 +361,7 @@
<Compile Include="Web\MachineBridge\MachineBridge.User.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.UserList.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Holiday.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Holyday.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.MailForm.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.UserGroup.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.UserAuth.cs" />

View File

@@ -0,0 +1,447 @@
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 Holyday () API
/// <summary>
/// 근태 목록 조회 (사용자 선택 가능)
/// </summary>
public string Holyday_GetList(string sd, string ed, string uid)
{
try
{
// 권한 확인
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.holyday));
var sql = @"SELECT h.*, u.name as UserName
FROM Holyday h WITH (nolock)
LEFT JOIN Users u ON h.uid = u.id
WHERE h.gcode = @gcode";
var parameters = new List<SqlParameter>();
parameters.Add(new SqlParameter("@gcode", info.Login.gcode));
// 권한에 따라 사용자 필터링
if (curLevel < 5)
{
// 일반 사용자는 본인 것만
sql += " AND h.uid = @uid";
parameters.Add(new SqlParameter("@uid", info.Login.no));
}
else if (!string.IsNullOrEmpty(uid) && uid != "%")
{
// 관리자가 특정 사용자 선택
sql += " AND h.uid = @uid";
parameters.Add(new SqlParameter("@uid", uid));
}
if (!string.IsNullOrEmpty(sd))
{
sql += " AND h.sdate >= @sd";
parameters.Add(new SqlParameter("@sd", sd));
}
if (!string.IsNullOrEmpty(ed))
{
sql += " AND h.sdate <= @ed";
parameters.Add(new SqlParameter("@ed", ed));
}
sql += " ORDER BY h.sdate DESC, h.idx DESC";
var cs = Properties.Settings.Default.gwcs;
using (var cn = new SqlConnection(cs))
using (var cmd = new SqlCommand(sql, cn))
{
cmd.Parameters.AddRange(parameters.ToArray());
using (var da = new SqlDataAdapter(cmd))
{
var dt = new DataTable();
da.Fill(dt);
return JsonConvert.SerializeObject(new { Success = true, Data = dt });
}
}
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
/// <summary>
/// 근태 상세 조회
/// </summary>
public string Holyday_GetDetail(int idx)
{
try
{
var sql = @"SELECT h.*, u.name as UserName
FROM Holyday h WITH (nolock)
LEFT JOIN Users u ON h.uid = u.id
WHERE h.idx = @idx AND h.gcode = @gcode";
var cs = Properties.Settings.Default.gwcs;
using (var cn = new SqlConnection(cs))
using (var cmd = new SqlCommand(sql, cn))
{
cmd.Parameters.AddWithValue("@idx", idx);
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
using (var da = new SqlDataAdapter(cmd))
{
var dt = new DataTable();
da.Fill(dt);
if (dt.Rows.Count > 0)
{
var row = dt.Rows[0];
var data = new Dictionary<string, object>();
foreach (DataColumn col in dt.Columns)
{
data[col.ColumnName] = row[col] == DBNull.Value ? null : row[col];
}
return JsonConvert.SerializeObject(new { Success = true, Data = data });
}
return JsonConvert.SerializeObject(new { Success = false, Message = "데이터를 찾을 수 없습니다." });
}
}
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
/// <summary>
/// 근태 추가
/// </summary>
public string Holyday_Add(string cate, string sdate, string edate, double term, double crtime,
double termDr, double drTime, string contents, string uid)
{
try
{
// 권한 확인
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.holyday));
if (curLevel < 5)
{
return JsonConvert.SerializeObject(new { Success = false, Message = "권한이 없습니다." });
}
// 마감 체크
var smon = sdate.Substring(0, 7);
if (DBM.GetMagamStatus(smon))
{
return JsonConvert.SerializeObject(new { Success = false, Message = $"등록일이 속한 월({smon})이 마감되었습니다." });
}
var sql = @"INSERT INTO Holyday (gcode, cate, sdate, edate, term, crtime, termDr, DrTime, contents, uid, wdate, wuid)
VALUES (@gcode, @cate, @sdate, @edate, @term, @crtime, @termDr, @drTime, @contents, @uid, GETDATE(), @wuid);
SELECT SCOPE_IDENTITY();";
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("@cate", cate ?? "");
cmd.Parameters.AddWithValue("@sdate", sdate);
cmd.Parameters.AddWithValue("@edate", edate);
cmd.Parameters.AddWithValue("@term", term);
cmd.Parameters.AddWithValue("@crtime", crtime);
cmd.Parameters.AddWithValue("@termDr", termDr);
cmd.Parameters.AddWithValue("@drTime", drTime);
cmd.Parameters.AddWithValue("@contents", contents ?? "");
cmd.Parameters.AddWithValue("@uid", uid);
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
cn.Open();
var newId = Convert.ToInt32(cmd.ExecuteScalar());
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다.", Data = new { idx = newId } });
}
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
/// <summary>
/// 근태 수정
/// </summary>
public string Holyday_Edit(int idx, string cate, string sdate, string edate, double term, double crtime,
double termDr, double drTime, string contents)
{
try
{
// 권한 확인
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.holyday));
if (curLevel < 5)
{
return JsonConvert.SerializeObject(new { Success = false, Message = "권한이 없습니다." });
}
// 마감 체크
var smon = sdate.Substring(0, 7);
if (DBM.GetMagamStatus(smon))
{
return JsonConvert.SerializeObject(new { Success = false, Message = $"등록일이 속한 월({smon})이 마감되었습니다." });
}
// 외부 연동 데이터 체크
var checkSql = "SELECT extcate, extidx FROM Holyday WHERE idx = @idx AND gcode = @gcode";
var cs = Properties.Settings.Default.gwcs;
using (var cn = new SqlConnection(cs))
{
using (var cmd = new SqlCommand(checkSql, cn))
{
cmd.Parameters.AddWithValue("@idx", idx);
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
cn.Open();
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
var extcate = reader["extcate"] != DBNull.Value ? reader["extcate"].ToString() : "";
var extidx = reader["extidx"] != DBNull.Value ? Convert.ToInt32(reader["extidx"]) : -1;
if (!string.IsNullOrEmpty(extcate) && extidx > 0)
{
return JsonConvert.SerializeObject(new {
Success = false,
Message = $"이 자료는 외부에서 자동생성된 자료입니다. (소스: {extcate}:{extidx})"
});
}
}
}
}
var sql = @"UPDATE Holyday SET
cate = @cate, sdate = @sdate, edate = @edate, term = @term, crtime = @crtime,
termDr = @termDr, DrTime = @drTime, contents = @contents, wuid = @wuid, wdate = GETDATE()
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("@cate", cate ?? "");
cmd.Parameters.AddWithValue("@sdate", sdate);
cmd.Parameters.AddWithValue("@edate", edate);
cmd.Parameters.AddWithValue("@term", term);
cmd.Parameters.AddWithValue("@crtime", crtime);
cmd.Parameters.AddWithValue("@termDr", termDr);
cmd.Parameters.AddWithValue("@drTime", drTime);
cmd.Parameters.AddWithValue("@contents", contents ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
var result = cmd.ExecuteNonQuery();
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "수정되었습니다." : "수정에 실패했습니다." });
}
}
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
/// <summary>
/// 근태 삭제
/// </summary>
public string Holyday_Delete(int idx)
{
try
{
// 권한 확인
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.holyday));
if (curLevel < 5)
{
return JsonConvert.SerializeObject(new { Success = false, Message = "권한이 없습니다." });
}
// 외부 연동 데이터 및 마감 체크
var checkSql = "SELECT extcate, extidx, sdate FROM Holyday WHERE idx = @idx AND gcode = @gcode";
var cs = Properties.Settings.Default.gwcs;
using (var cn = new SqlConnection(cs))
{
using (var cmd = new SqlCommand(checkSql, cn))
{
cmd.Parameters.AddWithValue("@idx", idx);
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
cn.Open();
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
var extcate = reader["extcate"] != DBNull.Value ? reader["extcate"].ToString() : "";
var extidx = reader["extidx"] != DBNull.Value ? Convert.ToInt32(reader["extidx"]) : -1;
var sdate = reader["sdate"] != DBNull.Value ? reader["sdate"].ToString() : "";
if (!string.IsNullOrEmpty(extcate) && extidx > 0)
{
return JsonConvert.SerializeObject(new {
Success = false,
Message = $"이 자료는 외부에서 자동생성된 자료입니다. (소스: {extcate}:{extidx})"
});
}
if (!string.IsNullOrEmpty(sdate) && sdate.Length >= 7)
{
var smon = sdate.Substring(0, 7);
if (DBM.GetMagamStatus(smon))
{
return JsonConvert.SerializeObject(new { Success = false, Message = $"등록일이 속한 월({smon})이 마감되었습니다." });
}
}
}
}
}
var sql = "DELETE FROM Holyday 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);
var result = cmd.ExecuteNonQuery();
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "삭제되었습니다." : "삭제에 실패했습니다." });
}
}
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
/// <summary>
/// 근태 사용자 목록 조회 (기간 내 데이터가 있는 사용자)
/// </summary>
public string Holyday_GetUserList(string sd, string ed)
{
try
{
// 권한 확인
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.holyday));
var sql = @"SELECT DISTINCT h.uid, u.name as UserName
FROM Holyday h WITH (nolock)
LEFT JOIN Users u ON h.uid = u.id
WHERE h.gcode = @gcode";
var parameters = new List<SqlParameter>();
parameters.Add(new SqlParameter("@gcode", info.Login.gcode));
if (!string.IsNullOrEmpty(sd))
{
sql += " AND h.sdate >= @sd";
parameters.Add(new SqlParameter("@sd", sd));
}
if (!string.IsNullOrEmpty(ed))
{
sql += " AND h.sdate <= @ed";
parameters.Add(new SqlParameter("@ed", ed));
}
sql += " ORDER BY u.name";
var cs = Properties.Settings.Default.gwcs;
using (var cn = new SqlConnection(cs))
using (var cmd = new SqlCommand(sql, cn))
{
cmd.Parameters.AddRange(parameters.ToArray());
using (var da = new SqlDataAdapter(cmd))
{
var dt = new DataTable();
da.Fill(dt);
return JsonConvert.SerializeObject(dt);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Holyday_GetUserList 오류: {ex.Message}");
return "[]";
}
}
/// <summary>
/// 근태 권한 정보 조회
/// </summary>
public string Holyday_GetPermission()
{
try
{
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.holyday));
bool canManage = curLevel >= 5;
return JsonConvert.SerializeObject(new
{
Success = true,
CurrentUserId = info.Login.no,
Level = curLevel,
CanManage = canManage
});
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
/// <summary>
/// 근태 잔량 조회 (연도별)
/// </summary>
public string Holyday_GetBalance(string year, string uid)
{
try
{
// 권한 확인
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.holyday));
// 본인 확인 또는 관리자 권한 확인
if (uid != info.Login.no && curLevel < 5)
{
return JsonConvert.SerializeObject(new { Success = false, Message = "권한이 없습니다." });
}
var sql = @"SELECT
cate,
ISNULL(SUM(termDr), 0) as TotalGenDays,
ISNULL(SUM(DrTime), 0) as TotalGenHours,
ISNULL(SUM(term), 0) as TotalUseDays,
ISNULL(SUM(crtime), 0) as TotalUseHours
FROM Holyday WITH (nolock)
WHERE gcode = @gcode AND uid = @uid AND sdate LIKE @year + '%'
GROUP BY cate";
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("@uid", uid);
cmd.Parameters.AddWithValue("@year", year);
using (var da = new SqlDataAdapter(cmd))
{
var dt = new DataTable();
da.Fill(dt);
return JsonConvert.SerializeObject(new { Success = true, Data = dt });
}
}
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
#endregion
}
}

View File

@@ -99,10 +99,10 @@ namespace Project.Web
{
try
{
// 뷰에서 기본 정보 조회, 원본 테이블에서 jobgrp, tag 추가 조회
// 뷰에서 기본 정보 조회, 원본 테이블에서 jobgrp, tag, otStart, otEnd 추가 조회
var sql = @"SELECT v.idx, v.pidx, v.pdate, v.id, v.name, v.type, v.svalue, v.hrs, v.ot,
v.requestpart, v.package, v.userprocess, v.status, v.projectName, v.description,
v.ww, v.otpms, v.process, j.jobgrp, j.tag
v.ww, v.otpms, v.process, j.jobgrp, j.tag, j.otStart, j.otEnd
FROM vJobReportForUser v WITH (nolock)
INNER JOIN JobReport j WITH (nolock) ON v.idx = j.idx
WHERE v.idx = @idx AND v.gcode = @gcode";
@@ -141,7 +141,8 @@ namespace Project.Web
/// 업무일지 추가 (JobReport 테이블)
/// </summary>
public string Jobreport_Add(string pdate, string projectName, int pidx, string requestpart, string package,
string type, string process, string status, string description, double hrs, double ot, string jobgrp, string tag)
string type, string process, string status, string description, double hrs, double ot, string jobgrp, string tag,
string otStart, string otEnd)
{
try
{
@@ -153,9 +154,9 @@ namespace Project.Web
}
var sql = @"INSERT INTO JobReport (gcode, uid, pdate, projectName, requestpart, package,
type, process, status, description, hrs, ot, jobgrp, tag, wuid, wdate, pidx)
type, process, status, description, hrs, ot, jobgrp, tag, wuid, wdate, pidx, otStart, otEnd)
VALUES (@gcode, @uid, @pdate, @projectName, @requestpart, @package,
@type, @process, @status, @description, @hrs, @ot, @jobgrp, @tag, @wuid, GETDATE(), @pidx);
@type, @process, @status, @description, @hrs, @ot, @jobgrp, @tag, @wuid, GETDATE(), @pidx, @otStart, @otEnd);
SELECT SCOPE_IDENTITY();";
var cs = Properties.Settings.Default.gwcs;
@@ -178,6 +179,20 @@ namespace Project.Web
cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? "");
cmd.Parameters.AddWithValue("@tag", tag ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
// otStart, otEnd 처리 (HH:mm 형식을 datetime으로 변환)
if (!string.IsNullOrEmpty(otStart) && !string.IsNullOrEmpty(otEnd))
{
var otStartDateTime = DateTime.Parse($"{pdate} {otStart}:00");
var otEndDateTime = DateTime.Parse($"{pdate} {otEnd}:00");
cmd.Parameters.AddWithValue("@otStart", otStartDateTime);
cmd.Parameters.AddWithValue("@otEnd", otEndDateTime);
}
else
{
cmd.Parameters.AddWithValue("@otStart", DBNull.Value);
cmd.Parameters.AddWithValue("@otEnd", DBNull.Value);
}
cn.Open();
var newId = Convert.ToInt32(cmd.ExecuteScalar());
@@ -194,7 +209,8 @@ namespace Project.Web
/// 업무일지 수정 (JobReport 테이블)
/// </summary>
public string Jobreport_Edit(int idx, string pdate, string projectName, int pidx, string requestpart, string package,
string type, string process, string status, string description, double hrs, double ot, string jobgrp, string tag)
string type, string process, string status, string description, double hrs, double ot, string jobgrp, string tag,
string otStart, string otEnd)
{
try
{
@@ -231,7 +247,7 @@ namespace Project.Web
pdate = @pdate, projectName = @projectName, pidx = @pidx, requestpart = @requestpart,
package = @package, type = @type, process = @process, status = @status,
description = @description, hrs = @hrs, ot = @ot, jobgrp = @jobgrp, tag = @tag,
wuid = @wuid, wdate = GETDATE()
otStart = @otStart, otEnd = @otEnd, wuid = @wuid, wdate = GETDATE()
WHERE idx = @idx AND gcode = @gcode";
var cs = Properties.Settings.Default.gwcs;
@@ -254,6 +270,20 @@ namespace Project.Web
cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? "");
cmd.Parameters.AddWithValue("@tag", tag ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
// otStart, otEnd 처리 (HH:mm 형식을 datetime으로 변환)
if (!string.IsNullOrEmpty(otStart) && !string.IsNullOrEmpty(otEnd))
{
var otStartDateTime = DateTime.Parse($"{pdate} {otStart}:00");
var otEndDateTime = DateTime.Parse($"{pdate} {otEnd}:00");
cmd.Parameters.AddWithValue("@otStart", otStartDateTime);
cmd.Parameters.AddWithValue("@otEnd", otEndDateTime);
}
else
{
cmd.Parameters.AddWithValue("@otStart", DBNull.Value);
cmd.Parameters.AddWithValue("@otEnd", DBNull.Value);
}
cn.Open();
var result = cmd.ExecuteNonQuery();

View File

@@ -646,7 +646,9 @@ namespace Project.Web
double ot = json.ot ?? 0.0;
string jobgrp = json.jobgrp ?? "";
string tag = json.tag ?? "";
string result = _bridge.Jobreport_Add(pdate, projectName, pidx, requestpart, package, jobType, process, status, description, hrs, ot, jobgrp, tag);
string otStart = json.otStart ?? "00:00";
string otEnd = json.otEnd ?? "00:00";
string result = _bridge.Jobreport_Add(pdate, projectName, pidx, requestpart, package, jobType, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd);
var response = new { type = "JOBREPORT_ADDED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
@@ -668,7 +670,9 @@ namespace Project.Web
double ot = json.ot ?? 0.0;
string jobgrp = json.jobgrp ?? "";
string tag = json.tag ?? "";
string result = _bridge.Jobreport_Edit(idx, pdate, projectName, pidx, requestpart, package, jobType, process, status, description, hrs, ot, jobgrp, tag);
string otStart = json.otStart ?? "00:00";
string otEnd = json.otEnd ?? "00:00";
string result = _bridge.Jobreport_Edit(idx, pdate, projectName, pidx, requestpart, package, jobType, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd);
var response = new { type = "JOBREPORT_EDITED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
@@ -729,6 +733,99 @@ namespace Project.Web
}
break;
// ===== Holyday (근태) API =====
case "HOLYDAY_GET_LIST":
{
string sd = json.sd ?? "";
string ed = json.ed ?? "";
string uid = json.uid ?? "%";
string result = _bridge.Holyday_GetList(sd, ed, uid);
var response = new { type = "HOLYDAY_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLYDAY_GET_DETAIL":
{
int idx = json.idx ?? 0;
string result = _bridge.Holyday_GetDetail(idx);
var response = new { type = "HOLYDAY_DETAIL_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLYDAY_ADD":
{
string cate = json.cate ?? "";
string sdate = json.sdate ?? "";
string edate = json.edate ?? "";
double term = json.term ?? 0.0;
double crtime = json.crtime ?? 0.0;
double termDr = json.termDr ?? 0.0;
double drTime = json.drTime ?? 0.0;
string contents = json.contents ?? "";
string uid = json.uid ?? "";
string result = _bridge.Holyday_Add(cate, sdate, edate, term, crtime, termDr, drTime, contents, uid);
var response = new { type = "HOLYDAY_ADDED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLYDAY_EDIT":
{
int idx = json.idx ?? 0;
string cate = json.cate ?? "";
string sdate = json.sdate ?? "";
string edate = json.edate ?? "";
double term = json.term ?? 0.0;
double crtime = json.crtime ?? 0.0;
double termDr = json.termDr ?? 0.0;
double drTime = json.drTime ?? 0.0;
string contents = json.contents ?? "";
string result = _bridge.Holyday_Edit(idx, cate, sdate, edate, term, crtime, termDr, drTime, contents);
var response = new { type = "HOLYDAY_EDITED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLYDAY_DELETE":
{
int idx = json.idx ?? 0;
string result = _bridge.Holyday_Delete(idx);
var response = new { type = "HOLYDAY_DELETED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLYDAY_GET_USERLIST":
{
string sd = json.sd ?? "";
string ed = json.ed ?? "";
string result = _bridge.Holyday_GetUserList(sd, ed);
var response = new { type = "HOLYDAY_USERLIST_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLYDAY_GET_PERMISSION":
{
string result = _bridge.Holyday_GetPermission();
var response = new { type = "HOLYDAY_PERMISSION_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "HOLYDAY_GET_BALANCE":
{
string year = json.year ?? "";
string uid = json.uid ?? "";
string result = _bridge.Holyday_GetBalance(year, uid);
var response = new { type = "HOLYDAY_BALANCE_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
// ===== Holiday API (월별근무표) =====
case "HOLIDAY_GET_LIST":
{

View File

@@ -1,9 +1,7 @@
import { MachineBridgeInterface, ApiResponse, TodoModel, PurchaseCount, HolyUser, HolyRequestUser, PurchaseItem, KuntaeModel, LoginStatusResponse, LoginResult, UserGroup, PreviousLoginInfo, UserInfoDetail, GroupUser, UserLevelInfo, UserFullData, JobReportItem, JobReportUser, CommonCodeGroup, CommonCode, ItemInfo, ItemDetail, SupplierStaff, PurchaseHistoryItem, JobReportPermission, AppVersionInfo, JobTypeItem, HolidayItem, MailFormItem, UserGroupItem, PermissionInfo, AuthItem, AuthFieldInfo, CheckAuthResponse, MyAuthInfo, AuthType, ProjectSearchItem } from './types';
// WebView2 환경 감지
const isWebView = typeof window !== 'undefined' &&
window.chrome?.webview?.hostObjects !== undefined;
// WebView2 환경인지 체크
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
// 비동기 프록시 캐싱 (한 번만 초기화)
const machine: MachineBridgeInterface | null = isWebView
? window.chrome!.webview!.hostObjects.machine
: null;
@@ -308,6 +306,50 @@ class CommunicationLayer {
}
}
// ===== Holyday (근태) API =====
public async getHolydayList(sd: string, ed: string, uid: string = '%'): Promise<ApiResponse<KuntaeModel[]>> {
return this.wsRequest<ApiResponse<KuntaeModel[]>>('HOLYDAY_GET_LIST', 'HOLYDAY_LIST_DATA', { sd, ed, uid });
}
public async getHolydayDetail(idx: number): Promise<ApiResponse<KuntaeModel>> {
return this.wsRequest<ApiResponse<KuntaeModel>>('HOLYDAY_GET_DETAIL', 'HOLYDAY_DETAIL_DATA', { idx });
}
public async addHolyday(
cate: string, sdate: string, edate: string, term: number, crtime: number,
termDr: number, drTime: number, contents: string, uid: string
): Promise<ApiResponse> {
return this.wsRequest<ApiResponse>('HOLYDAY_ADD', 'HOLYDAY_ADDED', {
cate, sdate, edate, term, crtime, termDr, drTime, contents, uid
});
}
public async editHolyday(
idx: number, cate: string, sdate: string, edate: string, term: number, crtime: number,
termDr: number, drTime: number, contents: string
): Promise<ApiResponse> {
return this.wsRequest<ApiResponse>('HOLYDAY_EDIT', 'HOLYDAY_EDITED', {
idx, cate, sdate, edate, term, crtime, termDr, drTime, contents
});
}
public async deleteHolyday(idx: number): Promise<ApiResponse> {
return this.wsRequest<ApiResponse>('HOLYDAY_DELETE', 'HOLYDAY_DELETED', { idx });
}
public async getHolydayUserList(sd: string, ed: string): Promise<ApiResponse> {
return this.wsRequest<ApiResponse>('HOLYDAY_GET_USERLIST', 'HOLYDAY_USERLIST_DATA', { sd, ed });
}
public async getHolydayPermission(): Promise<ApiResponse> {
return this.wsRequest<ApiResponse>('HOLYDAY_GET_PERMISSION', 'HOLYDAY_PERMISSION_DATA');
}
public async getHolydayBalance(year: string, uid: string): Promise<ApiResponse<HolydayBalance[]>> {
return this.wsRequest<ApiResponse<HolydayBalance[]>>('HOLYDAY_GET_BALANCE', 'HOLYDAY_BALANCE_DATA', { year, uid });
}
// ===== Favorite API =====
public async getFavoriteList(): Promise<ApiResponse> {
@@ -701,14 +743,14 @@ class CommunicationLayer {
public async addJobReport(
pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string,
type: string, process: string, status: string, description: string,
hrs: number, ot: number, jobgrp: string, tag: string
hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Jobreport_Add(pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
const result = await machine.Jobreport_Add(pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('JOBREPORT_ADD', 'JOBREPORT_ADDED', {
pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag
pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd
});
}
}
@@ -716,14 +758,14 @@ class CommunicationLayer {
public async editJobReport(
idx: number, pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string,
type: string, process: string, status: string, description: string,
hrs: number, ot: number, jobgrp: string, tag: string
hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Jobreport_Edit(idx, pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
const result = await machine.Jobreport_Edit(idx, pdate, projectName, pidx ?? -1, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('JOBREPORT_EDIT', 'JOBREPORT_EDITED', {
idx, pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag
idx, pdate, projectName, pidx: pidx ?? -1, requestpart, package: package_, jobType: type, process, status, description, hrs, ot, jobgrp, tag, otStart, otEnd
});
}
}
@@ -746,6 +788,15 @@ class CommunicationLayer {
}
}
public async getJobReportTypeList(sd: string, ed: string, uid: string = ''): Promise<ApiResponse<JobReportTypeItem[]>> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetTypeList(sd, ed, uid);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<JobReportTypeItem[]>>('JOBREPORT_GET_TYPE_LIST', 'JOBREPORT_TYPE_LIST_DATA', { sd, ed, uid });
}
}
public async getJobTypes(process: string = ''): Promise<ApiResponse<JobTypeItem[]>> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetJobTypes(process);
@@ -1012,12 +1063,12 @@ class CommunicationLayer {
* 현재 사용자의 프로젝트 목록 조회 (업무일지 콤보박스용)
* @returns ApiResponse<{idx: number, name: string, status: string}[]>
*/
public async getUserProjects(): Promise<ApiResponse<{idx: number, name: string, status: string}[]>> {
public async getUserProjects(): Promise<ApiResponse<{ idx: number, name: string, status: string }[]>> {
if (isWebView && machine) {
const result = await machine.Project_GetUserProjects();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<{idx: number, name: string, status: string}[]>>('PROJECT_GET_USER_PROJECTS', 'PROJECT_USER_PROJECTS_DATA');
return this.wsRequest<ApiResponse<{ idx: number, name: string, status: string }[]>>('PROJECT_GET_USER_PROJECTS', 'PROJECT_USER_PROJECTS_DATA');
}
}
}

View File

@@ -0,0 +1,350 @@
import { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { comms } from '@/communication';
import { JobReportDayItem, HolidayItem } from '@/types';
interface JobReportDayDialogProps {
isOpen: boolean;
onClose: () => void;
initialMonth?: string; // YYYY-MM format
}
interface DayColumn {
day: number;
dayOfWeek: string;
isHoliday: boolean;
holidayMemo?: string;
}
interface UserRow {
uid: string;
uname: string;
processs: string;
dailyData: Map<number, { hrs: number; ot: number; jobtype: string }>;
totalHrs: number;
totalOt: number;
totalHolidayOt: number;
}
export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportDayDialogProps) {
const [currentMonth, setCurrentMonth] = useState(initialMonth || new Date().toISOString().substring(0, 7));
const [loading, setLoading] = useState(false);
const [dayColumns, setDayColumns] = useState<DayColumn[]>([]);
const [userRows, setUserRows] = useState<UserRow[]>([]);
const [currentUserId, setCurrentUserId] = useState<string>('');
const [currentUserLevel, setCurrentUserLevel] = useState<number>(0);
const [authLevel, setAuthLevel] = useState<number>(0);
const [canViewAll, setCanViewAll] = useState<boolean>(false);
// 요일 배열
const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
// 권한 및 사용자 정보 로드
useEffect(() => {
if (isOpen) {
loadAuthAndUserInfo();
}
}, [isOpen]);
const loadAuthAndUserInfo = async () => {
try {
// 현재 로그인 사용자 정보 가져오기
const loginStatus = await comms.checkLoginStatus();
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
const userId = loginStatus.User.Id;
const userLevel = loginStatus.User.Level || 0;
setCurrentUserId(userId);
setCurrentUserLevel(userLevel);
// 업무일지(jobreport) 권한 가져오기
const authResponse = await comms.checkAuth('jobreport', 5);
const jobReportAuthLevel = authResponse.EffectiveLevel || 0;
setAuthLevel(jobReportAuthLevel);
// 유효 권한 레벨 = Max(사용자레벨, 권한레벨)
const effectiveLevel = Math.max(userLevel, jobReportAuthLevel);
setCanViewAll(effectiveLevel >= 5);
}
} catch (error) {
console.error('권한 정보 로드 오류:', error);
setCanViewAll(false);
}
};
// 데이터 로드
useEffect(() => {
if (isOpen && currentUserId) {
loadData();
}
}, [isOpen, currentMonth, currentUserId, canViewAll]);
const loadData = async () => {
setLoading(true);
try {
// 현재는 기존 API를 사용하여 월별 데이터를 가져옴
const startDate = `${currentMonth}-01`;
const year = parseInt(currentMonth.substring(0, 4));
const month = parseInt(currentMonth.substring(5, 7));
const lastDay = new Date(year, month, 0).getDate();
const endDate = `${currentMonth}-${String(lastDay).padStart(2, '0')}`;
const [jobReportResponse, holidayResponse] = await Promise.all([
comms.getJobReportList(startDate, endDate, '', ''),
comms.getHolidayList(currentMonth)
]);
if (jobReportResponse.Success && jobReportResponse.Data) {
processData(jobReportResponse.Data, holidayResponse.Data || [], lastDay);
}
} catch (error) {
console.error('데이터 로드 오류:', error);
} finally {
setLoading(false);
}
};
const processData = (items: any[], holidays: HolidayItem[], lastDay: number) => {
// 날짜 컬럼 생성
const columns: DayColumn[] = [];
const year = parseInt(currentMonth.substring(0, 4));
const month = parseInt(currentMonth.substring(5, 7));
for (let day = 1; day <= lastDay; day++) {
const date = new Date(year, month - 1, day);
const dayOfWeek = weekDays[date.getDay()];
const dateStr = `${currentMonth}-${String(day).padStart(2, '0')}`;
const holiday = holidays.find(h => h.pdate === dateStr);
columns.push({
day,
dayOfWeek,
isHoliday: holiday?.free || false,
holidayMemo: holiday?.memo
});
}
setDayColumns(columns);
// 사용자별 데이터 집계
const userMap = new Map<string, UserRow>();
items.forEach(item => {
const uid = item.id || item.uid;
const uname = item.name || item.uname || uid;
const pdate = item.pdate?.substring(0, 10);
if (!pdate || !pdate.startsWith(currentMonth)) return;
// 권한 체크: 권한 레벨이 5 미만이고 본인이 아니면 스킵
if (!canViewAll && uid !== currentUserId) return;
const day = parseInt(pdate.substring(8, 10));
if (!userMap.has(uid)) {
userMap.set(uid, {
uid,
uname,
processs: item.userprocess || item.process || '',
dailyData: new Map(),
totalHrs: 0,
totalOt: 0,
totalHolidayOt: 0
});
}
const userRow = userMap.get(uid)!;
const existing = userRow.dailyData.get(day);
const hrs = (existing?.hrs || 0) + (item.hrs || 0);
const ot = (existing?.ot || 0) + (item.ot || 0);
const jobtype = item.type || existing?.jobtype || '';
userRow.dailyData.set(day, { hrs, ot, jobtype });
userRow.totalHrs += item.hrs || 0;
// 휴일 OT 계산
const column = columns[day - 1];
if (column?.isHoliday) {
userRow.totalHolidayOt += item.ot || 0;
} else {
userRow.totalOt += item.ot || 0;
}
});
setUserRows(Array.from(userMap.values()).sort((a, b) => a.uname.localeCompare(b.uname)));
};
// 월 변경
const changeMonth = (delta: number) => {
const [year, month] = currentMonth.split('-').map(Number);
const newDate = new Date(year, month - 1 + delta, 1);
setCurrentMonth(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}`);
};
// 셀 색상 결정
const getCellStyle = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => {
if (!data) return 'text-gray-400';
if (data.jobtype === '휴가') return 'text-red-500 font-medium';
if (isHoliday) return 'text-green-500 font-medium';
if (data.hrs > 8) return 'text-blue-500 font-medium';
if (data.hrs < 8) return 'text-red-500';
if (data.ot > 0) return 'text-purple-500 font-medium';
return 'text-white';
};
// 셀 내용 포맷
const formatCellContent = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => {
if (!data) return '--';
if (data.jobtype === '휴가' && data.hrs + data.ot === 8) return '휴가';
const prefix = isHoliday ? '*' : '';
return `${prefix}${data.hrs}+${data.ot}`;
};
// 엑셀 내보내기 (간단한 CSV)
const exportToExcel = () => {
let csv = '사원명,' + dayColumns.map(c => `${c.day}(${c.dayOfWeek})`).join(',') + ',합계\n';
userRows.forEach(row => {
const cells = dayColumns.map(col => {
const data = row.dailyData.get(col.day);
return formatCellContent(data, col.isHoliday);
});
const summary = `${row.totalHrs}+${row.totalOt}(*${row.totalHolidayOt})`;
csv += `${row.uname},${cells.join(',')},${summary}\n`;
});
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `업무일지_일별집계_${currentMonth}.csv`;
link.click();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-2xl shadow-2xl w-full max-w-7xl max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between p-6 border-b border-white/10">
<div className="flex items-center gap-4">
<h2 className="text-2xl font-bold text-white"> </h2>
<div className="flex items-center gap-2">
<button
onClick={() => changeMonth(-1)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5 text-white" />
</button>
<span className="text-lg font-medium text-white min-w-[100px] text-center">
{currentMonth}
</span>
<button
onClick={() => changeMonth(1)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
<ChevronRight className="w-5 h-5 text-white" />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={exportToExcel}
className="px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors flex items-center gap-2"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-6 h-6 text-white" />
</button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-white/50"> ...</div>
</div>
) : (
<table className="w-full border-collapse">
<thead className="sticky top-0 bg-gray-800 z-10">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
</th>
{dayColumns.map(col => (
<th
key={col.day}
className={`px-2 py-2 text-center text-xs font-medium uppercase border border-white/10 ${col.isHoliday ? 'bg-green-900/30 text-green-400' :
col.dayOfWeek === '일' ? 'bg-red-900/30 text-red-400' :
col.dayOfWeek === '토' ? 'bg-blue-900/30 text-blue-400' :
'bg-gray-800 text-white/70'
}`}
title={col.holidayMemo}
>
{col.day}<br />({col.dayOfWeek})
</th>
))}
<th className="px-3 py-2 text-center text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
</th>
</tr>
</thead>
<tbody>
{userRows.length === 0 ? (
<tr>
<td colSpan={dayColumns.length + 2} className="px-4 py-8 text-center text-white/50">
.
</td>
</tr>
) : (
userRows.map(row => (
<tr key={row.uid} className="hover:bg-white/5 transition-colors">
<td className="px-3 py-2 text-sm text-white border border-white/10 whitespace-nowrap">
{row.uname}
</td>
{dayColumns.map(col => {
const data = row.dailyData.get(col.day);
return (
<td
key={col.day}
className={`px-2 py-2 text-center text-sm border border-white/10 ${getCellStyle(data, col.isHoliday)}`}
>
{formatCellContent(data, col.isHoliday)}
</td>
);
})}
<td className="px-3 py-2 text-center text-sm text-white border border-white/10 font-medium whitespace-nowrap">
{row.totalHrs}+{row.totalOt}(*{row.totalHolidayOt})
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
{/* 범례 */}
<div className="px-6 py-4 border-t border-white/10 bg-gray-800/50">
<div className="flex flex-wrap gap-4 text-xs text-white/70">
<div><span className="text-gray-400">--</span> : </div>
<div><span className="text-red-500"></span> : </div>
<div><span className="text-green-500">*8+2</span> : </div>
<div><span className="text-blue-500">9+0</span> : 8 </div>
<div><span className="text-red-500">7+0</span> : 8 </div>
<div><span className="text-purple-500">8+2</span> : 8+OT</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -18,6 +18,8 @@ export interface JobreportFormData {
description: string;
hrs: number;
ot: number;
otStart: string; // 초과근무 시작시간 (HH:mm 형식)
otEnd: string; // 초과근무 종료시간 (HH:mm 형식)
jobgrp: string;
tag: string;
}
@@ -39,6 +41,8 @@ export const initialFormData: JobreportFormData = {
description: '',
hrs: 8,
ot: 0,
otStart: '18:00',
otEnd: '20:00',
jobgrp: '',
tag: '',
};
@@ -339,11 +343,10 @@ export function JobreportEditModal({
<button
type="button"
onClick={() => setShowJobTypeModal(true)}
className={`w-full border rounded-lg px-4 py-2 text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-primary-400 transition-colors ${
formData.type
? 'bg-white/20 border-white/30 text-white'
: 'bg-pink-500/30 border-pink-400/50 text-pink-200'
}`}
className={`w-full border rounded-lg px-4 py-2 text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-primary-400 transition-colors ${formData.type
? 'bg-white/20 border-white/30 text-white'
: 'bg-pink-500/30 border-pink-400/50 text-pink-200'
}`}
>
<span>{getJobTypeDisplayText()}</span>
<ChevronDown className="w-4 h-4 text-white/50" />
@@ -411,6 +414,34 @@ export function JobreportEditModal({
</div>
</div>
{/* 5행: 초과근무 시간대 (OT > 0일 때만 표시) */}
{formData.ot > 0 && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="time"
value={formData.otStart}
onChange={(e) => handleFieldChange('otStart', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="time"
value={formData.otEnd}
onChange={(e) => handleFieldChange('otEnd', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
)}
{/* 업무내용 */}
<div>
<label className="block text-white/70 text-sm font-medium mb-2">

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { X, RefreshCw } from 'lucide-react';
import { comms } from '@/communication';
import { JobReportTypeItem } from '@/types';
interface JobreportTypeModalProps {
isOpen: boolean;
onClose: () => void;
startDate: string;
endDate: string;
userId: string;
}
export function JobreportTypeModal({
isOpen,
onClose,
startDate,
endDate,
userId,
}: JobreportTypeModalProps) {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<JobReportTypeItem[]>([]);
useEffect(() => {
if (isOpen && startDate && endDate) {
loadData();
}
}, [isOpen, startDate, endDate, userId]);
const loadData = async () => {
setLoading(true);
try {
const response = await comms.getJobReportTypeList(startDate, endDate, userId);
if (response.Success && response.Data) {
setItems(response.Data);
} else {
setItems([]);
}
} catch (error) {
console.error('업무형태별 집계 로드 오류:', error);
setItems([]);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
const totalHrs = items.reduce((acc, item) => acc + (item.hrs || 0), 0);
const totalOt = items.reduce((acc, item) => acc + (item.ot || 0), 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
<div className="bg-gray-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
{/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-white/5">
<h3 className="text-lg font-semibold text-white"> </h3>
<button
onClick={onClose}
className="text-white/50 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 컨텐츠 */}
<div className="p-6">
<div className="mb-4 text-white/70 text-sm">
: {startDate} ~ {endDate}
</div>
<div className="overflow-hidden rounded-xl border border-white/10">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">OT</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="flex items-center justify-center">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
<span className="text-white/50"> ...</span>
</div>
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-white/50">
.
</td>
</tr>
) : (
<>
{items.map((item, index) => (
<tr key={index} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
<td className="px-4 py-3 text-white text-sm text-right">{item.count}</td>
<td className="px-4 py-3 text-white text-sm text-right">{item.hrs}h</td>
<td className="px-4 py-3 text-white text-sm text-right">
{item.ot > 0 ? <span className="text-warning-400">{item.ot}h</span> : '-'}
</td>
</tr>
))}
{/* 합계 */}
<tr className="bg-white/5 font-semibold">
<td className="px-4 py-3 text-white text-sm"></td>
<td className="px-4 py-3 text-white text-sm text-right">
{items.reduce((acc, item) => acc + item.count, 0)}
</td>
<td className="px-4 py-3 text-white text-sm text-right">{totalHrs}h</td>
<td className="px-4 py-3 text-white text-sm text-right">
{totalOt > 0 ? <span className="text-warning-400">{totalOt}h</span> : '-'}
</td>
</tr>
</>
)}
</tbody>
</table>
</div>
</div>
{/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end bg-white/5">
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,295 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Calendar, Clock, FileText, User } from 'lucide-react';
import { KuntaeModel } from '@/types';
interface KuntaeEditModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: KuntaeFormData) => Promise<void>;
initialData?: KuntaeModel | null;
mode: 'add' | 'edit' | 'copy';
}
export interface KuntaeFormData {
idx?: number;
cate: string;
sdate: string;
edate: string;
term: number;
crtime: number;
termDr: number;
drTime: number;
contents: string;
uid: string;
}
const CATE_OPTIONS = ['연차', '대체', '공가', '경조', '병가', '오전반차', '오후반차', '조퇴', '외출', '지각', '결근', '휴직', '교육', '출장', '재택', '특근', '당직', '기타'];
export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }: KuntaeEditModalProps) {
const [formData, setFormData] = useState<KuntaeFormData>({
cate: '연차',
sdate: new Date().toISOString().split('T')[0],
edate: new Date().toISOString().split('T')[0],
term: 1,
crtime: 0,
termDr: 0,
drTime: 0,
contents: '',
uid: '',
});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
if (initialData) {
setFormData({
idx: mode === 'edit' ? initialData.idx : undefined,
cate: initialData.cate || '연차',
sdate: initialData.sdate || new Date().toISOString().split('T')[0],
edate: initialData.edate || new Date().toISOString().split('T')[0],
term: initialData.term || 0,
crtime: initialData.crtime || 0,
termDr: initialData.termDr || 0,
drTime: initialData.DrTime || 0,
contents: initialData.contents || '',
uid: initialData.uid || '',
});
} else {
// 초기화
setFormData({
cate: '연차',
sdate: new Date().toISOString().split('T')[0],
edate: new Date().toISOString().split('T')[0],
term: 1,
crtime: 0,
termDr: 0,
drTime: 0,
contents: '',
uid: '', // 상위 컴포넌트에서 현재 사용자 ID를 주입받거나 여기서 처리해야 함
});
}
}
}, [isOpen, initialData, mode]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'term' || name === 'crtime' || name === 'termDr' || name === 'drTime'
? parseFloat(value) || 0
: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await onSave(formData);
onClose();
} catch (error) {
console.error('Save error:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
// 날짜 변경 시 기간 자동 계산 (단순 1일 차이)
useEffect(() => {
if (formData.sdate && formData.edate) {
const start = new Date(formData.sdate);
const end = new Date(formData.edate);
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
// 연차/휴가 등일 때만 자동 계산
if (['연차', '공가', '경조', '병가', '교육', '출장'].includes(formData.cate)) {
setFormData(prev => ({ ...prev, term: diffDays }));
}
}
}, [formData.sdate, formData.edate, formData.cate]);
if (!isOpen) return null;
const title = mode === 'add' ? '근태 등록' : mode === 'edit' ? '근태 수정' : '근태 복사 등록';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
<div className="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-lg border border-white/10 overflow-hidden">
{/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center bg-white/5">
<h2 className="text-xl font-bold text-white flex items-center">
<Calendar className="w-5 h-5 mr-2 text-primary-400" />
{title}
</h2>
<button onClick={onClose} className="text-white/50 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* 구분 및 사용자 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-1"></label>
<select
name="cate"
value={formData.cate}
onChange={handleChange}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
>
{CATE_OPTIONS.map(opt => (
<option key={opt} value={opt} className="bg-[#1e1e2e]">{opt}</option>
))}
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-1"> ID</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
<input
type="text"
name="uid"
value={formData.uid}
onChange={handleChange}
readOnly={mode === 'edit'} // 수정 시에는 사용자 변경 불가
className={`w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 ${mode === 'edit' ? 'opacity-50 cursor-not-allowed' : ''}`}
placeholder="사번 입력"
/>
</div>
</div>
</div>
{/* 기간 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-1"></label>
<input
type="date"
name="sdate"
value={formData.sdate}
onChange={handleChange}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-1"></label>
<input
type="date"
name="edate"
value={formData.edate}
onChange={handleChange}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
{/* 사용량 (일/시간) */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-1"> ()</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
<input
type="number"
name="term"
value={formData.term}
onChange={handleChange}
step="0.5"
className="w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-1"> ()</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
<input
type="number"
name="crtime"
value={formData.crtime}
onChange={handleChange}
step="0.5"
className="w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
</div>
{/* 발생량 (일/시간) - 대체근무 등 발생 시 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-1"> ()</label>
<input
type="number"
name="termDr"
value={formData.termDr}
onChange={handleChange}
step="0.5"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-1"> ()</label>
<input
type="number"
name="drTime"
value={formData.drTime}
onChange={handleChange}
step="0.5"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
{/* 내용 */}
<div>
<label className="block text-white/70 text-sm font-medium mb-1"></label>
<div className="relative">
<FileText className="absolute left-3 top-3 w-4 h-4 text-white/50" />
<textarea
name="contents"
value={formData.contents}
onChange={handleChange}
rows={3}
className="w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
placeholder="근태 사유 또는 내용 입력"
/>
</div>
</div>
{/* 버튼 */}
<div className="flex justify-end space-x-3 pt-4 border-t border-white/10">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
</button>
<button
type="submit"
disabled={saving}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
{saving ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -5,10 +5,13 @@ import {
RefreshCw,
Copy,
Plus,
Calendar,
} from 'lucide-react';
import { comms } from '@/communication';
import { JobReportItem, JobReportUser } from '@/types';
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog';
import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal';
export function Jobreport() {
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
@@ -24,6 +27,8 @@ export function Jobreport() {
// 모달 상태
const [showModal, setShowModal] = useState(false);
const [showDayReportModal, setShowDayReportModal] = useState(false);
const [showTypeReportModal, setShowTypeReportModal] = useState(false);
const [editingItem, setEditingItem] = useState<JobReportItem | null>(null);
const [formData, setFormData] = useState<JobreportFormData>(initialFormData);
@@ -200,6 +205,8 @@ export function Jobreport() {
description: data.description || '',
hrs: 0, // 시간 초기화
ot: 0, // OT 초기화
otStart: data.otStart ? data.otStart.substring(11, 16) : '18:00',
otEnd: data.otEnd ? data.otEnd.substring(11, 16) : '20:00',
jobgrp: '',
tag: '',
});
@@ -230,6 +237,8 @@ export function Jobreport() {
description: data.description || '',
hrs: data.hrs || 0,
ot: data.ot || 0,
otStart: data.otStart ? data.otStart.substring(11, 16) : '18:00',
otEnd: data.otEnd ? data.otEnd.substring(11, 16) : '20:00',
jobgrp: data.jobgrp || '',
tag: data.tag || '',
});
@@ -276,7 +285,9 @@ export function Jobreport() {
formData.hrs || 0,
formData.ot || 0,
formData.jobgrp || '',
formData.tag || ''
formData.tag || '',
formData.otStart || '18:00',
formData.otEnd || '20:00'
);
} else {
response = await comms.addJobReport(
@@ -292,7 +303,9 @@ export function Jobreport() {
formData.hrs || 0,
formData.ot || 0,
formData.jobgrp || '',
formData.tag || ''
formData.tag || '',
formData.otStart || '18:00',
formData.otEnd || '20:00'
);
}
@@ -419,7 +432,7 @@ export function Jobreport() {
</div>
{/* 버튼 영역: 우측 수직 배치 */}
<div className="grid grid-rows-2 gap-y-3">
<div className="flex flex-col gap-3">
<button
onClick={handleSearchWithReset}
disabled={loading}
@@ -443,6 +456,24 @@ export function Jobreport() {
</div>
</div>
{/* 중앙: 집계 메뉴 */}
<div className="flex-shrink-0 flex flex-col gap-3 justify-center">
<button
onClick={() => setShowDayReportModal(true)}
className="h-12 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
>
<Calendar className="w-4 h-4 mr-2" />
</button>
<button
onClick={() => setShowTypeReportModal(true)}
className="h-12 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
>
<FileText className="w-4 h-4 mr-2" />
</button>
</div>
{/* 우측: 오늘 근무시간 */}
<div className="flex-shrink-0 w-48">
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center">
@@ -523,9 +554,8 @@ export function Jobreport() {
</td>
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs ${
item.status?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70'
}`}>
<span className={`px-2 py-1 rounded text-xs ${item.status?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70'
}`}>
{item.status || '-'}
</span>
</td>
@@ -602,6 +632,22 @@ export function Jobreport() {
setShowModal(false);
}}
/>
{/* 일별 집계 모달 */}
<JobReportDayDialog
isOpen={showDayReportModal}
onClose={() => setShowDayReportModal(false)}
initialMonth={startDate.substring(0, 7)}
/>
{/* 업무형태별 집계 모달 */}
<JobreportTypeModal
isOpen={showTypeReportModal}
onClose={() => setShowTypeReportModal(false)}
startDate={startDate}
endDate={endDate}
userId={selectedUser}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Calendar,
Search,
@@ -8,9 +8,17 @@ import {
CheckCircle,
XCircle,
RefreshCw,
Plus,
Edit,
Copy,
User,
ChevronLeft,
ChevronRight,
Filter
} from 'lucide-react';
import { comms } from '@/communication';
import { KuntaeModel } from '@/types';
import { KuntaeModel, HolydayPermission, HolydayUser, HolydayBalance } from '@/types';
import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal';
export function Kuntae() {
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
@@ -20,66 +28,132 @@ export function Kuntae() {
// 검색 조건
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedUser, setSelectedUser] = useState('%');
const [filterText, setFilterText] = useState(''); // 클라이언트 필터
// 통계
const [stats, setStats] = useState({
holyUsed: 0,
alternateUsed: 0,
holyRemain: 0,
alternateRemain: 0,
});
// 권한 및 사용자 목록
const [permission, setPermission] = useState<HolydayPermission | null>(null);
const [userList, setUserList] = useState<HolydayUser[]>([]);
// 날짜 초기화 (현재 월)
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<'add' | 'edit' | 'copy'>('add');
const [selectedItem, setSelectedItem] = useState<KuntaeModel | null>(null);
// 통계 (잔량 정보)
const [balances, setBalances] = useState<HolydayBalance[]>([]);
// 초기화
useEffect(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const init = async () => {
// 날짜 초기화 (현재 월)
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
setStartDate(startOfMonth.toISOString().split('T')[0]);
setEndDate(endOfMonth.toISOString().split('T')[0]);
const sd = startOfMonth.toISOString().split('T')[0];
const ed = endOfMonth.toISOString().split('T')[0];
setStartDate(sd);
setEndDate(ed);
// 권한 조회
try {
const permResponse = await comms.getHolydayPermission();
if (permResponse.Success && permResponse.Data) {
// @ts-ignore - API 응답 타입 불일치 해결
setPermission(permResponse.Data);
}
} catch (error) {
console.error('권한 조회 오류:', error);
}
};
init();
}, []);
// 데이터 로드
// 사용자 목록 로드 (기간 변경 시)
useEffect(() => {
const loadUsers = async () => {
if (!startDate || !endDate) return;
try {
const response = await comms.getHolydayUserList(startDate, endDate);
// @ts-ignore - API 응답이 배열로 옴
if (Array.isArray(response)) {
setUserList(response);
} else if (response.Success && response.Data) {
// @ts-ignore
setUserList(response.Data);
}
} catch (error) {
console.error('사용자 목록 로드 오류:', error);
}
};
loadUsers();
}, [startDate, endDate]);
// 데이터 및 잔량 로드
const loadData = useCallback(async () => {
if (!startDate || !endDate) return;
setLoading(true);
try {
const response = await comms.getKuntaeList(startDate, endDate);
if (response.Success && response.Data) {
setKuntaeList(response.Data);
updateStats(response.Data);
// 1. 목록 조회
const listResponse = await comms.getHolydayList(startDate, endDate, selectedUser);
if (listResponse.Success && listResponse.Data) {
setKuntaeList(listResponse.Data);
} else {
setKuntaeList([]);
}
// 2. 잔량 조회 (연도 기준)
const year = startDate.substring(0, 4);
// 전체 사용자(%) 선택 시에는 로그인한 사용자 기준 잔량을 보여주거나, 비워두는 게 맞음
// 여기서는 선택된 사용자가 있으면 그 사용자, 없으면 본인(권한 없으면 에러나겠지만 API에서 처리)
const targetUid = selectedUser === '%' ? (permission?.CurrentUserId || '') : selectedUser;
if (targetUid) {
const balanceResponse = await comms.getHolydayBalance(year, targetUid);
if (balanceResponse.Success && balanceResponse.Data) {
setBalances(balanceResponse.Data);
} else {
setBalances([]);
}
}
} catch (error) {
console.error('근태 목록 로드 오류:', error);
console.error('데이터 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, [startDate, endDate]);
}, [startDate, endDate, selectedUser, permission]);
// 통계 업데이트
const updateStats = (data: KuntaeModel[]) => {
const holyUsed = data.filter(item => item.cate === '연차' || item.cate === '휴가').length;
const alternateUsed = data.filter(item => item.cate === '대체').length;
setStats({
holyUsed,
alternateUsed,
holyRemain: 15 - holyUsed, // 예시 값
alternateRemain: 5 - alternateUsed, // 예시 값
});
};
// 검색
const handleSearch = () => {
if (new Date(startDate) > new Date(endDate)) {
alert('시작일은 종료일보다 늦을 수 없습니다.');
return;
}
// 초기 로드 및 검색 조건 변경 시 로드
useEffect(() => {
loadData();
}, [loadData]);
// 필터링된 목록
const filteredList = useMemo(() => {
if (!filterText) return kuntaeList;
const lowerText = filterText.toLowerCase();
return kuntaeList.filter(item =>
(item.cate && item.cate.toLowerCase().includes(lowerText)) ||
(item.contents && item.contents.toLowerCase().includes(lowerText)) ||
(item.UserName && item.UserName.toLowerCase().includes(lowerText))
);
}, [kuntaeList, filterText]);
// 월 이동
const moveMonth = (offset: number) => {
const current = new Date(startDate);
current.setMonth(current.getMonth() + offset);
const startOfMonth = new Date(current.getFullYear(), current.getMonth(), 1);
const endOfMonth = new Date(current.getFullYear(), current.getMonth() + 1, 0);
setStartDate(startOfMonth.toISOString().split('T')[0]);
setEndDate(endOfMonth.toISOString().split('T')[0]);
};
// 삭제
@@ -88,7 +162,7 @@ export function Kuntae() {
setProcessing(true);
try {
const response = await comms.deleteKuntae(id);
const response = await comms.deleteHolyday(id);
if (response.Success) {
alert('삭제되었습니다.');
loadData();
@@ -103,93 +177,218 @@ export function Kuntae() {
}
};
// 모달 열기
const openModal = (mode: 'add' | 'edit' | 'copy', item?: KuntaeModel) => {
setModalMode(mode);
setSelectedItem(item || null);
setIsModalOpen(true);
};
// 저장 처리
const handleSave = async (formData: KuntaeFormData) => {
try {
let response;
if (modalMode === 'add' || modalMode === 'copy') {
response = await comms.addHolyday(
formData.cate,
formData.sdate,
formData.edate,
formData.term,
formData.crtime,
formData.termDr,
formData.drTime,
formData.contents,
formData.uid || (permission?.CurrentUserId || '')
);
} else {
if (!formData.idx) return;
response = await comms.editHolyday(
formData.idx,
formData.cate,
formData.sdate,
formData.edate,
formData.term,
formData.crtime,
formData.termDr,
formData.drTime,
formData.contents
);
}
if (response.Success) {
alert(response.Message || '저장되었습니다.');
loadData();
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
throw error;
}
};
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('ko-KR');
return dateStr.split('T')[0];
};
return (
<div className="space-y-6 animate-fade-in">
{/* 개발중 경고 */}
<div className="bg-warning-500/20 border border-warning-500/30 rounded-xl p-4 flex items-center">
<AlertTriangle className="w-5 h-5 text-warning-400 mr-3 flex-shrink-0" />
<div>
<p className="text-white font-medium"> </p>
<p className="text-white/60 text-sm"> .</p>
</div>
</div>
{/* 검색 필터 */}
{/* 상단 컨트롤 바 */}
<div className="glass-effect rounded-2xl p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
<div className="flex flex-col space-y-4">
{/* 1행: 날짜, 사용자, 조회/등록 */}
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
{/* 날짜 선택 및 월 이동 */}
<div className="flex items-center gap-2 w-full md:w-auto">
<button
onClick={() => moveMonth(-1)}
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
/>
</div>
<button
onClick={() => moveMonth(1)}
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
{/* 사용자 선택 (관리자용) */}
{permission?.CanManage && (
<div className="w-full md:w-64">
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
<select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 appearance-none"
>
<option value="%" className="bg-[#1e1e2e]"> </option>
{userList.map(user => (
<option key={user.uid} value={user.uid} className="bg-[#1e1e2e]">
{user.UserName} ({user.uid})
</option>
))}
</select>
</div>
</div>
)}
{/* 조회 및 등록 버튼 */}
<div className="flex gap-2 w-full md:w-auto">
<button
onClick={() => loadData()}
disabled={loading}
className="flex-1 md:flex-none bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
</button>
<button
onClick={() => openModal('add')}
className="flex-1 md:flex-none bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center"
>
<Plus className="w-4 h-4 mr-2" />
</button>
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-end">
<button
onClick={handleSearch}
disabled={loading}
className="w-full bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
{/* 2행: 검색 필터 */}
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-md">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="구분, 내용, 성명으로 검색..."
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm placeholder-white/30"
/>
{filterText && (
<button
onClick={() => setFilterText('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white"
>
<XCircle className="w-4 h-4" />
</button>
)}
</button>
</div>
</div>
</div>
</div>
{/* 통계 카드 */}
{/* 통계 카드 (잔량 정보) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
title="휴가 사용"
value={stats.holyUsed}
icon={<Calendar className="w-6 h-6 text-primary-400" />}
color="text-primary-400"
/>
<StatCard
title="대체 사용"
value={stats.alternateUsed}
icon={<CheckCircle className="w-6 h-6 text-success-400" />}
color="text-success-400"
/>
<StatCard
title="잔량 (연차)"
value={stats.holyRemain}
icon={<Clock className="w-6 h-6 text-warning-400" />}
color="text-warning-400"
/>
<StatCard
title="잔량 (대체)"
value={stats.alternateRemain}
icon={<XCircle className="w-6 h-6 text-danger-400" />}
color="text-danger-400"
/>
{balances.length > 0 ? (
balances.map((bal, idx) => {
// 잔량 계산
const remainDays = bal.TotalGenDays - bal.TotalUseDays;
const remainHours = bal.TotalGenHours - bal.TotalUseHours;
// 아이콘 및 색상 결정
let icon = <Clock className="w-6 h-6" />;
let color = "text-white";
if (bal.cate === '연차') {
icon = <Calendar className="w-6 h-6 text-primary-400" />;
color = "text-primary-400";
} else if (bal.cate === '대체') {
icon = <RefreshCw className="w-6 h-6 text-success-400" />;
color = "text-success-400";
} else if (bal.cate === '휴가') {
icon = <CheckCircle className="w-6 h-6 text-warning-400" />;
color = "text-warning-400";
}
return (
<StatCard
key={idx}
title={`${bal.cate} 잔량`}
value={`${remainDays}${remainHours > 0 ? `(${remainHours}h)` : ''}`}
subValue={`발생: ${bal.TotalGenDays} / 사용: ${bal.TotalUseDays}`}
icon={icon}
color={color}
/>
);
})
) : (
// 데이터 없을 때 기본 카드 표시
<>
<StatCard title="연차 잔량" value="-" icon={<Calendar className="w-6 h-6 text-white/30" />} color="text-white/30" />
<StatCard title="대체 잔량" value="-" icon={<RefreshCw className="w-6 h-6 text-white/30" />} color="text-white/30" />
</>
)}
</div>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10">
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center">
<h3 className="text-lg font-semibold text-white"> </h3>
<span className="text-white/50 text-sm">
{filteredList.length}
{kuntaeList.length !== filteredList.length && ` (전체 ${kuntaeList.length}건 중)`}
</span>
</div>
<div className="overflow-x-auto">
@@ -199,56 +398,80 @@ export function Kuntae() {
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">()</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">()</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td colSpan={11} className="px-4 py-8 text-center">
<td colSpan={9} className="px-4 py-8 text-center">
<div className="flex items-center justify-center">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
<span className="text-white/50"> ...</span>
</div>
</td>
</tr>
) : kuntaeList.length === 0 ? (
) : filteredList.length === 0 ? (
<tr>
<td colSpan={11} className="px-4 py-8 text-center text-white/50">
.
<td colSpan={9} className="px-4 py-8 text-center text-white/50">
{filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'}
</td>
</tr>
) : (
kuntaeList.map((item) => (
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 text-white text-sm">{item.cate || '-'}</td>
filteredList.map((item) => (
<tr key={item.idx} className={`hover:bg-white/5 transition-colors ${item.extidx ? 'bg-black/20' : ''}`}>
<td className="px-4 py-3 text-white text-sm">
<span className={`px-2 py-1 rounded text-xs ${item.cate === '연차' ? 'bg-primary-500/20 text-primary-300' :
item.cate === '대체' ? 'bg-success-500/20 text-success-300' :
'bg-white/10 text-white/70'
}`}>
{item.cate || '-'}
</span>
</td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.sdate)}</td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.edate)}</td>
<td className="px-4 py-3 text-white text-sm">{item.uid || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.uname || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.term || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.term > 0 ? item.term : '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.crtime > 0 ? item.crtime : '-'}</td>
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}>
{item.contents || '-'}
</td>
<td className="px-4 py-3 text-white text-sm">{item.extcate || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.wuid || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.wdate || '-'}</td>
<td className="px-4 py-3 text-white text-sm">
{item.UserName || item.uname || item.uid}
</td>
<td className="px-4 py-3 text-white/50 text-xs">
{item.extcate ? `${item.extcate}` : '-'}
</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => handleDelete(item.idx)}
disabled={processing}
className="text-danger-400 hover:text-danger-300 transition-colors disabled:opacity-50"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center space-x-2">
<button
onClick={() => openModal('edit', item)}
className={`text-primary-400 hover:text-primary-300 transition-colors ${item.extidx ? 'opacity-50 cursor-not-allowed' : ''}`}
title={item.extidx ? "외부 연동 데이터는 수정할 수 없습니다" : "수정"}
disabled={!!item.extidx}
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => openModal('copy', item)}
className="text-success-400 hover:text-success-300 transition-colors"
title="복사"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item.idx)}
className={`text-danger-400 hover:text-danger-300 transition-colors ${item.extidx ? 'opacity-50 cursor-not-allowed' : ''}`}
title={item.extidx ? "외부 연동 데이터는 삭제할 수 없습니다" : "삭제"}
disabled={!!item.extidx}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
@@ -257,6 +480,15 @@ export function Kuntae() {
</table>
</div>
</div>
{/* 추가/수정 모달 */}
<KuntaeEditModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
initialData={selectedItem}
mode={modalMode}
/>
</div>
);
}
@@ -264,12 +496,13 @@ export function Kuntae() {
// 통계 카드 컴포넌트
interface StatCardProps {
title: string;
value: number;
value: string | number;
subValue?: string;
icon: React.ReactNode;
color: string;
}
function StatCard({ title, value, icon, color }: StatCardProps) {
function StatCard({ title, value, subValue, icon, color }: StatCardProps) {
return (
<div className="glass-effect rounded-xl p-4 card-hover">
<div className="flex items-center">
@@ -278,7 +511,8 @@ function StatCard({ title, value, icon, color }: StatCardProps) {
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/70">{title}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<p className={`text-xl font-bold ${color}`}>{value}</p>
{subValue && <p className="text-xs text-white/40 mt-1">{subValue}</p>}
</div>
</div>
</div>

View File

@@ -90,24 +90,47 @@ declare global {
}
}
// 근태 관련 타입
// 근태 관련 타입 (Holyday 테이블)
export interface KuntaeModel {
idx: number;
gcode: string;
cate: string; // 구분 (연차, 대체, 휴가 등)
sdate: string; // 시작일
edate: string; // 종료일
term: number; // 사용(일)
crtime: number; // 사용(시간)
termDr: number; // 발생(일)
DrTime: number; // 발생(시간)
contents: string; // 내용
uid: string; // 사용자 ID
UserName?: string; // 사용자 이름 (조인)
wuid: string; // 등록자 ID
wdate: string; // 등록일
extcate?: string; // 외부 소스 카테고리
extidx?: number; // 외부 소스 인덱스
}
// 근태 권한 정보 타입
export interface HolydayPermission {
Success: boolean;
CurrentUserId: string;
Level: number;
CanManage: boolean;
}
// 근태 사용자 목록 타입
export interface HolydayUser {
uid: string;
uname: string;
UserName: string;
}
// 근태 잔량 정보 타입
export interface HolydayBalance {
cate: string;
sdate: string | null;
edate: string | null;
term: number;
termdr: number;
drtime: number;
crtime: number;
contents: string;
tag: string;
extcate: string;
wuid: string;
wdate: string;
TotalGenDays: number;
TotalGenHours: number;
TotalUseDays: number;
TotalUseHours: number;
}
// 업무일지 관련 타입 (기존 - 사용 안함)
@@ -134,6 +157,8 @@ export interface JobReportItem {
svalue: string; // 업무형태 표시값
hrs: number;
ot: number;
otStart?: string; // 초과근무 시작시간
otEnd?: string; // 초과근무 종료시간
requestpart: string;
package: string;
userprocess: string; // 사용자 공정
@@ -333,8 +358,8 @@ export interface MachineBridgeInterface {
Jobreport_GetList(sd: string, ed: string, uid: string, cate: string, searchKey: string): Promise<string>;
Jobreport_GetUsers(): Promise<string>;
Jobreport_GetDetail(id: number): Promise<string>;
Jobreport_Add(pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
Jobreport_Edit(idx: number, pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
Jobreport_Add(pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string): Promise<string>;
Jobreport_Edit(idx: number, pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string, otStart: string, otEnd: string): Promise<string>;
Jobreport_Delete(id: number): Promise<string>;
Jobreport_GetPermission(targetUserId: string): Promise<string>;
Jobreport_GetJobTypes(process: string): Promise<string>;
@@ -741,3 +766,28 @@ export interface ProjectDailyMemo {
wdate?: string;
wname?: string;
}
// 일별 업무일지 집계 타입
export interface JobReportDayItem {
uid: string;
uname: string;
pdate: string;
hrs: number;
ot: number;
processs: string;
jobtype: string;
}
// 업무형태별 집계 타입
export interface JobReportTypeItem {
type: string;
hrs: number;
ot: number;
count: number;
}
export interface JobReportDayData {
items: JobReportDayItem[];
holidays: HolidayItem[];
}