근태(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.User.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.UserList.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.UserList.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Holiday.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.MailForm.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.UserGroup.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.UserGroup.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.UserAuth.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 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, 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.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) FROM vJobReportForUser v WITH (nolock)
INNER JOIN JobReport j WITH (nolock) ON v.idx = j.idx INNER JOIN JobReport j WITH (nolock) ON v.idx = j.idx
WHERE v.idx = @idx AND v.gcode = @gcode"; WHERE v.idx = @idx AND v.gcode = @gcode";
@@ -141,7 +141,8 @@ namespace Project.Web
/// 업무일지 추가 (JobReport 테이블) /// 업무일지 추가 (JobReport 테이블)
/// </summary> /// </summary>
public string Jobreport_Add(string pdate, string projectName, int pidx, string requestpart, string package, 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 try
{ {
@@ -153,9 +154,9 @@ namespace Project.Web
} }
var sql = @"INSERT INTO JobReport (gcode, uid, pdate, projectName, requestpart, package, 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, 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();"; SELECT SCOPE_IDENTITY();";
var cs = Properties.Settings.Default.gwcs; var cs = Properties.Settings.Default.gwcs;
@@ -178,6 +179,20 @@ namespace Project.Web
cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? ""); cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? "");
cmd.Parameters.AddWithValue("@tag", tag ?? ""); cmd.Parameters.AddWithValue("@tag", tag ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no); 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(); cn.Open();
var newId = Convert.ToInt32(cmd.ExecuteScalar()); var newId = Convert.ToInt32(cmd.ExecuteScalar());
@@ -194,7 +209,8 @@ namespace Project.Web
/// 업무일지 수정 (JobReport 테이블) /// 업무일지 수정 (JobReport 테이블)
/// </summary> /// </summary>
public string Jobreport_Edit(int idx, string pdate, string projectName, int pidx, string requestpart, string package, 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 try
{ {
@@ -231,7 +247,7 @@ namespace Project.Web
pdate = @pdate, projectName = @projectName, pidx = @pidx, requestpart = @requestpart, pdate = @pdate, projectName = @projectName, pidx = @pidx, requestpart = @requestpart,
package = @package, type = @type, process = @process, status = @status, package = @package, type = @type, process = @process, status = @status,
description = @description, hrs = @hrs, ot = @ot, jobgrp = @jobgrp, tag = @tag, 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"; WHERE idx = @idx AND gcode = @gcode";
var cs = Properties.Settings.Default.gwcs; var cs = Properties.Settings.Default.gwcs;
@@ -254,6 +270,20 @@ namespace Project.Web
cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? ""); cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? "");
cmd.Parameters.AddWithValue("@tag", tag ?? ""); cmd.Parameters.AddWithValue("@tag", tag ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no); 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(); cn.Open();
var result = cmd.ExecuteNonQuery(); var result = cmd.ExecuteNonQuery();

View File

@@ -646,7 +646,9 @@ namespace Project.Web
double ot = json.ot ?? 0.0; double ot = json.ot ?? 0.0;
string jobgrp = json.jobgrp ?? ""; string jobgrp = json.jobgrp ?? "";
string tag = json.tag ?? ""; 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) }; var response = new { type = "JOBREPORT_ADDED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response)); await Send(socket, JsonConvert.SerializeObject(response));
} }
@@ -668,7 +670,9 @@ namespace Project.Web
double ot = json.ot ?? 0.0; double ot = json.ot ?? 0.0;
string jobgrp = json.jobgrp ?? ""; string jobgrp = json.jobgrp ?? "";
string tag = json.tag ?? ""; 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) }; var response = new { type = "JOBREPORT_EDITED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response)); await Send(socket, JsonConvert.SerializeObject(response));
} }
@@ -729,6 +733,99 @@ namespace Project.Web
} }
break; 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 (월별근무표) ===== // ===== Holiday API (월별근무표) =====
case "HOLIDAY_GET_LIST": 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 const machine: MachineBridgeInterface | null = isWebView
? window.chrome!.webview!.hostObjects.machine ? window.chrome!.webview!.hostObjects.machine
: null; : 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 ===== // ===== Favorite API =====
public async getFavoriteList(): Promise<ApiResponse> { public async getFavoriteList(): Promise<ApiResponse> {
@@ -701,14 +743,14 @@ class CommunicationLayer {
public async addJobReport( public async addJobReport(
pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string, pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string,
type: string, process: string, status: string, description: 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> { ): Promise<ApiResponse> {
if (isWebView && machine) { 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); return JSON.parse(result);
} else { } else {
return this.wsRequest<ApiResponse>('JOBREPORT_ADD', 'JOBREPORT_ADDED', { 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( public async editJobReport(
idx: number, pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string, idx: number, pdate: string, projectName: string, pidx: number | null, requestpart: string, package_: string,
type: string, process: string, status: string, description: 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> { ): Promise<ApiResponse> {
if (isWebView && machine) { 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); return JSON.parse(result);
} else { } else {
return this.wsRequest<ApiResponse>('JOBREPORT_EDIT', 'JOBREPORT_EDITED', { 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[]>> { public async getJobTypes(process: string = ''): Promise<ApiResponse<JobTypeItem[]>> {
if (isWebView && machine) { if (isWebView && machine) {
const result = await machine.Jobreport_GetJobTypes(process); const result = await machine.Jobreport_GetJobTypes(process);
@@ -1012,12 +1063,12 @@ class CommunicationLayer {
* 현재 사용자의 프로젝트 목록 조회 (업무일지 콤보박스용) * 현재 사용자의 프로젝트 목록 조회 (업무일지 콤보박스용)
* @returns ApiResponse<{idx: number, name: string, status: string}[]> * @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) { if (isWebView && machine) {
const result = await machine.Project_GetUserProjects(); const result = await machine.Project_GetUserProjects();
return JSON.parse(result); return JSON.parse(result);
} else { } 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; description: string;
hrs: number; hrs: number;
ot: number; ot: number;
otStart: string; // 초과근무 시작시간 (HH:mm 형식)
otEnd: string; // 초과근무 종료시간 (HH:mm 형식)
jobgrp: string; jobgrp: string;
tag: string; tag: string;
} }
@@ -39,6 +41,8 @@ export const initialFormData: JobreportFormData = {
description: '', description: '',
hrs: 8, hrs: 8,
ot: 0, ot: 0,
otStart: '18:00',
otEnd: '20:00',
jobgrp: '', jobgrp: '',
tag: '', tag: '',
}; };
@@ -339,11 +343,10 @@ export function JobreportEditModal({
<button <button
type="button" type="button"
onClick={() => setShowJobTypeModal(true)} 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 ${ 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
formData.type ? 'bg-white/20 border-white/30 text-white'
? 'bg-white/20 border-white/30 text-white' : 'bg-pink-500/30 border-pink-400/50 text-pink-200'
: 'bg-pink-500/30 border-pink-400/50 text-pink-200' }`}
}`}
> >
<span>{getJobTypeDisplayText()}</span> <span>{getJobTypeDisplayText()}</span>
<ChevronDown className="w-4 h-4 text-white/50" /> <ChevronDown className="w-4 h-4 text-white/50" />
@@ -411,6 +414,34 @@ export function JobreportEditModal({
</div> </div>
</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> <div>
<label className="block text-white/70 text-sm font-medium mb-2"> <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, RefreshCw,
Copy, Copy,
Plus, Plus,
Calendar,
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { JobReportItem, JobReportUser } from '@/types'; import { JobReportItem, JobReportUser } from '@/types';
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal'; import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog';
import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal';
export function Jobreport() { export function Jobreport() {
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]); const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
@@ -24,6 +27,8 @@ export function Jobreport() {
// 모달 상태 // 모달 상태
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showDayReportModal, setShowDayReportModal] = useState(false);
const [showTypeReportModal, setShowTypeReportModal] = useState(false);
const [editingItem, setEditingItem] = useState<JobReportItem | null>(null); const [editingItem, setEditingItem] = useState<JobReportItem | null>(null);
const [formData, setFormData] = useState<JobreportFormData>(initialFormData); const [formData, setFormData] = useState<JobreportFormData>(initialFormData);
@@ -200,6 +205,8 @@ export function Jobreport() {
description: data.description || '', description: data.description || '',
hrs: 0, // 시간 초기화 hrs: 0, // 시간 초기화
ot: 0, // OT 초기화 ot: 0, // OT 초기화
otStart: data.otStart ? data.otStart.substring(11, 16) : '18:00',
otEnd: data.otEnd ? data.otEnd.substring(11, 16) : '20:00',
jobgrp: '', jobgrp: '',
tag: '', tag: '',
}); });
@@ -230,6 +237,8 @@ export function Jobreport() {
description: data.description || '', description: data.description || '',
hrs: data.hrs || 0, hrs: data.hrs || 0,
ot: data.ot || 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 || '', jobgrp: data.jobgrp || '',
tag: data.tag || '', tag: data.tag || '',
}); });
@@ -276,7 +285,9 @@ export function Jobreport() {
formData.hrs || 0, formData.hrs || 0,
formData.ot || 0, formData.ot || 0,
formData.jobgrp || '', formData.jobgrp || '',
formData.tag || '' formData.tag || '',
formData.otStart || '18:00',
formData.otEnd || '20:00'
); );
} else { } else {
response = await comms.addJobReport( response = await comms.addJobReport(
@@ -292,7 +303,9 @@ export function Jobreport() {
formData.hrs || 0, formData.hrs || 0,
formData.ot || 0, formData.ot || 0,
formData.jobgrp || '', formData.jobgrp || '',
formData.tag || '' formData.tag || '',
formData.otStart || '18:00',
formData.otEnd || '20:00'
); );
} }
@@ -419,7 +432,7 @@ export function Jobreport() {
</div> </div>
{/* 버튼 영역: 우측 수직 배치 */} {/* 버튼 영역: 우측 수직 배치 */}
<div className="grid grid-rows-2 gap-y-3"> <div className="flex flex-col gap-3">
<button <button
onClick={handleSearchWithReset} onClick={handleSearchWithReset}
disabled={loading} disabled={loading}
@@ -443,6 +456,24 @@ export function Jobreport() {
</div> </div>
</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="flex-shrink-0 w-48">
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center"> <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>
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td> <td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs ${ <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?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70' }`}>
}`}>
{item.status || '-'} {item.status || '-'}
</span> </span>
</td> </td>
@@ -602,6 +632,22 @@ export function Jobreport() {
setShowModal(false); 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> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
Calendar, Calendar,
Search, Search,
@@ -8,9 +8,17 @@ import {
CheckCircle, CheckCircle,
XCircle, XCircle,
RefreshCw, RefreshCw,
Plus,
Edit,
Copy,
User,
ChevronLeft,
ChevronRight,
Filter
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; 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() { export function Kuntae() {
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]); const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
@@ -20,66 +28,132 @@ export function Kuntae() {
// 검색 조건 // 검색 조건
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [selectedUser, setSelectedUser] = useState('%');
const [filterText, setFilterText] = useState(''); // 클라이언트 필터
// 통계 // 권한 및 사용자 목록
const [stats, setStats] = useState({ const [permission, setPermission] = useState<HolydayPermission | null>(null);
holyUsed: 0, const [userList, setUserList] = useState<HolydayUser[]>([]);
alternateUsed: 0,
holyRemain: 0,
alternateRemain: 0,
});
// 날짜 초기화 (현재 월) // 모달 상태
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(() => { useEffect(() => {
const now = new Date(); const init = async () => {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); // 날짜 초기화 (현재 월)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); 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]); const sd = startOfMonth.toISOString().split('T')[0];
setEndDate(endOfMonth.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 () => { const loadData = useCallback(async () => {
if (!startDate || !endDate) return; if (!startDate || !endDate) return;
setLoading(true); setLoading(true);
try { try {
const response = await comms.getKuntaeList(startDate, endDate); // 1. 목록 조회
if (response.Success && response.Data) { const listResponse = await comms.getHolydayList(startDate, endDate, selectedUser);
setKuntaeList(response.Data); if (listResponse.Success && listResponse.Data) {
updateStats(response.Data); setKuntaeList(listResponse.Data);
} else { } else {
setKuntaeList([]); 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) { } catch (error) {
console.error('근태 목록 로드 오류:', error); console.error('데이터 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.'); alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [startDate, endDate]); }, [startDate, endDate, selectedUser, permission]);
// 통계 업데이트 // 초기 로드 및 검색 조건 변경 시 로드
const updateStats = (data: KuntaeModel[]) => { useEffect(() => {
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;
}
loadData(); 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); setProcessing(true);
try { try {
const response = await comms.deleteKuntae(id); const response = await comms.deleteHolyday(id);
if (response.Success) { if (response.Success) {
alert('삭제되었습니다.'); alert('삭제되었습니다.');
loadData(); 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) => { const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-'; if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('ko-KR'); return dateStr.split('T')[0];
}; };
return ( return (
<div className="space-y-6 animate-fade-in"> <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="glass-effect rounded-2xl p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="flex flex-col space-y-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label> {/* 1행: 날짜, 사용자, 조회/등록 */}
<input <div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
type="date" {/* 날짜 선택 및 월 이동 */}
value={startDate} <div className="flex items-center gap-2 w-full md:w-auto">
onChange={(e) => setStartDate(e.target.value)} <button
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" 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>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label> {/* 2행: 검색 필터 */}
<input <div className="flex items-center gap-2">
type="date" <div className="relative flex-1 max-w-md">
value={endDate} <Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
onChange={(e) => setEndDate(e.target.value)} <input
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" type="text"
/> value={filterText}
</div> onChange={(e) => setFilterText(e.target.value)}
<div className="flex items-end"> placeholder="구분, 내용, 성명으로 검색..."
<button 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"
onClick={handleSearch} />
disabled={loading} {filterText && (
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" <button
> onClick={() => setFilterText('')}
{loading ? ( className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white"
<RefreshCw className="w-4 h-4 mr-2 animate-spin" /> >
) : ( <XCircle className="w-4 h-4" />
<Search className="w-4 h-4 mr-2" /> </button>
)} )}
</div>
</button>
</div> </div>
</div> </div>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 (잔량 정보) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard {balances.length > 0 ? (
title="휴가 사용" balances.map((bal, idx) => {
value={stats.holyUsed} // 잔량 계산
icon={<Calendar className="w-6 h-6 text-primary-400" />} const remainDays = bal.TotalGenDays - bal.TotalUseDays;
color="text-primary-400" const remainHours = bal.TotalGenHours - bal.TotalUseHours;
/>
<StatCard // 아이콘 및 색상 결정
title="대체 사용" let icon = <Clock className="w-6 h-6" />;
value={stats.alternateUsed} let color = "text-white";
icon={<CheckCircle className="w-6 h-6 text-success-400" />}
color="text-success-400" if (bal.cate === '연차') {
/> icon = <Calendar className="w-6 h-6 text-primary-400" />;
<StatCard color = "text-primary-400";
title="잔량 (연차)" } else if (bal.cate === '대체') {
value={stats.holyRemain} icon = <RefreshCw className="w-6 h-6 text-success-400" />;
icon={<Clock className="w-6 h-6 text-warning-400" />} color = "text-success-400";
color="text-warning-400" } else if (bal.cate === '휴가') {
/> icon = <CheckCircle className="w-6 h-6 text-warning-400" />;
<StatCard color = "text-warning-400";
title="잔량 (대체)" }
value={stats.alternateRemain}
icon={<XCircle className="w-6 h-6 text-danger-400" />} return (
color="text-danger-400" <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>
{/* 데이터 테이블 */} {/* 데이터 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <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> <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>
<div className="overflow-x-auto"> <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>
<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> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/10"> <tbody className="divide-y divide-white/10">
{loading ? ( {loading ? (
<tr> <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"> <div className="flex items-center justify-center">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" /> <RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
<span className="text-white/50"> ...</span> <span className="text-white/50"> ...</span>
</div> </div>
</td> </td>
</tr> </tr>
) : kuntaeList.length === 0 ? ( ) : filteredList.length === 0 ? (
<tr> <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> </td>
</tr> </tr>
) : ( ) : (
kuntaeList.map((item) => ( filteredList.map((item) => (
<tr key={item.idx} className="hover:bg-white/5 transition-colors"> <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">{item.cate || '-'}</td> <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.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">{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.term > 0 ? item.term : '-'}</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.crtime > 0 ? item.crtime : '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.term || '-'}</td>
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}> <td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}>
{item.contents || '-'} {item.contents || '-'}
</td> </td>
<td className="px-4 py-3 text-white text-sm">{item.extcate || '-'}</td> <td className="px-4 py-3 text-white text-sm">
<td className="px-4 py-3 text-white text-sm">{item.wuid || '-'}</td> {item.UserName || item.uname || item.uid}
<td className="px-4 py-3 text-white text-sm">{item.wdate || '-'}</td> </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"> <td className="px-4 py-3 text-sm">
<button <div className="flex items-center space-x-2">
onClick={() => handleDelete(item.idx)} <button
disabled={processing} onClick={() => openModal('edit', item)}
className="text-danger-400 hover:text-danger-300 transition-colors disabled:opacity-50" className={`text-primary-400 hover:text-primary-300 transition-colors ${item.extidx ? 'opacity-50 cursor-not-allowed' : ''}`}
title="삭제" title={item.extidx ? "외부 연동 데이터는 수정할 수 없습니다" : "수정"}
> disabled={!!item.extidx}
<Trash2 className="w-4 h-4" /> >
</button> <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> </td>
</tr> </tr>
)) ))
@@ -257,6 +480,15 @@ export function Kuntae() {
</table> </table>
</div> </div>
</div> </div>
{/* 추가/수정 모달 */}
<KuntaeEditModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
initialData={selectedItem}
mode={modalMode}
/>
</div> </div>
); );
} }
@@ -264,12 +496,13 @@ export function Kuntae() {
// 통계 카드 컴포넌트 // 통계 카드 컴포넌트
interface StatCardProps { interface StatCardProps {
title: string; title: string;
value: number; value: string | number;
subValue?: string;
icon: React.ReactNode; icon: React.ReactNode;
color: string; color: string;
} }
function StatCard({ title, value, icon, color }: StatCardProps) { function StatCard({ title, value, subValue, icon, color }: StatCardProps) {
return ( return (
<div className="glass-effect rounded-xl p-4 card-hover"> <div className="glass-effect rounded-xl p-4 card-hover">
<div className="flex items-center"> <div className="flex items-center">
@@ -278,7 +511,8 @@ function StatCard({ title, value, icon, color }: StatCardProps) {
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-white/70">{title}</p> <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> </div>
</div> </div>

View File

@@ -90,24 +90,47 @@ declare global {
} }
} }
// 근태 관련 타입 // 근태 관련 타입 (Holyday 테이블)
export interface KuntaeModel { export interface KuntaeModel {
idx: number; idx: number;
gcode: string; 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; uid: string;
uname: string; UserName: string;
}
// 근태 잔량 정보 타입
export interface HolydayBalance {
cate: string; cate: string;
sdate: string | null; TotalGenDays: number;
edate: string | null; TotalGenHours: number;
term: number; TotalUseDays: number;
termdr: number; TotalUseHours: number;
drtime: number;
crtime: number;
contents: string;
tag: string;
extcate: string;
wuid: string;
wdate: string;
} }
// 업무일지 관련 타입 (기존 - 사용 안함) // 업무일지 관련 타입 (기존 - 사용 안함)
@@ -134,6 +157,8 @@ export interface JobReportItem {
svalue: string; // 업무형태 표시값 svalue: string; // 업무형태 표시값
hrs: number; hrs: number;
ot: number; ot: number;
otStart?: string; // 초과근무 시작시간
otEnd?: string; // 초과근무 종료시간
requestpart: string; requestpart: string;
package: string; package: string;
userprocess: 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_GetList(sd: string, ed: string, uid: string, cate: string, searchKey: string): Promise<string>;
Jobreport_GetUsers(): Promise<string>; Jobreport_GetUsers(): Promise<string>;
Jobreport_GetDetail(id: number): 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_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): 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_Delete(id: number): Promise<string>;
Jobreport_GetPermission(targetUserId: string): Promise<string>; Jobreport_GetPermission(targetUserId: string): Promise<string>;
Jobreport_GetJobTypes(process: string): Promise<string>; Jobreport_GetJobTypes(process: string): Promise<string>;
@@ -741,3 +766,28 @@ export interface ProjectDailyMemo {
wdate?: string; wdate?: string;
wname?: 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[];
}

View File

@@ -77,8 +77,8 @@
this.cmb_kisuldiv = new System.Windows.Forms.ComboBox(); this.cmb_kisuldiv = new System.Windows.Forms.ComboBox();
this.textBox1 = new System.Windows.Forms.TextBox(); this.textBox1 = new System.Windows.Forms.TextBox();
this.cmb_kisullv = new System.Windows.Forms.ComboBox(); this.cmb_kisullv = new System.Windows.Forms.ComboBox();
this.dateTimePicker2 = new System.Windows.Forms.DateTimePicker(); this.dtOtEnd = new System.Windows.Forms.DateTimePicker();
this.dateTimePicker1 = new System.Windows.Forms.DateTimePicker(); this.dtOtStart = new System.Windows.Forms.DateTimePicker();
this.lbTitleTip = new System.Windows.Forms.Label(); this.lbTitleTip = new System.Windows.Forms.Label();
this.tbTag = new System.Windows.Forms.TextBox(); this.tbTag = new System.Windows.Forms.TextBox();
this.ta = new FPJ0000.dsPRJTableAdapters.JobReportTableAdapter(); this.ta = new FPJ0000.dsPRJTableAdapters.JobReportTableAdapter();
@@ -201,15 +201,6 @@
label6.TabIndex = 13; label6.TabIndex = 13;
label6.Text = "프로세스"; label6.Text = "프로세스";
// //
// label2
//
this.label2.AutoSize = true;
this.label2.Location = new System.Drawing.Point(160, 163);
this.label2.Name = "label2";
this.label2.Size = new System.Drawing.Size(53, 12);
this.label2.TabIndex = 21;
this.label2.Text = "초과시간";
//
// label7 // label7
// //
label7.AutoSize = true; label7.AutoSize = true;
@@ -257,6 +248,15 @@
label11.TabIndex = 25; label11.TabIndex = 25;
label11.Text = "* 비용은 자동 계산 됩니다"; label11.Text = "* 비용은 자동 계산 됩니다";
// //
// label2
//
this.label2.AutoSize = true;
this.label2.Location = new System.Drawing.Point(160, 163);
this.label2.Name = "label2";
this.label2.Size = new System.Drawing.Size(53, 12);
this.label2.TabIndex = 21;
this.label2.Text = "초과시간";
//
// lbSummary // lbSummary
// //
this.lbSummary.AutoSize = true; this.lbSummary.AutoSize = true;
@@ -489,8 +489,8 @@
this.panel1.Controls.Add(this.chkManagerAlert); this.panel1.Controls.Add(this.chkManagerAlert);
this.panel1.Controls.Add(this.tbProjectIndex); this.panel1.Controls.Add(this.tbProjectIndex);
this.panel1.Controls.Add(this.grpkisul); this.panel1.Controls.Add(this.grpkisul);
this.panel1.Controls.Add(this.dateTimePicker2); this.panel1.Controls.Add(this.dtOtEnd);
this.panel1.Controls.Add(this.dateTimePicker1); this.panel1.Controls.Add(this.dtOtStart);
this.panel1.Controls.Add(label7); this.panel1.Controls.Add(label7);
this.panel1.Controls.Add(this.cmbRequest); this.panel1.Controls.Add(this.cmbRequest);
this.panel1.Controls.Add(label1); this.panel1.Controls.Add(label1);
@@ -624,25 +624,25 @@
this.cmb_kisullv.Size = new System.Drawing.Size(108, 20); this.cmb_kisullv.Size = new System.Drawing.Size(108, 20);
this.cmb_kisullv.TabIndex = 10; this.cmb_kisullv.TabIndex = 10;
// //
// dateTimePicker2 // dtOtEnd
// //
this.dateTimePicker2.CustomFormat = "yyyy-MM-dd hh:mm:ss"; this.dtOtEnd.CustomFormat = "yyyy-MM-dd hh:mm:ss";
this.dateTimePicker2.Format = System.Windows.Forms.DateTimePickerFormat.Custom; this.dtOtEnd.Format = System.Windows.Forms.DateTimePickerFormat.Custom;
this.dateTimePicker2.Location = new System.Drawing.Point(460, 165); this.dtOtEnd.Location = new System.Drawing.Point(460, 165);
this.dateTimePicker2.Margin = new System.Windows.Forms.Padding(4); this.dtOtEnd.Margin = new System.Windows.Forms.Padding(4);
this.dateTimePicker2.Name = "dateTimePicker2"; this.dtOtEnd.Name = "dtOtEnd";
this.dateTimePicker2.Size = new System.Drawing.Size(153, 21); this.dtOtEnd.Size = new System.Drawing.Size(153, 21);
this.dateTimePicker2.TabIndex = 25; this.dtOtEnd.TabIndex = 25;
// //
// dateTimePicker1 // dtOtStart
// //
this.dateTimePicker1.CustomFormat = "yyyy-MM-dd hh:mm:ss"; this.dtOtStart.CustomFormat = "yyyy-MM-dd hh:mm:ss";
this.dateTimePicker1.Format = System.Windows.Forms.DateTimePickerFormat.Custom; this.dtOtStart.Format = System.Windows.Forms.DateTimePickerFormat.Custom;
this.dateTimePicker1.Location = new System.Drawing.Point(302, 165); this.dtOtStart.Location = new System.Drawing.Point(302, 165);
this.dateTimePicker1.Margin = new System.Windows.Forms.Padding(4); this.dtOtStart.Margin = new System.Windows.Forms.Padding(4);
this.dateTimePicker1.Name = "dateTimePicker1"; this.dtOtStart.Name = "dtOtStart";
this.dateTimePicker1.Size = new System.Drawing.Size(153, 21); this.dtOtStart.Size = new System.Drawing.Size(153, 21);
this.dateTimePicker1.TabIndex = 24; this.dtOtStart.TabIndex = 24;
// //
// lbTitleTip // lbTitleTip
// //
@@ -743,8 +743,8 @@
private RichTextBoxEx.RichTextBoxEx richTextBoxEx1; private RichTextBoxEx.RichTextBoxEx richTextBoxEx1;
private System.Windows.Forms.Panel panel1; private System.Windows.Forms.Panel panel1;
private System.Windows.Forms.TextBox tbTag; private System.Windows.Forms.TextBox tbTag;
private System.Windows.Forms.DateTimePicker dateTimePicker1; private System.Windows.Forms.DateTimePicker dtOtStart;
private System.Windows.Forms.DateTimePicker dateTimePicker2; private System.Windows.Forms.DateTimePicker dtOtEnd;
private System.Windows.Forms.Label lbTitleTip; private System.Windows.Forms.Label lbTitleTip;
private System.Windows.Forms.GroupBox grpkisul; private System.Windows.Forms.GroupBox grpkisul;
private System.Windows.Forms.ComboBox cmb_kisullv; private System.Windows.Forms.ComboBox cmb_kisullv;

View File

@@ -85,8 +85,8 @@ namespace FPJ0000.JobReport_
EnsureVisibleAndUsableSize(); EnsureVisibleAndUsableSize();
//사용자목록 //사용자목록
this.bs.DataSource = dr; this.bs.DataSource = dr;
this.dateTimePicker1.CustomFormat = "yyyy-MM-dd HH:mm.ss"; this.dtOtStart.CustomFormat = "yyyy-MM-dd HH:mm.ss";
this.dateTimePicker2.CustomFormat = "yyyy-MM-dd HH:mm.ss"; this.dtOtEnd.CustomFormat = "yyyy-MM-dd HH:mm.ss";
//해당 사용자에 걸린 프로젝트 목록 가져오기 //해당 사용자에 걸린 프로젝트 목록 가져오기
var userProject = FCOMMON.DBM.getUserProjectList(FCOMMON.info.Login.nameK); var userProject = FCOMMON.DBM.getUserProjectList(FCOMMON.info.Login.nameK);
@@ -239,23 +239,23 @@ namespace FPJ0000.JobReport_
textBox1.Text = dr.kisulamt.ToString(); textBox1.Text = dr.kisulamt.ToString();
} }
if (dr.IsotStartNull()) dateTimePicker1.Value = DateTime.Now; if (dr.IsotStartNull()) dtOtStart.Value = DateTime.Now;
else dateTimePicker1.Value = dr.otStart; else dtOtStart.Value = dr.otStart;
if (dr.IsotEndNull()) dateTimePicker2.Value = DateTime.Now; if (dr.IsotEndNull()) dtOtEnd.Value = DateTime.Now;
else dateTimePicker2.Value = dr.otEnd; else dtOtEnd.Value = dr.otEnd;
//if (cmbRequest.Text == "") cmbRequest.Text = "EE1"; //if (cmbRequest.Text == "") cmbRequest.Text = "EE1";
if (cmbPackage.Text == "") cmbPackage.Text = "Common"; if (cmbPackage.Text == "") cmbPackage.Text = "Common";
if (dr.ot != 0) if (dr.ot != 0)
{ {
dateTimePicker1.Enabled = true; dtOtStart.Enabled = true;
dateTimePicker2.Enabled = true; dtOtEnd.Enabled = true;
} }
else else
{ {
dateTimePicker1.Enabled = false; dtOtStart.Enabled = false;
dateTimePicker2.Enabled = false; dtOtEnd.Enabled = false;
} }
//프로젝트 번호 확인(프로젝트번호가 바뀌면 데이터를 업데이트 해준다) //프로젝트 번호 확인(프로젝트번호가 바뀌면 데이터를 업데이트 해준다)
@@ -638,13 +638,13 @@ namespace FPJ0000.JobReport_
if (double.TryParse(tbOt.Text, out double ot)) if (double.TryParse(tbOt.Text, out double ot))
{ {
var timeterm = dateTimePicker2.Value - dateTimePicker1.Value; var timeterm = dtOtEnd.Value - dtOtStart.Value;
if ((timeterm.TotalMinutes + 1) * 60 <= ot) if ((timeterm.TotalMinutes + 1) * 60 <= ot)
{ {
FCOMMON.Util.MsgE($"OT시간이 지정된 시간대보다 부족합니다.\nOT시간정보를 확인하세요\n" + FCOMMON.Util.MsgE($"OT시간이 지정된 시간대보다 부족합니다.\nOT시간정보를 확인하세요\n" +
$"\n입력시간범위 : {dateTimePicker1.Value.ToString("HH:mm:ss")}~{dateTimePicker2.Value.ToString("HH:mm:ss")}" + $"\n입력시간범위 : {dtOtStart.Value.ToString("HH:mm:ss")}~{dtOtEnd.Value.ToString("HH:mm:ss")}" +
$"\n입력시간(시) : {timeterm.TotalMinutes * 60}"); $"\n입력시간(시) : {timeterm.TotalMinutes * 60}");
dateTimePicker1.Focus(); dtOtStart.Focus();
return; return;
} }
@@ -657,8 +657,8 @@ namespace FPJ0000.JobReport_
else else
{ {
dr.ot = ot; dr.ot = ot;
dr.otStart = dateTimePicker1.Value; dr.otStart = dtOtStart.Value;
dr.otEnd = dateTimePicker2.Value; dr.otEnd = dtOtEnd.Value;
} }
} }
else else
@@ -950,30 +950,30 @@ namespace FPJ0000.JobReport_
if (ot != 0) if (ot != 0)
{ {
dateTimePicker1.Enabled = true; dtOtStart.Enabled = true;
dateTimePicker2.Enabled = true; dtOtEnd.Enabled = true;
//신규데이터라면 자동으로 시간을 설정해준다 //신규데이터라면 자동으로 시간을 설정해준다
if (dr.RowState == DataRowState.Added || dr.RowState == DataRowState.Detached) if (dr.RowState == DataRowState.Added || dr.RowState == DataRowState.Detached)
{ {
if (dateTimePicker1.Value == dateTimePicker2.Value) if (dtOtStart.Value == dtOtEnd.Value)
{ {
dateTimePicker1.Value = DateTime.Parse(DateTime.Now.ToString("yyyy-MM-dd" + " 18:00:00")); dtOtStart.Value = DateTime.Parse(DateTime.Now.ToString("yyyy-MM-dd" + " 18:00:00"));
dateTimePicker2.Value = dateTimePicker1.Value.AddHours(ot); dtOtEnd.Value = dtOtStart.Value.AddHours(ot);
} }
} }
} }
else else
{ {
dateTimePicker1.Enabled = false; dtOtStart.Enabled = false;
dateTimePicker2.Enabled = false; dtOtEnd.Enabled = false;
} }
} }
else else
{ {
dateTimePicker1.Enabled = false; dtOtStart.Enabled = false;
dateTimePicker2.Enabled = false; dtOtEnd.Enabled = false;
} }
} }