feat: 품목정보 상세 패널 추가 및 프로젝트/근태/권한 기능 확장
- Items: 우측에 이미지, 담당자, 입고/발주내역 패널 추가 (fItems 윈폼 동일) - Project: 목록 및 상세 다이얼로그 구현 - Kuntae: 오류검사/수정 기능 추가 - UserAuth: 사용자 권한 관리 페이지 추가 - UserGroup: 그룹정보 다이얼로그로 전환 - Header: 사용자 메뉴 서브메뉴 방향 수정, 즐겨찾기 기능 - Backend API: Items 상세/담당자/구매내역, 근태 오류검사, 프로젝트 목록 등 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -363,6 +363,7 @@
|
|||||||
<Compile Include="Web\MachineBridge\MachineBridge.Holiday.cs" />
|
<Compile Include="Web\MachineBridge\MachineBridge.Holiday.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\WebSocketServer.cs" />
|
<Compile Include="Web\MachineBridge\WebSocketServer.cs" />
|
||||||
<Compile Include="Web\Model\PageModel.cs" />
|
<Compile Include="Web\Model\PageModel.cs" />
|
||||||
<Compile Include="Web\Model\ProjectModel.cs" />
|
<Compile Include="Web\Model\ProjectModel.cs" />
|
||||||
|
|||||||
@@ -228,6 +228,50 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 즐겨찾기 목록 조회 (grp=17)
|
||||||
|
/// memo가 표시명, svalue가 URL
|
||||||
|
/// </summary>
|
||||||
|
public string Favorite_GetList()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = "select isnull(code,'') as code, isnull(memo,'') as name, isnull(svalue,'') as url " +
|
||||||
|
"from common WITH (nolock) " +
|
||||||
|
"where gcode = @gcode and grp = '17' and isnull(code,'') <> '' " +
|
||||||
|
"order by code";
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
var cn = new SqlConnection(cs);
|
||||||
|
var cmd = new SqlCommand(sql, cn);
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
|
||||||
|
var da = new SqlDataAdapter(cmd);
|
||||||
|
var dt = new DataTable();
|
||||||
|
da.Fill(dt);
|
||||||
|
da.Dispose();
|
||||||
|
cmd.Dispose();
|
||||||
|
cn.Dispose();
|
||||||
|
|
||||||
|
var result = new System.Collections.Generic.List<object>();
|
||||||
|
foreach (DataRow dr in dt.Rows)
|
||||||
|
{
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
name = dr["name"]?.ToString() ?? "",
|
||||||
|
url = dr["url"]?.ToString() ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Favorite_GetList 오류: {ex.Message}");
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Data = new object[] { }, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Items API
|
#region Items API
|
||||||
@@ -421,6 +465,349 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 품목 이미지 조회 (Base64 반환)
|
||||||
|
/// </summary>
|
||||||
|
public string Items_GetImage(int idx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand("SELECT image FROM Items WHERE idx = @idx AND gcode = @gcode", cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@idx", idx);
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
|
||||||
|
cn.Open();
|
||||||
|
var data = cmd.ExecuteScalar() as byte[];
|
||||||
|
|
||||||
|
if (data != null && data.Length > 0)
|
||||||
|
{
|
||||||
|
var base64 = Convert.ToBase64String(data);
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = base64 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = (string)null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = "이미지 조회 실패: " + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 품목 이미지 저장 (Base64 입력)
|
||||||
|
/// </summary>
|
||||||
|
public string Items_SaveImage(int idx, string base64Image)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] imageData = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(base64Image))
|
||||||
|
{
|
||||||
|
// data:image/png;base64, 형식 제거
|
||||||
|
if (base64Image.Contains(","))
|
||||||
|
{
|
||||||
|
base64Image = base64Image.Substring(base64Image.IndexOf(",") + 1);
|
||||||
|
}
|
||||||
|
imageData = Convert.FromBase64String(base64Image);
|
||||||
|
|
||||||
|
// 이미지 크기 조정 (640x480 제한, WinForms과 동일)
|
||||||
|
using (var ms = new System.IO.MemoryStream(imageData))
|
||||||
|
using (var img = System.Drawing.Image.FromStream(ms))
|
||||||
|
{
|
||||||
|
System.Drawing.Image resized = img;
|
||||||
|
bool needResize = false;
|
||||||
|
|
||||||
|
if (img.Width > 640)
|
||||||
|
{
|
||||||
|
var newRate = 640.0 / img.Width;
|
||||||
|
var newHeight = (int)(img.Height * newRate);
|
||||||
|
resized = new System.Drawing.Bitmap(640, newHeight);
|
||||||
|
using (var g = System.Drawing.Graphics.FromImage(resized))
|
||||||
|
{
|
||||||
|
g.DrawImage(img, new System.Drawing.Rectangle(0, 0, 640, newHeight));
|
||||||
|
}
|
||||||
|
needResize = true;
|
||||||
|
}
|
||||||
|
else if (img.Height > 480)
|
||||||
|
{
|
||||||
|
var newRate = 480.0 / img.Height;
|
||||||
|
var newWidth = (int)(img.Width * newRate);
|
||||||
|
resized = new System.Drawing.Bitmap(newWidth, 480);
|
||||||
|
using (var g = System.Drawing.Graphics.FromImage(resized))
|
||||||
|
{
|
||||||
|
g.DrawImage(img, new System.Drawing.Rectangle(0, 0, newWidth, 480));
|
||||||
|
}
|
||||||
|
needResize = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var outMs = new System.IO.MemoryStream())
|
||||||
|
{
|
||||||
|
resized.Save(outMs, System.Drawing.Imaging.ImageFormat.Jpeg);
|
||||||
|
imageData = outMs.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needResize)
|
||||||
|
{
|
||||||
|
resized.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand("UPDATE Items SET image = @image, wuid = @wuid, wdate = GETDATE() WHERE idx = @idx AND gcode = @gcode", cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@idx", idx);
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||||
|
|
||||||
|
if (imageData != null)
|
||||||
|
{
|
||||||
|
cmd.Parameters.Add("@image", SqlDbType.VarBinary, -1).Value = imageData;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cmd.Parameters.Add("@image", SqlDbType.VarBinary, -1).Value = DBNull.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
cn.Open();
|
||||||
|
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 Items_DeleteImage(int idx)
|
||||||
|
{
|
||||||
|
return Items_SaveImage(idx, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 품목의 공급처 담당자 조회
|
||||||
|
/// </summary>
|
||||||
|
public string Items_GetSupplierStaff(int supplyIdx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (supplyIdx <= 0)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = new object[] { } });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sql = @"SELECT idx, name, grade, dept, tel, email, memo
|
||||||
|
FROM Staff WITH (NOLOCK)
|
||||||
|
WHERE gcode = @gcode AND cid = @cid";
|
||||||
|
|
||||||
|
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("@cid", supplyIdx);
|
||||||
|
|
||||||
|
var da = new SqlDataAdapter(cmd);
|
||||||
|
var dt = new DataTable();
|
||||||
|
da.Fill(dt);
|
||||||
|
|
||||||
|
var result = new System.Collections.Generic.List<object>();
|
||||||
|
foreach (DataRow dr in dt.Rows)
|
||||||
|
{
|
||||||
|
var name = dr["name"]?.ToString() ?? "";
|
||||||
|
var grade = dr["grade"]?.ToString() ?? "";
|
||||||
|
if (!string.IsNullOrEmpty(grade))
|
||||||
|
{
|
||||||
|
name += $"({grade})";
|
||||||
|
}
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
idx = Convert.ToInt32(dr["idx"]),
|
||||||
|
name = name,
|
||||||
|
tel = dr["tel"]?.ToString() ?? "",
|
||||||
|
email = dr["email"]?.ToString() ?? "",
|
||||||
|
dept = dr["dept"]?.ToString() ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = "담당자 조회 실패: " + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 품목의 최근 입고내역 조회 (indate 기준)
|
||||||
|
/// </summary>
|
||||||
|
public string Items_GetIncomingHistory(int itemIdx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT TOP 10 idx, indate, request, pumqty, pumprice, state
|
||||||
|
FROM Purchase WITH (NOLOCK)
|
||||||
|
WHERE pumidx = @pumidx
|
||||||
|
AND ISNULL(indate, '') <> ''
|
||||||
|
AND ISNULL(isdel, 0) = 0
|
||||||
|
ORDER BY indate DESC";
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sql, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@pumidx", itemIdx);
|
||||||
|
|
||||||
|
var da = new SqlDataAdapter(cmd);
|
||||||
|
var dt = new DataTable();
|
||||||
|
da.Fill(dt);
|
||||||
|
|
||||||
|
var result = new System.Collections.Generic.List<object>();
|
||||||
|
foreach (DataRow dr in dt.Rows)
|
||||||
|
{
|
||||||
|
var date = dr["indate"]?.ToString() ?? "";
|
||||||
|
// 년도 2자리로 표시 (2024-01-01 -> 24-01-01)
|
||||||
|
if (date.Length > 9) date = date.Substring(2);
|
||||||
|
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
idx = Convert.ToInt32(dr["idx"]),
|
||||||
|
date = date,
|
||||||
|
request = dr["request"]?.ToString() ?? "",
|
||||||
|
qty = dr["pumqty"] == DBNull.Value ? 0 : Convert.ToInt32(dr["pumqty"]),
|
||||||
|
price = dr["pumprice"] == DBNull.Value ? 0m : Convert.ToDecimal(dr["pumprice"]),
|
||||||
|
state = dr["state"]?.ToString() ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = "입고내역 조회 실패: " + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 품목의 발주내역 조회 (pdate 기준)
|
||||||
|
/// </summary>
|
||||||
|
public string Items_GetOrderHistory(int itemIdx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT TOP 10 idx, pdate, request, pumqty, pumprice, state
|
||||||
|
FROM Purchase WITH (NOLOCK)
|
||||||
|
WHERE pumidx = @pumidx
|
||||||
|
AND state <> 'Cancled'
|
||||||
|
AND ISNULL(isdel, 0) = 0
|
||||||
|
ORDER BY pdate DESC";
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sql, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@pumidx", itemIdx);
|
||||||
|
|
||||||
|
var da = new SqlDataAdapter(cmd);
|
||||||
|
var dt = new DataTable();
|
||||||
|
da.Fill(dt);
|
||||||
|
|
||||||
|
var result = new System.Collections.Generic.List<object>();
|
||||||
|
foreach (DataRow dr in dt.Rows)
|
||||||
|
{
|
||||||
|
var date = dr["pdate"]?.ToString() ?? "";
|
||||||
|
// 년도 2자리로 표시 (2024-01-01 -> 24-01-01)
|
||||||
|
if (date.Length > 9) date = date.Substring(2);
|
||||||
|
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
idx = Convert.ToInt32(dr["idx"]),
|
||||||
|
date = date,
|
||||||
|
request = dr["request"]?.ToString() ?? "",
|
||||||
|
qty = dr["pumqty"] == DBNull.Value ? 0 : Convert.ToInt32(dr["pumqty"]),
|
||||||
|
price = dr["pumprice"] == DBNull.Value ? 0m : Convert.ToDecimal(dr["pumprice"]),
|
||||||
|
state = dr["state"]?.ToString() ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = "발주내역 조회 실패: " + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 품목 상세 정보 조회 (supplyidx 포함)
|
||||||
|
/// </summary>
|
||||||
|
public string Items_GetDetail(int idx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT idx, sid, cate, name, model, scale, unit, price, supply, supplyidx, manu, storage, disable, memo
|
||||||
|
FROM Items WITH (NOLOCK)
|
||||||
|
WHERE idx = @idx AND 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);
|
||||||
|
|
||||||
|
cn.Open();
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
var item = new
|
||||||
|
{
|
||||||
|
idx = reader.GetInt32(reader.GetOrdinal("idx")),
|
||||||
|
sid = reader["sid"]?.ToString() ?? "",
|
||||||
|
cate = reader["cate"]?.ToString() ?? "",
|
||||||
|
name = reader["name"]?.ToString() ?? "",
|
||||||
|
model = reader["model"]?.ToString() ?? "",
|
||||||
|
scale = reader["scale"]?.ToString() ?? "",
|
||||||
|
unit = reader["unit"]?.ToString() ?? "",
|
||||||
|
price = reader["price"] == DBNull.Value ? 0m : Convert.ToDecimal(reader["price"]),
|
||||||
|
supply = reader["supply"]?.ToString() ?? "",
|
||||||
|
supplyidx = reader["supplyidx"] == DBNull.Value ? -1 : Convert.ToInt32(reader["supplyidx"]),
|
||||||
|
manu = reader["manu"]?.ToString() ?? "",
|
||||||
|
storage = reader["storage"]?.ToString() ?? "",
|
||||||
|
disable = reader["disable"] != DBNull.Value && Convert.ToBoolean(reader["disable"]),
|
||||||
|
memo = reader["memo"]?.ToString() ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = item });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = "품목을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = "품목 상세 조회 실패: " + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,16 +93,19 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 업무일지 상세 조회 (vJobReportForUser 뷰 사용)
|
/// 업무일지 상세 조회 (vJobReportForUser 뷰 + JobReport 테이블 조인)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Jobreport_GetDetail(int id)
|
public string Jobreport_GetDetail(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sql = @"SELECT idx, pidx, pdate, id, name, type, svalue, hrs, ot, requestpart, package,
|
// 뷰에서 기본 정보 조회, 원본 테이블에서 jobgrp, tag 추가 조회
|
||||||
userprocess, status, projectName, description, ww, otpms, process
|
var sql = @"SELECT v.idx, v.pidx, v.pdate, v.id, v.name, v.type, v.svalue, v.hrs, v.ot,
|
||||||
FROM vJobReportForUser WITH (nolock)
|
v.requestpart, v.package, v.userprocess, v.status, v.projectName, v.description,
|
||||||
WHERE idx = @idx AND gcode = @gcode";
|
v.ww, v.otpms, v.process, j.jobgrp, j.tag
|
||||||
|
FROM vJobReportForUser v WITH (nolock)
|
||||||
|
INNER JOIN JobReport j WITH (nolock) ON v.idx = j.idx
|
||||||
|
WHERE v.idx = @idx AND v.gcode = @gcode";
|
||||||
|
|
||||||
var cs = Properties.Settings.Default.gwcs;
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
using (var cn = new SqlConnection(cs))
|
using (var cn = new SqlConnection(cs))
|
||||||
@@ -137,7 +140,7 @@ namespace Project.Web
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 업무일지 추가 (JobReport 테이블)
|
/// 업무일지 추가 (JobReport 테이블)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Jobreport_Add(string pdate, string projectName, 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)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -152,7 +155,7 @@ 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)
|
||||||
VALUES (@gcode, @uid, @pdate, @projectName, @requestpart, @package,
|
VALUES (@gcode, @uid, @pdate, @projectName, @requestpart, @package,
|
||||||
@type, @process, @status, @description, @hrs, @ot, @jobgrp, @tag, @wuid, GETDATE(), -1);
|
@type, @process, @status, @description, @hrs, @ot, @jobgrp, @tag, @wuid, GETDATE(), @pidx);
|
||||||
SELECT SCOPE_IDENTITY();";
|
SELECT SCOPE_IDENTITY();";
|
||||||
|
|
||||||
var cs = Properties.Settings.Default.gwcs;
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
@@ -163,6 +166,7 @@ namespace Project.Web
|
|||||||
cmd.Parameters.AddWithValue("@uid", info.Login.no);
|
cmd.Parameters.AddWithValue("@uid", info.Login.no);
|
||||||
cmd.Parameters.AddWithValue("@pdate", pdate);
|
cmd.Parameters.AddWithValue("@pdate", pdate);
|
||||||
cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
|
cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
|
||||||
|
cmd.Parameters.AddWithValue("@pidx", pidx);
|
||||||
cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
|
cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
|
||||||
cmd.Parameters.AddWithValue("@package", package ?? "");
|
cmd.Parameters.AddWithValue("@package", package ?? "");
|
||||||
cmd.Parameters.AddWithValue("@type", type ?? "");
|
cmd.Parameters.AddWithValue("@type", type ?? "");
|
||||||
@@ -189,7 +193,7 @@ namespace Project.Web
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 업무일지 수정 (JobReport 테이블)
|
/// 업무일지 수정 (JobReport 테이블)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Jobreport_Edit(int idx, string pdate, string projectName, 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)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -224,7 +228,7 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sql = @"UPDATE JobReport SET
|
var sql = @"UPDATE JobReport SET
|
||||||
pdate = @pdate, projectName = @projectName, 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()
|
wuid = @wuid, wdate = GETDATE()
|
||||||
@@ -238,6 +242,7 @@ namespace Project.Web
|
|||||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
cmd.Parameters.AddWithValue("@pdate", pdate);
|
cmd.Parameters.AddWithValue("@pdate", pdate);
|
||||||
cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
|
cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
|
||||||
|
cmd.Parameters.AddWithValue("@pidx", pidx);
|
||||||
cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
|
cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
|
||||||
cmd.Parameters.AddWithValue("@package", package ?? "");
|
cmd.Parameters.AddWithValue("@package", package ?? "");
|
||||||
cmd.Parameters.AddWithValue("@type", type ?? "");
|
cmd.Parameters.AddWithValue("@type", type ?? "");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Data.SqlClient;
|
using System.Data.SqlClient;
|
||||||
|
using System.Text;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using FCOMMON;
|
using FCOMMON;
|
||||||
|
|
||||||
@@ -11,6 +12,33 @@ namespace Project.Web
|
|||||||
{
|
{
|
||||||
#region Kuntae API
|
#region Kuntae API
|
||||||
|
|
||||||
|
#region 오류검사 관련 클래스
|
||||||
|
public class KuntaeErrorCheckResult
|
||||||
|
{
|
||||||
|
public string Date { get; set; }
|
||||||
|
public string Gubun { get; set; }
|
||||||
|
public string OccurDay { get; set; }
|
||||||
|
public string OccurTime { get; set; }
|
||||||
|
public string UseDay { get; set; }
|
||||||
|
public string UseTime { get; set; }
|
||||||
|
public string CateError { get; set; }
|
||||||
|
public bool IsError { get; set; }
|
||||||
|
public bool IsMagam { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KuntaeErrorCheckProgress
|
||||||
|
{
|
||||||
|
public string CurrentDate { get; set; }
|
||||||
|
public double JobreportOT { get; set; }
|
||||||
|
public double HolidayRequestDay { get; set; }
|
||||||
|
public double HolidayRequestTime { get; set; }
|
||||||
|
public double KuntaeCRDay { get; set; }
|
||||||
|
public double KuntaeCRTime { get; set; }
|
||||||
|
public double KuntaeDRDay { get; set; }
|
||||||
|
public double KuntaeDRTime { get; set; }
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 근태 목록 조회
|
/// 근태 목록 조회
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -90,6 +118,350 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 근태 오류 검사 실행
|
||||||
|
/// </summary>
|
||||||
|
public string Kuntae_ErrorCheck(string sd, string ed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startDate = DateTime.Parse(sd);
|
||||||
|
var endDate = DateTime.Parse(ed);
|
||||||
|
var gcode = info.Login.gcode;
|
||||||
|
var uid = info.Login.no;
|
||||||
|
|
||||||
|
var okList = new List<KuntaeErrorCheckResult>();
|
||||||
|
var ngList = new List<KuntaeErrorCheckResult>();
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
{
|
||||||
|
cn.Open();
|
||||||
|
using (var cmd = new SqlCommand("", cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.Add("@gcode", SqlDbType.VarChar).Value = gcode;
|
||||||
|
cmd.Parameters.Add("@uid", SqlDbType.VarChar).Value = uid;
|
||||||
|
|
||||||
|
var idx = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var currentDate = startDate.AddDays(idx++);
|
||||||
|
if (currentDate > endDate) break;
|
||||||
|
|
||||||
|
var pdate = currentDate.ToString("yyyy-MM-dd");
|
||||||
|
var result = CheckDateError(cmd, pdate, gcode);
|
||||||
|
|
||||||
|
if (result.IsError)
|
||||||
|
ngList.Add(result);
|
||||||
|
else
|
||||||
|
okList.Add(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new {
|
||||||
|
Success = true,
|
||||||
|
OkList = okList,
|
||||||
|
NgList = ngList
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 특정 날짜의 오류 검사
|
||||||
|
/// </summary>
|
||||||
|
private KuntaeErrorCheckResult CheckDateError(SqlCommand cmd, string pdate, string gcode)
|
||||||
|
{
|
||||||
|
var result = new KuntaeErrorCheckResult
|
||||||
|
{
|
||||||
|
Date = pdate,
|
||||||
|
Gubun = "입력/생성",
|
||||||
|
IsError = false,
|
||||||
|
IsMagam = false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 업무일지 OT2 합계 (발생시간)
|
||||||
|
cmd.CommandText = $"SELECT SUM(ISNULL(ot2,0)) FROM jobreport WHERE gcode = @gcode AND pdate='{pdate}' AND ISNULL(ot,0) > 0 AND ISNULL(ot2,0) > 0";
|
||||||
|
var jobreportOT = 0.0;
|
||||||
|
var objJobreport = cmd.ExecuteScalar();
|
||||||
|
if (objJobreport != null && objJobreport != DBNull.Value)
|
||||||
|
jobreportOT = Convert.ToDouble(objJobreport);
|
||||||
|
|
||||||
|
// 2. 휴가신청 확인 (승인된 것만)
|
||||||
|
cmd.CommandText = $"SELECT cate, SUM(HolyDays), SUM(HolyTimes) FROM EETGW_HolydayRequest WHERE gcode = @gcode AND sdate = '{pdate}' AND ISNULL(conf,0) = 1 GROUP BY cate";
|
||||||
|
var holidayDay = 0.0;
|
||||||
|
var holidayTime = 0.0;
|
||||||
|
var cateListD = new Dictionary<string, double>();
|
||||||
|
var cateListT = new Dictionary<string, double>();
|
||||||
|
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var cate = reader[0].ToString();
|
||||||
|
var vDay = reader[1] != DBNull.Value ? Convert.ToDouble(reader[1]) : 0.0;
|
||||||
|
var vTime = reader[2] != DBNull.Value ? Convert.ToDouble(reader[2]) : 0.0;
|
||||||
|
|
||||||
|
holidayDay += vDay;
|
||||||
|
holidayTime += vTime;
|
||||||
|
|
||||||
|
if (vDay != 0.0)
|
||||||
|
{
|
||||||
|
if (cateListD.ContainsKey(cate))
|
||||||
|
cateListD[cate] += vDay;
|
||||||
|
else
|
||||||
|
cateListD.Add(cate, vDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vTime != 0.0)
|
||||||
|
{
|
||||||
|
if (cateListT.ContainsKey(cate))
|
||||||
|
cateListT[cate] += vTime;
|
||||||
|
else
|
||||||
|
cateListT.Add(cate, vTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 근태입력자료 확인
|
||||||
|
cmd.CommandText = $@"SELECT cate, SUM(term), SUM(crtime), SUM(termdr), SUM(drtime), SUM(drtimepms)
|
||||||
|
FROM Holyday
|
||||||
|
WHERE gcode = @gcode AND sdate = '{pdate}' AND ISNULL(extidx,-1) <> -1
|
||||||
|
GROUP BY cate";
|
||||||
|
|
||||||
|
var kuntaeCRDay = 0.0;
|
||||||
|
var kuntaeCRTime = 0.0;
|
||||||
|
var kuntaeDRDay = 0.0;
|
||||||
|
var kuntaeDRTime = 0.0;
|
||||||
|
var dCRD = new Dictionary<string, double>();
|
||||||
|
var dCRT = new Dictionary<string, double>();
|
||||||
|
var dDRD = new Dictionary<string, double>();
|
||||||
|
var dDRT = new Dictionary<string, double>();
|
||||||
|
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var cate = reader[0].ToString();
|
||||||
|
var vCRD = reader[1] != DBNull.Value ? Convert.ToDouble(reader[1]) : 0.0;
|
||||||
|
var vCRT = reader[2] != DBNull.Value ? Convert.ToDouble(reader[2]) : 0.0;
|
||||||
|
var vDRD = reader[3] != DBNull.Value ? Convert.ToDouble(reader[3]) : 0.0;
|
||||||
|
var vDRT = reader[4] != DBNull.Value ? Convert.ToDouble(reader[4]) : 0.0;
|
||||||
|
|
||||||
|
if (vCRD != 0.0)
|
||||||
|
{
|
||||||
|
if (dCRD.ContainsKey(cate)) dCRD[cate] += vCRD;
|
||||||
|
else dCRD.Add(cate, vCRD);
|
||||||
|
}
|
||||||
|
if (vCRT != 0.0)
|
||||||
|
{
|
||||||
|
if (dCRT.ContainsKey(cate)) dCRT[cate] += vCRT;
|
||||||
|
else dCRT.Add(cate, vCRT);
|
||||||
|
}
|
||||||
|
if (vDRD != 0.0)
|
||||||
|
{
|
||||||
|
if (dDRD.ContainsKey(cate)) dDRD[cate] += vDRD;
|
||||||
|
else dDRD.Add(cate, vDRD);
|
||||||
|
}
|
||||||
|
if (vDRT != 0.0)
|
||||||
|
{
|
||||||
|
if (dDRT.ContainsKey(cate)) dDRT[cate] += vDRT;
|
||||||
|
else dDRT.Add(cate, vDRT);
|
||||||
|
}
|
||||||
|
|
||||||
|
kuntaeCRDay += vCRD;
|
||||||
|
kuntaeCRTime += vCRT;
|
||||||
|
kuntaeDRDay += vDRD;
|
||||||
|
kuntaeDRTime += vDRT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 카테고리별 데이터 확인
|
||||||
|
var sbCate = new StringBuilder();
|
||||||
|
var cateErr = false;
|
||||||
|
|
||||||
|
// 휴가신청(일) vs 근태입력(CR일)
|
||||||
|
foreach (var item in cateListD)
|
||||||
|
{
|
||||||
|
if (!dCRD.ContainsKey(item.Key))
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}(X)");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (dCRD[item.Key] != item.Value)
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}({dCRD[item.Key]}|{item.Value})");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cateErr)
|
||||||
|
{
|
||||||
|
foreach (var item in cateListT)
|
||||||
|
{
|
||||||
|
if (!dCRT.ContainsKey(item.Key))
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}(X)");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (dCRT[item.Key] != item.Value)
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}({dCRT[item.Key]}|{item.Value})");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cateErr)
|
||||||
|
{
|
||||||
|
foreach (var item in dCRD)
|
||||||
|
{
|
||||||
|
if (item.Key.Equals("대체")) continue;
|
||||||
|
if (!cateListD.ContainsKey(item.Key))
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}(X)");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (cateListD[item.Key] != item.Value)
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}({cateListD[item.Key]}|{item.Value})");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cateErr)
|
||||||
|
{
|
||||||
|
foreach (var item in dCRT)
|
||||||
|
{
|
||||||
|
if (item.Key.Equals("대체")) continue;
|
||||||
|
if (!cateListT.ContainsKey(item.Key))
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}(X)");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (cateListT[item.Key] != item.Value)
|
||||||
|
{
|
||||||
|
sbCate.Append($"{item.Key}({cateListT[item.Key]}|{item.Value})");
|
||||||
|
cateErr = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 결과 생성
|
||||||
|
result.OccurDay = $"--/{kuntaeDRDay}";
|
||||||
|
result.OccurTime = $"{jobreportOT}/{kuntaeDRTime}";
|
||||||
|
result.UseDay = $"{holidayDay}/{kuntaeCRDay}";
|
||||||
|
result.UseTime = $"{holidayTime}/{kuntaeCRTime}";
|
||||||
|
result.CateError = sbCate.ToString();
|
||||||
|
|
||||||
|
// 오류 여부 판단
|
||||||
|
if (jobreportOT != kuntaeDRTime) result.IsError = true;
|
||||||
|
if (holidayDay != kuntaeCRDay) result.IsError = true;
|
||||||
|
if (holidayTime != kuntaeCRTime) result.IsError = true;
|
||||||
|
if (cateErr) result.IsError = true;
|
||||||
|
|
||||||
|
// 마감 여부 확인
|
||||||
|
if (result.IsError)
|
||||||
|
{
|
||||||
|
result.IsMagam = DBM.GetMagamStatus(pdate.Substring(0, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 근태 오류 수정 (재생성)
|
||||||
|
/// </summary>
|
||||||
|
public string Kuntae_FixError(string pdate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var gcode = info.Login.gcode;
|
||||||
|
var uid = info.Login.no;
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
{
|
||||||
|
cn.Open();
|
||||||
|
using (var cmd = new SqlCommand("", cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.Add("@gcode", SqlDbType.VarChar).Value = gcode;
|
||||||
|
cmd.Parameters.Add("@uid", SqlDbType.VarChar).Value = uid;
|
||||||
|
cmd.Parameters.Add("@pdate", SqlDbType.VarChar).Value = pdate;
|
||||||
|
|
||||||
|
// 1. 근태-업무일지자료 삭제
|
||||||
|
cmd.CommandText = "DELETE FROM Holyday WHERE gcode = @gcode AND extcate = 'HO' AND sdate = @pdate AND ISNULL(extidx,-1) <> -1";
|
||||||
|
var cnt1 = cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
// 2. 근태-업무일지자료 생성
|
||||||
|
cmd.CommandText = @"INSERT INTO Holyday(gcode, cate, sdate, edate, term, crtime, termdr, DrTime, contents, [uid], wdate, wuid, extcate, extidx)
|
||||||
|
SELECT gcode, '대체', pdate, pdate, 0, 0, 0, ISNULL(ot2,0), projectname, uid, GETDATE(), @uid + '-ERR', 'HO', idx
|
||||||
|
FROM jobreport
|
||||||
|
WHERE gcode = @gcode AND pdate = @pdate AND ISNULL(ot2,0) > 0 AND ISNULL(ot,0) > 0";
|
||||||
|
var cnt2 = cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
// 3. 근태-휴가신청자료 삭제
|
||||||
|
cmd.CommandText = "DELETE FROM Holyday WHERE gcode = @gcode AND extcate = '휴가' AND sdate = @pdate AND ISNULL(extidx,-1) <> -1";
|
||||||
|
var cnt3 = cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
// 4. 근태-휴가신청자료 생성 (승인완료된 자료 대상)
|
||||||
|
cmd.CommandText = @"INSERT INTO Holyday(gcode, cate, sdate, edate, term, crtime, termdr, DrTime, contents, [uid], wdate, wuid, extcate, extidx)
|
||||||
|
SELECT gcode, cate, sdate, edate, ISNULL(holydays,0), ISNULL(holytimes,0), 0, 0, HolyReason, uid, GETDATE(), @uid + '-ERR', '휴가', idx
|
||||||
|
FROM EETGW_HolydayRequest
|
||||||
|
WHERE gcode = @gcode AND sdate = @pdate AND ISNULL(conf,0) = 1";
|
||||||
|
var cnt4 = cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new {
|
||||||
|
Success = true,
|
||||||
|
Message = $"{pdate} 재생성 완료 (삭제: {cnt1 + cnt3}건, 생성: {cnt2 + cnt4}건)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 여러 날짜 오류 일괄 수정
|
||||||
|
/// </summary>
|
||||||
|
public string Kuntae_FixErrors(string dates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dateList = JsonConvert.DeserializeObject<List<string>>(dates);
|
||||||
|
var results = new List<object>();
|
||||||
|
|
||||||
|
foreach (var date in dateList)
|
||||||
|
{
|
||||||
|
var resultJson = Kuntae_FixError(date);
|
||||||
|
var resultObj = JsonConvert.DeserializeObject<dynamic>(resultJson);
|
||||||
|
results.Add(new { Date = date, Success = (bool)resultObj.Success, Message = (string)resultObj.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Results = results });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,5 +206,431 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Project Search API (업무일지용)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 업무일지용 프로젝트 검색 (Projects 테이블 + 과거 업무일지 항목명)
|
||||||
|
/// </summary>
|
||||||
|
public string Project_Search(string keyword)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
var result = new List<object>();
|
||||||
|
|
||||||
|
// 1. Projects 테이블에서 검색
|
||||||
|
var sqlProjects = @"SELECT idx, name,
|
||||||
|
ISNULL(userManager,'') as userManager,
|
||||||
|
ISNULL(userMain,'') as userMain,
|
||||||
|
ISNULL(status,'') as status
|
||||||
|
FROM Projects WITH (nolock)
|
||||||
|
WHERE gcode = @gcode
|
||||||
|
AND (name LIKE @keyword OR CAST(idx AS VARCHAR) LIKE @keyword)
|
||||||
|
AND status NOT IN ('보류', '취소', '완료(보고)')
|
||||||
|
ORDER BY status DESC, pdate DESC, name";
|
||||||
|
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sqlProjects, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
cmd.Parameters.AddWithValue("@keyword", "%" + (keyword ?? "") + "%");
|
||||||
|
|
||||||
|
using (var da = new SqlDataAdapter(cmd))
|
||||||
|
{
|
||||||
|
var dt = new DataTable();
|
||||||
|
da.Fill(dt);
|
||||||
|
foreach (DataRow row in dt.Rows)
|
||||||
|
{
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
idx = Convert.ToInt32(row["idx"]),
|
||||||
|
name = row["name"]?.ToString() ?? "",
|
||||||
|
userManager = row["userManager"]?.ToString() ?? "",
|
||||||
|
userMain = row["userMain"]?.ToString() ?? "",
|
||||||
|
status = row["status"]?.ToString() ?? "",
|
||||||
|
source = "project"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 과거 업무일지 항목명에서 검색 (프로젝트에 없는 항목들)
|
||||||
|
var sqlJobItems = @"SELECT TOP 50
|
||||||
|
MAX(pdate) as lastDate,
|
||||||
|
projectName,
|
||||||
|
ISNULL(MAX(pidx), -1) as pidx
|
||||||
|
FROM JobReport WITH (nolock)
|
||||||
|
WHERE gcode = @gcode
|
||||||
|
AND projectName LIKE @keyword
|
||||||
|
AND projectName IS NOT NULL
|
||||||
|
AND projectName <> ''
|
||||||
|
GROUP BY projectName
|
||||||
|
ORDER BY MAX(pdate) DESC";
|
||||||
|
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sqlJobItems, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
cmd.Parameters.AddWithValue("@keyword", "%" + (keyword ?? "") + "%");
|
||||||
|
|
||||||
|
using (var da = new SqlDataAdapter(cmd))
|
||||||
|
{
|
||||||
|
var dt = new DataTable();
|
||||||
|
da.Fill(dt);
|
||||||
|
foreach (DataRow row in dt.Rows)
|
||||||
|
{
|
||||||
|
var pidx = row["pidx"] != DBNull.Value ? Convert.ToInt32(row["pidx"]) : -1;
|
||||||
|
var projectName = row["projectName"]?.ToString() ?? "";
|
||||||
|
|
||||||
|
// 이미 Projects에서 가져온 항목과 중복 체크
|
||||||
|
if (!result.Exists(r => ((dynamic)r).name == projectName))
|
||||||
|
{
|
||||||
|
result.Add(new
|
||||||
|
{
|
||||||
|
idx = pidx,
|
||||||
|
name = projectName,
|
||||||
|
userManager = "",
|
||||||
|
userMain = "",
|
||||||
|
status = "",
|
||||||
|
source = "jobreport",
|
||||||
|
lastDate = row["lastDate"] != DBNull.Value
|
||||||
|
? Convert.ToDateTime(row["lastDate"]).ToString("yyyy-MM-dd")
|
||||||
|
: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 현재 사용자의 프로젝트 목록 (업무일지 콤보박스용)
|
||||||
|
/// </summary>
|
||||||
|
public string Project_GetUserProjects()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
var userName = info.Login.no;
|
||||||
|
|
||||||
|
var sql = @"SELECT idx, name, ISNULL(status,'') as status
|
||||||
|
FROM Projects WITH (nolock)
|
||||||
|
WHERE gcode = @gcode
|
||||||
|
AND (ISNULL(userManager,'') LIKE @userName
|
||||||
|
OR ISNULL(userMain,'') LIKE @userName
|
||||||
|
OR ISNULL(usersub,'') LIKE @userName)
|
||||||
|
AND status NOT IN ('보류', '취소', '완료(보고)')
|
||||||
|
ORDER BY status DESC, pdate DESC, name";
|
||||||
|
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sql, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
cmd.Parameters.AddWithValue("@userName", "%" + userName + "%");
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
#region Project List API (fProjectList 화면용)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 프로젝트 분류(category) 목록 조회
|
||||||
|
/// </summary>
|
||||||
|
public string Project_GetCategories()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT category FROM projects WITH (nolock)
|
||||||
|
WHERE gcode = @gcode AND ISNULL(category,'') <> ''
|
||||||
|
GROUP BY category ORDER BY category";
|
||||||
|
|
||||||
|
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);
|
||||||
|
var result = new List<string> { "--전체--" };
|
||||||
|
cn.Open();
|
||||||
|
using (var rdr = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (rdr.Read())
|
||||||
|
{
|
||||||
|
result.Add(rdr[0]?.ToString() ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 프로젝트 공정(userprocess) 목록 조회
|
||||||
|
/// </summary>
|
||||||
|
public string Project_GetProcesses()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT userprocess FROM projects WITH (nolock)
|
||||||
|
WHERE gcode = @gcode AND ISNULL(userprocess,'') <> ''
|
||||||
|
GROUP BY userprocess ORDER BY userprocess";
|
||||||
|
|
||||||
|
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);
|
||||||
|
var result = new List<string> { "전체" };
|
||||||
|
cn.Open();
|
||||||
|
using (var rdr = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (rdr.Read())
|
||||||
|
{
|
||||||
|
result.Add(rdr[0]?.ToString() ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 프로젝트 목록 조회 (fProjectList 화면용)
|
||||||
|
/// </summary>
|
||||||
|
public string Project_GetList(string statusFilter, string category, string process, string userFilter, string yearStart, string yearEnd, string dateType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT idx, pidx, status, asset, [level], rev,
|
||||||
|
[process], part, pdate, [name], userManager, usermain, usersub, userhw2, reqstaff,
|
||||||
|
costo, costn, cnt, remark_req, remark_ans, sdate, ddate, edate, odate, progress,
|
||||||
|
bmajoritem, memo, wuid, wdate, orderno, crdue, import, [path], userprocess,
|
||||||
|
bCost, bFanOut, bHighlight, div, model, serial,
|
||||||
|
championid, designid, epanelid, softwareid,
|
||||||
|
effect_tangible, effect_intangible,
|
||||||
|
dbo.getUserName2(championid, usermanager) as name_champion,
|
||||||
|
dbo.getUserName2(designid, usermain) as name_design,
|
||||||
|
dbo.getUserName2(epanelid, userhw2) as name_epanel,
|
||||||
|
dbo.getUserName2(softwareid, usersub) as name_software,
|
||||||
|
category, ReqLine, ReqSite, ReqPackage, ReqPlant, pno, kdate, jasmin, sfi,
|
||||||
|
(SELECT MAX(pdate) FROM ProjectsHistory WHERE pidx = Projects.idx) as lasthistory_date,
|
||||||
|
dbo.getLastProjectScheduleNo(gcode, idx) as lastSchNo,
|
||||||
|
cramount, panelimage, Priority, sfi_count,
|
||||||
|
dbo.getScheduleProgress(idx) as ProgressPrj,
|
||||||
|
dbo.getProjectFinishRate(gcode, idx) AS finishrate
|
||||||
|
FROM Projects WITH (nolock)
|
||||||
|
WHERE gcode = @gcode AND ISNULL(div,'') <> 'EB' AND ISNULL(isdel,0) = 0";
|
||||||
|
|
||||||
|
var parameters = new List<SqlParameter>();
|
||||||
|
parameters.Add(new SqlParameter("@gcode", info.Login.gcode));
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (!string.IsNullOrEmpty(statusFilter) && statusFilter != "all")
|
||||||
|
{
|
||||||
|
var statuses = statusFilter.Split(',');
|
||||||
|
var statusParams = new List<string>();
|
||||||
|
for (int i = 0; i < statuses.Length; i++)
|
||||||
|
{
|
||||||
|
var paramName = "@status" + i;
|
||||||
|
statusParams.Add(paramName);
|
||||||
|
parameters.Add(new SqlParameter(paramName, statuses[i].Trim()));
|
||||||
|
}
|
||||||
|
sql += " AND status IN (" + string.Join(",", statusParams) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 분류 필터
|
||||||
|
if (!string.IsNullOrEmpty(category) && category != "--전체--")
|
||||||
|
{
|
||||||
|
sql += " AND ISNULL(category,'') LIKE @category";
|
||||||
|
parameters.Add(new SqlParameter("@category", category + "%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 필터
|
||||||
|
if (!string.IsNullOrEmpty(process) && process != "전체")
|
||||||
|
{
|
||||||
|
sql += " AND ISNULL(userprocess,'') = @process";
|
||||||
|
parameters.Add(new SqlParameter("@process", process));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 필터
|
||||||
|
if (!string.IsNullOrEmpty(userFilter))
|
||||||
|
{
|
||||||
|
sql += @" AND (ISNULL(userManager,'') LIKE @userFilter
|
||||||
|
OR ISNULL(usermain,'') LIKE @userFilter
|
||||||
|
OR ISNULL(reqstaff,'') LIKE @userFilter
|
||||||
|
OR ISNULL(usersub,'') LIKE @userFilter
|
||||||
|
OR dbo.getUserName(championid) LIKE @userFilter
|
||||||
|
OR dbo.getUserName(designid) LIKE @userFilter
|
||||||
|
OR dbo.getUserName(epanelid) LIKE @userFilter
|
||||||
|
OR dbo.getUserName(softwareid) LIKE @userFilter)";
|
||||||
|
parameters.Add(new SqlParameter("@userFilter", "%" + userFilter + "%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
if (!string.IsNullOrEmpty(dateType) && dateType != "0" && !string.IsNullOrEmpty(yearStart))
|
||||||
|
{
|
||||||
|
string dateField = "sdate";
|
||||||
|
switch (dateType)
|
||||||
|
{
|
||||||
|
case "1": dateField = "sdate"; break;
|
||||||
|
case "2": dateField = "ddate"; break;
|
||||||
|
case "3": dateField = "edate"; break;
|
||||||
|
case "4": dateField = "odate"; break;
|
||||||
|
}
|
||||||
|
sql += $" AND {dateField} BETWEEN @dateStart AND @dateEnd";
|
||||||
|
parameters.Add(new SqlParameter("@dateStart", yearStart + "-01-01"));
|
||||||
|
parameters.Add(new SqlParameter("@dateEnd", (string.IsNullOrEmpty(yearEnd) ? yearStart : yearEnd) + "-12-31"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
sql += @" ORDER BY (CASE
|
||||||
|
WHEN status = '진행' THEN '0'
|
||||||
|
WHEN status = '검토' THEN '1'
|
||||||
|
WHEN status = '대기' THEN '2'
|
||||||
|
WHEN status = '완료' THEN '3'
|
||||||
|
WHEN status = '완료(보고)' THEN '4'
|
||||||
|
WHEN status = '보류' THEN '5'
|
||||||
|
WHEN status = '취소' THEN '9'
|
||||||
|
ELSE '5' END), userManager, sdate";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 상태별 건수 집계
|
||||||
|
var statusCounts = new Dictionary<string, int>
|
||||||
|
{
|
||||||
|
{ "검토", 0 }, { "진행", 0 }, { "대기", 0 },
|
||||||
|
{ "보류", 0 }, { "완료", 0 }, { "완료(보고)", 0 }, { "취소", 0 }
|
||||||
|
};
|
||||||
|
decimal sumCosto = 0, sumCostn = 0;
|
||||||
|
|
||||||
|
foreach (DataRow row in dt.Rows)
|
||||||
|
{
|
||||||
|
var st = row["status"]?.ToString() ?? "";
|
||||||
|
if (statusCounts.ContainsKey(st))
|
||||||
|
statusCounts[st]++;
|
||||||
|
|
||||||
|
if (row["costo"] != DBNull.Value) sumCosto += Convert.ToDecimal(row["costo"]);
|
||||||
|
if (row["costn"] != DBNull.Value) sumCostn += Convert.ToDecimal(row["costn"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = dt,
|
||||||
|
StatusCounts = statusCounts,
|
||||||
|
TotalCosto = sumCosto,
|
||||||
|
TotalCostn = sumCostn,
|
||||||
|
CurrentUser = info.Login.nameK
|
||||||
|
}, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 프로젝트 히스토리 조회
|
||||||
|
/// </summary>
|
||||||
|
public string Project_GetHistory(int projectIdx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT idx, pidx, pdate, progress, remark, wuid, wdate,
|
||||||
|
dbo.getUserName(wuid) as wname
|
||||||
|
FROM ProjectsHistory WITH (nolock)
|
||||||
|
WHERE pidx = @pidx
|
||||||
|
ORDER BY pdate DESC, idx DESC";
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sql, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@pidx", projectIdx);
|
||||||
|
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 Project_GetDailyMemo(int projectIdx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT idx, pidx, pdate, remark, wuid, wdate,
|
||||||
|
dbo.getUserName(wuid) as wname
|
||||||
|
FROM EETGW_ProjecthistoryD WITH (nolock)
|
||||||
|
WHERE pidx = @pidx
|
||||||
|
ORDER BY pdate DESC, idx DESC";
|
||||||
|
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sql, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@pidx", projectIdx);
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
339
Project/Web/MachineBridge/MachineBridge.UserAuth.cs
Normal file
339
Project/Web/MachineBridge/MachineBridge.UserAuth.cs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
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 UserAuth API (사용자 권한)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 사용자 권한 접근 가능 여부 확인 (Level 5 이상 또는 account 권한 5 이상)
|
||||||
|
/// </summary>
|
||||||
|
public string UserAuth_CanAccess()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int curLevel = Math.Max(info.Login.level, FCOMMON.DBM.getAuth(FCOMMON.DBM.eAuthType.account));
|
||||||
|
bool canAccess = curLevel >= 5;
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
CanAccess = canAccess,
|
||||||
|
Level = curLevel,
|
||||||
|
Message = canAccess ? "" : "(관리자/계정담당자) 전용 메뉴 입니다"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 사용자 권한 목록 조회
|
||||||
|
/// </summary>
|
||||||
|
public string UserAuth_GetList()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT idx, [user], gcode, account, purchase, purchaseEB, holyday,
|
||||||
|
project, jobreport, scheapp, equipment, otconfirm, holyreq, kuntae
|
||||||
|
FROM Auth WITH (nolock)
|
||||||
|
WHERE gcode = @gcode
|
||||||
|
ORDER BY [user]";
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 UserAuth_Save(int idx, string user, int account, int purchase, int purchaseEB,
|
||||||
|
int holyday, int project, int jobreport, int scheapp, int equipment, int otconfirm, int holyreq, int kuntae)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cs = Properties.Settings.Default.gwcs;
|
||||||
|
|
||||||
|
if (idx == 0)
|
||||||
|
{
|
||||||
|
// 신규 추가
|
||||||
|
// 먼저 중복 확인
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
{
|
||||||
|
var checkSql = "SELECT COUNT(*) FROM Auth WHERE gcode = @gcode AND [user] = @user";
|
||||||
|
using (var cmd = new SqlCommand(checkSql, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
cmd.Parameters.AddWithValue("@user", user ?? "");
|
||||||
|
cn.Open();
|
||||||
|
var count = (int)cmd.ExecuteScalar();
|
||||||
|
if (count > 0)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = "이미 등록된 사용자입니다." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sql = @"INSERT INTO Auth (gcode, [user], account, purchase, purchaseEB, holyday,
|
||||||
|
project, jobreport, scheapp, equipment, otconfirm, holyreq, kuntae)
|
||||||
|
VALUES (@gcode, @user, @account, @purchase, @purchaseEB, @holyday,
|
||||||
|
@project, @jobreport, @scheapp, @equipment, @otconfirm, @holyreq, @kuntae);
|
||||||
|
SELECT SCOPE_IDENTITY();";
|
||||||
|
|
||||||
|
using (var cn = new SqlConnection(cs))
|
||||||
|
using (var cmd = new SqlCommand(sql, cn))
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||||
|
cmd.Parameters.AddWithValue("@user", user ?? "");
|
||||||
|
cmd.Parameters.AddWithValue("@account", account);
|
||||||
|
cmd.Parameters.AddWithValue("@purchase", purchase);
|
||||||
|
cmd.Parameters.AddWithValue("@purchaseEB", purchaseEB);
|
||||||
|
cmd.Parameters.AddWithValue("@holyday", holyday);
|
||||||
|
cmd.Parameters.AddWithValue("@project", project);
|
||||||
|
cmd.Parameters.AddWithValue("@jobreport", jobreport);
|
||||||
|
cmd.Parameters.AddWithValue("@scheapp", scheapp);
|
||||||
|
cmd.Parameters.AddWithValue("@equipment", equipment);
|
||||||
|
cmd.Parameters.AddWithValue("@otconfirm", otconfirm);
|
||||||
|
cmd.Parameters.AddWithValue("@holyreq", holyreq);
|
||||||
|
cmd.Parameters.AddWithValue("@kuntae", kuntae);
|
||||||
|
|
||||||
|
cn.Open();
|
||||||
|
var newId = Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다.", Data = new { idx = newId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 수정
|
||||||
|
var sql = @"UPDATE Auth SET
|
||||||
|
[user] = @user, account = @account, purchase = @purchase, purchaseEB = @purchaseEB,
|
||||||
|
holyday = @holyday, project = @project, jobreport = @jobreport, scheapp = @scheapp,
|
||||||
|
equipment = @equipment, otconfirm = @otconfirm, holyreq = @holyreq, kuntae = @kuntae
|
||||||
|
WHERE idx = @idx AND gcode = @gcode";
|
||||||
|
|
||||||
|
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);
|
||||||
|
cmd.Parameters.AddWithValue("@user", user ?? "");
|
||||||
|
cmd.Parameters.AddWithValue("@account", account);
|
||||||
|
cmd.Parameters.AddWithValue("@purchase", purchase);
|
||||||
|
cmd.Parameters.AddWithValue("@purchaseEB", purchaseEB);
|
||||||
|
cmd.Parameters.AddWithValue("@holyday", holyday);
|
||||||
|
cmd.Parameters.AddWithValue("@project", project);
|
||||||
|
cmd.Parameters.AddWithValue("@jobreport", jobreport);
|
||||||
|
cmd.Parameters.AddWithValue("@scheapp", scheapp);
|
||||||
|
cmd.Parameters.AddWithValue("@equipment", equipment);
|
||||||
|
cmd.Parameters.AddWithValue("@otconfirm", otconfirm);
|
||||||
|
cmd.Parameters.AddWithValue("@holyreq", holyreq);
|
||||||
|
cmd.Parameters.AddWithValue("@kuntae", kuntae);
|
||||||
|
|
||||||
|
cn.Open();
|
||||||
|
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 UserAuth_Delete(int idx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = "DELETE FROM Auth WHERE idx = @idx AND 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);
|
||||||
|
|
||||||
|
cn.Open();
|
||||||
|
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 UserAuth_GetFields()
|
||||||
|
{
|
||||||
|
var fields = new[]
|
||||||
|
{
|
||||||
|
new { field = "user", label = "사용자 ID", description = "권한을 설정할 사용자 ID" },
|
||||||
|
new { field = "account", label = "계정", description = "계정 관리 권한" },
|
||||||
|
new { field = "purchase", label = "구매", description = "구매 관리 권한" },
|
||||||
|
new { field = "purchaseEB", label = "구매(전자실)", description = "전자실 구매 권한" },
|
||||||
|
new { field = "holyday", label = "출근부", description = "출근부 관리 권한" },
|
||||||
|
new { field = "project", label = "프로젝트", description = "프로젝트 관리 권한" },
|
||||||
|
new { field = "jobreport", label = "업무일지", description = "업무일지 관리 권한" },
|
||||||
|
new { field = "scheapp", label = "스케쥴", description = "스케쥴 관리 권한" },
|
||||||
|
new { field = "equipment", label = "장비목록", description = "장비 목록 관리 권한" },
|
||||||
|
new { field = "otconfirm", label = "OT승인", description = "초과근무 승인 권한" },
|
||||||
|
new { field = "holyreq", label = "휴가요청", description = "휴가 요청 관리 권한" },
|
||||||
|
new { field = "kuntae", label = "근태", description = "근태 관리 권한" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new { Success = true, Data = fields });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 범용 권한 체크 API
|
||||||
|
/// authType: purchase, holyday, project, jobreport, savecost, equipment, otconfirm, kuntae, holyreq, account, purchaseEB
|
||||||
|
/// requiredLevel: 필요한 최소 레벨 (기본값 5)
|
||||||
|
/// </summary>
|
||||||
|
public string CheckAuth(string authType, int requiredLevel = 5)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 사용자 기본 레벨
|
||||||
|
int userLevel = info.Login.level;
|
||||||
|
|
||||||
|
// authType에 해당하는 권한 레벨 조회
|
||||||
|
int authLevel = 0;
|
||||||
|
if (!string.IsNullOrEmpty(authType))
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<DBM.eAuthType>(authType, true, out var eType))
|
||||||
|
{
|
||||||
|
authLevel = DBM.getAuth(eType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 둘 중 높은 값 사용
|
||||||
|
int effectiveLevel = Math.Max(userLevel, authLevel);
|
||||||
|
bool canAccess = effectiveLevel >= requiredLevel;
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
CanAccess = canAccess,
|
||||||
|
UserLevel = userLevel,
|
||||||
|
AuthLevel = authLevel,
|
||||||
|
EffectiveLevel = effectiveLevel,
|
||||||
|
RequiredLevel = requiredLevel,
|
||||||
|
AuthType = authType,
|
||||||
|
Message = canAccess ? "" : $"이 기능은 레벨 {requiredLevel} 이상 권한이 필요합니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 현재 로그인한 사용자의 전체 권한 정보 조회
|
||||||
|
/// </summary>
|
||||||
|
public string GetMyAuth()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = @"SELECT idx, [user], account, purchase, purchaseEB, holyday,
|
||||||
|
project, jobreport, scheapp, equipment, otconfirm, holyreq, kuntae
|
||||||
|
FROM Auth WITH (nolock)
|
||||||
|
WHERE gcode = @gcode AND [user] = @user";
|
||||||
|
|
||||||
|
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("@user", info.Login.no);
|
||||||
|
cn.Open();
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = new
|
||||||
|
{
|
||||||
|
UserLevel = info.Login.level,
|
||||||
|
account = reader["account"] != DBNull.Value ? (int)reader["account"] : 0,
|
||||||
|
purchase = reader["purchase"] != DBNull.Value ? (int)reader["purchase"] : 0,
|
||||||
|
purchaseEB = reader["purchaseEB"] != DBNull.Value ? (int)reader["purchaseEB"] : 0,
|
||||||
|
holyday = reader["holyday"] != DBNull.Value ? (int)reader["holyday"] : 0,
|
||||||
|
project = reader["project"] != DBNull.Value ? (int)reader["project"] : 0,
|
||||||
|
jobreport = reader["jobreport"] != DBNull.Value ? (int)reader["jobreport"] : 0,
|
||||||
|
scheapp = reader["scheapp"] != DBNull.Value ? (int)reader["scheapp"] : 0,
|
||||||
|
equipment = reader["equipment"] != DBNull.Value ? (int)reader["equipment"] : 0,
|
||||||
|
otconfirm = reader["otconfirm"] != DBNull.Value ? (int)reader["otconfirm"] : 0,
|
||||||
|
holyreq = reader["holyreq"] != DBNull.Value ? (int)reader["holyreq"] : 0,
|
||||||
|
kuntae = reader["kuntae"] != DBNull.Value ? (int)reader["kuntae"] : 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Auth 테이블에 없는 경우 기본값 반환
|
||||||
|
return JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = new
|
||||||
|
{
|
||||||
|
UserLevel = info.Login.level,
|
||||||
|
account = 0,
|
||||||
|
purchase = 0,
|
||||||
|
purchaseEB = 0,
|
||||||
|
holyday = 0,
|
||||||
|
project = 0,
|
||||||
|
jobreport = 0,
|
||||||
|
scheapp = 0,
|
||||||
|
equipment = 0,
|
||||||
|
otconfirm = 0,
|
||||||
|
holyreq = 0,
|
||||||
|
kuntae = 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -411,6 +411,14 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "FAVORITE_GET_LIST":
|
||||||
|
{
|
||||||
|
string result = _bridge.Favorite_GetList();
|
||||||
|
var response = new { type = "FAVORITE_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
// ===== Items API =====
|
// ===== Items API =====
|
||||||
case "ITEMS_GET_CATEGORIES":
|
case "ITEMS_GET_CATEGORIES":
|
||||||
{
|
{
|
||||||
@@ -460,6 +468,70 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "ITEMS_GET_IMAGE":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string result = _bridge.Items_GetImage(idx);
|
||||||
|
var response = new { type = "ITEMS_IMAGE_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ITEMS_SAVE_IMAGE":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string base64Image = json.base64Image ?? "";
|
||||||
|
string result = _bridge.Items_SaveImage(idx, base64Image);
|
||||||
|
var response = new { type = "ITEMS_IMAGE_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ITEMS_DELETE_IMAGE":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string result = _bridge.Items_DeleteImage(idx);
|
||||||
|
var response = new { type = "ITEMS_IMAGE_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ITEMS_GET_DETAIL":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string result = _bridge.Items_GetDetail(idx);
|
||||||
|
var response = new { type = "ITEMS_DETAIL_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ITEMS_GET_SUPPLIER_STAFF":
|
||||||
|
{
|
||||||
|
int supplyIdx = json.supplyIdx ?? 0;
|
||||||
|
string result = _bridge.Items_GetSupplierStaff(supplyIdx);
|
||||||
|
var response = new { type = "ITEMS_SUPPLIER_STAFF_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ITEMS_GET_INCOMING_HISTORY":
|
||||||
|
{
|
||||||
|
int itemIdx = json.itemIdx ?? 0;
|
||||||
|
string result = _bridge.Items_GetIncomingHistory(itemIdx);
|
||||||
|
var response = new { type = "ITEMS_INCOMING_HISTORY_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ITEMS_GET_ORDER_HISTORY":
|
||||||
|
{
|
||||||
|
int itemIdx = json.itemIdx ?? 0;
|
||||||
|
string result = _bridge.Items_GetOrderHistory(itemIdx);
|
||||||
|
var response = new { type = "ITEMS_ORDER_HISTORY_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
// ===== UserList API =====
|
// ===== UserList API =====
|
||||||
case "USERLIST_GET_CURRENT_LEVEL":
|
case "USERLIST_GET_CURRENT_LEVEL":
|
||||||
{
|
{
|
||||||
@@ -563,9 +635,10 @@ namespace Project.Web
|
|||||||
{
|
{
|
||||||
string pdate = json.pdate ?? "";
|
string pdate = json.pdate ?? "";
|
||||||
string projectName = json.projectName ?? "";
|
string projectName = json.projectName ?? "";
|
||||||
|
int pidx = json.pidx ?? -1;
|
||||||
string requestpart = json.requestpart ?? "";
|
string requestpart = json.requestpart ?? "";
|
||||||
string package = json.package ?? "";
|
string package = json.package ?? "";
|
||||||
string type1 = json.type ?? "";
|
string jobType = json.jobType ?? ""; // type -> jobType (WebSocket type 필드 충돌 방지)
|
||||||
string process = json.process ?? "";
|
string process = json.process ?? "";
|
||||||
string status = json.status ?? "진행 완료";
|
string status = json.status ?? "진행 완료";
|
||||||
string description = json.description ?? "";
|
string description = json.description ?? "";
|
||||||
@@ -573,7 +646,7 @@ 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, requestpart, package, type1, process, status, description, hrs, ot, jobgrp, tag);
|
string result = _bridge.Jobreport_Add(pdate, projectName, pidx, requestpart, package, jobType, process, status, description, hrs, ot, jobgrp, tag);
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -584,9 +657,10 @@ namespace Project.Web
|
|||||||
int idx = json.idx ?? 0;
|
int idx = json.idx ?? 0;
|
||||||
string pdate = json.pdate ?? "";
|
string pdate = json.pdate ?? "";
|
||||||
string projectName = json.projectName ?? "";
|
string projectName = json.projectName ?? "";
|
||||||
|
int pidx = json.pidx ?? -1;
|
||||||
string requestpart = json.requestpart ?? "";
|
string requestpart = json.requestpart ?? "";
|
||||||
string package = json.package ?? "";
|
string package = json.package ?? "";
|
||||||
string type2 = json.type ?? "";
|
string jobType = json.jobType ?? ""; // type -> jobType (WebSocket type 필드 충돌 방지)
|
||||||
string process = json.process ?? "";
|
string process = json.process ?? "";
|
||||||
string status = json.status ?? "";
|
string status = json.status ?? "";
|
||||||
string description = json.description ?? "";
|
string description = json.description ?? "";
|
||||||
@@ -594,7 +668,7 @@ 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, requestpart, package, type2, process, status, description, hrs, ot, jobgrp, tag);
|
string result = _bridge.Jobreport_Edit(idx, pdate, projectName, pidx, requestpart, package, jobType, process, status, description, hrs, ot, jobgrp, tag);
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -635,6 +709,354 @@ namespace Project.Web
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ===== Kuntae API =====
|
||||||
|
case "GET_KUNTAE_LIST":
|
||||||
|
{
|
||||||
|
string sd = json.sd ?? "";
|
||||||
|
string ed = json.ed ?? "";
|
||||||
|
string result = _bridge.Kuntae_GetList(sd, ed);
|
||||||
|
var response = new { type = "KUNTAE_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "DELETE_KUNTAE":
|
||||||
|
{
|
||||||
|
int id = json.id ?? 0;
|
||||||
|
string result = _bridge.Kuntae_Delete(id);
|
||||||
|
var response = new { type = "KUNTAE_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== Holiday API (월별근무표) =====
|
||||||
|
case "HOLIDAY_GET_LIST":
|
||||||
|
{
|
||||||
|
string month = json.month ?? "";
|
||||||
|
string result = _bridge.Holiday_GetList(month);
|
||||||
|
var response = new { type = "HOLIDAY_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "HOLIDAY_SAVE":
|
||||||
|
{
|
||||||
|
string month = json.month ?? "";
|
||||||
|
string holidaysJson = JsonConvert.SerializeObject(json.holidays);
|
||||||
|
string result = _bridge.Holiday_Save(month, holidaysJson);
|
||||||
|
var response = new { type = "HOLIDAY_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "HOLIDAY_INITIALIZE":
|
||||||
|
{
|
||||||
|
string month = json.month ?? "";
|
||||||
|
string result = _bridge.Holiday_Initialize(month);
|
||||||
|
var response = new { type = "HOLIDAY_INITIALIZED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== MailForm API (메일양식) =====
|
||||||
|
case "MAILFORM_GET_LIST":
|
||||||
|
{
|
||||||
|
string result = _bridge.MailForm_GetList();
|
||||||
|
var response = new { type = "MAILFORM_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MAILFORM_GET_DETAIL":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string result = _bridge.MailForm_GetDetail(idx);
|
||||||
|
var response = new { type = "MAILFORM_DETAIL_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MAILFORM_ADD":
|
||||||
|
{
|
||||||
|
string cate = json.cate ?? "";
|
||||||
|
string title = json.title ?? "";
|
||||||
|
string tolist = json.tolist ?? "";
|
||||||
|
string bcc = json.bcc ?? "";
|
||||||
|
string cc = json.cc ?? "";
|
||||||
|
string subject = json.subject ?? "";
|
||||||
|
string tail = json.tail ?? "";
|
||||||
|
string body = json.body ?? "";
|
||||||
|
bool selfTo = json.selfTo ?? false;
|
||||||
|
bool selfCC = json.selfCC ?? false;
|
||||||
|
bool selfBCC = json.selfBCC ?? false;
|
||||||
|
string exceptmail = json.exceptmail ?? "";
|
||||||
|
string exceptmailcc = json.exceptmailcc ?? "";
|
||||||
|
string result = _bridge.MailForm_Add(cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc);
|
||||||
|
var response = new { type = "MAILFORM_ADDED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MAILFORM_EDIT":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string cate = json.cate ?? "";
|
||||||
|
string title = json.title ?? "";
|
||||||
|
string tolist = json.tolist ?? "";
|
||||||
|
string bcc = json.bcc ?? "";
|
||||||
|
string cc = json.cc ?? "";
|
||||||
|
string subject = json.subject ?? "";
|
||||||
|
string tail = json.tail ?? "";
|
||||||
|
string body = json.body ?? "";
|
||||||
|
bool selfTo = json.selfTo ?? false;
|
||||||
|
bool selfCC = json.selfCC ?? false;
|
||||||
|
bool selfBCC = json.selfBCC ?? false;
|
||||||
|
string exceptmail = json.exceptmail ?? "";
|
||||||
|
string exceptmailcc = json.exceptmailcc ?? "";
|
||||||
|
string result = _bridge.MailForm_Edit(idx, cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc);
|
||||||
|
var response = new { type = "MAILFORM_EDITED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MAILFORM_DELETE":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string result = _bridge.MailForm_Delete(idx);
|
||||||
|
var response = new { type = "MAILFORM_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== UserGroup API (그룹정보/권한설정) =====
|
||||||
|
case "USERGROUP_GET_LIST":
|
||||||
|
{
|
||||||
|
string result = _bridge.UserGroup_GetList();
|
||||||
|
var response = new { type = "USERGROUP_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERGROUP_ADD":
|
||||||
|
{
|
||||||
|
string dept = json.dept ?? "";
|
||||||
|
string path_kj = json.path_kj ?? "";
|
||||||
|
int permission = json.permission ?? 1;
|
||||||
|
bool advpurchase = json.advpurchase ?? false;
|
||||||
|
bool advkisul = json.advkisul ?? false;
|
||||||
|
string managerinfo = json.managerinfo ?? "";
|
||||||
|
string devinfo = json.devinfo ?? "";
|
||||||
|
bool usemail = json.usemail ?? false;
|
||||||
|
string result = _bridge.UserGroup_Add(dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail);
|
||||||
|
var response = new { type = "USERGROUP_ADDED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERGROUP_EDIT":
|
||||||
|
{
|
||||||
|
string originalDept = json.originalDept ?? "";
|
||||||
|
string dept = json.dept ?? "";
|
||||||
|
string path_kj = json.path_kj ?? "";
|
||||||
|
int permission = json.permission ?? 1;
|
||||||
|
bool advpurchase = json.advpurchase ?? false;
|
||||||
|
bool advkisul = json.advkisul ?? false;
|
||||||
|
string managerinfo = json.managerinfo ?? "";
|
||||||
|
string devinfo = json.devinfo ?? "";
|
||||||
|
bool usemail = json.usemail ?? false;
|
||||||
|
string result = _bridge.UserGroup_Edit(originalDept, dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail);
|
||||||
|
var response = new { type = "USERGROUP_EDITED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERGROUP_DELETE":
|
||||||
|
{
|
||||||
|
string dept = json.dept ?? "";
|
||||||
|
string result = _bridge.UserGroup_Delete(dept);
|
||||||
|
var response = new { type = "USERGROUP_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERGROUP_GET_PERMISSION_INFO":
|
||||||
|
{
|
||||||
|
string result = _bridge.UserGroup_GetPermissionInfo();
|
||||||
|
var response = new { type = "USERGROUP_PERMISSION_INFO", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== UserAuth API (사용자 권한) =====
|
||||||
|
case "USERAUTH_CAN_ACCESS":
|
||||||
|
{
|
||||||
|
string result = _bridge.UserAuth_CanAccess();
|
||||||
|
var response = new { type = "USERAUTH_CAN_ACCESS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERAUTH_GET_LIST":
|
||||||
|
{
|
||||||
|
string result = _bridge.UserAuth_GetList();
|
||||||
|
var response = new { type = "USERAUTH_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERAUTH_SAVE":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string user = json.user ?? "";
|
||||||
|
int account = json.account ?? 0;
|
||||||
|
int purchase = json.purchase ?? 0;
|
||||||
|
int purchaseEB = json.purchaseEB ?? 0;
|
||||||
|
int holyday = json.holyday ?? 0;
|
||||||
|
int project = json.project ?? 0;
|
||||||
|
int jobreport = json.jobreport ?? 0;
|
||||||
|
int scheapp = json.scheapp ?? 0;
|
||||||
|
int equipment = json.equipment ?? 0;
|
||||||
|
int otconfirm = json.otconfirm ?? 0;
|
||||||
|
int holyreq = json.holyreq ?? 0;
|
||||||
|
int kuntae = json.kuntae ?? 0;
|
||||||
|
string result = _bridge.UserAuth_Save(idx, user, account, purchase, purchaseEB, holyday, project, jobreport, scheapp, equipment, otconfirm, holyreq, kuntae);
|
||||||
|
var response = new { type = "USERAUTH_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERAUTH_DELETE":
|
||||||
|
{
|
||||||
|
int idx = json.idx ?? 0;
|
||||||
|
string result = _bridge.UserAuth_Delete(idx);
|
||||||
|
var response = new { type = "USERAUTH_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "USERAUTH_GET_FIELDS":
|
||||||
|
{
|
||||||
|
string result = _bridge.UserAuth_GetFields();
|
||||||
|
var response = new { type = "USERAUTH_FIELDS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== 범용 권한 체크 API =====
|
||||||
|
case "CHECK_AUTH":
|
||||||
|
{
|
||||||
|
string authType = json.authType ?? "";
|
||||||
|
int requiredLevel = json.requiredLevel ?? 5;
|
||||||
|
string result = _bridge.CheckAuth(authType, requiredLevel);
|
||||||
|
var response = new { type = "CHECK_AUTH_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GET_MY_AUTH":
|
||||||
|
{
|
||||||
|
string result = _bridge.GetMyAuth();
|
||||||
|
var response = new { type = "MY_AUTH_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== 프로젝트 검색 API (업무일지용) =====
|
||||||
|
case "PROJECT_SEARCH":
|
||||||
|
{
|
||||||
|
string keyword = json.keyword ?? "";
|
||||||
|
string result = _bridge.Project_Search(keyword);
|
||||||
|
var response = new { type = "PROJECT_SEARCH_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PROJECT_GET_USER_PROJECTS":
|
||||||
|
{
|
||||||
|
string result = _bridge.Project_GetUserProjects();
|
||||||
|
var response = new { type = "PROJECT_USER_PROJECTS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PROJECT_GET_CATEGORIES":
|
||||||
|
{
|
||||||
|
string result = _bridge.Project_GetCategories();
|
||||||
|
var response = new { type = "PROJECT_CATEGORIES_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PROJECT_GET_PROCESSES":
|
||||||
|
{
|
||||||
|
string result = _bridge.Project_GetProcesses();
|
||||||
|
var response = new { type = "PROJECT_PROCESSES_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PROJECT_GET_LIST":
|
||||||
|
{
|
||||||
|
string statusFilter = json.statusFilter ?? "";
|
||||||
|
string category = json.category ?? "";
|
||||||
|
string process = json.process ?? "";
|
||||||
|
string userFilter = json.userFilter ?? "";
|
||||||
|
string yearStart = json.yearStart ?? "";
|
||||||
|
string yearEnd = json.yearEnd ?? "";
|
||||||
|
string dateType = json.dateType ?? "0";
|
||||||
|
string result = _bridge.Project_GetList(statusFilter, category, process, userFilter, yearStart, yearEnd, dateType);
|
||||||
|
var response = new { type = "PROJECT_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PROJECT_GET_HISTORY":
|
||||||
|
{
|
||||||
|
int projectIdx = json.projectIdx ?? 0;
|
||||||
|
string result = _bridge.Project_GetHistory(projectIdx);
|
||||||
|
var response = new { type = "PROJECT_HISTORY_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PROJECT_GET_DAILY_MEMO":
|
||||||
|
{
|
||||||
|
int projectIdx = json.projectIdx ?? 0;
|
||||||
|
string result = _bridge.Project_GetDailyMemo(projectIdx);
|
||||||
|
var response = new { type = "PROJECT_DAILY_MEMO_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== 근태 오류검사 API =====
|
||||||
|
case "KUNTAE_ERROR_CHECK":
|
||||||
|
{
|
||||||
|
string sd = json.sd ?? "";
|
||||||
|
string ed = json.ed ?? "";
|
||||||
|
string result = _bridge.Kuntae_ErrorCheck(sd, ed);
|
||||||
|
var response = new { type = "KUNTAE_ERROR_CHECK_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "KUNTAE_FIX_ERROR":
|
||||||
|
{
|
||||||
|
string pdate = json.pdate ?? "";
|
||||||
|
string result = _bridge.Kuntae_FixError(pdate);
|
||||||
|
var response = new { type = "KUNTAE_FIX_ERROR_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "KUNTAE_FIX_ERRORS":
|
||||||
|
{
|
||||||
|
string dates = JsonConvert.SerializeObject(json.dates);
|
||||||
|
string result = _bridge.Kuntae_FixErrors(dates);
|
||||||
|
var response = new { type = "KUNTAE_FIX_ERRORS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||||
|
await Send(socket, JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
Console.WriteLine($"[WS] Unknown message type: {type}");
|
Console.WriteLine($"[WS] Unknown message type: {type}");
|
||||||
break;
|
break;
|
||||||
|
|||||||
18
Project/fMain.Designer.cs
generated
18
Project/fMain.Designer.cs
generated
@@ -292,6 +292,7 @@
|
|||||||
//
|
//
|
||||||
// itemsToolStripMenuItem
|
// itemsToolStripMenuItem
|
||||||
//
|
//
|
||||||
|
this.itemsToolStripMenuItem.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.itemsToolStripMenuItem.Name = "itemsToolStripMenuItem";
|
this.itemsToolStripMenuItem.Name = "itemsToolStripMenuItem";
|
||||||
this.itemsToolStripMenuItem.Size = new System.Drawing.Size(153, 24);
|
this.itemsToolStripMenuItem.Size = new System.Drawing.Size(153, 24);
|
||||||
this.itemsToolStripMenuItem.Text = "품목정보";
|
this.itemsToolStripMenuItem.Text = "품목정보";
|
||||||
@@ -305,40 +306,46 @@
|
|||||||
this.권한설정ToolStripMenuItem,
|
this.권한설정ToolStripMenuItem,
|
||||||
this.toolStripMenuItem12,
|
this.toolStripMenuItem12,
|
||||||
this.그룹정보ToolStripMenuItem});
|
this.그룹정보ToolStripMenuItem});
|
||||||
|
this.userInfoToolStripMenuItem.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.userInfoToolStripMenuItem.Name = "userInfoToolStripMenuItem";
|
this.userInfoToolStripMenuItem.Name = "userInfoToolStripMenuItem";
|
||||||
this.userInfoToolStripMenuItem.Size = new System.Drawing.Size(153, 24);
|
this.userInfoToolStripMenuItem.Size = new System.Drawing.Size(153, 24);
|
||||||
this.userInfoToolStripMenuItem.Text = "사용자";
|
this.userInfoToolStripMenuItem.Text = "사용자";
|
||||||
//
|
//
|
||||||
// userAccountToolStripMenuItem
|
// userAccountToolStripMenuItem
|
||||||
//
|
//
|
||||||
|
this.userAccountToolStripMenuItem.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.userAccountToolStripMenuItem.Name = "userAccountToolStripMenuItem";
|
this.userAccountToolStripMenuItem.Name = "userAccountToolStripMenuItem";
|
||||||
this.userAccountToolStripMenuItem.Size = new System.Drawing.Size(134, 24);
|
this.userAccountToolStripMenuItem.Size = new System.Drawing.Size(152, 24);
|
||||||
this.userAccountToolStripMenuItem.Text = "계정정보";
|
this.userAccountToolStripMenuItem.Text = "계정정보";
|
||||||
this.userAccountToolStripMenuItem.Click += new System.EventHandler(this.userAccountToolStripMenuItem_Click);
|
this.userAccountToolStripMenuItem.Click += new System.EventHandler(this.userAccountToolStripMenuItem_Click);
|
||||||
//
|
//
|
||||||
// myAccouserToolStripMenuItem
|
// myAccouserToolStripMenuItem
|
||||||
//
|
//
|
||||||
|
this.myAccouserToolStripMenuItem.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.myAccouserToolStripMenuItem.Name = "myAccouserToolStripMenuItem";
|
this.myAccouserToolStripMenuItem.Name = "myAccouserToolStripMenuItem";
|
||||||
this.myAccouserToolStripMenuItem.Size = new System.Drawing.Size(134, 24);
|
this.myAccouserToolStripMenuItem.Size = new System.Drawing.Size(152, 24);
|
||||||
this.myAccouserToolStripMenuItem.Text = "계정목록";
|
this.myAccouserToolStripMenuItem.Text = "계정목록";
|
||||||
this.myAccouserToolStripMenuItem.Click += new System.EventHandler(this.myAccouserToolStripMenuItem_Click);
|
this.myAccouserToolStripMenuItem.Click += new System.EventHandler(this.myAccouserToolStripMenuItem_Click);
|
||||||
//
|
//
|
||||||
// 권한설정ToolStripMenuItem
|
// 권한설정ToolStripMenuItem
|
||||||
//
|
//
|
||||||
|
this.권한설정ToolStripMenuItem.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.권한설정ToolStripMenuItem.Name = "권한설정ToolStripMenuItem";
|
this.권한설정ToolStripMenuItem.Name = "권한설정ToolStripMenuItem";
|
||||||
this.권한설정ToolStripMenuItem.Size = new System.Drawing.Size(134, 24);
|
this.권한설정ToolStripMenuItem.Size = new System.Drawing.Size(152, 24);
|
||||||
this.권한설정ToolStripMenuItem.Text = "권한설정";
|
this.권한설정ToolStripMenuItem.Text = "권한설정";
|
||||||
this.권한설정ToolStripMenuItem.Click += new System.EventHandler(this.권한설정ToolStripMenuItem_Click);
|
this.권한설정ToolStripMenuItem.Click += new System.EventHandler(this.권한설정ToolStripMenuItem_Click);
|
||||||
//
|
//
|
||||||
// toolStripMenuItem12
|
// toolStripMenuItem12
|
||||||
//
|
//
|
||||||
|
this.toolStripMenuItem12.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.toolStripMenuItem12.Name = "toolStripMenuItem12";
|
this.toolStripMenuItem12.Name = "toolStripMenuItem12";
|
||||||
this.toolStripMenuItem12.Size = new System.Drawing.Size(131, 6);
|
this.toolStripMenuItem12.Size = new System.Drawing.Size(149, 6);
|
||||||
//
|
//
|
||||||
// 그룹정보ToolStripMenuItem
|
// 그룹정보ToolStripMenuItem
|
||||||
//
|
//
|
||||||
|
this.그룹정보ToolStripMenuItem.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.그룹정보ToolStripMenuItem.Name = "그룹정보ToolStripMenuItem";
|
this.그룹정보ToolStripMenuItem.Name = "그룹정보ToolStripMenuItem";
|
||||||
this.그룹정보ToolStripMenuItem.Size = new System.Drawing.Size(134, 24);
|
this.그룹정보ToolStripMenuItem.Size = new System.Drawing.Size(152, 24);
|
||||||
this.그룹정보ToolStripMenuItem.Text = "그룹정보";
|
this.그룹정보ToolStripMenuItem.Text = "그룹정보";
|
||||||
this.그룹정보ToolStripMenuItem.Click += new System.EventHandler(this.그룹정보ToolStripMenuItem_Click);
|
this.그룹정보ToolStripMenuItem.Click += new System.EventHandler(this.그룹정보ToolStripMenuItem_Click);
|
||||||
//
|
//
|
||||||
@@ -351,6 +358,7 @@
|
|||||||
//
|
//
|
||||||
// mn_kuntae
|
// mn_kuntae
|
||||||
//
|
//
|
||||||
|
this.mn_kuntae.ForeColor = System.Drawing.Color.Blue;
|
||||||
this.mn_kuntae.Name = "mn_kuntae";
|
this.mn_kuntae.Name = "mn_kuntae";
|
||||||
this.mn_kuntae.Size = new System.Drawing.Size(153, 24);
|
this.mn_kuntae.Size = new System.Drawing.Size(153, 24);
|
||||||
this.mn_kuntae.Text = "월별 근무표";
|
this.mn_kuntae.Text = "월별 근무표";
|
||||||
|
|||||||
@@ -141,15 +141,6 @@
|
|||||||
0IOABBs4KBjggQGBjQM9XoQwIYIHBB4yUpjo8AFLBS9fKjAgYSJCAQEyeJAgIUAEnj4RAgjgQUPPCgwQ
|
0IOABBs4KBjggQGBjQM9XoQwIYIHBB4yUpjo8AFLBS9fKjAgYSJCAQEyeJAgIUAEnj4RAgjgQUPPCgwQ
|
||||||
9Ey51APJABUIYKAwoADNBBlfRrCwtUOGBT49JEhAwIAFABQaFEDb0cMBAwg0DECbtOGFAoD7GlQomCHD
|
9Ey51APJABUIYKAwoADNBBlfRrCwtUOGBT49JEhAwIAFABQaFEDb0cMBAwg0DECbtOGFAoD7GlQomCHD
|
||||||
gAA7
|
gAA7
|
||||||
</value>
|
|
||||||
</data>
|
|
||||||
<data name="로그인ToolStripMenuItem.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
|
||||||
<value>
|
|
||||||
R0lGODlhEAAQAIQXAM2VE9+3RfjopNSgIc+YFMmLEdyxVubEWt6xOuC6b+zau9SkF9mpL9uzbcySEuG2
|
|
||||||
QeS8SsaHD+fBSv354vbii+3LYf///////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBF
|
|
||||||
Mi4wAwEBAAAh+QQBAAAXACwAAAAAEAAQAAAIggAvCBwo0IJBCwQTFqwAAQEDhAoXTpgoYQDEhBYqTKDA
|
|
||||||
kYKEBRclciRAoMEDCREuZtw40oKCCihVauxIIYEBmCkJruxYoWfMggYPsOyJU+WAABMqCJDgM+eFg0iV
|
|
||||||
Aigg4WfBo0kFADAYwWnBABSkQjSIcYDYiAMtBHCwFW3ag24HBgQAOw==
|
|
||||||
</value>
|
</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="codesToolStripMenuItem.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
<data name="codesToolStripMenuItem.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
@@ -452,16 +443,16 @@
|
|||||||
<data name="toolStripButton3.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
<data name="toolStripButton3.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
<value>
|
<value>
|
||||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
|
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
|
||||||
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIGSURBVDhPlY9PaNNgGMbfi7iD+Oei4t3NphkeJogwETxI
|
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIHSURBVDhPY2CAgZnGrAxLtTMZluosYFim08KwWFcdLL5K
|
||||||
E8GDKGyCXqZidUO7r+06EONhhS75vuq8DbQKWyJUBb30ItK4g1odztlpTRgM2hwmiCiDoWj6yheWugTm
|
i4dhmVYhwzLteQzLdFoZlqlLwfWggKU6qxmW6fyH48U6PxiWaHoyLNO8jiK+VOsJwyotCTTNWjYgSYG1
|
||||||
nwceSJ73+T0kAL7Gu9aBHo2DLt4BQxyBic4OLy8KG8AQEmBEC2CIWTA6drSYgHTxHhgitjwhfoPJiARG
|
lv/9Tub8T7lc/z/3ZMX/eXNiP9S05P9LvdzwP/Jc2X/VbV5QQ7T7UA1YpjOBf435seQr9d+Kb/X8L7nZ
|
||||||
pBbIdcGBorA9BAvd/Lj5wT48UunH01UFByoZLNw8+eVy9mLzTPUq9r5O486SvDISzQcHDPH6pvt7n/XN
|
8//QzJz/jzrT/l+dlPkTJAbDJrtDTzAs0bmGasBSXcHiW911MEUdh+r/Xy+N+L+/1f7v9UKvzz3bCuEG
|
||||||
KcvEppi0KE6N92N99Cy+uxH/zjPfex4ffwGT4vvggN65hdjaFb+Um1KwlurBcvaAW0vIS7SUaA0QW33D
|
FN/qugBSj2oAAwND8c3uuTBFlZc6lp0vDDl4Odnt28Ugtf/bKpz2Fl/s+AiSK7jZ8w5dLxjAXFB0s6cE
|
||||||
+8EBACCWdssvDb/NGTOJY0+rfYeWZ4+2Yylz8AmZzX3lt0sW/RxmPflfMGjRJH9fzPZsa6QlnDsn/HQu
|
xH/RGiH+uMzz/5UMrT9Psm3UCm/26hff6vpcdKvnPLpeMCi+02NcfKPrOIz/uNjT81Gpx38wLvHyAKu5
|
||||||
dLcnLLab2OrSoE1nwqwnMk+7yAf1uf/eIJJUT8XQc1KOeR1LSxFbGwmAq0Xmta3+s5ORz/sDzvDhOM+U
|
2V1afKu7BUUjMii+0y0GYz+p8MqCGfCk0jsTJFZ/v54j99ZEPhRNuMCjCs++J+Ve/0H4cblXL7o8QfC4
|
||||||
BaVtwB7bGIDWUj0j5Z0hGbkbQzIL3/+qRlp61PqFVOxh+P5HYVlpW1RPvPpETyH3x9HelzwL9wJqmtci
|
zHMj3AulHhvQ5fGC//vrOV50RZ1+0xP3H4RfdkaeAomhq0MB/w70a/490Nv3/0DvmfsHen//P9D7HxlD
|
||||||
rsnyaLLpBZP9QJPhaq9k016nTHf9Bktj612T3a6oSjMMrWXedU1a4Cy4ZcbChX81Z6Fp5ve7Jr2LJiv+
|
xc6A1ezv0UBo3DaR/e+B3vknOuv/oWvChUFq/x7omQfSy/B3f28vugJiMUgvw78DfbZ/D/Qs/3+gdxUp
|
||||||
jznD2V8tj6pV862slAAAAABJRU5ErkJggg==
|
GKQHpBcAJ5WqUADLneEAAAAASUVORK5CYII=
|
||||||
</value>
|
</value>
|
||||||
</data>
|
</data>
|
||||||
<metadata name="$this.TrayHeight" type="System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
<metadata name="$this.TrayHeight" type="System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/layout';
|
import { Layout } from '@/components/layout';
|
||||||
import { Dashboard, Todo, Kuntae, Jobreport, PlaceholderPage, Login, CommonCodePage, ItemsPage, UserListPage, MonthlyWorkPage, MailFormPage, UserGroupPage } from '@/pages';
|
import { Dashboard, Todo, Kuntae, Jobreport, Project, Login, CommonCodePage, ItemsPage, UserListPage, MonthlyWorkPage, MailFormPage, UserAuthPage } from '@/pages';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { UserInfo } from '@/types';
|
import { UserInfo } from '@/types';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
@@ -85,15 +85,24 @@ export default function App() {
|
|||||||
<Route path="/todo" element={<Todo />} />
|
<Route path="/todo" element={<Todo />} />
|
||||||
<Route path="/kuntae" element={<Kuntae />} />
|
<Route path="/kuntae" element={<Kuntae />} />
|
||||||
<Route path="/jobreport" element={<Jobreport />} />
|
<Route path="/jobreport" element={<Jobreport />} />
|
||||||
<Route path="/project" element={<PlaceholderPage title="프로젝트" />} />
|
<Route path="/project" element={<Project />} />
|
||||||
<Route path="/common" element={<CommonCodePage />} />
|
<Route path="/common" element={<CommonCodePage />} />
|
||||||
<Route path="/items" element={<ItemsPage />} />
|
<Route path="/items" element={<ItemsPage />} />
|
||||||
<Route path="/user/list" element={<UserListPage />} />
|
<Route path="/user/list" element={<UserListPage />} />
|
||||||
|
<Route path="/user/auth" element={<UserAuthPage />} />
|
||||||
<Route path="/monthly-work" element={<MonthlyWorkPage />} />
|
<Route path="/monthly-work" element={<MonthlyWorkPage />} />
|
||||||
<Route path="/mail-form" element={<MailFormPage />} />
|
<Route path="/mail-form" element={<MailFormPage />} />
|
||||||
<Route path="/user-group" element={<UserGroupPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
{/* Tailwind Breakpoint Indicator - 개발용 */}
|
||||||
|
<div className="fixed bottom-2 right-2 z-50 bg-black/80 text-white text-xs px-2 py-1 rounded font-mono">
|
||||||
|
<span className="sm:hidden">XS</span>
|
||||||
|
<span className="hidden sm:inline md:hidden">SM</span>
|
||||||
|
<span className="hidden md:inline lg:hidden">MD</span>
|
||||||
|
<span className="hidden lg:inline xl:hidden">LG</span>
|
||||||
|
<span className="hidden xl:inline 2xl:hidden">XL</span>
|
||||||
|
<span className="hidden 2xl:inline">2XL</span>
|
||||||
|
</div>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MachineBridgeInterface, ApiResponse, TodoModel, PurchaseCount, HolyUser, HolyRequestUser, PurchaseItem, KuntaeModel, LoginStatusResponse, LoginResult, UserGroup, PreviousLoginInfo, UserInfoDetail, GroupUser, UserLevelInfo, UserFullData, JobReportItem, JobReportUser, CommonCodeGroup, CommonCode, ItemInfo, JobReportPermission, AppVersionInfo, JobTypeItem, HolidayItem, MailFormItem, UserGroupItem, PermissionInfo } from './types';
|
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 환경인지 체크
|
// WebView2 환경인지 체크
|
||||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||||
@@ -281,6 +281,101 @@ class CommunicationLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async kuntaeErrorCheck(sd: string, ed: string): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Kuntae_ErrorCheck(sd, ed);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('KUNTAE_ERROR_CHECK', 'KUNTAE_ERROR_CHECK_DATA', { sd, ed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async kuntaeFixError(pdate: string): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Kuntae_FixError(pdate);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('KUNTAE_FIX_ERROR', 'KUNTAE_FIX_ERROR_DATA', { pdate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async kuntaeFixErrors(dates: string[]): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Kuntae_FixErrors(JSON.stringify(dates));
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('KUNTAE_FIX_ERRORS', 'KUNTAE_FIX_ERRORS_DATA', { dates });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Favorite API =====
|
||||||
|
|
||||||
|
public async getFavoriteList(): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Favorite_GetList();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('FAVORITE_GET_LIST', 'FAVORITE_LIST_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Project List API =====
|
||||||
|
|
||||||
|
public async getProjectCategories(): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Project_GetCategories();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('PROJECT_GET_CATEGORIES', 'PROJECT_CATEGORIES_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProjectProcesses(): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Project_GetProcesses();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('PROJECT_GET_PROCESSES', 'PROJECT_PROCESSES_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProjectList(
|
||||||
|
statusFilter: string,
|
||||||
|
category: string,
|
||||||
|
process: string,
|
||||||
|
userFilter: string,
|
||||||
|
yearStart: string,
|
||||||
|
yearEnd: string,
|
||||||
|
dateType: string
|
||||||
|
): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Project_GetList(statusFilter, category, process, userFilter, yearStart, yearEnd, dateType);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('PROJECT_GET_LIST', 'PROJECT_LIST_DATA', {
|
||||||
|
statusFilter, category, process, userFilter, yearStart, yearEnd, dateType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProjectHistory(projectIdx: number): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Project_GetHistory(projectIdx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('PROJECT_GET_HISTORY', 'PROJECT_HISTORY_DATA', { projectIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProjectDailyMemo(projectIdx: number): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Project_GetDailyMemo(projectIdx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('PROJECT_GET_DAILY_MEMO', 'PROJECT_DAILY_MEMO_DATA', { projectIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== Login API =====
|
// ===== Login API =====
|
||||||
|
|
||||||
@@ -450,6 +545,69 @@ class CommunicationLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getItemImage(idx: number): Promise<ApiResponse<string>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Items_GetImage(idx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<string>>('ITEMS_GET_IMAGE', 'ITEMS_IMAGE_DATA', { idx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveItemImage(idx: number, base64Image: string): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Items_SaveImage(idx, base64Image);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('ITEMS_SAVE_IMAGE', 'ITEMS_IMAGE_SAVED', { idx, base64Image });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteItemImage(idx: number): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Items_DeleteImage(idx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('ITEMS_DELETE_IMAGE', 'ITEMS_IMAGE_DELETED', { idx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getItemDetail(idx: number): Promise<ApiResponse<ItemDetail>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Items_GetDetail(idx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<ItemDetail>>('ITEMS_GET_DETAIL', 'ITEMS_DETAIL_DATA', { idx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSupplierStaff(supplyIdx: number): Promise<ApiResponse<SupplierStaff[]>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Items_GetSupplierStaff(supplyIdx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<SupplierStaff[]>>('ITEMS_GET_SUPPLIER_STAFF', 'ITEMS_SUPPLIER_STAFF_DATA', { supplyIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIncomingHistory(itemIdx: number): Promise<ApiResponse<PurchaseHistoryItem[]>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Items_GetIncomingHistory(itemIdx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<PurchaseHistoryItem[]>>('ITEMS_GET_INCOMING_HISTORY', 'ITEMS_INCOMING_HISTORY_DATA', { itemIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getOrderHistory(itemIdx: number): Promise<ApiResponse<PurchaseHistoryItem[]>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Items_GetOrderHistory(itemIdx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<PurchaseHistoryItem[]>>('ITEMS_GET_ORDER_HISTORY', 'ITEMS_ORDER_HISTORY_DATA', { itemIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== UserList API =====
|
// ===== UserList API =====
|
||||||
|
|
||||||
public async getCurrentUserLevel(): Promise<ApiResponse<UserLevelInfo>> {
|
public async getCurrentUserLevel(): Promise<ApiResponse<UserLevelInfo>> {
|
||||||
@@ -541,31 +699,31 @@ class CommunicationLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async addJobReport(
|
public async addJobReport(
|
||||||
pdate: string, projectName: string, 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
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse> {
|
||||||
if (isWebView && machine) {
|
if (isWebView && machine) {
|
||||||
const result = await machine.Jobreport_Add(pdate, projectName, 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);
|
||||||
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, requestpart, package: package_, 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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async editJobReport(
|
public async editJobReport(
|
||||||
idx: number, pdate: string, projectName: string, 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
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse> {
|
||||||
if (isWebView && machine) {
|
if (isWebView && machine) {
|
||||||
const result = await machine.Jobreport_Edit(idx, pdate, projectName, 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);
|
||||||
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, requestpart, package: package_, 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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,6 +908,118 @@ class CommunicationLayer {
|
|||||||
return this.wsRequest<ApiResponse<PermissionInfo[]>>('USERGROUP_GET_PERMISSION_INFO', 'USERGROUP_PERMISSION_INFO');
|
return this.wsRequest<ApiResponse<PermissionInfo[]>>('USERGROUP_GET_PERMISSION_INFO', 'USERGROUP_PERMISSION_INFO');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== UserAuth API (사용자 권한) =====
|
||||||
|
|
||||||
|
public async userAuthCanAccess(): Promise<{ Success: boolean; CanAccess?: boolean; Level?: number; Message?: string }> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.UserAuth_CanAccess();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<{ Success: boolean; CanAccess?: boolean; Level?: number; Message?: string }>('USERAUTH_CAN_ACCESS', 'USERAUTH_CAN_ACCESS_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserAuthList(): Promise<ApiResponse<AuthItem[]>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.UserAuth_GetList();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<AuthItem[]>>('USERAUTH_GET_LIST', 'USERAUTH_LIST_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveUserAuth(
|
||||||
|
idx: number, user: string, account: number, purchase: number, purchaseEB: number,
|
||||||
|
holyday: number, project: number, jobreport: number, scheapp: number,
|
||||||
|
equipment: number, otconfirm: number, holyreq: number, kuntae: number
|
||||||
|
): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.UserAuth_Save(idx, user, account, purchase, purchaseEB, holyday, project, jobreport, scheapp, equipment, otconfirm, holyreq, kuntae);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('USERAUTH_SAVE', 'USERAUTH_SAVED', {
|
||||||
|
idx, user, account, purchase, purchaseEB, holyday, project, jobreport, scheapp, equipment, otconfirm, holyreq, kuntae
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteUserAuth(idx: number): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.UserAuth_Delete(idx);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('USERAUTH_DELETE', 'USERAUTH_DELETED', { idx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserAuthFields(): Promise<ApiResponse<AuthFieldInfo[]>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.UserAuth_GetFields();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<AuthFieldInfo[]>>('USERAUTH_GET_FIELDS', 'USERAUTH_FIELDS_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 범용 권한 체크 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 범용 권한 체크 API
|
||||||
|
* @param authType 권한 타입 (purchase, holyday, project, jobreport, account 등)
|
||||||
|
* @param requiredLevel 필요한 최소 레벨 (기본값 5)
|
||||||
|
* @returns CheckAuthResponse - CanAccess, UserLevel, AuthLevel, EffectiveLevel 등
|
||||||
|
*/
|
||||||
|
public async checkAuth(authType: AuthType | string, requiredLevel: number = 5): Promise<CheckAuthResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.CheckAuth(authType, requiredLevel);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<CheckAuthResponse>('CHECK_AUTH', 'CHECK_AUTH_DATA', { authType, requiredLevel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인한 사용자의 전체 권한 정보 조회
|
||||||
|
* @returns ApiResponse<MyAuthInfo> - 사용자 레벨 및 각 항목별 권한 레벨
|
||||||
|
*/
|
||||||
|
public async getMyAuth(): Promise<ApiResponse<MyAuthInfo>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.GetMyAuth();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<MyAuthInfo>>('GET_MY_AUTH', 'MY_AUTH_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 프로젝트 검색 API (업무일지용) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 검색 (Projects 테이블 + 과거 업무일지 항목명)
|
||||||
|
* @param keyword 검색어
|
||||||
|
* @returns ApiResponse<ProjectSearchItem[]>
|
||||||
|
*/
|
||||||
|
public async searchProjects(keyword: string): Promise<ApiResponse<ProjectSearchItem[]>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Project_Search(keyword);
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<ProjectSearchItem[]>>('PROJECT_SEARCH', 'PROJECT_SEARCH_DATA', { keyword });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 사용자의 프로젝트 목록 조회 (업무일지 콤보박스용)
|
||||||
|
* @returns ApiResponse<{idx: number, name: string, status: string}[]>
|
||||||
|
*/
|
||||||
|
public async getUserProjects(): Promise<ApiResponse<{idx: number, name: string, status: string}[]>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.Project_GetUserProjects();
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<{idx: number, name: string, status: string}[]>>('PROJECT_GET_USER_PROJECTS', 'PROJECT_USER_PROJECTS_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const comms = new CommunicationLayer();
|
export const comms = new CommunicationLayer();
|
||||||
|
|||||||
188
Project/frontend/src/components/favorite/FavoriteDialog.tsx
Normal file
188
Project/frontend/src/components/favorite/FavoriteDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Star, Folder, Globe, FileText, Database, Server, Mail, Image, Film, Music, Archive, Terminal, Settings, HardDrive, Network, Cloud } from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { FavoriteItem } from '@/types';
|
||||||
|
|
||||||
|
interface FavoriteDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 유형에 따른 아이콘 및 색상 반환
|
||||||
|
function getIconInfo(url: string): { icon: React.ElementType; color: string; bgColor: string } {
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
|
||||||
|
// 로컬 폴더/드라이브 경로
|
||||||
|
if (lowerUrl.match(/^[a-z]:\\/i) || lowerUrl.startsWith('\\\\') || lowerUrl.startsWith('file:')) {
|
||||||
|
// 네트워크 경로
|
||||||
|
if (lowerUrl.startsWith('\\\\')) {
|
||||||
|
return { icon: Network, color: 'text-purple-400', bgColor: 'from-purple-500/30 to-purple-600/30' };
|
||||||
|
}
|
||||||
|
return { icon: Folder, color: 'text-yellow-400', bgColor: 'from-yellow-500/30 to-yellow-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 웹 URL
|
||||||
|
if (lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')) {
|
||||||
|
// 특정 서비스 감지
|
||||||
|
if (lowerUrl.includes('mail') || lowerUrl.includes('outlook') || lowerUrl.includes('gmail')) {
|
||||||
|
return { icon: Mail, color: 'text-red-400', bgColor: 'from-red-500/30 to-red-600/30' };
|
||||||
|
}
|
||||||
|
if (lowerUrl.includes('cloud') || lowerUrl.includes('drive') || lowerUrl.includes('onedrive') || lowerUrl.includes('dropbox')) {
|
||||||
|
return { icon: Cloud, color: 'text-sky-400', bgColor: 'from-sky-500/30 to-sky-600/30' };
|
||||||
|
}
|
||||||
|
if (lowerUrl.includes('server') || lowerUrl.includes('admin')) {
|
||||||
|
return { icon: Server, color: 'text-orange-400', bgColor: 'from-orange-500/30 to-orange-600/30' };
|
||||||
|
}
|
||||||
|
if (lowerUrl.includes('database') || lowerUrl.includes('sql') || lowerUrl.includes('db')) {
|
||||||
|
return { icon: Database, color: 'text-emerald-400', bgColor: 'from-emerald-500/30 to-emerald-600/30' };
|
||||||
|
}
|
||||||
|
return { icon: Globe, color: 'text-blue-400', bgColor: 'from-blue-500/30 to-blue-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실행 파일
|
||||||
|
if (lowerUrl.endsWith('.exe') || lowerUrl.endsWith('.bat') || lowerUrl.endsWith('.cmd') || lowerUrl.endsWith('.ps1')) {
|
||||||
|
return { icon: Terminal, color: 'text-green-400', bgColor: 'from-green-500/30 to-green-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문서 파일
|
||||||
|
if (lowerUrl.match(/\.(doc|docx|pdf|txt|xls|xlsx|ppt|pptx|hwp)$/i)) {
|
||||||
|
return { icon: FileText, color: 'text-blue-400', bgColor: 'from-blue-500/30 to-blue-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 파일
|
||||||
|
if (lowerUrl.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
||||||
|
return { icon: Image, color: 'text-pink-400', bgColor: 'from-pink-500/30 to-pink-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비디오 파일
|
||||||
|
if (lowerUrl.match(/\.(mp4|avi|mkv|mov|wmv)$/i)) {
|
||||||
|
return { icon: Film, color: 'text-violet-400', bgColor: 'from-violet-500/30 to-violet-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오디오 파일
|
||||||
|
if (lowerUrl.match(/\.(mp3|wav|flac|aac|ogg)$/i)) {
|
||||||
|
return { icon: Music, color: 'text-rose-400', bgColor: 'from-rose-500/30 to-rose-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 압축 파일
|
||||||
|
if (lowerUrl.match(/\.(zip|rar|7z|tar|gz)$/i)) {
|
||||||
|
return { icon: Archive, color: 'text-amber-400', bgColor: 'from-amber-500/30 to-amber-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 파일
|
||||||
|
if (lowerUrl.match(/\.(ini|cfg|config|xml|json|yaml|yml)$/i)) {
|
||||||
|
return { icon: Settings, color: 'text-gray-400', bgColor: 'from-gray-500/30 to-gray-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드라이브 루트
|
||||||
|
if (lowerUrl.match(/^[a-z]:$/i)) {
|
||||||
|
return { icon: HardDrive, color: 'text-slate-400', bgColor: 'from-slate-500/30 to-slate-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값: 폴더
|
||||||
|
return { icon: Folder, color: 'text-yellow-400', bgColor: 'from-yellow-500/30 to-yellow-600/30' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoriteDialog({ isOpen, onClose }: FavoriteDialogProps) {
|
||||||
|
const [favorites, setFavorites] = useState<FavoriteItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadFavorites();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadFavorites = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await comms.getFavoriteList();
|
||||||
|
if (response.Success && response.Data) {
|
||||||
|
// 이름으로 정렬
|
||||||
|
const sorted = (response.Data as FavoriteItem[]).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, 'ko')
|
||||||
|
);
|
||||||
|
setFavorites(sorted);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('즐겨찾기 로드 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemClick = (url: string) => {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="relative w-full max-w-4xl mx-4 glass-effect-solid rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Star className="w-5 h-5 text-yellow-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">즐겨찾기</h2>
|
||||||
|
<span className="text-white/50 text-sm">({favorites.length}개)</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 max-h-[70vh] overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60" />
|
||||||
|
</div>
|
||||||
|
) : favorites.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-white/50">
|
||||||
|
등록된 즐겨찾기가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||||
|
{favorites.map((item, index) => {
|
||||||
|
const { icon: Icon, color, bgColor } = getIconInfo(item.url);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleItemClick(item.url)}
|
||||||
|
className="group flex flex-col items-center p-4 rounded-xl bg-white/5 hover:bg-white/15 border border-white/10 hover:border-white/30 transition-all duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${bgColor} flex items-center justify-center mb-3 group-hover:opacity-80 transition-opacity`}>
|
||||||
|
<Icon className={`w-5 h-5 ${color}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-white/80 group-hover:text-white text-center line-clamp-2 leading-tight">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-3 border-t border-white/10 bg-black/20">
|
||||||
|
<p className="text-xs text-white/40 text-center">
|
||||||
|
클릭하면 새 탭에서 열립니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
Project/frontend/src/components/favorite/index.ts
Normal file
1
Project/frontend/src/components/favorite/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FavoriteDialog } from './FavoriteDialog';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { X, Save, Trash2 } from 'lucide-react';
|
import { X, Save, Trash2, Upload, Clipboard, ImageIcon } from 'lucide-react';
|
||||||
import { ItemInfo } from '@/types';
|
import { ItemInfo } from '@/types';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
|
||||||
interface ItemEditDialogProps {
|
interface ItemEditDialogProps {
|
||||||
item: ItemInfo | null;
|
item: ItemInfo | null;
|
||||||
@@ -13,13 +14,169 @@ interface ItemEditDialogProps {
|
|||||||
export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: ItemEditDialogProps) {
|
export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: ItemEditDialogProps) {
|
||||||
const [editData, setEditData] = useState<ItemInfo | null>(null);
|
const [editData, setEditData] = useState<ItemInfo | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [imageData, setImageData] = useState<string | null>(null);
|
||||||
|
const [imageLoading, setImageLoading] = useState(false);
|
||||||
|
const [imageSaving, setImageSaving] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
setEditData({ ...item });
|
setEditData({ ...item });
|
||||||
|
setImageData(null);
|
||||||
|
// 기존 품목인 경우 이미지 로드
|
||||||
|
if (item.idx > 0) {
|
||||||
|
loadImage(item.idx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
|
// 이미지 로드
|
||||||
|
const loadImage = async (idx: number) => {
|
||||||
|
setImageLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await comms.getItemImage(idx);
|
||||||
|
if (result.Success && result.Data) {
|
||||||
|
setImageData(`data:image/jpeg;base64,${result.Data}`);
|
||||||
|
} else {
|
||||||
|
setImageData(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이미지 로드 실패:', error);
|
||||||
|
setImageData(null);
|
||||||
|
} finally {
|
||||||
|
setImageLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// 이미지를 Base64로 변환
|
||||||
|
const convertToBase64 = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 파일 처리
|
||||||
|
const handleImageFile = async (file: File) => {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('이미지 파일만 업로드 가능합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64 = await convertToBase64(file);
|
||||||
|
setImageData(base64);
|
||||||
|
|
||||||
|
// 기존 품목인 경우 바로 저장
|
||||||
|
if (editData && editData.idx > 0) {
|
||||||
|
setImageSaving(true);
|
||||||
|
const result = await comms.saveItemImage(editData.idx, base64);
|
||||||
|
if (!result.Success) {
|
||||||
|
alert(result.Message || '이미지 저장 실패');
|
||||||
|
}
|
||||||
|
setImageSaving(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이미지 처리 실패:', error);
|
||||||
|
alert('이미지 처리에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 선택
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleImageFile(file);
|
||||||
|
}
|
||||||
|
// 같은 파일 다시 선택 가능하도록
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 클립보드에서 붙여넣기
|
||||||
|
const handlePaste = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const items = await navigator.clipboard.read();
|
||||||
|
for (const item of items) {
|
||||||
|
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||||
|
if (imageType) {
|
||||||
|
const blob = await item.getType(imageType);
|
||||||
|
const file = new File([blob], 'clipboard-image.png', { type: imageType });
|
||||||
|
await handleImageFile(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert('클립보드에 이미지가 없습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('클립보드 읽기 실패:', error);
|
||||||
|
alert('클립보드에서 이미지를 가져올 수 없습니다.');
|
||||||
|
}
|
||||||
|
}, [editData]);
|
||||||
|
|
||||||
|
// 드래그 앤 드롭
|
||||||
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
await handleImageFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 삭제
|
||||||
|
const handleDeleteImage = async () => {
|
||||||
|
if (!editData) return;
|
||||||
|
|
||||||
|
if (!confirm('이미지를 삭제하시겠습니까?')) return;
|
||||||
|
|
||||||
|
if (editData.idx > 0) {
|
||||||
|
setImageSaving(true);
|
||||||
|
const result = await comms.deleteItemImage(editData.idx);
|
||||||
|
if (result.Success) {
|
||||||
|
setImageData(null);
|
||||||
|
} else {
|
||||||
|
alert(result.Message || '이미지 삭제 실패');
|
||||||
|
}
|
||||||
|
setImageSaving(false);
|
||||||
|
} else {
|
||||||
|
setImageData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen || !editData) return null;
|
if (!isOpen || !editData) return null;
|
||||||
|
|
||||||
const isNew = editData.idx === 0;
|
const isNew = editData.idx === 0;
|
||||||
@@ -48,10 +205,13 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* 배경 오버레이 */}
|
{/* 배경 오버레이 */}
|
||||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onMouseDown={onClose} />
|
||||||
|
|
||||||
{/* 다이얼로그 */}
|
{/* 다이얼로그 - 이미지 영역 포함해서 더 넓게 */}
|
||||||
<div className="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 border border-white/10">
|
<div
|
||||||
|
className="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl mx-4 border border-white/10"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
<h2 className="text-lg font-semibold text-white">
|
<h2 className="text-lg font-semibold text-white">
|
||||||
@@ -65,145 +225,245 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 내용 */}
|
{/* 내용 - 좌우 레이아웃 */}
|
||||||
<div className="p-4 space-y-4 max-h-[60vh] overflow-auto">
|
<div className="flex max-h-[70vh]">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* 왼쪽: 폼 필드 */}
|
||||||
{/* SID */}
|
<div className="flex-1 p-4 space-y-4 overflow-auto">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* SID */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-1">SID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.sid}
|
||||||
|
onChange={(e) => setEditData({ ...editData, sid: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분류 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-1">분류</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.cate}
|
||||||
|
onChange={(e) => setEditData({ ...editData, cate: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품명 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">SID</label>
|
<label className="block text-sm font-medium text-white/70 mb-1">품명</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editData.sid}
|
value={editData.name}
|
||||||
onChange={(e) => setEditData({ ...editData, sid: e.target.value })}
|
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 분류 */}
|
{/* 모델 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">분류</label>
|
<label className="block text-sm font-medium text-white/70 mb-1">모델</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editData.cate}
|
value={editData.model}
|
||||||
onChange={(e) => setEditData({ ...editData, cate: e.target.value })}
|
onChange={(e) => setEditData({ ...editData, model: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 품명 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">품명</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editData.name}
|
|
||||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모델 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">모델</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editData.model}
|
|
||||||
onChange={(e) => setEditData({ ...editData, model: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{/* 규격 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">규격</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editData.scale}
|
|
||||||
onChange={(e) => setEditData({ ...editData, scale: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 단위 */}
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* 규격 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-1">규격</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.scale}
|
||||||
|
onChange={(e) => setEditData({ ...editData, scale: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단위 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-1">단위</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.unit}
|
||||||
|
onChange={(e) => setEditData({ ...editData, unit: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단가 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-1">단가</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editData.price}
|
||||||
|
onChange={(e) => setEditData({ ...editData, price: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* 공급처 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-1">공급처</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.supply}
|
||||||
|
onChange={(e) => setEditData({ ...editData, supply: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제조사 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white/70 mb-1">제조사</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.manu}
|
||||||
|
onChange={(e) => setEditData({ ...editData, manu: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 보관장소 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">단위</label>
|
<label className="block text-sm font-medium text-white/70 mb-1">보관장소</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editData.unit}
|
value={editData.storage}
|
||||||
onChange={(e) => setEditData({ ...editData, unit: e.target.value })}
|
onChange={(e) => setEditData({ ...editData, storage: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 단가 */}
|
{/* 메모 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">단가</label>
|
<label className="block text-sm font-medium text-white/70 mb-1">메모</label>
|
||||||
<input
|
<textarea
|
||||||
type="number"
|
value={editData.memo}
|
||||||
value={editData.price}
|
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
||||||
onChange={(e) => setEditData({ ...editData, price: parseFloat(e.target.value) || 0 })}
|
rows={2}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-right"
|
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{/* 공급처 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">공급처</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editData.supply}
|
|
||||||
onChange={(e) => setEditData({ ...editData, supply: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 제조사 */}
|
{/* 비활성화 */}
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">제조사</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="checkbox"
|
||||||
value={editData.manu}
|
id="disable"
|
||||||
onChange={(e) => setEditData({ ...editData, manu: e.target.value })}
|
checked={editData.disable}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-white/20 bg-white/10"
|
||||||
/>
|
/>
|
||||||
|
<label htmlFor="disable" className="text-sm text-white/70">비활성화</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 보관장소 */}
|
{/* 오른쪽: 이미지 영역 */}
|
||||||
<div>
|
<div className="w-72 p-4 border-l border-white/10 flex flex-col">
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">보관장소</label>
|
<label className="block text-sm font-medium text-white/70 mb-2">이미지</label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editData.storage}
|
|
||||||
onChange={(e) => setEditData({ ...editData, storage: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메모 */}
|
{/* 이미지 드롭존 */}
|
||||||
<div>
|
<div
|
||||||
<label className="block text-sm font-medium text-white/70 mb-1">메모</label>
|
ref={dropZoneRef}
|
||||||
<textarea
|
onDragEnter={handleDragEnter}
|
||||||
value={editData.memo}
|
onDragLeave={handleDragLeave}
|
||||||
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
onDragOver={handleDragOver}
|
||||||
rows={2}
|
onDrop={handleDrop}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
|
className={`flex-1 min-h-[200px] rounded-lg border-2 border-dashed transition-colors flex items-center justify-center overflow-hidden ${
|
||||||
/>
|
isDragging
|
||||||
</div>
|
? 'border-blue-400 bg-blue-500/20'
|
||||||
|
: 'border-white/20 bg-white/5 hover:border-white/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{imageLoading ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white/50 mx-auto mb-2"></div>
|
||||||
|
<p className="text-white/50 text-sm">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : imageSaving ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-400 mx-auto mb-2"></div>
|
||||||
|
<p className="text-white/50 text-sm">저장 중...</p>
|
||||||
|
</div>
|
||||||
|
) : imageData ? (
|
||||||
|
<img
|
||||||
|
src={imageData}
|
||||||
|
alt="품목 이미지"
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4">
|
||||||
|
<ImageIcon className="w-12 h-12 text-white/30 mx-auto mb-2" />
|
||||||
|
<p className="text-white/50 text-sm">
|
||||||
|
이미지를 드래그하거나<br />
|
||||||
|
아래 버튼을 사용하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 비활성화 */}
|
{/* 이미지 버튼들 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="mt-3 space-y-2">
|
||||||
<input
|
{/* 파일 선택 */}
|
||||||
type="checkbox"
|
<input
|
||||||
id="disable"
|
ref={fileInputRef}
|
||||||
checked={editData.disable}
|
type="file"
|
||||||
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
|
accept="image/*"
|
||||||
className="w-4 h-4 rounded border-white/20 bg-white/10"
|
onChange={handleFileSelect}
|
||||||
/>
|
className="hidden"
|
||||||
<label htmlFor="disable" className="text-sm text-white/70">비활성화</label>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={imageSaving}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/80 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
파일 선택
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 붙여넣기 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePaste}
|
||||||
|
disabled={imageSaving}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/80 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Clipboard className="w-4 h-4" />
|
||||||
|
붙여넣기
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 이미지 삭제 */}
|
||||||
|
{imageData && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteImage}
|
||||||
|
disabled={imageSaving}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-red-600/20 hover:bg-red-600/40 rounded-lg text-red-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
이미지 삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 신규 품목 안내 */}
|
||||||
|
{isNew && imageData && (
|
||||||
|
<p className="text-xs text-yellow-400/80 text-center">
|
||||||
|
* 품목 저장 후 이미지가 적용됩니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export function JobTypeSelectModal({
|
|||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||||
const [selectedPath, setSelectedPath] = useState<string>('');
|
const [selectedPath, setSelectedPath] = useState<string>('');
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
// 데이터 로드
|
// 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -161,9 +172,8 @@ export function JobTypeSelectModal({
|
|||||||
|
|
||||||
// 항목 더블클릭
|
// 항목 더블클릭
|
||||||
const handleDoubleClick = (item: JobTypeItem) => {
|
const handleDoubleClick = (item: JobTypeItem) => {
|
||||||
const process = item.process || 'N/A';
|
// 원본 값 그대로 전달 (N/A 변환은 상위에서 처리)
|
||||||
const jobgrp = item.jobgrp || 'N/A';
|
onSelect(item.process || '', item.jobgrp || '', item.type);
|
||||||
onSelect(process, jobgrp, item.type);
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,7 +182,10 @@ export function JobTypeSelectModal({
|
|||||||
if (selectedPath) {
|
if (selectedPath) {
|
||||||
const parts = selectedPath.split('|');
|
const parts = selectedPath.split('|');
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
onSelect(parts[0], parts[1], parts[2]);
|
// N/A 값은 빈 문자열로 변환하여 전달 (상위에서 처리)
|
||||||
|
const process = parts[0] === 'N/A' ? '' : parts[0];
|
||||||
|
const jobgrp = parts[1] === 'N/A' ? '' : parts[1];
|
||||||
|
onSelect(process, jobgrp, parts[2]);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,12 +196,12 @@ export function JobTypeSelectModal({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60]"
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60]"
|
||||||
onClick={onClose}
|
onMouseDown={onClose}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center min-h-screen p-4">
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
<div
|
<div
|
||||||
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up max-h-[85vh] flex flex-col"
|
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up max-h-[85vh] flex flex-col"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||||
@@ -300,13 +313,22 @@ export function JobTypeSelectModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 항목 표시 */}
|
{/* 선택된 항목 표시 (WinForms과 동일: type ← jobgrp) */}
|
||||||
{selectedPath && (
|
{selectedPath && (
|
||||||
<div className="px-6 py-3 border-t border-white/10 bg-primary-500/10">
|
<div className="px-6 py-3 border-t border-white/10 bg-primary-500/10">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-white/50">선택: </span>
|
<span className="text-white/50">선택: </span>
|
||||||
<span className="text-primary-300 font-medium">
|
<span className="text-primary-300 font-medium">
|
||||||
{selectedPath.split('|').reverse().join(' ← ')}
|
{(() => {
|
||||||
|
const parts = selectedPath.split('|');
|
||||||
|
// parts[0]=process, parts[1]=jobgrp, parts[2]=type
|
||||||
|
const type = parts[2] || '';
|
||||||
|
const jobgrp = parts[1] || '';
|
||||||
|
if (jobgrp && jobgrp !== 'N/A') {
|
||||||
|
return `${type} ← ${jobgrp}`;
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { FileText, Plus, Trash2, X, Loader2, ChevronDown } from 'lucide-react';
|
import { FileText, Plus, Trash2, X, Loader2, ChevronDown, Search } from 'lucide-react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { JobReportItem } from '@/types';
|
import { JobReportItem, CommonCode } from '@/types';
|
||||||
import { JobTypeSelectModal } from './JobTypeSelectModal';
|
import { JobTypeSelectModal } from './JobTypeSelectModal';
|
||||||
|
import { ProjectSearchDialog } from './ProjectSearchDialog';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
|
||||||
export interface JobreportFormData {
|
export interface JobreportFormData {
|
||||||
pdate: string;
|
pdate: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
pidx: number | null; // 프로젝트 인덱스 (-1이면 프로젝트 연결 없음)
|
||||||
requestpart: string;
|
requestpart: string;
|
||||||
package: string;
|
package: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -27,6 +30,7 @@ const formatDateLocal = (date: Date) => {
|
|||||||
export const initialFormData: JobreportFormData = {
|
export const initialFormData: JobreportFormData = {
|
||||||
pdate: formatDateLocal(new Date()),
|
pdate: formatDateLocal(new Date()),
|
||||||
projectName: '',
|
projectName: '',
|
||||||
|
pidx: null,
|
||||||
requestpart: '',
|
requestpart: '',
|
||||||
package: '',
|
package: '',
|
||||||
type: '',
|
type: '',
|
||||||
@@ -61,6 +65,52 @@ export function JobreportEditModal({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: JobreportEditModalProps) {
|
}: JobreportEditModalProps) {
|
||||||
const [showJobTypeModal, setShowJobTypeModal] = useState(false);
|
const [showJobTypeModal, setShowJobTypeModal] = useState(false);
|
||||||
|
const [showProjectSearch, setShowProjectSearch] = useState(false);
|
||||||
|
const [requestPartList, setRequestPartList] = useState<CommonCode[]>([]);
|
||||||
|
const [packageList, setPackageList] = useState<CommonCode[]>([]);
|
||||||
|
const [processList, setProcessList] = useState<CommonCode[]>([]);
|
||||||
|
const [statusList, setStatusList] = useState<CommonCode[]>([]);
|
||||||
|
const [loadingCodes, setLoadingCodes] = useState(false);
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen && !showJobTypeModal && !showProjectSearch) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose, showJobTypeModal, showProjectSearch]);
|
||||||
|
|
||||||
|
// 공용코드 로드 (WebSocket에서 동일 응답타입 충돌 방지를 위해 순차 로드)
|
||||||
|
const loadCommonCodes = useCallback(async () => {
|
||||||
|
setLoadingCodes(true);
|
||||||
|
try {
|
||||||
|
// WebSocket 모드에서는 같은 응답타입을 사용하므로 순차적으로 로드
|
||||||
|
const requestPart = await comms.getCommonList('13'); // 요청부서
|
||||||
|
setRequestPartList(requestPart || []);
|
||||||
|
|
||||||
|
const packages = await comms.getCommonList('14'); // 패키지
|
||||||
|
setPackageList(packages || []);
|
||||||
|
|
||||||
|
const processes = await comms.getCommonList('16'); // 공정(프로세스)
|
||||||
|
setProcessList(processes || []);
|
||||||
|
|
||||||
|
const statuses = await comms.getCommonList('12'); // 상태
|
||||||
|
setStatusList(statuses || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공용코드 로드 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingCodes(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadCommonCodes();
|
||||||
|
}
|
||||||
|
}, [isOpen, loadCommonCodes]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -73,22 +123,30 @@ export function JobreportEditModal({
|
|||||||
|
|
||||||
// 업무형태 선택 처리
|
// 업무형태 선택 처리
|
||||||
const handleJobTypeSelect = (process: string, jobgrp: string, type: string) => {
|
const handleJobTypeSelect = (process: string, jobgrp: string, type: string) => {
|
||||||
|
// WinForms과 동일하게 N/A 처리: jobgrp만 N/A 처리, process는 빈 값 허용
|
||||||
|
const normalizedJobgrp = (!jobgrp || jobgrp === '(N/A)') ? 'N/A' : jobgrp;
|
||||||
|
// process가 N/A면 빈 문자열로 (공정 드롭다운에서 선택하도록)
|
||||||
|
const normalizedProcess = (process === 'N/A') ? '' : process;
|
||||||
|
|
||||||
onFormChange({
|
onFormChange({
|
||||||
...formData,
|
...formData,
|
||||||
process,
|
process: normalizedProcess || formData.process, // process가 없으면 기존 값 유지
|
||||||
jobgrp,
|
jobgrp: normalizedJobgrp,
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업무형태 표시 텍스트
|
// 업무형태 표시 텍스트 (type ← jobgrp 형태, WinForms과 동일)
|
||||||
const getJobTypeDisplayText = () => {
|
const getJobTypeDisplayText = () => {
|
||||||
if (!formData.type) {
|
if (!formData.type) {
|
||||||
return '업무형태를 선택하세요';
|
return '업무형태를 선택하세요';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WinForms: fullname = $"{jtype} ← {jgrp}"
|
||||||
if (formData.jobgrp && formData.jobgrp !== 'N/A') {
|
if (formData.jobgrp && formData.jobgrp !== 'N/A') {
|
||||||
return `${formData.type} ← ${formData.jobgrp}`;
|
return `${formData.type} ← ${formData.jobgrp}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formData.type;
|
return formData.type;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,12 +196,12 @@ export function JobreportEditModal({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
||||||
onClick={onClose}
|
onMouseDown={onClose}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center min-h-screen p-4">
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
<div
|
<div
|
||||||
className="glass-effect rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto"
|
className="glass-effect rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-slate-800/95 backdrop-blur z-10">
|
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-slate-800/95 backdrop-blur z-10">
|
||||||
@@ -178,43 +236,98 @@ export function JobreportEditModal({
|
|||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||||
프로젝트명 *
|
프로젝트명 *
|
||||||
|
{formData.pidx !== null && formData.pidx > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-primary-400 font-mono">[pidx: {formData.pidx}]</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="text"
|
<input
|
||||||
value={formData.projectName}
|
type="text"
|
||||||
onChange={(e) => handleFieldChange('projectName', e.target.value)}
|
value={formData.projectName}
|
||||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
onChange={(e) => {
|
||||||
placeholder="프로젝트 또는 아이템명"
|
handleFieldChange('projectName', e.target.value);
|
||||||
required
|
// 프로젝트명을 직접 수정하면 pidx 연결 해제
|
||||||
/>
|
if (formData.pidx !== null && formData.pidx > 0) {
|
||||||
|
onFormChange({ ...formData, projectName: e.target.value, pidx: -1 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowProjectSearch(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||||
|
placeholder="프로젝트명 입력 후 Enter로 검색"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowProjectSearch(true)}
|
||||||
|
className="px-3 py-2 bg-white/20 hover:bg-white/30 border border-white/30 rounded-lg text-white transition-colors"
|
||||||
|
title="프로젝트 검색"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2행: 요청부서, 패키지 */}
|
{/* 2행: 요청부서, 패키지, 공정 */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<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">
|
||||||
요청부서
|
요청부서
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
|
||||||
value={formData.requestpart}
|
value={formData.requestpart}
|
||||||
onChange={(e) => handleFieldChange('requestpart', e.target.value)}
|
onChange={(e) => handleFieldChange('requestpart', e.target.value)}
|
||||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
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"
|
||||||
placeholder="요청부서"
|
disabled={loadingCodes}
|
||||||
/>
|
>
|
||||||
|
<option value="" className="bg-gray-800">선택...</option>
|
||||||
|
{requestPartList.map((item) => (
|
||||||
|
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||||
|
{item.memo || item.svalue}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</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">
|
||||||
패키지
|
패키지
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
|
||||||
value={formData.package}
|
value={formData.package}
|
||||||
onChange={(e) => handleFieldChange('package', e.target.value)}
|
onChange={(e) => handleFieldChange('package', e.target.value)}
|
||||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
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"
|
||||||
placeholder="패키지"
|
disabled={loadingCodes}
|
||||||
/>
|
>
|
||||||
|
<option value="" className="bg-gray-800">선택...</option>
|
||||||
|
{packageList.map((item) => (
|
||||||
|
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||||
|
{item.memo || item.svalue}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||||
|
공정 *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.process}
|
||||||
|
onChange={(e) => handleFieldChange('process', 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"
|
||||||
|
disabled={loadingCodes}
|
||||||
|
>
|
||||||
|
<option value="" className="bg-gray-800">선택...</option>
|
||||||
|
{processList.map((item) => (
|
||||||
|
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||||
|
{item.memo || item.svalue}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,33 +348,33 @@ export function JobreportEditModal({
|
|||||||
<span>{getJobTypeDisplayText()}</span>
|
<span>{getJobTypeDisplayText()}</span>
|
||||||
<ChevronDown className="w-4 h-4 text-white/50" />
|
<ChevronDown className="w-4 h-4 text-white/50" />
|
||||||
</button>
|
</button>
|
||||||
{formData.process && (
|
|
||||||
<div className="mt-1 text-xs text-white/50">
|
|
||||||
공정: {formData.process}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4행: 상태, 근무시간, 초과시간 */}
|
{/* 4행: 상태, 근무시간, 초과시간 */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<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">
|
||||||
상태
|
상태 *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={(e) => handleFieldChange('status', e.target.value)}
|
onChange={(e) => handleFieldChange('status', 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"
|
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"
|
||||||
|
disabled={loadingCodes}
|
||||||
>
|
>
|
||||||
<option value="진행 완료" className="bg-gray-800">
|
{statusList.length > 0 ? (
|
||||||
진행 완료
|
statusList.map((item) => (
|
||||||
</option>
|
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
|
||||||
<option value="진행 중" className="bg-gray-800">
|
{item.memo || item.svalue}
|
||||||
진행 중
|
</option>
|
||||||
</option>
|
))
|
||||||
<option value="대기" className="bg-gray-800">
|
) : (
|
||||||
대기
|
<>
|
||||||
</option>
|
<option value="진행 완료" className="bg-gray-800">진행 완료</option>
|
||||||
|
<option value="진행 중" className="bg-gray-800">진행 중</option>
|
||||||
|
<option value="대기" className="bg-gray-800">대기</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -366,6 +479,20 @@ export function JobreportEditModal({
|
|||||||
onClose={() => setShowJobTypeModal(false)}
|
onClose={() => setShowJobTypeModal(false)}
|
||||||
onSelect={handleJobTypeSelect}
|
onSelect={handleJobTypeSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 프로젝트 검색 다이얼로그 */}
|
||||||
|
<ProjectSearchDialog
|
||||||
|
isOpen={showProjectSearch}
|
||||||
|
onClose={() => setShowProjectSearch(false)}
|
||||||
|
onSelect={(project) => {
|
||||||
|
onFormChange({
|
||||||
|
...formData,
|
||||||
|
projectName: project.name,
|
||||||
|
pidx: project.idx > 0 ? project.idx : -1,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
initialSearchKey={formData.projectName}
|
||||||
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Search, X, Folder, FileText, Check } from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { ProjectSearchItem } from '@/types';
|
||||||
|
|
||||||
|
interface ProjectSearchDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (project: { idx: number; name: string }) => void;
|
||||||
|
initialSearchKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSearchDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
initialSearchKey = '',
|
||||||
|
}: ProjectSearchDialogProps) {
|
||||||
|
const [projects, setProjects] = useState<ProjectSearchItem[]>([]);
|
||||||
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<ProjectSearchItem | null>(null);
|
||||||
|
|
||||||
|
// 프로젝트 검색
|
||||||
|
const searchProjects = useCallback(async (keyword: string) => {
|
||||||
|
if (!keyword.trim()) {
|
||||||
|
setProjects([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await comms.searchProjects(keyword);
|
||||||
|
if (result.Success && result.Data) {
|
||||||
|
setProjects(result.Data);
|
||||||
|
} else {
|
||||||
|
setProjects([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 검색 실패:', error);
|
||||||
|
setProjects([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 다이얼로그 열릴 때 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSearchKey(initialSearchKey);
|
||||||
|
setSelectedProject(null);
|
||||||
|
if (initialSearchKey) {
|
||||||
|
searchProjects(initialSearchKey);
|
||||||
|
} else {
|
||||||
|
setProjects([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, initialSearchKey, searchProjects]);
|
||||||
|
|
||||||
|
// 검색어 변경 시 검색 (디바운스)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (searchKey.trim()) {
|
||||||
|
searchProjects(searchKey);
|
||||||
|
} else {
|
||||||
|
setProjects([]);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchKey, searchProjects]);
|
||||||
|
|
||||||
|
// 선택 확정
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedProject) {
|
||||||
|
onSelect({ idx: selectedProject.idx, name: selectedProject.name });
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60]"
|
||||||
|
onMouseDown={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="glass-effect rounded-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="w-5 h-5 text-primary-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">프로젝트/항목 검색</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/60 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="p-4 border-b border-white/10 shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
placeholder="프로젝트명 또는 번호로 검색..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로젝트 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-400 mx-auto mb-2"></div>
|
||||||
|
<p className="text-white/50">검색 중...</p>
|
||||||
|
</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-white/50">
|
||||||
|
{searchKey ? '검색 결과가 없습니다' : '검색어를 입력하세요'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<button
|
||||||
|
key={`${project.source}-${project.idx}-${index}`}
|
||||||
|
onClick={() => setSelectedProject(project)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
onSelect({ idx: project.idx, name: project.name });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||||
|
selectedProject?.idx === project.idx && selectedProject?.name === project.name
|
||||||
|
? 'bg-primary-500/30 border border-primary-400/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 아이콘 */}
|
||||||
|
<div className={`shrink-0 ${project.source === 'project' ? 'text-blue-400' : 'text-gray-400'}`}>
|
||||||
|
{project.source === 'project' ? (
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{project.idx > 0 && (
|
||||||
|
<span className="text-xs text-white/40 font-mono">[{project.idx}]</span>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-white truncate">{project.name}</span>
|
||||||
|
{project.status && (
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
project.status === '진행' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
project.status === '준비' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
'bg-gray-500/20 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/50 mt-0.5 truncate">
|
||||||
|
{project.source === 'project' ? (
|
||||||
|
<>
|
||||||
|
{project.userManager && `담당: ${project.userManager}`}
|
||||||
|
{project.userMain && ` | 챔피언: ${project.userMain}`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
과거 등록 항목
|
||||||
|
{project.lastDate && ` | 최근: ${project.lastDate}`}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택 체크 */}
|
||||||
|
{selectedProject?.idx === project.idx && selectedProject?.name === project.name && (
|
||||||
|
<Check className="w-5 h-5 text-primary-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="p-4 border-t border-white/10 flex items-center justify-between shrink-0">
|
||||||
|
<span className="text-sm text-white/50">
|
||||||
|
{projects.length}건
|
||||||
|
{selectedProject && ` | 선택: ${selectedProject.name}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedProject}
|
||||||
|
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X, Search, RefreshCw, ChevronLeft, ChevronRight, Check, AlertTriangle } from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { KuntaeErrorCheckResult, KuntaeErrorCheckResponse } from '@/types';
|
||||||
|
|
||||||
|
interface KuntaeErrorCheckDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜를 2자리 연도로 포맷팅 (2025-01-15 -> 25-01-15)
|
||||||
|
const formatDateShort = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const parts = dateStr.split('-');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return `${parts[0].slice(-2)}-${parts[1]}-${parts[2]}`;
|
||||||
|
}
|
||||||
|
return dateStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KuntaeErrorCheckDialog({ isOpen, onClose }: KuntaeErrorCheckDialogProps) {
|
||||||
|
// 날짜 상태
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
// 검사 결과
|
||||||
|
const [okList, setOkList] = useState<KuntaeErrorCheckResult[]>([]);
|
||||||
|
const [ngList, setNgList] = useState<KuntaeErrorCheckResult[]>([]);
|
||||||
|
|
||||||
|
// 선택된 오류 항목
|
||||||
|
const [selectedErrors, setSelectedErrors] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [isFixing, setIsFixing] = useState(false);
|
||||||
|
const [currentDate, setCurrentDate] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
// 이번달로 초기화
|
||||||
|
const setThisMonth = useCallback(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
setStartDate(firstDay.toISOString().split('T')[0]);
|
||||||
|
setEndDate(lastDay.toISOString().split('T')[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 이전달
|
||||||
|
const setPrevMonth = useCallback(() => {
|
||||||
|
const current = startDate ? new Date(startDate) : new Date();
|
||||||
|
const firstDay = new Date(current.getFullYear(), current.getMonth() - 1, 1);
|
||||||
|
const lastDay = new Date(current.getFullYear(), current.getMonth(), 0);
|
||||||
|
setStartDate(firstDay.toISOString().split('T')[0]);
|
||||||
|
setEndDate(lastDay.toISOString().split('T')[0]);
|
||||||
|
}, [startDate]);
|
||||||
|
|
||||||
|
// 다음달
|
||||||
|
const setNextMonth = useCallback(() => {
|
||||||
|
const current = startDate ? new Date(startDate) : new Date();
|
||||||
|
const firstDay = new Date(current.getFullYear(), current.getMonth() + 1, 1);
|
||||||
|
const lastDay = new Date(current.getFullYear(), current.getMonth() + 2, 0);
|
||||||
|
setStartDate(firstDay.toISOString().split('T')[0]);
|
||||||
|
setEndDate(lastDay.toISOString().split('T')[0]);
|
||||||
|
}, [startDate]);
|
||||||
|
|
||||||
|
// 다이얼로그 열릴 때 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setThisMonth();
|
||||||
|
setOkList([]);
|
||||||
|
setNgList([]);
|
||||||
|
setSelectedErrors(new Set());
|
||||||
|
setMessage('');
|
||||||
|
setCurrentDate('');
|
||||||
|
}
|
||||||
|
}, [isOpen, setThisMonth]);
|
||||||
|
|
||||||
|
// ESC 키 핸들러
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen && !isChecking && !isFixing) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, isChecking, isFixing, onClose]);
|
||||||
|
|
||||||
|
// 검사 실행
|
||||||
|
const handleCheck = async () => {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
setMessage('검사 기간을 설정해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
setOkList([]);
|
||||||
|
setNgList([]);
|
||||||
|
setSelectedErrors(new Set());
|
||||||
|
setMessage('검사 중...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await comms.kuntaeErrorCheck(startDate, endDate) as KuntaeErrorCheckResponse;
|
||||||
|
|
||||||
|
if (result.Success) {
|
||||||
|
setOkList(result.OkList || []);
|
||||||
|
setNgList(result.NgList || []);
|
||||||
|
|
||||||
|
// 마감되지 않은 오류 항목 자동 선택
|
||||||
|
const autoSelect = new Set<string>();
|
||||||
|
(result.NgList || []).forEach(item => {
|
||||||
|
if (!item.IsMagam) {
|
||||||
|
autoSelect.add(item.Date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSelectedErrors(autoSelect);
|
||||||
|
|
||||||
|
setMessage(`검사 완료: 정상 ${result.OkList?.length || 0}건, 오류 ${result.NgList?.length || 0}건`);
|
||||||
|
} else {
|
||||||
|
setMessage(result.Message || '검사 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage('검사 중 오류가 발생했습니다.');
|
||||||
|
console.error('Error checking:', error);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
setCurrentDate('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 오류 수정
|
||||||
|
const handleFix = async () => {
|
||||||
|
if (selectedErrors.size === 0) {
|
||||||
|
setMessage('정정할 자료가 선택되지 않았습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('선택한 항목을 재생성 할까요?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFixing(true);
|
||||||
|
setMessage('수정 중...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dates = Array.from(selectedErrors);
|
||||||
|
const result = await comms.kuntaeFixErrors(dates);
|
||||||
|
|
||||||
|
if (result.Success) {
|
||||||
|
setMessage('수정이 완료되었습니다. 다시 검사를 실행해주세요.');
|
||||||
|
// 수정 후 자동으로 재검사
|
||||||
|
setTimeout(() => handleCheck(), 500);
|
||||||
|
} else {
|
||||||
|
setMessage(result.Message || '수정 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage('수정 중 오류가 발생했습니다.');
|
||||||
|
console.error('Error fixing:', error);
|
||||||
|
} finally {
|
||||||
|
setIsFixing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 체크박스 토글
|
||||||
|
const toggleError = (date: string, isMagam: boolean) => {
|
||||||
|
if (isMagam) return; // 마감된 항목은 선택 불가
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedErrors);
|
||||||
|
if (newSelected.has(date)) {
|
||||||
|
newSelected.delete(date);
|
||||||
|
} else {
|
||||||
|
newSelected.add(date);
|
||||||
|
}
|
||||||
|
setSelectedErrors(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const toggleAllErrors = () => {
|
||||||
|
const selectableItems = ngList.filter(item => !item.IsMagam);
|
||||||
|
if (selectedErrors.size === selectableItems.length) {
|
||||||
|
setSelectedErrors(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedErrors(new Set(selectableItems.map(item => item.Date)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10000]">
|
||||||
|
<div className="glass-effect-solid rounded-xl w-[900px] max-h-[90vh] flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-warning-400" />
|
||||||
|
근태 자료 오류 확인
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isChecking || isFixing}
|
||||||
|
className="text-white/60 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검사 버튼 */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={isChecking || isFixing}
|
||||||
|
className="w-full py-4 bg-primary-500 hover:bg-primary-600 disabled:bg-gray-500
|
||||||
|
text-white font-bold text-xl rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isChecking ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
검사 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="w-6 h-6" />
|
||||||
|
검사
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검사 기간 */}
|
||||||
|
<div className="px-6 py-3 border-t border-white/10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-white/70 text-sm font-medium">검사기간:</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-white/50">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={setPrevMonth}
|
||||||
|
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
이전달
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={setThisMonth}
|
||||||
|
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
이번달
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={setNextMonth}
|
||||||
|
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
다음달
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 검사 날짜 표시 */}
|
||||||
|
{currentDate && (
|
||||||
|
<div className="px-6 py-3 bg-black/30 text-center">
|
||||||
|
<span className="text-4xl font-mono text-green-400">{currentDate}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 테이블 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||||
|
{/* 정상 목록 */}
|
||||||
|
{okList.length > 0 && (
|
||||||
|
<div className="border border-white/10 rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-white/5 px-4 py-2 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<span className="text-white/70 font-medium">정상 ({okList.length}건)</span>
|
||||||
|
{message && (
|
||||||
|
<span className="text-white/60 text-sm">{message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead className="bg-white/5 sticky top-0">
|
||||||
|
<tr className="text-white/60">
|
||||||
|
<th className="px-2 py-2 text-left w-20 border-r border-white/10">일자</th>
|
||||||
|
<th className="px-3 py-2 text-left w-24 border-r border-white/10">구분</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(일)</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(시간)</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(일)</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(시간)</th>
|
||||||
|
<th className="px-3 py-2 text-left">비고</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{okList.map((item) => (
|
||||||
|
<tr key={item.Date} className="text-white/80 hover:bg-white/5">
|
||||||
|
<td className="px-2 py-2 w-20 border-r border-white/10">{formatDateShort(item.Date)}</td>
|
||||||
|
<td className="px-3 py-2 border-r border-white/10">{item.Gubun}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurDay}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurTime}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseDay}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseTime}</td>
|
||||||
|
<td className="px-3 py-2">{item.CateError}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 오류 목록 */}
|
||||||
|
{ngList.length > 0 && (
|
||||||
|
<div className="border border-danger-500/50 rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-danger-500/10 px-4 py-2 border-b border-danger-500/30 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-danger-400 font-medium">
|
||||||
|
불량 - 불일치 데이터가 있는 날짜입니다 ({ngList.length}건)
|
||||||
|
</span>
|
||||||
|
{message && okList.length === 0 && (
|
||||||
|
<span className="text-white/60 text-sm">{message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleAllErrors}
|
||||||
|
className="text-xs text-white/60 hover:text-white"
|
||||||
|
>
|
||||||
|
{selectedErrors.size === ngList.filter(i => !i.IsMagam).length ? '전체 해제' : '전체 선택'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead className="bg-white/5 sticky top-0">
|
||||||
|
<tr className="text-white/60">
|
||||||
|
<th className="px-2 py-2 w-10 border-r border-white/10">
|
||||||
|
<Check className="w-4 h-4 mx-auto" />
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-2 text-left w-20 border-r border-white/10">일자</th>
|
||||||
|
<th className="px-3 py-2 text-left w-24 border-r border-white/10">구분</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(일)</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">발생(시간)</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(일)</th>
|
||||||
|
<th className="px-2 py-2 text-center w-16 border-r border-white/10">사용(시간)</th>
|
||||||
|
<th className="px-3 py-2 text-left">비고</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{ngList.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.Date}
|
||||||
|
className={`hover:bg-white/5 cursor-pointer ${
|
||||||
|
item.IsMagam ? 'text-blue-400' : 'text-danger-400'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleError(item.Date, item.IsMagam)}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedErrors.has(item.Date)}
|
||||||
|
onChange={() => toggleError(item.Date, item.IsMagam)}
|
||||||
|
disabled={item.IsMagam}
|
||||||
|
className="w-4 h-4 rounded accent-primary-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 w-20 border-r border-white/10">{formatDateShort(item.Date)}</td>
|
||||||
|
<td className="px-3 py-2 border-r border-white/10">{item.Gubun}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurDay}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.OccurTime}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseDay}</td>
|
||||||
|
<td className="px-2 py-2 text-center border-r border-white/10">{item.UseTime}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{item.CateError}
|
||||||
|
{item.IsMagam && <span className="ml-2 text-xs">(마감됨)</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{okList.length === 0 && ngList.length === 0 && !isChecking && (
|
||||||
|
<div className="text-center py-12 text-white/50">
|
||||||
|
검사 버튼을 클릭하여 근태 자료를 검사하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={handleFix}
|
||||||
|
disabled={isChecking || isFixing || selectedErrors.size === 0}
|
||||||
|
className="w-full py-3 bg-warning-500 hover:bg-warning-600 disabled:bg-gray-500
|
||||||
|
text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isFixing ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
|
수정 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
오류 수정 ({selectedErrors.size}건)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,13 +17,19 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
Mail,
|
Mail,
|
||||||
Shield,
|
Shield,
|
||||||
|
List,
|
||||||
|
AlertTriangle,
|
||||||
|
Star,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
||||||
|
import { UserGroupDialog } from '@/components/user/UserGroupDialog';
|
||||||
|
import { KuntaeErrorCheckDialog } from '@/components/kuntae/KuntaeErrorCheckDialog';
|
||||||
|
import { FavoriteDialog } from '@/components/favorite/FavoriteDialog';
|
||||||
import { AmkorLogo } from './AmkorLogo';
|
import { AmkorLogo } from './AmkorLogo';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
isConnected: boolean;
|
isConnected?: boolean; // deprecated, no longer used
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -54,12 +60,32 @@ interface DropdownMenuConfig {
|
|||||||
items: MenuItem[];
|
items: MenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 메뉴 항목
|
// 좌측 메뉴 항목
|
||||||
const navItems: NavItem[] = [
|
const leftNavItems: NavItem[] = [
|
||||||
{ path: '/jobreport', icon: FileText, label: '업무일지' },
|
{ path: '/jobreport', icon: FileText, label: '업무일지' },
|
||||||
{ path: '/project', icon: FolderKanban, label: '프로젝트' },
|
{ path: '/project', icon: FolderKanban, label: '프로젝트' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 좌측 드롭다운 메뉴 (근태)
|
||||||
|
const leftDropdownMenus: DropdownMenuConfig[] = [
|
||||||
|
{
|
||||||
|
label: '근태',
|
||||||
|
icon: ClockIcon,
|
||||||
|
items: [
|
||||||
|
{ type: 'link', path: '/kuntae', icon: List, label: '목록' },
|
||||||
|
{ type: 'action', icon: AlertTriangle, label: '오류검사', action: 'kuntaeErrorCheck' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 좌측 단독 액션 버튼 (즐겨찾기)
|
||||||
|
const leftActionItems: NavItem[] = [
|
||||||
|
{ icon: Star, label: '즐겨찾기', action: 'favorite' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 우측 메뉴 항목
|
||||||
|
const rightNavItems: NavItem[] = [
|
||||||
{ path: '/todo', icon: CheckSquare, label: '할일' },
|
{ path: '/todo', icon: CheckSquare, label: '할일' },
|
||||||
{ path: '/kuntae', icon: ClockIcon, label: '근태' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드롭다운 메뉴 (2단계 지원)
|
// 드롭다운 메뉴 (2단계 지원)
|
||||||
@@ -80,12 +106,13 @@ const dropdownMenus: DropdownMenuConfig[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ icon: User, label: '정보', action: 'userInfo' },
|
{ icon: User, label: '정보', action: 'userInfo' },
|
||||||
{ path: '/user/list', icon: Users, label: '목록' },
|
{ path: '/user/list', icon: Users, label: '목록' },
|
||||||
|
{ path: '/user/auth', icon: Shield, label: '권한' },
|
||||||
|
{ icon: Users, label: '그룹정보', action: 'userGroup' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
||||||
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
||||||
{ type: 'link', path: '/user-group', icon: Shield, label: '그룹정보' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -162,6 +189,21 @@ function DropdownNavMenu({
|
|||||||
<item.icon className="w-4 h-4" />
|
<item.icon className="w-4 h-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
) : item.type === 'action' ? (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (item.action) {
|
||||||
|
onAction?.(item.action);
|
||||||
|
}
|
||||||
|
onItemClick?.();
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 text-sm transition-colors text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -185,7 +227,7 @@ function DropdownNavMenu({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeSubmenu === item.label && item.submenu && (
|
{activeSubmenu === item.label && item.submenu && (
|
||||||
<div className="absolute left-full top-0 ml-1 min-w-[120px] glass-effect-solid rounded-lg py-1 z-[10000]">
|
<div className="absolute right-full top-0 mr-1 min-w-[120px] glass-effect-solid rounded-lg py-1 z-[10000]">
|
||||||
{item.submenu.items.map((subItem) => (
|
{item.submenu.items.map((subItem) => (
|
||||||
subItem.path ? (
|
subItem.path ? (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -279,6 +321,20 @@ function MobileDropdownMenu({
|
|||||||
<item.icon className="w-4 h-4" />
|
<item.icon className="w-4 h-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
) : item.type === 'action' ? (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.action) {
|
||||||
|
onAction?.(item.action);
|
||||||
|
}
|
||||||
|
onItemClick?.();
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200 text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div key={item.label}>
|
<div key={item.label}>
|
||||||
<button
|
<button
|
||||||
@@ -334,14 +390,23 @@ function MobileDropdownMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ isConnected }: HeaderProps) {
|
export function Header(_props: HeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [showUserInfoDialog, setShowUserInfoDialog] = useState(false);
|
const [showUserInfoDialog, setShowUserInfoDialog] = useState(false);
|
||||||
|
const [showUserGroupDialog, setShowUserGroupDialog] = useState(false);
|
||||||
|
const [showKuntaeErrorCheckDialog, setShowKuntaeErrorCheckDialog] = useState(false);
|
||||||
|
const [showFavoriteDialog, setShowFavoriteDialog] = useState(false);
|
||||||
|
|
||||||
const handleAction = (action: string) => {
|
const handleAction = (action: string) => {
|
||||||
if (action === 'userInfo') {
|
if (action === 'userInfo') {
|
||||||
setShowUserInfoDialog(true);
|
setShowUserInfoDialog(true);
|
||||||
|
} else if (action === 'userGroup') {
|
||||||
|
setShowUserGroupDialog(true);
|
||||||
|
} else if (action === 'kuntaeErrorCheck') {
|
||||||
|
setShowKuntaeErrorCheckDialog(true);
|
||||||
|
} else if (action === 'favorite') {
|
||||||
|
setShowFavoriteDialog(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,15 +431,54 @@ export function Header({ isConnected }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation - Left */}
|
||||||
<nav className="hidden lg:flex items-center space-x-1">
|
<nav className="hidden lg:flex items-center space-x-1">
|
||||||
{/* 드롭다운 메뉴들 */}
|
{/* 좌측 일반 메뉴들 */}
|
||||||
|
{leftNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path!}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium',
|
||||||
|
isActive
|
||||||
|
? 'bg-white/20 text-white shadow-lg'
|
||||||
|
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 좌측 드롭다운 메뉴들 (근태) */}
|
||||||
|
{leftDropdownMenus.map((menu) => (
|
||||||
|
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 좌측 액션 버튼들 (즐겨찾기) */}
|
||||||
|
{leftActionItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => item.action && handleAction(item.action)}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Desktop Navigation - Right */}
|
||||||
|
<nav className="hidden lg:flex items-center space-x-1">
|
||||||
|
{/* 드롭다운 메뉴들 (공용정보) */}
|
||||||
{dropdownMenus.map((menu) => (
|
{dropdownMenus.map((menu) => (
|
||||||
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 일반 메뉴들 */}
|
{/* 우측 메뉴들 (할일) */}
|
||||||
{navItems.map((item) => (
|
{rightNavItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path!}
|
to={item.path!}
|
||||||
@@ -392,21 +496,61 @@ export function Header({ isConnected }: HeaderProps) {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right Section: Connection Status (Icon only) */}
|
|
||||||
<div
|
|
||||||
className={`w-2.5 h-2.5 rounded-full ${
|
|
||||||
isConnected ? 'bg-success-400 animate-pulse' : 'bg-danger-400'
|
|
||||||
}`}
|
|
||||||
title={isConnected ? '연결됨' : '연결 끊김'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Navigation Dropdown */}
|
{/* Mobile Navigation Dropdown */}
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<div className="lg:hidden border-t border-white/10">
|
<div className="lg:hidden border-t border-white/10">
|
||||||
<nav className="px-4 py-2 space-y-1">
|
<nav className="px-4 py-2 space-y-1">
|
||||||
{/* 드롭다운 메뉴들 */}
|
{/* 좌측 일반 메뉴들 */}
|
||||||
|
{leftNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path!}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
'flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? 'bg-white/20 text-white'
|
||||||
|
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 좌측 드롭다운 메뉴들 (근태) */}
|
||||||
|
{leftDropdownMenus.map((menu) => (
|
||||||
|
<MobileDropdownMenu
|
||||||
|
key={menu.label}
|
||||||
|
menu={menu}
|
||||||
|
onItemClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 좌측 액션 버튼들 (즐겨찾기) */}
|
||||||
|
{leftActionItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.action) handleAction(item.action);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200 text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="border-t border-white/10 my-2" />
|
||||||
|
|
||||||
|
{/* 우측 드롭다운 메뉴들 (공용정보) */}
|
||||||
{dropdownMenus.map((menu) => (
|
{dropdownMenus.map((menu) => (
|
||||||
<MobileDropdownMenu
|
<MobileDropdownMenu
|
||||||
key={menu.label}
|
key={menu.label}
|
||||||
@@ -416,8 +560,8 @@ export function Header({ isConnected }: HeaderProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 일반 메뉴들 */}
|
{/* 우측 메뉴들 (할일) */}
|
||||||
{navItems.map((item) => (
|
{rightNavItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path!}
|
to={item.path!}
|
||||||
@@ -445,6 +589,24 @@ export function Header({ isConnected }: HeaderProps) {
|
|||||||
isOpen={showUserInfoDialog}
|
isOpen={showUserInfoDialog}
|
||||||
onClose={() => setShowUserInfoDialog(false)}
|
onClose={() => setShowUserInfoDialog(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* User Group Dialog */}
|
||||||
|
<UserGroupDialog
|
||||||
|
isOpen={showUserGroupDialog}
|
||||||
|
onClose={() => setShowUserGroupDialog(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Kuntae Error Check Dialog */}
|
||||||
|
<KuntaeErrorCheckDialog
|
||||||
|
isOpen={showKuntaeErrorCheckDialog}
|
||||||
|
onClose={() => setShowKuntaeErrorCheckDialog(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Favorite Dialog */}
|
||||||
|
<FavoriteDialog
|
||||||
|
isOpen={showFavoriteDialog}
|
||||||
|
onClose={() => setShowFavoriteDialog(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
488
Project/frontend/src/components/project/ProjectDetailDialog.tsx
Normal file
488
Project/frontend/src/components/project/ProjectDetailDialog.tsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
FolderOpen,
|
||||||
|
Save,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { ProjectListItem, ProjectHistory, ProjectDailyMemo } from '@/types';
|
||||||
|
|
||||||
|
interface ProjectDetailDialogProps {
|
||||||
|
project: ProjectListItem;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태별 색상 매핑
|
||||||
|
const statusColors: Record<string, { text: string; bg: string; border: string }> = {
|
||||||
|
검토: { text: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30' },
|
||||||
|
진행: { text: 'text-green-400', bg: 'bg-green-500/20', border: 'border-green-500/30' },
|
||||||
|
대기: { text: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30' },
|
||||||
|
보류: { text: 'text-orange-400', bg: 'bg-orange-500/20', border: 'border-orange-500/30' },
|
||||||
|
완료: { text: 'text-purple-400', bg: 'bg-purple-500/20', border: 'border-purple-500/30' },
|
||||||
|
'완료(보고)': { text: 'text-gray-400', bg: 'bg-gray-500/20', border: 'border-gray-500/30' },
|
||||||
|
취소: { text: 'text-red-400', bg: 'bg-red-500/20', border: 'border-red-500/30' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = ['검토', '진행', '대기', '보류', '완료', '완료(보고)', '취소'];
|
||||||
|
|
||||||
|
export function ProjectDetailDialog({ project, onClose }: ProjectDetailDialogProps) {
|
||||||
|
const [history, setHistory] = useState<ProjectHistory[]>([]);
|
||||||
|
const [dailyMemos, setDailyMemos] = useState<ProjectDailyMemo[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<'history' | 'memo' | 'complete'>('history');
|
||||||
|
|
||||||
|
// 편집 가능한 필드들
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: project.name || '',
|
||||||
|
status: project.status || '',
|
||||||
|
process: project.process || '',
|
||||||
|
part: project.part || '',
|
||||||
|
asset: project.asset || '',
|
||||||
|
model: project.model || '',
|
||||||
|
serial: project.serial || '',
|
||||||
|
orderno: project.orderno || '',
|
||||||
|
userManager: project.userManager || '',
|
||||||
|
usermain: project.usermain || '',
|
||||||
|
usersub: project.usersub || '',
|
||||||
|
userhw2: project.userhw2 || '',
|
||||||
|
ReqLine: project.ReqLine || '',
|
||||||
|
reqstaff: project.reqstaff || '',
|
||||||
|
ReqSite: project.ReqSite || '',
|
||||||
|
ReqPlant: project.ReqPlant || '',
|
||||||
|
ReqPackage: project.ReqPackage || '',
|
||||||
|
remark_req: project.remark_req || '',
|
||||||
|
sdate: project.sdate?.substring(0, 10) || '',
|
||||||
|
ddate: project.ddate?.substring(0, 10) || '',
|
||||||
|
edate: project.edate?.substring(0, 10) || '',
|
||||||
|
odate: project.odate?.substring(0, 10) || '',
|
||||||
|
costo: project.costo?.toString() || '',
|
||||||
|
costn: project.costn?.toString() || '',
|
||||||
|
cnt: project.cnt?.toString() || '',
|
||||||
|
path: project.path || '',
|
||||||
|
memo: project.memo || '',
|
||||||
|
progress: project.progress?.toString() || '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDetails();
|
||||||
|
}, [project.idx]);
|
||||||
|
|
||||||
|
const loadDetails = async () => {
|
||||||
|
try {
|
||||||
|
const [historyRes, memoRes] = await Promise.all([
|
||||||
|
comms.getProjectHistory(project.idx),
|
||||||
|
comms.getProjectDailyMemo(project.idx),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (historyRes.Success && historyRes.Data) {
|
||||||
|
setHistory(historyRes.Data as ProjectHistory[]);
|
||||||
|
}
|
||||||
|
if (memoRes.Success && memoRes.Data) {
|
||||||
|
setDailyMemos(memoRes.Data as ProjectDailyMemo[]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('상세 정보 로드 오류:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return dateStr.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openJasmin = (jasminId?: number) => {
|
||||||
|
if (jasminId && jasminId > 0) {
|
||||||
|
window.open(`https://scwa.amkor.co.kr/jasmine/view/${jasminId}`, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = statusColors[formData.status] || { text: 'text-white', bg: 'bg-white/10', border: 'border-white/20' };
|
||||||
|
|
||||||
|
// 입력 필드 스타일
|
||||||
|
const inputClass = "w-full px-2 py-1.5 text-sm bg-white/5 border border-white/20 rounded text-white focus:outline-none focus:border-primary-500";
|
||||||
|
const labelClass = "text-xs text-white/60 mb-1 block";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-gray-900/95 border border-white/10 rounded-2xl shadow-2xl w-[1200px] h-[90vh] flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => handleChange('status', e.target.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border ${statusColor.bg} ${statusColor.text} ${statusColor.border}`}
|
||||||
|
>
|
||||||
|
{statusOptions.map(opt => (
|
||||||
|
<option key={opt} value={opt} className="bg-gray-800 text-white">{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{project.name}</h2>
|
||||||
|
<p className="text-sm text-white/50">IDX: {project.idx} | PNO: {project.pno || '-'} | Order: {project.orderno || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
{project.path && (
|
||||||
|
<button className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 text-white/80 rounded-lg transition-colors text-sm">
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
폴더
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{project.jasmin && project.jasmin > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => openJasmin(project.jasmin)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 text-white/80 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Jasmin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors ml-2">
|
||||||
|
<X className="w-5 h-5 text-white/70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 - 스크롤 가능 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* 왼쪽 영역 */}
|
||||||
|
<div className="w-[400px] shrink-0 space-y-4">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">기본 정보</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>프로젝트명</label>
|
||||||
|
<input type="text" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>공정</label>
|
||||||
|
<input type="text" value={formData.process} onChange={(e) => handleChange('process', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>파트</label>
|
||||||
|
<input type="text" value={formData.part} onChange={(e) => handleChange('part', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Asset</label>
|
||||||
|
<input type="text" value={formData.asset} onChange={(e) => handleChange('asset', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Order#</label>
|
||||||
|
<input type="text" value={formData.orderno} onChange={(e) => handleChange('orderno', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Model#</label>
|
||||||
|
<input type="text" value={formData.model} onChange={(e) => handleChange('model', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Serial#</label>
|
||||||
|
<input type="text" value={formData.serial} onChange={(e) => handleChange('serial', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 담당자 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">담당자</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Champion</label>
|
||||||
|
<input type="text" value={formData.userManager} onChange={(e) => handleChange('userManager', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Design</label>
|
||||||
|
<input type="text" value={formData.usermain} onChange={(e) => handleChange('usermain', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>S/W</label>
|
||||||
|
<input type="text" value={formData.usersub} onChange={(e) => handleChange('usersub', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>ePanel</label>
|
||||||
|
<input type="text" value={formData.userhw2} onChange={(e) => handleChange('userhw2', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요청 정보 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">요청 정보</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Line</label>
|
||||||
|
<input type="text" value={formData.ReqLine} onChange={(e) => handleChange('ReqLine', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>요청자</label>
|
||||||
|
<input type="text" value={formData.reqstaff} onChange={(e) => handleChange('reqstaff', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Site</label>
|
||||||
|
<input type="text" value={formData.ReqSite} onChange={(e) => handleChange('ReqSite', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Plant</label>
|
||||||
|
<input type="text" value={formData.ReqPlant} onChange={(e) => handleChange('ReqPlant', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Package</label>
|
||||||
|
<input type="text" value={formData.ReqPackage} onChange={(e) => handleChange('ReqPackage', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>요청내용</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.remark_req}
|
||||||
|
onChange={(e) => handleChange('remark_req', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className={`${inputClass} resize-none`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙 영역 */}
|
||||||
|
<div className="w-[280px] shrink-0 space-y-4">
|
||||||
|
{/* 일정 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">일정</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>시작일</label>
|
||||||
|
<input type="date" value={formData.sdate} onChange={(e) => handleChange('sdate', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>만료일</label>
|
||||||
|
<input type="date" value={formData.ddate} onChange={(e) => handleChange('ddate', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>완료일</label>
|
||||||
|
<input type="date" value={formData.edate} onChange={(e) => handleChange('edate', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>출고일</label>
|
||||||
|
<input type="date" value={formData.odate} onChange={(e) => handleChange('odate', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">진행률</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={formData.progress}
|
||||||
|
onChange={(e) => handleChange('progress', e.target.value)}
|
||||||
|
className="flex-1 h-2 bg-white/10 rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={formData.progress}
|
||||||
|
onChange={(e) => handleChange('progress', e.target.value)}
|
||||||
|
className="w-16 px-2 py-1.5 text-sm bg-white/5 border border-white/20 rounded text-white text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-white/60">%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-3 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-500 transition-all"
|
||||||
|
style={{ width: `${formData.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비용 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">비용</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>예상비용</label>
|
||||||
|
<input type="number" value={formData.costo} onChange={(e) => handleChange('costo', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>실비용</label>
|
||||||
|
<input type="number" value={formData.costn} onChange={(e) => handleChange('costn', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>수량</label>
|
||||||
|
<input type="number" value={formData.cnt} onChange={(e) => handleChange('cnt', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장경로 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-3 pb-2 border-b border-white/10">저장경로</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.path}
|
||||||
|
onChange={(e) => handleChange('path', e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="\\server\path..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 영역 - 탭 */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col glass-effect rounded-xl overflow-hidden">
|
||||||
|
{/* 탭 헤더 */}
|
||||||
|
<div className="flex border-b border-white/10 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('history')}
|
||||||
|
className={`px-4 py-3 text-sm transition-colors ${activeTab === 'history'
|
||||||
|
? 'bg-white/10 text-white border-b-2 border-primary-500'
|
||||||
|
: 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
주간업무현황 ({history.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('memo')}
|
||||||
|
className={`px-4 py-3 text-sm transition-colors ${activeTab === 'memo'
|
||||||
|
? 'bg-white/10 text-white border-b-2 border-primary-500'
|
||||||
|
: 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
일업무현황 ({dailyMemos.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('complete')}
|
||||||
|
className={`px-4 py-3 text-sm transition-colors ${activeTab === 'complete'
|
||||||
|
? 'bg-white/10 text-white border-b-2 border-primary-500'
|
||||||
|
: 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
완료정보
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex bg-white/5 text-xs text-white/60 border-b border-white/10 shrink-0">
|
||||||
|
<div className="w-24 px-3 py-2 border-r border-white/10">날짜</div>
|
||||||
|
<div className="flex-1 px-3 py-2 border-r border-white/10">내용</div>
|
||||||
|
<div className="w-20 px-3 py-2">작성자</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-white/40 text-sm">등록된 내용이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
history.map((h, idx) => (
|
||||||
|
<div key={h.idx} className={`flex text-sm border-b border-white/5 ${idx % 2 === 0 ? 'bg-white/[0.02]' : ''} hover:bg-white/5`}>
|
||||||
|
<div className="w-24 px-3 py-2 border-r border-white/10 text-white/50">{formatDate(h.pdate)}</div>
|
||||||
|
<div className="flex-1 px-3 py-2 border-r border-white/10 text-white">{h.remark}</div>
|
||||||
|
<div className="w-20 px-3 py-2 text-white/50">{h.wname}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'memo' && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex bg-white/5 text-xs text-white/60 border-b border-white/10 shrink-0">
|
||||||
|
<div className="w-24 px-3 py-2 border-r border-white/10">날짜</div>
|
||||||
|
<div className="flex-1 px-3 py-2 border-r border-white/10">내용</div>
|
||||||
|
<div className="w-20 px-3 py-2">작성자</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{dailyMemos.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-white/40 text-sm">등록된 메모가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
dailyMemos.map((m, idx) => (
|
||||||
|
<div key={m.idx} className={`flex text-sm border-b border-white/5 ${idx % 2 === 0 ? 'bg-white/[0.02]' : ''} hover:bg-white/5`}>
|
||||||
|
<div className="w-24 px-3 py-2 border-r border-white/10 text-white/50">{formatDate(m.pdate)}</div>
|
||||||
|
<div className="flex-1 px-3 py-2 border-r border-white/10 text-white">{m.remark}</div>
|
||||||
|
<div className="w-20 px-3 py-2 text-white/50">{m.wname}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'complete' && (
|
||||||
|
<div className="p-4 space-y-4 overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>배경</label>
|
||||||
|
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="프로젝트 배경..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>설명</label>
|
||||||
|
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="프로젝트 설명..." />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>개선 전</label>
|
||||||
|
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="개선 전 상태..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>개선 후</label>
|
||||||
|
<textarea rows={3} className={`${inputClass} resize-none`} placeholder="개선 후 상태..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>유형효과</label>
|
||||||
|
<textarea rows={2} className={`${inputClass} resize-none`} placeholder="유형 효과..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>무형효과</label>
|
||||||
|
<textarea rows={2} className={`${inputClass} resize-none`} placeholder="무형 효과..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-white/10 shrink-0 bg-white/5">
|
||||||
|
<span className="text-sm text-white/50">PNO: {project.pno || '-'} | Jasmin: {project.jasmin || '-'} | 진행률: {formData.progress}%</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
Project/frontend/src/components/project/index.ts
Normal file
1
Project/frontend/src/components/project/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectDetailDialog } from './ProjectDetailDialog';
|
||||||
530
Project/frontend/src/components/user/UserGroupDialog.tsx
Normal file
530
Project/frontend/src/components/user/UserGroupDialog.tsx
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { UserGroupItem, PermissionInfo } from '@/types';
|
||||||
|
|
||||||
|
interface UserGroupDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: Partial<UserGroupItem> = {
|
||||||
|
dept: '',
|
||||||
|
path_kj: '',
|
||||||
|
permission: 0,
|
||||||
|
advpurchase: false,
|
||||||
|
advkisul: false,
|
||||||
|
managerinfo: '',
|
||||||
|
devinfo: '',
|
||||||
|
usemail: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비트 연산 헬퍼 함수
|
||||||
|
const getBit = (value: number, index: number): boolean => {
|
||||||
|
return ((value >> index) & 1) === 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBit = (value: number, index: number, flag: boolean): number => {
|
||||||
|
if (flag) {
|
||||||
|
return value | (1 << index);
|
||||||
|
} else {
|
||||||
|
return value & ~(1 << index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserGroupDialog({ isOpen, onClose }: UserGroupDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [groups, setGroups] = useState<UserGroupItem[]>([]);
|
||||||
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [showPermissionModal, setShowPermissionModal] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<UserGroupItem | null>(null);
|
||||||
|
const [formData, setFormData] = useState<Partial<UserGroupItem>>(initialFormData);
|
||||||
|
const [permissionInfo, setPermissionInfo] = useState<PermissionInfo[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [groupsRes, permRes] = await Promise.all([
|
||||||
|
comms.getUserGroupList(),
|
||||||
|
comms.getPermissionInfo()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (groupsRes.Success && groupsRes.Data) {
|
||||||
|
setGroups(groupsRes.Data);
|
||||||
|
}
|
||||||
|
if (permRes.Success && permRes.Data) {
|
||||||
|
setPermissionInfo(permRes.Data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('데이터 로드 오류:', error);
|
||||||
|
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [isOpen, loadData]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const filteredItems = groups.filter(item =>
|
||||||
|
!searchKey ||
|
||||||
|
item.dept?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||||
|
item.managerinfo?.toLowerCase().includes(searchKey.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (item: UserGroupItem) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setFormData({
|
||||||
|
dept: item.dept || '',
|
||||||
|
path_kj: item.path_kj || '',
|
||||||
|
permission: item.permission || 0,
|
||||||
|
advpurchase: item.advpurchase || false,
|
||||||
|
advkisul: item.advkisul || false,
|
||||||
|
managerinfo: item.managerinfo || '',
|
||||||
|
devinfo: item.devinfo || '',
|
||||||
|
usemail: item.usemail || false,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPermissionModal = (item: UserGroupItem) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
dept: item.dept,
|
||||||
|
permission: item.permission || 0,
|
||||||
|
});
|
||||||
|
setShowPermissionModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePermissionChange = (index: number, checked: boolean) => {
|
||||||
|
const newPermission = setBit(formData.permission || 0, index, checked);
|
||||||
|
setFormData({ ...formData, permission: newPermission });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePermission = async () => {
|
||||||
|
if (!editingItem) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await comms.editUserGroup(
|
||||||
|
editingItem.dept,
|
||||||
|
editingItem.dept,
|
||||||
|
editingItem.path_kj || '',
|
||||||
|
formData.permission || 0,
|
||||||
|
editingItem.advpurchase || false,
|
||||||
|
editingItem.advkisul || false,
|
||||||
|
editingItem.managerinfo || '',
|
||||||
|
editingItem.devinfo || '',
|
||||||
|
editingItem.usemail || false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.Success) {
|
||||||
|
setShowPermissionModal(false);
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
alert(response.Message || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 오류:', error);
|
||||||
|
alert('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.dept?.trim()) {
|
||||||
|
alert('부서명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingItem) {
|
||||||
|
response = await comms.editUserGroup(
|
||||||
|
editingItem.dept,
|
||||||
|
formData.dept || '',
|
||||||
|
formData.path_kj || '',
|
||||||
|
formData.permission || 0,
|
||||||
|
formData.advpurchase || false,
|
||||||
|
formData.advkisul || false,
|
||||||
|
formData.managerinfo || '',
|
||||||
|
formData.devinfo || '',
|
||||||
|
formData.usemail || false
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response = await comms.addUserGroup(
|
||||||
|
formData.dept || '',
|
||||||
|
formData.path_kj || '',
|
||||||
|
formData.permission || 0,
|
||||||
|
formData.advpurchase || false,
|
||||||
|
formData.advkisul || false,
|
||||||
|
formData.managerinfo || '',
|
||||||
|
formData.devinfo || '',
|
||||||
|
formData.usemail || false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Success) {
|
||||||
|
setShowModal(false);
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
alert(response.Message || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 오류:', error);
|
||||||
|
alert('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (item: UserGroupItem) => {
|
||||||
|
if (!confirm(`"${item.dept}" 그룹을 삭제하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await comms.deleteUserGroup(item.dept);
|
||||||
|
if (response.Success) {
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
alert(response.Message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('삭제 오류:', error);
|
||||||
|
alert('삭제 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionCount = (permission: number): number => {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
if (getBit(permission, i)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-gray-900/95 border border-white/10 rounded-2xl shadow-2xl w-[1000px] max-h-[85vh] flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
|
<Users className="w-5 h-5 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">그룹정보</h2>
|
||||||
|
<p className="text-white/50 text-sm">부서/그룹 및 권한 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
placeholder="부서명 검색..."
|
||||||
|
className="pl-10 pr-4 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 w-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openAddModal}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>새 그룹</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors ml-2">
|
||||||
|
<X className="w-5 h-5 text-white/70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-white/50">
|
||||||
|
<Users className="w-12 h-12 mb-4 opacity-50" />
|
||||||
|
<p>등록된 그룹이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-white/5 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">부서명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">경로</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-20">권한</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">구매</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">기술</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-16">메일</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70">관리자정보</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 w-24">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<tr key={`${item.dept}-${index}`} className="hover:bg-white/5 transition-colors">
|
||||||
|
<td className="px-4 py-2 text-white font-medium">{item.dept}</td>
|
||||||
|
<td className="px-4 py-2 text-white/70 text-xs">{item.path_kj || '-'}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => openPermissionModal(item)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
<span>{getPermissionCount(item.permission || 0)}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{item.advpurchase && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{item.advkisul && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{item.usemail && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-white/70 text-xs truncate max-w-40">{item.managerinfo || '-'}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(item)}
|
||||||
|
className="p-1.5 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item)}
|
||||||
|
className="p-1.5 hover:bg-danger-500/20 rounded text-white/70 hover:text-danger-400 transition-colors"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-white/10 shrink-0 bg-white/5">
|
||||||
|
<span className="text-sm text-white/50">{filteredItems.length}개 그룹</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 편집 모달 */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="bg-gray-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{editingItem ? '그룹 수정' : '새 그룹'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/70 text-sm mb-1">부서명 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.dept || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, dept: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/70 text-sm mb-1">경로 (path_kj)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.path_kj || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, path_kj: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<label className="flex items-center space-x-2 text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.advpurchase || false}
|
||||||
|
onChange={(e) => setFormData({ ...formData, advpurchase: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>구매고급</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2 text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.advkisul || false}
|
||||||
|
onChange={(e) => setFormData({ ...formData, advkisul: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>기술고급</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2 text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.usemail || false}
|
||||||
|
onChange={(e) => setFormData({ ...formData, usemail: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>메일 사용</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/70 text-sm mb-1">관리자 정보</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.managerinfo || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, managerinfo: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/70 text-sm mb-1">개발자 정보</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.devinfo || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, devinfo: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
<span>저장</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 권한 설정 모달 */}
|
||||||
|
{showPermissionModal && editingItem && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="bg-gray-800 rounded-2xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">권한 설정</h2>
|
||||||
|
<p className="text-white/60 text-sm">{editingItem.dept}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPermissionModal(false)}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{permissionInfo.map((perm) => (
|
||||||
|
<label
|
||||||
|
key={perm.index}
|
||||||
|
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-white/5 cursor-pointer"
|
||||||
|
title={perm.description}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={getBit(formData.permission || 0, perm.index)}
|
||||||
|
onChange={(e) => handlePermissionChange(perm.index, e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-white/80 text-sm">{perm.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPermissionModal(false)}
|
||||||
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSavePermission}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
<span>저장</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,17 @@ function PasswordDialog({ isOpen, onClose, onConfirm }: PasswordDialogProps) {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!newPassword) {
|
if (!newPassword) {
|
||||||
setError('새 비밀번호를 입력하세요.');
|
setError('새 비밀번호를 입력하세요.');
|
||||||
@@ -141,6 +152,17 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
|||||||
}
|
}
|
||||||
}, [isOpen, userId]);
|
}, [isOpen, userId]);
|
||||||
|
|
||||||
|
// ESC 키로 닫기 (비밀번호 다이얼로그가 열려있지 않을 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen && !showPasswordDialog) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose, showPasswordDialog]);
|
||||||
|
|
||||||
const loadUserInfo = async () => {
|
const loadUserInfo = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|||||||
212
Project/frontend/src/components/user/UserSearchDialog.tsx
Normal file
212
Project/frontend/src/components/user/UserSearchDialog.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Search, X, Users, Check } from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { GroupUser } from '@/types';
|
||||||
|
|
||||||
|
interface UserSearchDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (user: GroupUser) => void;
|
||||||
|
title?: string;
|
||||||
|
excludeUsers?: string[]; // 제외할 사용자 ID 목록
|
||||||
|
initialSearchKey?: string; // 초기 검색어
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSearchDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
title = '사용자 검색',
|
||||||
|
excludeUsers = [],
|
||||||
|
initialSearchKey = '',
|
||||||
|
}: UserSearchDialogProps) {
|
||||||
|
const [users, setUsers] = useState<GroupUser[]>([]);
|
||||||
|
const [filteredUsers, setFilteredUsers] = useState<GroupUser[]>([]);
|
||||||
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<GroupUser | null>(null);
|
||||||
|
|
||||||
|
// 사용자 목록 로드
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await comms.getUserList('%');
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
// 제외 목록에 없고, 계정 사용 중이고, 퇴사하지 않은 사용자만 표시
|
||||||
|
const filtered = result.filter(
|
||||||
|
(u: GroupUser) =>
|
||||||
|
u.useUserState &&
|
||||||
|
!excludeUsers.includes(u.id) &&
|
||||||
|
!u.outdate // 퇴사일이 없는 사용자만 (재직 중)
|
||||||
|
);
|
||||||
|
setUsers(filtered);
|
||||||
|
setFilteredUsers(filtered);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 목록 로드 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [excludeUsers]);
|
||||||
|
|
||||||
|
// 다이얼로그 열릴 때 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadUsers();
|
||||||
|
setSearchKey(initialSearchKey); // 초기 검색어 설정
|
||||||
|
setSelectedUser(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, loadUsers, initialSearchKey]);
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchKey.trim()) {
|
||||||
|
setFilteredUsers(users);
|
||||||
|
} else {
|
||||||
|
const key = searchKey.toLowerCase();
|
||||||
|
setFilteredUsers(
|
||||||
|
users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.id.toLowerCase().includes(key) ||
|
||||||
|
(u.name || '').toLowerCase().includes(key) ||
|
||||||
|
(u.email || '').toLowerCase().includes(key)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [searchKey, users]);
|
||||||
|
|
||||||
|
// 선택 확정
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedUser) {
|
||||||
|
onSelect(selectedUser);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="glass-effect rounded-xl w-full max-w-lg max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-primary-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/60 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="p-4 border-b border-white/10 shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
placeholder="사번, 이름, 이메일로 검색..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-400 mx-auto mb-2"></div>
|
||||||
|
<p className="text-white/50">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-white/50">
|
||||||
|
{searchKey ? '검색 결과가 없습니다' : '사용자가 없습니다'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => setSelectedUser(user)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
onSelect(user);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||||
|
selectedUser?.id === user.id
|
||||||
|
? 'bg-primary-500/30 border border-primary-400/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-white/80 text-sm">{user.id}</span>
|
||||||
|
<span className="font-medium text-white">{user.name}</span>
|
||||||
|
{user.grade && (
|
||||||
|
<span className="text-xs text-white/50 bg-white/10 px-1.5 py-0.5 rounded">
|
||||||
|
{user.grade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/50 mt-0.5 truncate">
|
||||||
|
{user.email || '-'} {user.processs && `| ${user.processs}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedUser?.id === user.id && (
|
||||||
|
<Check className="w-5 h-5 text-primary-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="p-4 border-t border-white/10 flex items-center justify-between shrink-0">
|
||||||
|
<span className="text-sm text-white/50">
|
||||||
|
{filteredUsers.length}명 {selectedUser && `| 선택: ${selectedUser.id} (${selectedUser.name})`}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedUser}
|
||||||
|
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { UserInfoDialog } from './UserInfoDialog';
|
export { UserInfoDialog } from './UserInfoDialog';
|
||||||
|
export { UserSearchDialog } from './UserSearchDialog';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Plus, Package } from 'lucide-react';
|
import { Search, Plus, Package, Image, Users, TrendingDown, ShoppingCart } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { ItemInfo } from '@/types';
|
import { ItemInfo, ItemDetail, SupplierStaff, PurchaseHistoryItem } from '@/types';
|
||||||
import { ItemEditDialog } from '@/components/items';
|
import { ItemEditDialog } from '@/components/items';
|
||||||
|
|
||||||
export function ItemsPage() {
|
export function ItemsPage() {
|
||||||
@@ -15,6 +15,14 @@ export function ItemsPage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState<ItemInfo | null>(null);
|
const [selectedItem, setSelectedItem] = useState<ItemInfo | null>(null);
|
||||||
|
|
||||||
|
// 우측 패널 상태
|
||||||
|
const [selectedItemDetail, setSelectedItemDetail] = useState<ItemDetail | null>(null);
|
||||||
|
const [itemImage, setItemImage] = useState<string | null>(null);
|
||||||
|
const [supplierStaff, setSupplierStaff] = useState<SupplierStaff[]>([]);
|
||||||
|
const [incomingHistory, setIncomingHistory] = useState<PurchaseHistoryItem[]>([]);
|
||||||
|
const [orderHistory, setOrderHistory] = useState<PurchaseHistoryItem[]>([]);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCategories();
|
loadCategories();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -36,6 +44,12 @@ export function ItemsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
// 선택 초기화
|
||||||
|
setSelectedItemDetail(null);
|
||||||
|
setItemImage(null);
|
||||||
|
setSupplierStaff([]);
|
||||||
|
setIncomingHistory([]);
|
||||||
|
setOrderHistory([]);
|
||||||
try {
|
try {
|
||||||
const result = await comms.getItemList(selectedCategory, searchKey);
|
const result = await comms.getItemList(selectedCategory, searchKey);
|
||||||
setItems(result);
|
setItems(result);
|
||||||
@@ -46,6 +60,58 @@ export function ItemsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 품목 선택 시 상세 정보 로드
|
||||||
|
const loadItemDetail = async (item: ItemInfo) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
// 병렬로 상세정보, 이미지, 구매내역 로드
|
||||||
|
const [detailRes, imageRes, incomingRes, orderRes] = await Promise.all([
|
||||||
|
comms.getItemDetail(item.idx),
|
||||||
|
comms.getItemImage(item.idx),
|
||||||
|
comms.getIncomingHistory(item.idx),
|
||||||
|
comms.getOrderHistory(item.idx)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (detailRes.Success && detailRes.Data) {
|
||||||
|
setSelectedItemDetail(detailRes.Data);
|
||||||
|
|
||||||
|
// 공급처 담당자 로드 (supplyidx가 있을 때만)
|
||||||
|
if (detailRes.Data.supplyidx && detailRes.Data.supplyidx > 0) {
|
||||||
|
const staffRes = await comms.getSupplierStaff(detailRes.Data.supplyidx);
|
||||||
|
if (staffRes.Success && staffRes.Data) {
|
||||||
|
setSupplierStaff(staffRes.Data);
|
||||||
|
} else {
|
||||||
|
setSupplierStaff([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSupplierStaff([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageRes.Success && imageRes.Data) {
|
||||||
|
setItemImage(imageRes.Data);
|
||||||
|
} else {
|
||||||
|
setItemImage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingRes.Success && incomingRes.Data) {
|
||||||
|
setIncomingHistory(incomingRes.Data);
|
||||||
|
} else {
|
||||||
|
setIncomingHistory([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderRes.Success && orderRes.Data) {
|
||||||
|
setOrderHistory(orderRes.Data);
|
||||||
|
} else {
|
||||||
|
setOrderHistory([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('상세 정보 로드 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (item: ItemInfo) => {
|
const handleSave = async (item: ItemInfo) => {
|
||||||
const response = await comms.saveItem(item);
|
const response = await comms.saveItem(item);
|
||||||
if (response.Success) {
|
if (response.Success) {
|
||||||
@@ -63,6 +129,14 @@ export function ItemsPage() {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setItems(items.filter((i) => i.idx !== idx));
|
setItems(items.filter((i) => i.idx !== idx));
|
||||||
|
// 상세 패널 초기화
|
||||||
|
if (selectedItemDetail?.idx === idx) {
|
||||||
|
setSelectedItemDetail(null);
|
||||||
|
setItemImage(null);
|
||||||
|
setSupplierStaff([]);
|
||||||
|
setIncomingHistory([]);
|
||||||
|
setOrderHistory([]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert(response.Message || '삭제 실패');
|
alert(response.Message || '삭제 실패');
|
||||||
}
|
}
|
||||||
@@ -89,6 +163,11 @@ export function ItemsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = (item: ItemInfo) => {
|
const handleRowClick = (item: ItemInfo) => {
|
||||||
|
// 상세 정보 로드
|
||||||
|
loadItemDetail(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowDoubleClick = (item: ItemInfo) => {
|
||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -156,61 +235,206 @@ export function ItemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 메인 컨텐츠: 목록 + 상세 패널 */}
|
||||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
<div className="flex-1 flex gap-4 min-h-0">
|
||||||
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
{/* 품목 목록 (좌측) */}
|
||||||
<Package className="w-5 h-5 text-white/70" />
|
<div className="flex-1 glass-effect rounded-xl overflow-hidden flex flex-col">
|
||||||
<h2 className="text-lg font-semibold text-white">품목 목록</h2>
|
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
||||||
<span className="text-sm text-white/50">({filteredItems.length}건)</span>
|
<Package className="w-5 h-5 text-white/70" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">품목 목록</h2>
|
||||||
|
<span className="text-sm text-white/50">({filteredItems.length}건)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-white/5 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">분류</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-white/70">품명</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-white/70">모델</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium text-white/70 w-24">단가</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">공급처</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.idx || 'new'}
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
onDoubleClick={() => handleRowDoubleClick(item)}
|
||||||
|
className={clsx(
|
||||||
|
'hover:bg-white/10 transition-colors cursor-pointer',
|
||||||
|
item.disable && 'opacity-50',
|
||||||
|
selectedItemDetail?.idx === item.idx && 'bg-blue-600/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
|
||||||
|
<td className="px-3 py-2 text-white/70">{item.cate}</td>
|
||||||
|
<td className="px-3 py-2 text-white">{item.name}</td>
|
||||||
|
<td className="px-3 py-2 text-white/70">{item.model}</td>
|
||||||
|
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2 text-white/70">{item.supply}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredItems.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-white/50">
|
||||||
|
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
{/* 상세 패널 (우측) */}
|
||||||
{loading ? (
|
<div className="w-80 flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-center h-32">
|
{/* 이미지 */}
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
<div className="glass-effect rounded-xl p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||||
|
<Image className="w-4 h-4 text-white/70" />
|
||||||
|
<h3 className="text-sm font-medium text-white">품목 이미지</h3>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="aspect-[4/3] bg-white/5 rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
{detailLoading ? (
|
||||||
<thead className="bg-white/5 sticky top-0">
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white/50"></div>
|
||||||
<tr>
|
) : itemImage ? (
|
||||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th>
|
<img
|
||||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">분류</th>
|
src={`data:image/jpeg;base64,${itemImage}`}
|
||||||
<th className="px-3 py-2 text-left font-medium text-white/70">품명</th>
|
alt="품목 이미지"
|
||||||
<th className="px-3 py-2 text-left font-medium text-white/70">모델</th>
|
className="max-w-full max-h-full object-contain"
|
||||||
<th className="px-3 py-2 text-right font-medium text-white/70 w-24">단가</th>
|
/>
|
||||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">공급처</th>
|
) : (
|
||||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">제조사</th>
|
<span className="text-white/30 text-sm">이미지 없음</span>
|
||||||
</tr>
|
)}
|
||||||
</thead>
|
</div>
|
||||||
<tbody className="divide-y divide-white/5">
|
</div>
|
||||||
{filteredItems.map((item) => (
|
|
||||||
<tr
|
{/* 공급처 담당자 */}
|
||||||
key={item.idx || 'new'}
|
<div className="glass-effect rounded-xl p-3">
|
||||||
onClick={() => handleRowClick(item)}
|
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||||
className={clsx(
|
<Users className="w-4 h-4 text-white/70" />
|
||||||
'hover:bg-white/10 transition-colors cursor-pointer',
|
<h3 className="text-sm font-medium text-white">
|
||||||
item.disable && 'opacity-50'
|
{selectedItemDetail?.supply ? `[${selectedItemDetail.supply}] 담당자` : '공급처 담당자'}
|
||||||
)}
|
</h3>
|
||||||
>
|
</div>
|
||||||
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
|
<div className="max-h-32 overflow-auto">
|
||||||
<td className="px-3 py-2 text-white/70">{item.cate}</td>
|
{detailLoading ? (
|
||||||
<td className="px-3 py-2 text-white">{item.name}</td>
|
<div className="text-center py-2">
|
||||||
<td className="px-3 py-2 text-white/70">{item.model}</td>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div>
|
||||||
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
|
</div>
|
||||||
<td className="px-3 py-2 text-white/70">{item.supply}</td>
|
) : supplierStaff.length > 0 ? (
|
||||||
<td className="px-3 py-2 text-white/70">{item.manu}</td>
|
<table className="w-full text-xs">
|
||||||
</tr>
|
<thead className="bg-white/5">
|
||||||
))}
|
<tr>
|
||||||
{filteredItems.length === 0 && (
|
<th className="px-2 py-1 text-left text-white/60">이름</th>
|
||||||
<tr>
|
<th className="px-2 py-1 text-left text-white/60">연락처</th>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-white/50">
|
<th className="px-2 py-1 text-left text-white/60">이메일</th>
|
||||||
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
|
</tr>
|
||||||
</td>
|
</thead>
|
||||||
</tr>
|
<tbody className="divide-y divide-white/5">
|
||||||
)}
|
{supplierStaff.map((staff) => (
|
||||||
</tbody>
|
<tr key={staff.idx}>
|
||||||
</table>
|
<td className="px-2 py-1 text-white">{staff.name}</td>
|
||||||
)}
|
<td className="px-2 py-1 text-white/70">{staff.tel}</td>
|
||||||
|
<td className="px-2 py-1 text-white/70 truncate max-w-[100px]" title={staff.email}>{staff.email}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-white/30 text-xs py-2">담당자 정보 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 입고내역 */}
|
||||||
|
<div className="glass-effect rounded-xl p-3 flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||||
|
<TrendingDown className="w-4 h-4 text-green-400" />
|
||||||
|
<h3 className="text-sm font-medium text-white">최근 입고내역</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="text-center py-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
) : incomingHistory.length > 0 ? (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-white/5 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-1 py-1 text-left text-white/60">일자</th>
|
||||||
|
<th className="px-1 py-1 text-left text-white/60">요청자</th>
|
||||||
|
<th className="px-1 py-1 text-right text-white/60">수량</th>
|
||||||
|
<th className="px-1 py-1 text-right text-white/60">금액</th>
|
||||||
|
<th className="px-1 py-1 text-left text-white/60">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{incomingHistory.map((h) => (
|
||||||
|
<tr key={h.idx}>
|
||||||
|
<td className="px-1 py-1 text-white/80 whitespace-nowrap">{h.date}</td>
|
||||||
|
<td className="px-1 py-1 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td>
|
||||||
|
<td className="px-1 py-1 text-white text-right">{h.qty.toLocaleString()}</td>
|
||||||
|
<td className="px-1 py-1 text-white text-right">{h.price.toLocaleString()}</td>
|
||||||
|
<td className="px-1 py-1 text-white/70">{h.state}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-white/30 text-xs py-2">입고내역 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 발주내역 */}
|
||||||
|
<div className="glass-effect rounded-xl p-3 flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||||
|
<ShoppingCart className="w-4 h-4 text-blue-400" />
|
||||||
|
<h3 className="text-sm font-medium text-white">발주내역</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="text-center py-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
) : orderHistory.length > 0 ? (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-white/5 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-1 py-1 text-left text-white/60">일자</th>
|
||||||
|
<th className="px-1 py-1 text-left text-white/60">요청자</th>
|
||||||
|
<th className="px-1 py-1 text-right text-white/60">수량</th>
|
||||||
|
<th className="px-1 py-1 text-right text-white/60">금액</th>
|
||||||
|
<th className="px-1 py-1 text-left text-white/60">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{orderHistory.map((h) => (
|
||||||
|
<tr key={h.idx}>
|
||||||
|
<td className="px-1 py-1 text-white/80 whitespace-nowrap">{h.date}</td>
|
||||||
|
<td className="px-1 py-1 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td>
|
||||||
|
<td className="px-1 py-1 text-white text-right">{h.qty.toLocaleString()}</td>
|
||||||
|
<td className="px-1 py-1 text-white text-right">{h.price.toLocaleString()}</td>
|
||||||
|
<td className="px-1 py-1 text-white/70">{h.state}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-white/30 text-xs py-2">발주내역 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function Jobreport() {
|
|||||||
|
|
||||||
// 페이징 상태
|
// 페이징 상태
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 15;
|
const pageSize = 10;
|
||||||
|
|
||||||
// 권한 상태
|
// 권한 상태
|
||||||
const [canViewOT, setCanViewOT] = useState(false);
|
const [canViewOT, setCanViewOT] = useState(false);
|
||||||
@@ -191,6 +191,7 @@ export function Jobreport() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
||||||
projectName: data.projectName || '',
|
projectName: data.projectName || '',
|
||||||
|
pidx: data.pidx ?? null, // pidx도 복사
|
||||||
requestpart: data.requestpart || '',
|
requestpart: data.requestpart || '',
|
||||||
package: data.package || '',
|
package: data.package || '',
|
||||||
type: data.type || '',
|
type: data.type || '',
|
||||||
@@ -220,6 +221,7 @@ export function Jobreport() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
pdate: data.pdate ? data.pdate.split('T')[0] : '',
|
pdate: data.pdate ? data.pdate.split('T')[0] : '',
|
||||||
projectName: data.projectName || '',
|
projectName: data.projectName || '',
|
||||||
|
pidx: data.pidx ?? null,
|
||||||
requestpart: data.requestpart || '',
|
requestpart: data.requestpart || '',
|
||||||
package: data.package || '',
|
package: data.package || '',
|
||||||
type: data.type || '',
|
type: data.type || '',
|
||||||
@@ -228,8 +230,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,
|
||||||
jobgrp: '', // 뷰에 없는 필드
|
jobgrp: data.jobgrp || '',
|
||||||
tag: '', // 뷰에 없는 필드
|
tag: data.tag || '',
|
||||||
});
|
});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}
|
}
|
||||||
@@ -264,6 +266,7 @@ export function Jobreport() {
|
|||||||
itemIdx,
|
itemIdx,
|
||||||
formData.pdate || '',
|
formData.pdate || '',
|
||||||
formData.projectName || '',
|
formData.projectName || '',
|
||||||
|
formData.pidx,
|
||||||
formData.requestpart || '',
|
formData.requestpart || '',
|
||||||
formData.package || '',
|
formData.package || '',
|
||||||
formData.type || '',
|
formData.type || '',
|
||||||
@@ -279,6 +282,7 @@ export function Jobreport() {
|
|||||||
response = await comms.addJobReport(
|
response = await comms.addJobReport(
|
||||||
formData.pdate || '',
|
formData.pdate || '',
|
||||||
formData.projectName || '',
|
formData.projectName || '',
|
||||||
|
formData.pidx,
|
||||||
formData.requestpart || '',
|
formData.requestpart || '',
|
||||||
formData.package || '',
|
formData.package || '',
|
||||||
formData.type || '',
|
formData.type || '',
|
||||||
|
|||||||
457
Project/frontend/src/pages/Project.tsx
Normal file
457
Project/frontend/src/pages/Project.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { ProjectListItem, ProjectListResponse } from '@/types';
|
||||||
|
import { ProjectDetailDialog } from '@/components/project';
|
||||||
|
|
||||||
|
// 상태별 색상 매핑
|
||||||
|
const statusColors: Record<string, { text: string; bg: string }> = {
|
||||||
|
검토: { text: 'text-blue-400', bg: 'bg-blue-500/20' },
|
||||||
|
진행: { text: 'text-green-400', bg: 'bg-green-500/20' },
|
||||||
|
대기: { text: 'text-yellow-400', bg: 'bg-yellow-500/20' },
|
||||||
|
보류: { text: 'text-orange-400', bg: 'bg-orange-500/20' },
|
||||||
|
완료: { text: 'text-purple-400', bg: 'bg-purple-500/20' },
|
||||||
|
'완료(보고)': { text: 'text-gray-400', bg: 'bg-gray-500/20' },
|
||||||
|
취소: { text: 'text-red-400', bg: 'bg-red-500/20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Project() {
|
||||||
|
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<ProjectListItem | null>(null);
|
||||||
|
const [showDetailDialog, setShowDetailDialog] = useState(false);
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [processes, setProcesses] = useState<string[]>([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('--전체--');
|
||||||
|
const [selectedProcess, setSelectedProcess] = useState('전체');
|
||||||
|
const [userFilter, setUserFilter] = useState('');
|
||||||
|
const [currentUserName, setCurrentUserName] = useState('');
|
||||||
|
|
||||||
|
// 상태 필터 체크박스
|
||||||
|
const [statusChecks, setStatusChecks] = useState({
|
||||||
|
검토: true,
|
||||||
|
진행: true,
|
||||||
|
대기: true,
|
||||||
|
보류: true,
|
||||||
|
완료: false,
|
||||||
|
'완료(보고)': false,
|
||||||
|
취소: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
const [dateType, setDateType] = useState('0');
|
||||||
|
const [yearStart, setYearStart] = useState(new Date().getFullYear().toString());
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
const [filteredProjects, setFilteredProjects] = useState<ProjectListItem[]>([]);
|
||||||
|
|
||||||
|
// 상태별 건수
|
||||||
|
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// 페이징
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
// 연도 목록 생성
|
||||||
|
const years = Array.from({ length: new Date().getFullYear() - 2009 }, (_, i) => (2010 + i).toString());
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const d = dateStr.substring(0, 10);
|
||||||
|
if (d.length < 10) return d;
|
||||||
|
return `${d.substring(2, 4)}-${d.substring(5, 7)}-${d.substring(8, 10)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
// 현재 사용자 로드
|
||||||
|
try {
|
||||||
|
const loginStatus = await comms.checkLoginStatus();
|
||||||
|
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||||
|
const userName = (loginStatus.User as { NameK?: string }).NameK || loginStatus.User.Name || '';
|
||||||
|
setCurrentUserName(userName);
|
||||||
|
setUserFilter(userName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('로그인 정보 로드 오류:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 분류/공정 목록 로드
|
||||||
|
try {
|
||||||
|
const [catRes, procRes] = await Promise.all([
|
||||||
|
comms.getProjectCategories(),
|
||||||
|
comms.getProjectProcesses(),
|
||||||
|
]);
|
||||||
|
if (catRes.Success && catRes.Data) setCategories(catRes.Data as string[]);
|
||||||
|
if (procRes.Success && procRes.Data) setProcesses(procRes.Data as string[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('필터 목록 로드 오류:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadProjects = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const checkedStatuses = Object.entries(statusChecks)
|
||||||
|
.filter(([, checked]) => checked)
|
||||||
|
.map(([status]) => status);
|
||||||
|
|
||||||
|
const statusFilter = checkedStatuses.length === 7 ? 'all' : checkedStatuses.join(',');
|
||||||
|
|
||||||
|
const response = await comms.getProjectList(
|
||||||
|
statusFilter,
|
||||||
|
selectedCategory,
|
||||||
|
selectedProcess,
|
||||||
|
userFilter,
|
||||||
|
dateType !== '0' ? yearStart : '',
|
||||||
|
dateType !== '0' ? yearStart : '',
|
||||||
|
dateType
|
||||||
|
) as ProjectListResponse;
|
||||||
|
|
||||||
|
if (response.Success && response.Data) {
|
||||||
|
setProjects(response.Data);
|
||||||
|
setFilteredProjects(response.Data);
|
||||||
|
if (response.StatusCounts) {
|
||||||
|
setStatusCounts(response.StatusCounts as Record<string, number>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 로드 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [statusChecks, selectedCategory, selectedProcess, userFilter, dateType, yearStart]);
|
||||||
|
|
||||||
|
// 필터 변경 시 자동 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUserName) {
|
||||||
|
loadProjects();
|
||||||
|
}
|
||||||
|
}, [loadProjects, currentUserName]);
|
||||||
|
|
||||||
|
// 검색어 필터링
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchKey.trim()) {
|
||||||
|
setFilteredProjects(projects);
|
||||||
|
} else {
|
||||||
|
const key = searchKey.toLowerCase();
|
||||||
|
const filtered = projects.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name?.toLowerCase().includes(key) ||
|
||||||
|
p.userManager?.toLowerCase().includes(key) ||
|
||||||
|
p.usermain?.toLowerCase().includes(key) ||
|
||||||
|
p.orderno?.toLowerCase().includes(key) ||
|
||||||
|
p.memo?.toLowerCase().includes(key)
|
||||||
|
);
|
||||||
|
setFilteredProjects(filtered);
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchKey, projects]);
|
||||||
|
|
||||||
|
// 프로젝트 선택 시 다이얼로그 표시
|
||||||
|
const handleSelectProject = (project: ProjectListItem) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setShowDetailDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다이얼로그 닫기
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setShowDetailDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 담당자 필터 토글
|
||||||
|
const toggleUserFilter = () => {
|
||||||
|
setUserFilter(userFilter ? '' : currentUserName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 체크박스 토글
|
||||||
|
const toggleStatus = (status: string) => {
|
||||||
|
setStatusChecks((prev) => ({ ...prev, [status]: !prev[status as keyof typeof prev] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이징 계산
|
||||||
|
const totalPages = Math.ceil(filteredProjects.length / pageSize);
|
||||||
|
const paginatedProjects = filteredProjects.slice(
|
||||||
|
(currentPage - 1) * pageSize,
|
||||||
|
currentPage * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
// 자스민 링크 열기
|
||||||
|
const openJasmin = (jasminId?: number) => {
|
||||||
|
if (jasminId && jasminId > 0) {
|
||||||
|
window.open(`https://scwa.amkor.co.kr/jasmine/view/${jasminId}`, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="glass-effect rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FolderKanban className="w-6 h-6 text-primary-400" />
|
||||||
|
<h1 className="text-xl font-bold text-white">프로젝트 목록</h1>
|
||||||
|
<span className="text-white/50 text-sm">({filteredProjects.length}건)</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadProjects}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
<span>새로고침</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 상태 필터 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{Object.entries(statusChecks).map(([status, checked]) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => toggleStatus(status)}
|
||||||
|
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||||
|
checked
|
||||||
|
? `${statusColors[status]?.bg || 'bg-white/20'} ${statusColors[status]?.text || 'text-white'} font-semibold`
|
||||||
|
: 'bg-white/5 text-white/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
<span className="ml-1 text-xs">({statusCounts[status] || 0})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 필터 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={toggleUserFilter}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||||
|
userFilter ? 'bg-primary-500/20 text-primary-400' : 'bg-white/5 text-white/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>{userFilter || '전체'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat} className="bg-gray-800">
|
||||||
|
{cat}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedProcess}
|
||||||
|
onChange={(e) => setSelectedProcess(e.target.value)}
|
||||||
|
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
{processes.map((proc) => (
|
||||||
|
<option key={proc} value={proc} className="bg-gray-800">
|
||||||
|
{proc}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-white/50" />
|
||||||
|
<select
|
||||||
|
value={dateType}
|
||||||
|
onChange={(e) => setDateType(e.target.value)}
|
||||||
|
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="0" className="bg-gray-800">전체기간</option>
|
||||||
|
<option value="1" className="bg-gray-800">시작일</option>
|
||||||
|
<option value="2" className="bg-gray-800">만료일</option>
|
||||||
|
<option value="3" className="bg-gray-800">완료일</option>
|
||||||
|
<option value="4" className="bg-gray-800">출고일</option>
|
||||||
|
</select>
|
||||||
|
{dateType !== '0' && (
|
||||||
|
<select
|
||||||
|
value={yearStart}
|
||||||
|
onChange={(e) => setYearStart(e.target.value)}
|
||||||
|
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y} className="bg-gray-800">
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<Search className="w-4 h-4 text-white/50" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
placeholder="프로젝트명, 담당자..."
|
||||||
|
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="glass-effect rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-white/5 sticky top-0">
|
||||||
|
<tr className="text-white/60 text-left">
|
||||||
|
<th className="px-3 py-2 w-16">상태</th>
|
||||||
|
<th className="px-3 py-2">프로젝트명</th>
|
||||||
|
<th className="px-3 py-2 w-20">담당</th>
|
||||||
|
<th className="px-3 py-2 w-28">요청자</th>
|
||||||
|
<th className="px-3 py-2 w-20 text-center">진행률</th>
|
||||||
|
<th className="px-3 py-2 w-24">시작</th>
|
||||||
|
<th className="px-3 py-2 w-24">만료/완료</th>
|
||||||
|
<th className="px-3 py-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
||||||
|
로딩중...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedProjects.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
||||||
|
프로젝트가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedProjects.map((project) => {
|
||||||
|
const statusColor = statusColors[project.status] || { text: 'text-white', bg: 'bg-white/10' };
|
||||||
|
const isSelected = selectedProject?.idx === project.idx;
|
||||||
|
const rowBg = project.bHighlight
|
||||||
|
? 'bg-lime-500/10'
|
||||||
|
: project.bCost
|
||||||
|
? 'bg-yellow-500/10'
|
||||||
|
: project.bmajoritem
|
||||||
|
? 'bg-pink-500/10'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={project.idx}
|
||||||
|
onClick={() => handleSelectProject(project)}
|
||||||
|
className={`cursor-pointer transition-colors ${rowBg} ${
|
||||||
|
isSelected ? 'bg-primary-500/20' : 'hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-2 ${statusColor.text}`}>
|
||||||
|
<div className="truncate max-w-xs" title={project.name}>
|
||||||
|
{project.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-white/70">{project.name_champion || project.userManager}</td>
|
||||||
|
<td className="px-3 py-2 text-white/70 text-xs">
|
||||||
|
<div>{project.ReqLine}</div>
|
||||||
|
<div className="text-white/50">{project.reqstaff}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-500 transition-all"
|
||||||
|
style={{ width: `${project.progress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-white/50">{project.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-white/50">{formatDate(project.sdate)}</td>
|
||||||
|
<td className="px-3 py-2 text-white/50 text-xs">
|
||||||
|
<div>{formatDate(project.ddate)}</div>
|
||||||
|
<div className="text-white/40">{formatDate(project.edate)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{project.jasmin && project.jasmin > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openJasmin(project.jasmin);
|
||||||
|
}}
|
||||||
|
className="text-primary-400 hover:text-primary-300"
|
||||||
|
title="자스민 열기"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이징 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-white/70" />
|
||||||
|
</button>
|
||||||
|
<span className="text-white/70 text-sm">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5 text-white/70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로젝트 상세 다이얼로그 */}
|
||||||
|
{showDetailDialog && selectedProject && (
|
||||||
|
<ProjectDetailDialog
|
||||||
|
project={selectedProject}
|
||||||
|
onClose={handleCloseDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
502
Project/frontend/src/pages/UserAuth.tsx
Normal file
502
Project/frontend/src/pages/UserAuth.tsx
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Shield, Plus, Save, Trash2, Search, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { AuthItem, AuthFieldInfo, GroupUser } from '@/types';
|
||||||
|
import { UserSearchDialog } from '@/components/user';
|
||||||
|
|
||||||
|
interface AuthFormData {
|
||||||
|
idx: number;
|
||||||
|
user: string;
|
||||||
|
account: number;
|
||||||
|
purchase: number;
|
||||||
|
purchaseEB: number;
|
||||||
|
holyday: number;
|
||||||
|
project: number;
|
||||||
|
jobreport: number;
|
||||||
|
scheapp: number;
|
||||||
|
equipment: number;
|
||||||
|
otconfirm: number;
|
||||||
|
holyreq: number;
|
||||||
|
kuntae: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: AuthFormData = {
|
||||||
|
idx: 0,
|
||||||
|
user: '',
|
||||||
|
account: 0,
|
||||||
|
purchase: 0,
|
||||||
|
purchaseEB: 0,
|
||||||
|
holyday: 0,
|
||||||
|
project: 0,
|
||||||
|
jobreport: 0,
|
||||||
|
scheapp: 0,
|
||||||
|
equipment: 0,
|
||||||
|
otconfirm: 0,
|
||||||
|
holyreq: 0,
|
||||||
|
kuntae: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 필드 정보 (하드코딩 - API와 동일)
|
||||||
|
const authFields: AuthFieldInfo[] = [
|
||||||
|
{ field: 'user', label: '사용자 ID', description: '권한을 설정할 사용자 ID' },
|
||||||
|
{ field: 'account', label: '계정', description: '계정 관리 권한' },
|
||||||
|
{ field: 'purchase', label: '구매', description: '구매 관리 권한' },
|
||||||
|
{ field: 'purchaseEB', label: '구매(전자실)', description: '전자실 구매 권한' },
|
||||||
|
{ field: 'holyday', label: '출근부', description: '출근부 관리 권한' },
|
||||||
|
{ field: 'project', label: '프로젝트', description: '프로젝트 관리 권한' },
|
||||||
|
{ field: 'jobreport', label: '업무일지', description: '업무일지 관리 권한' },
|
||||||
|
{ field: 'scheapp', label: '스케쥴', description: '스케쥴 관리 권한' },
|
||||||
|
{ field: 'equipment', label: '장비목록', description: '장비 목록 관리 권한' },
|
||||||
|
{ field: 'otconfirm', label: 'OT승인', description: '초과근무 승인 권한' },
|
||||||
|
{ field: 'holyreq', label: '휴가요청', description: '휴가 요청 관리 권한' },
|
||||||
|
{ field: 'kuntae', label: '근태', description: '근태 관리 권한' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function UserAuth() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [canAccess, setCanAccess] = useState(false);
|
||||||
|
const [accessMessage, setAccessMessage] = useState('');
|
||||||
|
const [authList, setAuthList] = useState<AuthItem[]>([]);
|
||||||
|
const [filteredList, setFilteredList] = useState<AuthItem[]>([]);
|
||||||
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
const [selectedItem, setSelectedItem] = useState<AuthItem | null>(null);
|
||||||
|
const [formData, setFormData] = useState<AuthFormData>(initialFormData);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [userNameMap, setUserNameMap] = useState<Record<string, string>>({});
|
||||||
|
const [showUserSearch, setShowUserSearch] = useState(false);
|
||||||
|
|
||||||
|
// 접근 권한 확인
|
||||||
|
const checkAccess = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await comms.userAuthCanAccess();
|
||||||
|
if (response.Success) {
|
||||||
|
setCanAccess(response.CanAccess ?? false);
|
||||||
|
if (!response.CanAccess) {
|
||||||
|
setAccessMessage(response.Message || '(관리자/계정담당자) 전용 메뉴 입니다');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCanAccess(false);
|
||||||
|
setAccessMessage(response.Message || '접근 권한 확인 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('접근 권한 확인 오류:', error);
|
||||||
|
setCanAccess(false);
|
||||||
|
setAccessMessage('접근 권한 확인 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 사용자 이름 목록 로드
|
||||||
|
const loadUserNames = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await comms.getUserList('%');
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
const nameMap: Record<string, string> = {};
|
||||||
|
result.forEach((user: GroupUser) => {
|
||||||
|
nameMap[user.id] = user.name;
|
||||||
|
});
|
||||||
|
setUserNameMap(nameMap);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 이름 목록 로드 오류:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await comms.getUserAuthList();
|
||||||
|
if (response.Success && response.Data) {
|
||||||
|
setAuthList(response.Data);
|
||||||
|
setFilteredList(response.Data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('권한 목록 로드 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
await checkAccess();
|
||||||
|
await loadUserNames();
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, [checkAccess, loadUserNames, loadData]);
|
||||||
|
|
||||||
|
// 검색 필터링 (사번 또는 이름으로 검색)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchKey.trim()) {
|
||||||
|
setFilteredList(authList);
|
||||||
|
} else {
|
||||||
|
const key = searchKey.toLowerCase();
|
||||||
|
setFilteredList(authList.filter(item =>
|
||||||
|
item.user.toLowerCase().includes(key) ||
|
||||||
|
(userNameMap[item.user] || '').toLowerCase().includes(key)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, [searchKey, authList, userNameMap]);
|
||||||
|
|
||||||
|
// 항목 선택
|
||||||
|
const handleSelectItem = (item: AuthItem) => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (!confirm('변경사항이 있습니다. 저장하지 않고 다른 항목을 선택하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedItem(item);
|
||||||
|
setFormData({
|
||||||
|
idx: item.idx,
|
||||||
|
user: item.user,
|
||||||
|
account: item.account || 0,
|
||||||
|
purchase: item.purchase || 0,
|
||||||
|
purchaseEB: item.purchaseEB || 0,
|
||||||
|
holyday: item.holyday || 0,
|
||||||
|
project: item.project || 0,
|
||||||
|
jobreport: item.jobreport || 0,
|
||||||
|
scheapp: item.scheapp || 0,
|
||||||
|
equipment: item.equipment || 0,
|
||||||
|
otconfirm: item.otconfirm || 0,
|
||||||
|
holyreq: item.holyreq || 0,
|
||||||
|
kuntae: item.kuntae || 0,
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 새 항목 추가 - 사용자 검색 다이얼로그 열기
|
||||||
|
const handleAddNew = () => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (!confirm('변경사항이 있습니다. 저장하지 않고 새 항목을 추가하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowUserSearch(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 검색에서 선택 시
|
||||||
|
const handleUserSelected = async (user: GroupUser) => {
|
||||||
|
// 이미 등록된 사용자인지 확인
|
||||||
|
const existingItem = authList.find(item => item.user === user.id);
|
||||||
|
if (existingItem) {
|
||||||
|
// 이미 등록되어 있으면 해당 항목 선택
|
||||||
|
handleSelectItem(existingItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 사용자 권한 추가
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
const response = await comms.saveUserAuth(
|
||||||
|
0, // 새 항목
|
||||||
|
user.id,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 모든 권한 0으로 초기화
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.Success) {
|
||||||
|
// 목록 새로고침
|
||||||
|
await loadData();
|
||||||
|
// 새로 추가된 항목 선택
|
||||||
|
const data = response.Data as { idx?: number } | undefined;
|
||||||
|
const newIdx = data?.idx;
|
||||||
|
if (newIdx) {
|
||||||
|
// 잠시 후 새 항목 선택 (loadData가 완료된 후)
|
||||||
|
setTimeout(() => {
|
||||||
|
const newItem = authList.find(item => item.user === user.id);
|
||||||
|
if (newItem) {
|
||||||
|
handleSelectItem(newItem);
|
||||||
|
} else {
|
||||||
|
// authList가 아직 업데이트되지 않았을 수 있으므로 폼 데이터 직접 설정
|
||||||
|
setSelectedItem(null);
|
||||||
|
setFormData({
|
||||||
|
idx: newIdx,
|
||||||
|
user: user.id,
|
||||||
|
account: 0, purchase: 0, purchaseEB: 0, holyday: 0,
|
||||||
|
project: 0, jobreport: 0, scheapp: 0, equipment: 0,
|
||||||
|
otconfirm: 0, holyreq: 0, kuntae: 0,
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(response.Message || '사용자 추가에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 추가 오류:', error);
|
||||||
|
alert('사용자 추가 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 변경
|
||||||
|
const handleFieldChange = (field: keyof AuthFormData, value: string | number) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.user.trim()) {
|
||||||
|
alert('사용자 ID를 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
const response = await comms.saveUserAuth(
|
||||||
|
formData.idx,
|
||||||
|
formData.user,
|
||||||
|
formData.account,
|
||||||
|
formData.purchase,
|
||||||
|
formData.purchaseEB,
|
||||||
|
formData.holyday,
|
||||||
|
formData.project,
|
||||||
|
formData.jobreport,
|
||||||
|
formData.scheapp,
|
||||||
|
formData.equipment,
|
||||||
|
formData.otconfirm,
|
||||||
|
formData.holyreq,
|
||||||
|
formData.kuntae
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.Success) {
|
||||||
|
setHasChanges(false);
|
||||||
|
await loadData();
|
||||||
|
// 새로 추가한 경우 해당 항목 선택
|
||||||
|
const data = response.Data as { idx?: number } | undefined;
|
||||||
|
if (formData.idx === 0 && data?.idx) {
|
||||||
|
const newIdx = data.idx;
|
||||||
|
const newItem = authList.find(item => item.idx === newIdx);
|
||||||
|
if (newItem) {
|
||||||
|
setSelectedItem(newItem);
|
||||||
|
setFormData(prev => ({ ...prev, idx: newIdx }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(response.Message || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 오류:', error);
|
||||||
|
alert('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
|
||||||
|
if (!confirm(`"${selectedItem.user}" 사용자의 권한 설정을 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
const response = await comms.deleteUserAuth(selectedItem.idx);
|
||||||
|
if (response.Success) {
|
||||||
|
setSelectedItem(null);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
setHasChanges(false);
|
||||||
|
await loadData();
|
||||||
|
} else {
|
||||||
|
alert(response.Message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('삭제 오류:', error);
|
||||||
|
alert('삭제 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 접근 불가 화면
|
||||||
|
if (!canAccess && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="glass-effect rounded-2xl p-8 text-center max-w-md">
|
||||||
|
<AlertCircle className="w-16 h-16 text-danger-400 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2">접근 권한 없음</h2>
|
||||||
|
<p className="text-white/70">{accessMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6 animate-fade-in h-full flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-primary-500/20 rounded-xl">
|
||||||
|
<Shield className="w-6 h-6 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">사용자 권한</h1>
|
||||||
|
<p className="text-white/60 text-sm">사용자별 기능 접근 권한 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-5 h-5 text-white/70 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<div className="flex-1 flex gap-6 min-h-0">
|
||||||
|
{/* 좌측: 사용자 목록 */}
|
||||||
|
<div className="w-80 glass-effect rounded-2xl p-4 flex flex-col">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
placeholder="사번/이름 검색..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={handleAddNew}
|
||||||
|
className="w-full mb-4 flex items-center justify-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>새 사용자 추가</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-primary-400 mx-auto mb-2" />
|
||||||
|
<p className="text-white/50">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredList.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-white/50">
|
||||||
|
{searchKey ? '검색 결과가 없습니다' : '등록된 사용자가 없습니다'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredList.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.idx}
|
||||||
|
onClick={() => handleSelectItem(item)}
|
||||||
|
className={`w-full text-left px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
selectedItem?.idx === item.idx
|
||||||
|
? 'bg-primary-500/30 border border-primary-400/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-white">
|
||||||
|
{item.user}
|
||||||
|
{userNameMap[item.user] && (
|
||||||
|
<span className="text-white/60 font-normal ml-2">({userNameMap[item.user]})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/50 mt-1">
|
||||||
|
계정: {item.account || 0} | 구매: {item.purchase || 0} | 업무일지: {item.jobreport || 0}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 항목 수 */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-white/10 text-center text-sm text-white/50">
|
||||||
|
총 {filteredList.length}명
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 상세 편집 */}
|
||||||
|
<div className="flex-1 glass-effect rounded-2xl p-6 overflow-y-auto">
|
||||||
|
{formData.idx === 0 && !formData.user ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-white/50">
|
||||||
|
<div className="text-center">
|
||||||
|
<Shield className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>좌측에서 사용자를 선택하거나</p>
|
||||||
|
<p>새 사용자를 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 권한 설정 그리드 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-white/50 text-sm mb-4">0: 권한 없음, 1~9: 레벨 (숫자가 높을수록 높은 권한)</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{authFields.filter(f => f.field !== 'user').map(field => (
|
||||||
|
<div key={field.field} className="bg-white/5 rounded-lg p-3">
|
||||||
|
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
value={formData[field.field as keyof AuthFormData] || 0}
|
||||||
|
onChange={(e) => handleFieldChange(field.field as keyof AuthFormData, parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-center focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-white/40 mt-1 truncate" title={field.description}>
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex justify-between pt-4 border-t border-white/10">
|
||||||
|
<div>
|
||||||
|
{selectedItem && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={processing}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-danger-500 hover:bg-danger-600 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>삭제</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={processing || !hasChanges}
|
||||||
|
className="flex items-center space-x-2 px-6 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
<span>{processing ? '저장 중...' : '저장'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 검색 다이얼로그 */}
|
||||||
|
<UserSearchDialog
|
||||||
|
isOpen={showUserSearch}
|
||||||
|
onClose={() => setShowUserSearch(false)}
|
||||||
|
onSelect={handleUserSelected}
|
||||||
|
title="사용자 선택"
|
||||||
|
excludeUsers={authList.map(item => item.user)}
|
||||||
|
initialSearchKey={searchKey}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export { Dashboard } from './Dashboard';
|
|||||||
export { Todo } from './Todo';
|
export { Todo } from './Todo';
|
||||||
export { Kuntae } from './Kuntae';
|
export { Kuntae } from './Kuntae';
|
||||||
export { Jobreport } from './Jobreport';
|
export { Jobreport } from './Jobreport';
|
||||||
|
export { Project } from './Project';
|
||||||
export { PlaceholderPage } from './Placeholder';
|
export { PlaceholderPage } from './Placeholder';
|
||||||
export { Login } from './Login';
|
export { Login } from './Login';
|
||||||
export { CommonCodePage } from './CommonCode';
|
export { CommonCodePage } from './CommonCode';
|
||||||
@@ -10,3 +11,4 @@ export { UserListPage } from './UserList';
|
|||||||
export { MonthlyWorkPage } from './MonthlyWork';
|
export { MonthlyWorkPage } from './MonthlyWork';
|
||||||
export { MailFormPage } from './MailForm';
|
export { MailFormPage } from './MailForm';
|
||||||
export { UserGroupPage } from './UserGroup';
|
export { UserGroupPage } from './UserGroup';
|
||||||
|
export { default as UserAuthPage } from './UserAuth';
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export interface JobreportModel {
|
|||||||
wdate: string;
|
wdate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업무일지 타입 (vJobReportForUser 뷰)
|
// 업무일지 타입 (vJobReportForUser 뷰 + JobReport 테이블)
|
||||||
export interface JobReportItem {
|
export interface JobReportItem {
|
||||||
idx: number;
|
idx: number;
|
||||||
pidx: number;
|
pidx: number;
|
||||||
@@ -143,6 +143,8 @@ export interface JobReportItem {
|
|||||||
ww: string;
|
ww: string;
|
||||||
otpms: string; // OT PMS
|
otpms: string; // OT PMS
|
||||||
process: string;
|
process: string;
|
||||||
|
jobgrp?: string; // 업무분류 (상세 조회 시)
|
||||||
|
tag?: string; // 태그 (상세 조회 시)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업무일지 사용자 타입
|
// 업무일지 사용자 타입
|
||||||
@@ -243,12 +245,37 @@ export interface ItemInfo {
|
|||||||
unit: string;
|
unit: string;
|
||||||
price: number;
|
price: number;
|
||||||
supply: string;
|
supply: string;
|
||||||
|
supplyidx?: number;
|
||||||
manu: string;
|
manu: string;
|
||||||
storage: string;
|
storage: string;
|
||||||
disable: boolean;
|
disable: boolean;
|
||||||
memo: string;
|
memo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 품목 상세 정보 (supplyidx 포함)
|
||||||
|
export interface ItemDetail extends ItemInfo {
|
||||||
|
supplyidx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공급처 담당자 타입
|
||||||
|
export interface SupplierStaff {
|
||||||
|
idx: number;
|
||||||
|
name: string;
|
||||||
|
tel: string;
|
||||||
|
email: string;
|
||||||
|
dept: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구매내역 항목 타입 (입고/발주)
|
||||||
|
export interface PurchaseHistoryItem {
|
||||||
|
idx: number;
|
||||||
|
date: string;
|
||||||
|
request: string;
|
||||||
|
qty: number;
|
||||||
|
price: number;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 사용자 목록 관련 타입
|
// 사용자 목록 관련 타입
|
||||||
export interface GroupUser {
|
export interface GroupUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -295,13 +322,19 @@ export interface MachineBridgeInterface {
|
|||||||
// Kuntae API
|
// Kuntae API
|
||||||
Kuntae_GetList(sd: string, ed: string): Promise<string>;
|
Kuntae_GetList(sd: string, ed: string): Promise<string>;
|
||||||
Kuntae_Delete(id: number): Promise<string>;
|
Kuntae_Delete(id: number): Promise<string>;
|
||||||
|
Kuntae_ErrorCheck(sd: string, ed: string): Promise<string>;
|
||||||
|
Kuntae_FixError(pdate: string): Promise<string>;
|
||||||
|
Kuntae_FixErrors(dates: string): Promise<string>;
|
||||||
|
|
||||||
|
// Favorite API
|
||||||
|
Favorite_GetList(): Promise<string>;
|
||||||
|
|
||||||
// Jobreport API (JobReport 뷰/테이블)
|
// Jobreport API (JobReport 뷰/테이블)
|
||||||
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, 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): Promise<string>;
|
||||||
Jobreport_Edit(idx: number, pdate: string, projectName: string, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
|
Jobreport_Edit(idx: number, pdate: string, projectName: string, pidx: number, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
|
||||||
Jobreport_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>;
|
||||||
@@ -333,6 +366,13 @@ export interface MachineBridgeInterface {
|
|||||||
Items_GetList(category: string, searchKey: string): Promise<string>;
|
Items_GetList(category: string, searchKey: string): Promise<string>;
|
||||||
Items_Save(idx: number, sid: string, cate: string, name: string, model: string, scale: string, unit: string, price: number, supply: string, manu: string, storage: string, disable: boolean, memo: string): Promise<string>;
|
Items_Save(idx: number, sid: string, cate: string, name: string, model: string, scale: string, unit: string, price: number, supply: string, manu: string, storage: string, disable: boolean, memo: string): Promise<string>;
|
||||||
Items_Delete(idx: number): Promise<string>;
|
Items_Delete(idx: number): Promise<string>;
|
||||||
|
Items_GetImage(idx: number): Promise<string>;
|
||||||
|
Items_SaveImage(idx: number, base64Image: string): Promise<string>;
|
||||||
|
Items_DeleteImage(idx: number): Promise<string>;
|
||||||
|
Items_GetDetail(idx: number): Promise<string>;
|
||||||
|
Items_GetSupplierStaff(supplyIdx: number): Promise<string>;
|
||||||
|
Items_GetIncomingHistory(itemIdx: number): Promise<string>;
|
||||||
|
Items_GetOrderHistory(itemIdx: number): Promise<string>;
|
||||||
|
|
||||||
// UserList API
|
// UserList API
|
||||||
UserList_GetCurrentLevel(): Promise<string>;
|
UserList_GetCurrentLevel(): Promise<string>;
|
||||||
@@ -360,6 +400,28 @@ export interface MachineBridgeInterface {
|
|||||||
UserGroup_Edit(originalDept: string, dept: string, path_kj: string, permission: number, advpurchase: boolean, advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean): Promise<string>;
|
UserGroup_Edit(originalDept: string, dept: string, path_kj: string, permission: number, advpurchase: boolean, advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean): Promise<string>;
|
||||||
UserGroup_Delete(dept: string): Promise<string>;
|
UserGroup_Delete(dept: string): Promise<string>;
|
||||||
UserGroup_GetPermissionInfo(): Promise<string>;
|
UserGroup_GetPermissionInfo(): Promise<string>;
|
||||||
|
|
||||||
|
// UserAuth API (사용자 권한)
|
||||||
|
UserAuth_CanAccess(): Promise<string>;
|
||||||
|
UserAuth_GetList(): Promise<string>;
|
||||||
|
UserAuth_Save(idx: number, user: string, account: number, purchase: number, purchaseEB: number, holyday: number, project: number, jobreport: number, scheapp: number, equipment: number, otconfirm: number, holyreq: number, kuntae: number): Promise<string>;
|
||||||
|
UserAuth_Delete(idx: number): Promise<string>;
|
||||||
|
UserAuth_GetFields(): Promise<string>;
|
||||||
|
|
||||||
|
// 범용 권한 체크 API
|
||||||
|
CheckAuth(authType: string, requiredLevel: number): Promise<string>;
|
||||||
|
GetMyAuth(): Promise<string>;
|
||||||
|
|
||||||
|
// 프로젝트 검색 API (업무일지용)
|
||||||
|
Project_Search(keyword: string): Promise<string>;
|
||||||
|
Project_GetUserProjects(): Promise<string>;
|
||||||
|
|
||||||
|
// 프로젝트 목록 API
|
||||||
|
Project_GetCategories(): Promise<string>;
|
||||||
|
Project_GetProcesses(): Promise<string>;
|
||||||
|
Project_GetList(statusFilter: string, category: string, process: string, userFilter: string, yearStart: string, yearEnd: string, dateType: string): Promise<string>;
|
||||||
|
Project_GetHistory(projectIdx: number): Promise<string>;
|
||||||
|
Project_GetDailyMemo(projectIdx: number): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 권한 정보 타입
|
// 사용자 권한 정보 타입
|
||||||
@@ -465,3 +527,217 @@ export interface PermissionInfo {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자 권한 항목 타입 (Auth 테이블)
|
||||||
|
export interface AuthItem {
|
||||||
|
idx: number;
|
||||||
|
user: string;
|
||||||
|
gcode?: string;
|
||||||
|
account: number;
|
||||||
|
purchase: number;
|
||||||
|
purchaseEB: number;
|
||||||
|
holyday: number;
|
||||||
|
project: number;
|
||||||
|
jobreport: number;
|
||||||
|
scheapp: number;
|
||||||
|
equipment: number;
|
||||||
|
otconfirm: number;
|
||||||
|
holyreq: number;
|
||||||
|
kuntae: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 권한 필드 정보
|
||||||
|
export interface AuthFieldInfo {
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 범용 권한 체크 응답 타입
|
||||||
|
export interface CheckAuthResponse {
|
||||||
|
Success: boolean;
|
||||||
|
CanAccess?: boolean;
|
||||||
|
UserLevel?: number;
|
||||||
|
AuthLevel?: number;
|
||||||
|
EffectiveLevel?: number;
|
||||||
|
RequiredLevel?: number;
|
||||||
|
AuthType?: string;
|
||||||
|
Message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내 권한 정보 응답 타입
|
||||||
|
export interface MyAuthInfo {
|
||||||
|
UserLevel: number;
|
||||||
|
account: number;
|
||||||
|
purchase: number;
|
||||||
|
purchaseEB: number;
|
||||||
|
holyday: number;
|
||||||
|
project: number;
|
||||||
|
jobreport: number;
|
||||||
|
scheapp: number;
|
||||||
|
equipment: number;
|
||||||
|
otconfirm: number;
|
||||||
|
holyreq: number;
|
||||||
|
kuntae: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 타입 (FCOMMON DBM.eAuthType과 일치)
|
||||||
|
export type AuthType = 'purchase' | 'holyday' | 'project' | 'jobreport' | 'savecost' | 'equipment' | 'otconfirm' | 'kuntae' | 'holyreq' | 'account' | 'purchaseEB' | 'scheapp';
|
||||||
|
|
||||||
|
// 프로젝트 검색 결과 항목 타입
|
||||||
|
export interface ProjectSearchItem {
|
||||||
|
idx: number;
|
||||||
|
name: string;
|
||||||
|
userManager: string;
|
||||||
|
userMain: string;
|
||||||
|
status: string;
|
||||||
|
source: 'project' | 'jobreport';
|
||||||
|
lastDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 근태 오류검사 결과 항목 타입
|
||||||
|
export interface KuntaeErrorCheckResult {
|
||||||
|
Date: string;
|
||||||
|
Gubun: string;
|
||||||
|
OccurDay: string;
|
||||||
|
OccurTime: string;
|
||||||
|
UseDay: string;
|
||||||
|
UseTime: string;
|
||||||
|
CateError: string;
|
||||||
|
IsError: boolean;
|
||||||
|
IsMagam: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 근태 오류검사 응답 타입
|
||||||
|
export interface KuntaeErrorCheckResponse {
|
||||||
|
Success: boolean;
|
||||||
|
Message?: string;
|
||||||
|
OkList?: KuntaeErrorCheckResult[];
|
||||||
|
NgList?: KuntaeErrorCheckResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 근태 오류수정 결과 타입
|
||||||
|
export interface KuntaeFixErrorResult {
|
||||||
|
Date: string;
|
||||||
|
Success: boolean;
|
||||||
|
Message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 즐겨찾기 아이템 타입
|
||||||
|
export interface FavoriteItem {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 목록 아이템 타입
|
||||||
|
export interface ProjectListItem {
|
||||||
|
idx: number;
|
||||||
|
pidx?: number;
|
||||||
|
status: string;
|
||||||
|
asset?: string;
|
||||||
|
level?: number;
|
||||||
|
rev?: number;
|
||||||
|
process?: string;
|
||||||
|
part?: string;
|
||||||
|
pdate?: string;
|
||||||
|
name: string;
|
||||||
|
userManager?: string;
|
||||||
|
usermain?: string;
|
||||||
|
usersub?: string;
|
||||||
|
userhw2?: string;
|
||||||
|
reqstaff?: string;
|
||||||
|
costo?: number;
|
||||||
|
costn?: number;
|
||||||
|
cnt?: number;
|
||||||
|
remark_req?: string;
|
||||||
|
remark_ans?: string;
|
||||||
|
sdate?: string;
|
||||||
|
ddate?: string;
|
||||||
|
edate?: string;
|
||||||
|
odate?: string;
|
||||||
|
progress?: number;
|
||||||
|
bmajoritem?: boolean;
|
||||||
|
memo?: string;
|
||||||
|
wuid?: number;
|
||||||
|
wdate?: string;
|
||||||
|
orderno?: string;
|
||||||
|
crdue?: string;
|
||||||
|
import?: string;
|
||||||
|
path?: string;
|
||||||
|
userprocess?: string;
|
||||||
|
bCost?: boolean;
|
||||||
|
bFanOut?: boolean;
|
||||||
|
bHighlight?: boolean;
|
||||||
|
div?: string;
|
||||||
|
model?: string;
|
||||||
|
serial?: string;
|
||||||
|
championid?: number;
|
||||||
|
designid?: number;
|
||||||
|
epanelid?: number;
|
||||||
|
softwareid?: number;
|
||||||
|
effect_tangible?: string;
|
||||||
|
effect_intangible?: string;
|
||||||
|
name_champion?: string;
|
||||||
|
name_design?: string;
|
||||||
|
name_epanel?: string;
|
||||||
|
name_software?: string;
|
||||||
|
category?: string;
|
||||||
|
ReqLine?: string;
|
||||||
|
ReqSite?: string;
|
||||||
|
ReqPackage?: string;
|
||||||
|
ReqPlant?: string;
|
||||||
|
pno?: number;
|
||||||
|
kdate?: string;
|
||||||
|
jasmin?: number;
|
||||||
|
sfi?: string;
|
||||||
|
lasthistory_date?: string;
|
||||||
|
lastSchNo?: number;
|
||||||
|
cramount?: number;
|
||||||
|
panelimage?: string;
|
||||||
|
Priority?: number;
|
||||||
|
sfi_count?: number;
|
||||||
|
ProgressPrj?: number;
|
||||||
|
finishrate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 목록 응답 타입
|
||||||
|
export interface ProjectListResponse {
|
||||||
|
Success: boolean;
|
||||||
|
Message?: string;
|
||||||
|
Data?: ProjectListItem[];
|
||||||
|
StatusCounts?: {
|
||||||
|
검토: number;
|
||||||
|
진행: number;
|
||||||
|
대기: number;
|
||||||
|
보류: number;
|
||||||
|
완료: number;
|
||||||
|
'완료(보고)': number;
|
||||||
|
취소: number;
|
||||||
|
};
|
||||||
|
TotalCosto?: number;
|
||||||
|
TotalCostn?: number;
|
||||||
|
CurrentUser?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 히스토리 타입
|
||||||
|
export interface ProjectHistory {
|
||||||
|
idx: number;
|
||||||
|
pidx: number;
|
||||||
|
pdate: string;
|
||||||
|
progress?: number;
|
||||||
|
remark?: string;
|
||||||
|
wuid?: number;
|
||||||
|
wdate?: string;
|
||||||
|
wname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 일일 메모 타입
|
||||||
|
export interface ProjectDailyMemo {
|
||||||
|
idx: number;
|
||||||
|
pidx: number;
|
||||||
|
pdate: string;
|
||||||
|
remark?: string;
|
||||||
|
wuid?: number;
|
||||||
|
wdate?: string;
|
||||||
|
wname?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// 파일명에서 해시 제거 - 고정된 파일명 사용
|
||||||
|
entryFileNames: 'assets/[name].js',
|
||||||
|
chunkFileNames: 'assets/[name].js',
|
||||||
|
assetFileNames: 'assets/[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
base: './',
|
base: './',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user