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:
backuppc
2025-11-28 17:36:20 +09:00
parent c9b5d756e1
commit adcdc40169
32 changed files with 6668 additions and 292 deletions

View File

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

View File

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

View File

@@ -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 ?? "");

View File

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

View File

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

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

View File

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

View File

@@ -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 = "월별 근무표";

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
export { FavoriteDialog } from './FavoriteDialog';

View File

@@ -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,8 +225,10 @@ 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="flex-1 p-4 space-y-4 overflow-auto">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* SID */} {/* SID */}
<div> <div>
@@ -207,6 +369,104 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
</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-2"></label>
{/* 이미지 드롭존 */}
<div
ref={dropZoneRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={`flex-1 min-h-[200px] rounded-lg border-2 border-dashed transition-colors flex items-center justify-center overflow-hidden ${
isDragging
? '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="mt-3 space-y-2">
{/* 파일 선택 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<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 className="flex items-center justify-between p-4 border-t border-white/10"> <div className="flex items-center justify-between p-4 border-t border-white/10">
<div> <div>

View File

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

View File

@@ -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>
<div className="flex gap-2">
<input <input
type="text" type="text"
value={formData.projectName} value={formData.projectName}
onChange={(e) => handleFieldChange('projectName', e.target.value)} onChange={(e) => {
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" handleFieldChange('projectName', e.target.value);
placeholder="프로젝트 또는 아이템명" // 프로젝트명을 직접 수정하면 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 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 value="대기" className="bg-gray-800">
</option> </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
); );

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
export { ProjectDetailDialog } from './ProjectDetailDialog';

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

View File

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

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

View File

@@ -1 +1,2 @@
export { UserInfoDialog } from './UserInfoDialog'; export { UserInfoDialog } from './UserInfoDialog';
export { UserSearchDialog } from './UserSearchDialog';

View File

@@ -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,8 +235,10 @@ 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="flex-1 glass-effect rounded-xl overflow-hidden flex flex-col">
<div className="p-4 border-b border-white/10 flex items-center gap-2"> <div className="p-4 border-b border-white/10 flex items-center gap-2">
<Package className="w-5 h-5 text-white/70" /> <Package className="w-5 h-5 text-white/70" />
<h2 className="text-lg font-semibold text-white"> </h2> <h2 className="text-lg font-semibold text-white"> </h2>
@@ -179,7 +260,6 @@ export function ItemsPage() {
<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-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>
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -187,9 +267,11 @@ export function ItemsPage() {
<tr <tr
key={item.idx || 'new'} key={item.idx || 'new'}
onClick={() => handleRowClick(item)} onClick={() => handleRowClick(item)}
onDoubleClick={() => handleRowDoubleClick(item)}
className={clsx( className={clsx(
'hover:bg-white/10 transition-colors cursor-pointer', 'hover:bg-white/10 transition-colors cursor-pointer',
item.disable && 'opacity-50' 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 font-mono">{item.sid}</td>
@@ -198,12 +280,11 @@ export function ItemsPage() {
<td className="px-3 py-2 text-white/70">{item.model}</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 text-right">{(item.price ?? 0).toLocaleString()}</td>
<td className="px-3 py-2 text-white/70">{item.supply}</td> <td className="px-3 py-2 text-white/70">{item.supply}</td>
<td className="px-3 py-2 text-white/70">{item.manu}</td>
</tr> </tr>
))} ))}
{filteredItems.length === 0 && ( {filteredItems.length === 0 && (
<tr> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-white/50"> <td colSpan={6} className="px-4 py-8 text-center text-white/50">
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'} {items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
</td> </td>
</tr> </tr>
@@ -214,6 +295,149 @@ export function ItemsPage() {
</div> </div>
</div> </div>
{/* 상세 패널 (우측) */}
<div className="w-80 flex flex-col gap-4">
{/* 이미지 */}
<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 className="aspect-[4/3] bg-white/5 rounded-lg flex items-center justify-center overflow-hidden">
{detailLoading ? (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white/50"></div>
) : itemImage ? (
<img
src={`data:image/jpeg;base64,${itemImage}`}
alt="품목 이미지"
className="max-w-full max-h-full object-contain"
/>
) : (
<span className="text-white/30 text-sm"> </span>
)}
</div>
</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">
<Users className="w-4 h-4 text-white/70" />
<h3 className="text-sm font-medium text-white">
{selectedItemDetail?.supply ? `[${selectedItemDetail.supply}] 담당자` : '공급처 담당자'}
</h3>
</div>
<div className="max-h-32 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>
) : supplierStaff.length > 0 ? (
<table className="w-full text-xs">
<thead className="bg-white/5">
<tr>
<th className="px-2 py-1 text-left text-white/60"></th>
<th className="px-2 py-1 text-left text-white/60"></th>
<th className="px-2 py-1 text-left text-white/60"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{supplierStaff.map((staff) => (
<tr key={staff.idx}>
<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>
{/* 편집 다이얼로그 */} {/* 편집 다이얼로그 */}
<ItemEditDialog <ItemEditDialog
item={selectedItem} item={selectedItem}

View File

@@ -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 || '',

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

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

View File

@@ -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';

View File

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

View File

@@ -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: './',
}); });