feat: React 프론트엔드 기능 대폭 확장
- 월별근무표: 휴일/근무일 관리, 자동 초기화 - 메일양식: 템플릿 CRUD, To/CC/BCC 설정 - 그룹정보: 부서 관리, 비트 연산 기반 권한 설정 - 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정 - 웹소켓 메시지 type 충돌 버그 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
namespace Project.Dialog
|
||||
{
|
||||
partial class fDashboardNew
|
||||
partial class fDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
@@ -15,26 +15,18 @@ using System.Windows.Forms;
|
||||
|
||||
namespace Project.Dialog
|
||||
{
|
||||
public partial class fDashboardNew : fBase
|
||||
public partial class fDashboard : fBase
|
||||
{
|
||||
private Web.WebSocketServer _wsServer;
|
||||
private WebView2 webView;
|
||||
public fDashboardNew()
|
||||
private Web.MachineBridge _machineBridge;
|
||||
|
||||
public fDashboard()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
|
||||
InitializeWebView2();
|
||||
_machineBridge = new Web.MachineBridge(this);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_wsServer = new Web.WebSocketServer("http://localhost:8081/", this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("Failed to start WebSocket Server (Port 8081). Run as Admin or allow port.\n" + ex.Message);
|
||||
}
|
||||
}
|
||||
bool loadok = false;
|
||||
public void RefreshView()
|
||||
{
|
||||
@@ -80,7 +72,7 @@ namespace Project.Dialog
|
||||
await this.webView.EnsureCoreWebView2Async();
|
||||
}
|
||||
|
||||
var wwwroot = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Web", "wwwroot");
|
||||
var wwwroot = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Web", "dist");
|
||||
webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
|
||||
"hmi.local",
|
||||
wwwroot,
|
||||
@@ -88,16 +80,13 @@ namespace Project.Dialog
|
||||
|
||||
|
||||
// 2. Inject Native Object
|
||||
webView.CoreWebView2.AddHostObjectToScript("machine", new Web.MachineBridge(this));
|
||||
webView.CoreWebView2.AddHostObjectToScript("machine", _machineBridge);
|
||||
|
||||
Pub.WebServiceURL = "http://hmi.local";
|
||||
|
||||
// OWIN 서버의 DashBoard 페이지로 연결
|
||||
if (FCOMMON.info.Login.no.isEmpty())
|
||||
webView.Source = new Uri($"{Pub.WebServiceURL}/login.html");
|
||||
else
|
||||
webView.Source = new Uri($"{Pub.WebServiceURL}/DashBoard");
|
||||
|
||||
|
||||
RefreshPage();
|
||||
label1.Visible = false;
|
||||
loadok = true;
|
||||
}
|
||||
@@ -106,6 +95,18 @@ namespace Project.Dialog
|
||||
MessageBox.Show($"WebView2 초기화 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 로그인 상태에 따라서 페이지를 전환한다
|
||||
/// </summary>
|
||||
public void RefreshPage()
|
||||
{
|
||||
webView.Source = new Uri($"{Pub.WebServiceURL}/index.html");
|
||||
//if (FCOMMON.info.Login.no.isEmpty())
|
||||
//webView.Source = new Uri($"{Pub.WebServiceURL}/login.html");
|
||||
// else
|
||||
// webView.Source = new Uri($"{Pub.WebServiceURL}/DashBoard/index.html");
|
||||
}
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
@@ -192,24 +192,24 @@ namespace Project.Dialog
|
||||
}
|
||||
}
|
||||
|
||||
FCOMMON.info.Login.no = drUser.id;
|
||||
FCOMMON.info.Login.nameK = drUser.name;
|
||||
FCOMMON.info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text;
|
||||
FCOMMON.info.Login.level = drGrpUser.level;
|
||||
FCOMMON.info.Login.email = drUser.email;
|
||||
FCOMMON.info.Login.nameE = drUser.nameE;
|
||||
FCOMMON.info.Login.hp = drUser.hp;
|
||||
FCOMMON.info.Login.tel = drUser.tel;
|
||||
FCOMMON.info.Login.title = drUser.dept + "(" + drUser.grade + ")";
|
||||
FCOMMON.info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView;
|
||||
info.Login.no = drUser.id;
|
||||
info.Login.nameK = drUser.name;
|
||||
info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text;
|
||||
info.Login.level = drGrpUser.level;
|
||||
info.Login.email = drUser.email;
|
||||
info.Login.nameE = drUser.nameE;
|
||||
info.Login.hp = drUser.hp;
|
||||
info.Login.tel = drUser.tel;
|
||||
info.Login.title = drUser.dept + "(" + drUser.grade + ")";
|
||||
info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView;
|
||||
//var gcode = FCOMMON.DBM.ExecuteScalar("select isnull(gcode,'NOGCODE') from UserGroup where dept ='" + cmbDept.Text + "'");
|
||||
var gperm = FCOMMON.DBM.ExecuteScalar("select isnull(permission,0) from UserGroup where dept ='" + cmbDept.Text + "'");
|
||||
FCOMMON.info.Login.gcode = gCode;// gcode;
|
||||
FCOMMON.info.Login.process = drUser.id == "dev" ? "개발자" : drGrpUser.Process;
|
||||
FCOMMON.info.Login.permission = 0;
|
||||
FCOMMON.info.Login.gpermission = int.Parse(gperm);
|
||||
//FCOMMON.info.datapath = Pub.setting.SharedDataPath;
|
||||
FCOMMON.info.ShowBuyerror = Pub.setting.Showbuyerror; //210625
|
||||
info.Login.gcode = gCode;// gcode;
|
||||
info.Login.process = drUser.id == "dev" ? "개발자" : drGrpUser.Process;
|
||||
info.Login.permission = 0;
|
||||
info.Login.gpermission = int.Parse(gperm);
|
||||
//info.datapath = Pub.setting.SharedDataPath;
|
||||
info.ShowBuyerror = Pub.setting.Showbuyerror; //210625
|
||||
|
||||
|
||||
|
||||
@@ -220,34 +220,34 @@ namespace Project.Dialog
|
||||
{
|
||||
return;
|
||||
}
|
||||
FCOMMON.info.Login.no = "dev";
|
||||
FCOMMON.info.Login.nameK = "개발자";
|
||||
FCOMMON.info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text;
|
||||
FCOMMON.info.Login.level = 10;
|
||||
FCOMMON.info.Login.email = "";
|
||||
FCOMMON.info.Login.nameE = "DEVELOPER";
|
||||
FCOMMON.info.Login.hp = "";
|
||||
FCOMMON.info.Login.tel = "";
|
||||
FCOMMON.info.Login.title = "업무일지 개발자";
|
||||
FCOMMON.info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView;
|
||||
info.Login.no = "dev";
|
||||
info.Login.nameK = "개발자";
|
||||
info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text;
|
||||
info.Login.level = 10;
|
||||
info.Login.email = "";
|
||||
info.Login.nameE = "DEVELOPER";
|
||||
info.Login.hp = "";
|
||||
info.Login.tel = "";
|
||||
info.Login.title = "업무일지 개발자";
|
||||
info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView;
|
||||
//var gcode = FCOMMON.DBM.ExecuteScalar("select isnull(gcode,'NOGCODE') from UserGroup where dept ='" + cmbDept.Text + "'");
|
||||
var gperm = FCOMMON.DBM.ExecuteScalar("select isnull(permission,0) from UserGroup where dept ='" + cmbDept.Text + "'");
|
||||
FCOMMON.info.Login.gcode = gCode;
|
||||
FCOMMON.info.Login.process = "개발자";
|
||||
FCOMMON.info.Login.permission = 0;
|
||||
FCOMMON.info.Login.gpermission = int.Parse(gperm);
|
||||
info.Login.gcode = gCode;
|
||||
info.Login.process = "개발자";
|
||||
info.Login.permission = 0;
|
||||
info.Login.gpermission = int.Parse(gperm);
|
||||
//var datapath = FCOMMON.DBM.getCodeSavlue("55", "01");
|
||||
//FCOMMON.info.datapath = datapath;// Pub.setting.SharedDataPath;
|
||||
FCOMMON.info.ShowBuyerror = Pub.setting.Showbuyerror; //210625
|
||||
//info.datapath = datapath;// Pub.setting.SharedDataPath;
|
||||
info.ShowBuyerror = Pub.setting.Showbuyerror; //210625
|
||||
}
|
||||
|
||||
//if (FCOMMON.info.datapath.isEmpty() && gCode == "EET1P") //210524
|
||||
// FCOMMON.info.datapath = @"\\k4fs3201n\k4bpartcenter$";
|
||||
//if (info.datapath.isEmpty() && gCode == "EET1P") //210524
|
||||
// info.datapath = @"\\k4fs3201n\k4bpartcenter$";
|
||||
//using (var dbEnity = new EEEntitiesMain())
|
||||
//{
|
||||
// var drGrpUser = dbEnity.EETGW_GroupUser.Where(t => t.uid == userdr.id & t.gcode == gCode).FirstOrDefault();
|
||||
// if (drGrpUser == null) FCOMMON.info.Login.process = (userdr.id == "dev" ? "개발자" : "");
|
||||
// else FCOMMON.info.Login.process = drGrpUser.Process;
|
||||
// if (drGrpUser == null) info.Login.process = (userdr.id == "dev" ? "개발자" : "");
|
||||
// else info.Login.process = drGrpUser.Process;
|
||||
//}
|
||||
|
||||
//로그인정보 기록
|
||||
@@ -260,7 +260,7 @@ namespace Project.Dialog
|
||||
Pub.MakeAutoJobReportByAuto();
|
||||
|
||||
DialogResult = DialogResult.OK;
|
||||
FCOMMON.info.Login.loginusetime = (DateTime.Now - dt).TotalMilliseconds;
|
||||
info.Login.loginusetime = (DateTime.Now - dt).TotalMilliseconds;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -293,7 +293,7 @@ namespace Project.Dialog
|
||||
try
|
||||
{
|
||||
var ta = new dsMSSQLTableAdapters.EETGW_LoginInfoTableAdapter();
|
||||
ta.Insert(FCOMMON.info.Login.no, DateTime.Now, ip, fullname, info.Login.no, DateTime.Now);
|
||||
ta.Insert(info.Login.no, DateTime.Now, ip, fullname, info.Login.no, DateTime.Now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -336,9 +336,9 @@ namespace Project.Dialog
|
||||
//}
|
||||
|
||||
var gCode = this.cmbDept.SelectedValue.ToString();// as dsMSSQL.UserGroupRow;
|
||||
FCOMMON.info.Login.gcode = gCode;
|
||||
FCOMMON.info.Login.no = "new";
|
||||
FCOMMON.info.Login.dept = this.cmbDept.Text;
|
||||
info.Login.gcode = gCode;
|
||||
info.Login.no = "new";
|
||||
info.Login.dept = this.cmbDept.Text;
|
||||
|
||||
var dlg = FCOMMON.Util.MsgQ($"현재 선택된 그룹[{this.cmbDept.Text}]의 사용자를 추가할까요?\n" +
|
||||
"추가된 사용자는 담당자로부터 승인 완료되어야 접속이 가능 합니다\n" +
|
||||
|
||||
@@ -220,11 +220,11 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Dialog\fDashboardNew.cs">
|
||||
<Compile Include="Dialog\fDashboard.cs">
|
||||
<SubType>Form</SubType>
|
||||
</Compile>
|
||||
<Compile Include="Dialog\fDashboardNew.Designer.cs">
|
||||
<DependentUpon>fDashboardNew.cs</DependentUpon>
|
||||
<Compile Include="Dialog\fDashboard.Designer.cs">
|
||||
<DependentUpon>fDashboard.cs</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="fSystemCheck.cs">
|
||||
<SubType>Form</SubType>
|
||||
@@ -358,6 +358,12 @@
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.Jobreport.cs" />
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.Kuntae.cs" />
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.Project.cs" />
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.User.cs" />
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.UserList.cs" />
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.Holiday.cs" />
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.MailForm.cs" />
|
||||
<Compile Include="Web\MachineBridge\MachineBridge.UserGroup.cs" />
|
||||
<Compile Include="Web\MachineBridge\WebSocketServer.cs" />
|
||||
<Compile Include="Web\Model\PageModel.cs" />
|
||||
<Compile Include="Web\Model\ProjectModel.cs" />
|
||||
<Compile Include="Web\Model\TodoModel.cs" />
|
||||
@@ -365,7 +371,6 @@
|
||||
<Compile Include="Settings.cs" />
|
||||
<Compile Include="SqlServerTypes\Loader.cs" />
|
||||
<Compile Include="StateMachine\ReportUserData.cs" />
|
||||
<Compile Include="Web\WebSocketServer.cs" />
|
||||
<Compile Include="_Common\fADSUserList.cs">
|
||||
<SubType>Form</SubType>
|
||||
</Compile>
|
||||
@@ -426,8 +431,8 @@
|
||||
<EmbeddedResource Include="Dev\fDisableItem.resx">
|
||||
<DependentUpon>fDisableItem.cs</DependentUpon>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Dialog\fDashboardNew.resx">
|
||||
<DependentUpon>fDashboardNew.cs</DependentUpon>
|
||||
<EmbeddedResource Include="Dialog\fDashboard.resx">
|
||||
<DependentUpon>fDashboard.cs</DependentUpon>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Dialog\fDebug.resx">
|
||||
<DependentUpon>fDebug.cs</DependentUpon>
|
||||
@@ -732,6 +737,9 @@
|
||||
<ItemGroup />
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<Import Project="..\packages\Microsoft.Web.WebView2.1.0.2210.55\build\Microsoft.Web.WebView2.targets" Condition="Exists('..\packages\Microsoft.Web.WebView2.1.0.2210.55\build\Microsoft.Web.WebView2.targets')" />
|
||||
<PropertyGroup>
|
||||
<PostBuildEvent>xcopy /E /I /Y "$(ProjectDir)frontend\dist\*" "$(TargetDir)Web\Dist\"</PostBuildEvent>
|
||||
</PropertyGroup>
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<ErrorText>이 프로젝트는 이 컴퓨터에 없는 NuGet 패키지를 참조합니다. 해당 패키지를 다운로드하려면 NuGet 패키지 복원을 사용하십시오. 자세한 내용은 http://go.microsoft.com/fwlink/?LinkID=322105를 참조하십시오. 누락된 파일은 {0}입니다.</ErrorText>
|
||||
|
||||
@@ -229,5 +229,198 @@ namespace Project.Web
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Items API
|
||||
|
||||
/// <summary>
|
||||
/// 품목 카테고리 목록 조회
|
||||
/// </summary>
|
||||
public string Items_GetCategories()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = "SELECT DISTINCT cate FROM Items WITH (NOLOCK) WHERE gcode = @gcode AND ISNULL(cate,'') <> '' ORDER BY cate";
|
||||
|
||||
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<string>();
|
||||
foreach (DataRow dr in dt.Rows)
|
||||
{
|
||||
result.Add(dr["cate"]?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "카테고리 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 품목 목록 조회
|
||||
/// </summary>
|
||||
public string Items_GetList(string category, string searchKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cateSearch = string.IsNullOrEmpty(category) || category == "all" ? "%" : category;
|
||||
var skey = string.IsNullOrEmpty(searchKey) || searchKey == "%" ? "%" : $"%{searchKey}%";
|
||||
|
||||
var sql = @"SELECT idx, sid, cate, name, model, scale, unit, price, supply, manu, storage, disable, memo
|
||||
FROM Items WITH (NOLOCK)
|
||||
WHERE gcode = @gcode
|
||||
AND ISNULL(cate,'') LIKE @cate
|
||||
AND (ISNULL(sid,'') LIKE @search OR ISNULL(name,'') LIKE @search OR ISNULL(model,'') LIKE @search)
|
||||
ORDER BY sid, name";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@cate", cateSearch);
|
||||
cmd.Parameters.AddWithValue("@search", skey);
|
||||
|
||||
var da = new SqlDataAdapter(cmd);
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
da.Dispose();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(dt, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "품목 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 품목 저장
|
||||
/// </summary>
|
||||
public string Items_Save(int idx, string sid, string cate, string name, string model,
|
||||
string scale, string unit, decimal price, string supply, string manu, string storage, bool disable, string memo)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var sql = string.Empty;
|
||||
var cmd = new SqlCommand();
|
||||
cmd.Connection = cn;
|
||||
|
||||
// 신규 추가 시 SID 중복 체크
|
||||
if (idx == 0 && !string.IsNullOrEmpty(sid))
|
||||
{
|
||||
var checkSql = "SELECT COUNT(*) FROM Items WITH (NOLOCK) WHERE gcode = @gcode AND sid = @sid";
|
||||
var checkCmd = new SqlCommand(checkSql, cn);
|
||||
checkCmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
checkCmd.Parameters.AddWithValue("@sid", sid);
|
||||
|
||||
cn.Open();
|
||||
var count = (int)checkCmd.ExecuteScalar();
|
||||
cn.Close();
|
||||
checkCmd.Dispose();
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
cn.Dispose();
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = $"이미 존재하는 SID입니다: {sid}" });
|
||||
}
|
||||
}
|
||||
|
||||
if (idx > 0)
|
||||
{
|
||||
sql = @"UPDATE Items SET
|
||||
sid = @sid, cate = @cate, name = @name, model = @model,
|
||||
scale = @scale, unit = @unit, price = @price, supply = @supply,
|
||||
manu = @manu, storage = @storage, disable = @disable, memo = @memo,
|
||||
wuid = @wuid, wdate = GETDATE()
|
||||
WHERE idx = @idx AND gcode = @gcode";
|
||||
}
|
||||
else
|
||||
{
|
||||
sql = @"INSERT INTO Items (gcode, sid, cate, name, model, scale, unit, price, supply, manu, storage, disable, memo, wuid, wdate)
|
||||
VALUES (@gcode, @sid, @cate, @name, @model, @scale, @unit, @price, @supply, @manu, @storage, @disable, @memo, @wuid, GETDATE())";
|
||||
}
|
||||
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@sid", sid ?? "");
|
||||
cmd.Parameters.AddWithValue("@cate", cate ?? "");
|
||||
cmd.Parameters.AddWithValue("@name", name ?? "");
|
||||
cmd.Parameters.AddWithValue("@model", model ?? "");
|
||||
cmd.Parameters.AddWithValue("@scale", scale ?? "");
|
||||
cmd.Parameters.AddWithValue("@unit", unit ?? "");
|
||||
cmd.Parameters.AddWithValue("@price", price);
|
||||
cmd.Parameters.AddWithValue("@supply", supply ?? "");
|
||||
cmd.Parameters.AddWithValue("@manu", manu ?? "");
|
||||
cmd.Parameters.AddWithValue("@storage", storage ?? "");
|
||||
cmd.Parameters.AddWithValue("@disable", disable);
|
||||
cmd.Parameters.AddWithValue("@memo", memo ?? "");
|
||||
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||
|
||||
if (idx > 0)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@idx", idx);
|
||||
}
|
||||
|
||||
cn.Open();
|
||||
var result = cmd.ExecuteNonQuery();
|
||||
cn.Close();
|
||||
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
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_Delete(int idx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var sql = "DELETE FROM Items WHERE idx = @idx AND gcode = @gcode";
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
|
||||
cmd.Parameters.AddWithValue("@idx", idx);
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
|
||||
cn.Open();
|
||||
var result = cmd.ExecuteNonQuery();
|
||||
cn.Close();
|
||||
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "삭제되었습니다." : "삭제에 실패했습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "삭제 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
177
Project/Web/MachineBridge/MachineBridge.Holiday.cs
Normal file
177
Project/Web/MachineBridge/MachineBridge.Holiday.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
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 Holiday API (월별근무표)
|
||||
|
||||
/// <summary>
|
||||
/// 월별근무표 목록 조회
|
||||
/// </summary>
|
||||
public string Holiday_GetList(string month)
|
||||
{
|
||||
try
|
||||
{
|
||||
var monthPattern = month + "%";
|
||||
var sql = @"SELECT idx, pdate, free, memo, wuid, wdate
|
||||
FROM HolidayList WITH (nolock)
|
||||
WHERE pdate LIKE @month
|
||||
ORDER BY pdate";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@month", monthPattern);
|
||||
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 Holiday_Save(string month, string holidaysJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var holidays = JsonConvert.DeserializeObject<List<HolidayItem>>(holidaysJson);
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
|
||||
using (var cn = new SqlConnection(cs))
|
||||
{
|
||||
cn.Open();
|
||||
using (var tran = cn.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var item in holidays)
|
||||
{
|
||||
var sql = @"
|
||||
IF EXISTS (SELECT 1 FROM HolidayList WHERE pdate = @pdate)
|
||||
UPDATE HolidayList SET free = @free, memo = @memo, wuid = @wuid, wdate = GETDATE() WHERE pdate = @pdate
|
||||
ELSE
|
||||
INSERT INTO HolidayList (pdate, free, memo, wuid, wdate) VALUES (@pdate, @free, @memo, @wuid, GETDATE())";
|
||||
|
||||
using (var cmd = new SqlCommand(sql, cn, tran))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@pdate", item.pdate);
|
||||
cmd.Parameters.AddWithValue("@free", item.free);
|
||||
cmd.Parameters.AddWithValue("@memo", item.memo ?? "");
|
||||
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
tran.Commit();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다." });
|
||||
}
|
||||
catch
|
||||
{
|
||||
tran.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 월별근무표 초기 데이터 생성 (해당 월에 데이터가 없을 때)
|
||||
/// </summary>
|
||||
public string Holiday_Initialize(string month)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 해당 월 데이터 존재 여부 확인
|
||||
var monthPattern = month + "%";
|
||||
var checkSql = "SELECT COUNT(*) FROM HolidayList WITH (nolock) WHERE pdate LIKE @month";
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
|
||||
using (var cn = new SqlConnection(cs))
|
||||
{
|
||||
cn.Open();
|
||||
|
||||
using (var checkCmd = new SqlCommand(checkSql, cn))
|
||||
{
|
||||
checkCmd.Parameters.AddWithValue("@month", monthPattern);
|
||||
var count = (int)checkCmd.ExecuteScalar();
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "이미 데이터가 존재합니다.", Created = false });
|
||||
}
|
||||
}
|
||||
|
||||
// 해당 월의 모든 날짜 생성
|
||||
var startDate = DateTime.Parse(month + "-01");
|
||||
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||
|
||||
using (var tran = cn.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
||||
{
|
||||
var isFree = date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday;
|
||||
var memo = date.DayOfWeek == DayOfWeek.Saturday ? "토요일" :
|
||||
date.DayOfWeek == DayOfWeek.Sunday ? "일요일" : "";
|
||||
|
||||
var sql = @"INSERT INTO HolidayList (pdate, free, memo, wuid, wdate)
|
||||
VALUES (@pdate, @free, @memo, @wuid, GETDATE())";
|
||||
|
||||
using (var cmd = new SqlCommand(sql, cn, tran))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@pdate", date.ToString("yyyy-MM-dd"));
|
||||
cmd.Parameters.AddWithValue("@free", isFree);
|
||||
cmd.Parameters.AddWithValue("@memo", memo);
|
||||
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
tran.Commit();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "초기 데이터가 생성되었습니다.", Created = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
tran.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class HolidayItem
|
||||
{
|
||||
public int idx { get; set; }
|
||||
public string pdate { get; set; }
|
||||
public bool free { get; set; }
|
||||
public string memo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -12,62 +12,47 @@ namespace Project.Web
|
||||
#region Jobreport API
|
||||
|
||||
/// <summary>
|
||||
/// 업무일지 목록 조회
|
||||
/// 업무일지 목록 조회 (vJobReportForUser 뷰 사용)
|
||||
/// </summary>
|
||||
public string Jobreport_GetList(string sd, string ed, string uid, string cate, string doit)
|
||||
public string Jobreport_GetList(string sd, string ed, string uid, string cate, string searchKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"SELECT j.idx, j.jdate, j.uid, j.cate, j.title, j.doit, j.remark, j.jfrom, j.jto,
|
||||
u.name as userName, j.wdate
|
||||
FROM EETGW_Jobreport j WITH (nolock)
|
||||
LEFT JOIN Users u ON j.uid = u.id
|
||||
WHERE j.gcode = @gcode";
|
||||
var sql = @"SELECT idx, pidx, pdate, id, name,type, svalue, hrs,ot,requestpart,package,userprocess,status, projectName, description, ww,otpms,process
|
||||
FROM vJobReportForUser WITH (nolock)
|
||||
WHERE gcode = @gcode AND (pdate BETWEEN @sd AND @ed)";
|
||||
|
||||
var parameters = new List<SqlParameter>();
|
||||
parameters.Add(new SqlParameter("@gcode", info.Login.gcode));
|
||||
|
||||
if (!string.IsNullOrEmpty(sd))
|
||||
{
|
||||
sql += " AND j.jdate >= @sd";
|
||||
parameters.Add(new SqlParameter("@sd", sd));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(ed))
|
||||
{
|
||||
sql += " AND j.jdate <= @ed";
|
||||
parameters.Add(new SqlParameter("@ed", ed));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(uid))
|
||||
{
|
||||
sql += " AND j.uid = @uid";
|
||||
sql += " AND id = @uid";
|
||||
parameters.Add(new SqlParameter("@uid", uid));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(cate))
|
||||
|
||||
if (!string.IsNullOrEmpty(searchKey))
|
||||
{
|
||||
sql += " AND j.cate = @cate";
|
||||
parameters.Add(new SqlParameter("@cate", cate));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(doit))
|
||||
{
|
||||
sql += " AND j.doit = @doit";
|
||||
parameters.Add(new SqlParameter("@doit", doit));
|
||||
sql += " AND (requestpart LIKE @searchKey OR package LIKE @searchKey OR projectName LIKE @searchKey OR process LIKE @searchKey OR [type] LIKE @searchKey OR description LIKE @searchKey)";
|
||||
parameters.Add(new SqlParameter("@searchKey", "%" + searchKey + "%"));
|
||||
}
|
||||
|
||||
sql += " ORDER BY j.jdate DESC, j.idx DESC";
|
||||
sql += " ORDER BY pdate DESC, idx DESC";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var da = new SqlDataAdapter(cmd);
|
||||
using (var da = new SqlDataAdapter(cmd))
|
||||
{
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
da.Dispose();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = dt }, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = dt });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -88,18 +73,17 @@ namespace Project.Web
|
||||
ORDER BY u.name";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
|
||||
var da = new SqlDataAdapter(cmd);
|
||||
using (var da = new SqlDataAdapter(cmd))
|
||||
{
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
da.Dispose();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(dt, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
return JsonConvert.SerializeObject(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -109,37 +93,41 @@ namespace Project.Web
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 업무일지 상세 조회
|
||||
/// 업무일지 상세 조회 (vJobReportForUser 뷰 사용)
|
||||
/// </summary>
|
||||
public string Jobreport_GetDetail(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"SELECT j.*, u.name as userName
|
||||
FROM EETGW_Jobreport j WITH (nolock)
|
||||
LEFT JOIN Users u ON j.uid = u.id
|
||||
WHERE j.idx = @idx AND j.gcode = @gcode";
|
||||
var sql = @"SELECT idx, pidx, pdate, id, name, type, svalue, hrs, ot, requestpart, package,
|
||||
userprocess, status, projectName, description, ww, otpms, process
|
||||
FROM vJobReportForUser WITH (nolock)
|
||||
WHERE idx = @idx AND gcode = @gcode";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@idx", id);
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
|
||||
var da = new SqlDataAdapter(cmd);
|
||||
using (var da = new SqlDataAdapter(cmd))
|
||||
{
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
da.Dispose();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
if (dt.Rows.Count > 0)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = dt.Rows[0] }, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
var row = dt.Rows[0];
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (DataColumn col in dt.Columns)
|
||||
{
|
||||
data[col.ColumnName] = row[col] == DBNull.Value ? null : row[col];
|
||||
}
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = data });
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
@@ -147,38 +135,51 @@ namespace Project.Web
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 업무일지 추가
|
||||
/// 업무일지 추가 (JobReport 테이블)
|
||||
/// </summary>
|
||||
public string Jobreport_Add(string jdate, string cate, string title, string doit, string remark, string jfrom, string jto)
|
||||
public string Jobreport_Add(string pdate, string projectName, string requestpart, string package,
|
||||
string type, string process, string status, string description, double hrs, double ot, string jobgrp, string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"INSERT INTO EETGW_Jobreport (gcode, uid, jdate, cate, title, doit, remark, jfrom, jto, wuid, wdate)
|
||||
VALUES (@gcode, @uid, @jdate, @cate, @title, @doit, @remark, @jfrom, @jto, @wuid, GETDATE());
|
||||
// 마감 체크
|
||||
var smon = pdate.Substring(0, 7);
|
||||
if (DBM.GetMagamStatus(smon))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = $"등록일이 속한 월({smon})이 마감되었습니다." });
|
||||
}
|
||||
|
||||
var sql = @"INSERT INTO JobReport (gcode, uid, pdate, projectName, requestpart, package,
|
||||
type, process, status, description, hrs, ot, jobgrp, tag, wuid, wdate, pidx)
|
||||
VALUES (@gcode, @uid, @pdate, @projectName, @requestpart, @package,
|
||||
@type, @process, @status, @description, @hrs, @ot, @jobgrp, @tag, @wuid, GETDATE(), -1);
|
||||
SELECT SCOPE_IDENTITY();";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@uid", info.Login.no);
|
||||
cmd.Parameters.AddWithValue("@jdate", jdate ?? DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
cmd.Parameters.AddWithValue("@cate", cate ?? "");
|
||||
cmd.Parameters.AddWithValue("@title", title ?? "");
|
||||
cmd.Parameters.AddWithValue("@doit", doit ?? "");
|
||||
cmd.Parameters.AddWithValue("@remark", remark ?? "");
|
||||
cmd.Parameters.AddWithValue("@jfrom", jfrom ?? "");
|
||||
cmd.Parameters.AddWithValue("@jto", jto ?? "");
|
||||
cmd.Parameters.AddWithValue("@pdate", pdate);
|
||||
cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
|
||||
cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
|
||||
cmd.Parameters.AddWithValue("@package", package ?? "");
|
||||
cmd.Parameters.AddWithValue("@type", type ?? "");
|
||||
cmd.Parameters.AddWithValue("@process", process ?? "");
|
||||
cmd.Parameters.AddWithValue("@status", status ?? "진행 완료");
|
||||
cmd.Parameters.AddWithValue("@description", description ?? "");
|
||||
cmd.Parameters.AddWithValue("@hrs", hrs);
|
||||
cmd.Parameters.AddWithValue("@ot", ot);
|
||||
cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? "");
|
||||
cmd.Parameters.AddWithValue("@tag", tag ?? "");
|
||||
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||
|
||||
cn.Open();
|
||||
var newId = Convert.ToInt32(cmd.ExecuteScalar());
|
||||
cn.Close();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다.", Data = new { idx = newId } });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
@@ -186,40 +187,74 @@ namespace Project.Web
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 업무일지 수정
|
||||
/// 업무일지 수정 (JobReport 테이블)
|
||||
/// </summary>
|
||||
public string Jobreport_Edit(int idx, string jdate, string cate, string title, string doit, string remark, string jfrom, string jto)
|
||||
public string Jobreport_Edit(int idx, string pdate, string projectName, string requestpart, string package,
|
||||
string type, string process, string status, string description, double hrs, double ot, string jobgrp, string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"UPDATE EETGW_Jobreport SET
|
||||
jdate = @jdate, cate = @cate, title = @title, doit = @doit,
|
||||
remark = @remark, jfrom = @jfrom, jto = @jto,
|
||||
// 권한 체크
|
||||
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.jobreport));
|
||||
|
||||
// 마감 체크
|
||||
var smon = pdate.Substring(0, 7);
|
||||
if (DBM.GetMagamStatus(smon))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = $"등록일이 속한 월({smon})이 마감되었습니다." });
|
||||
}
|
||||
|
||||
// 본인 자료인지 체크 (관리자가 아닌 경우)
|
||||
if (curLevel < 5)
|
||||
{
|
||||
var checkSql = "SELECT uid FROM JobReport WHERE idx = @idx AND gcode = @gcode";
|
||||
var cs2 = Properties.Settings.Default.gwcs;
|
||||
using (var cn2 = new SqlConnection(cs2))
|
||||
using (var cmd2 = new SqlCommand(checkSql, cn2))
|
||||
{
|
||||
cmd2.Parameters.AddWithValue("@idx", idx);
|
||||
cmd2.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cn2.Open();
|
||||
var ownerUid = cmd2.ExecuteScalar()?.ToString();
|
||||
if (ownerUid != info.Login.no)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "타인의 자료는 수정할 수 없습니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sql = @"UPDATE JobReport SET
|
||||
pdate = @pdate, projectName = @projectName, requestpart = @requestpart,
|
||||
package = @package, type = @type, process = @process, status = @status,
|
||||
description = @description, hrs = @hrs, ot = @ot, jobgrp = @jobgrp, tag = @tag,
|
||||
wuid = @wuid, wdate = GETDATE()
|
||||
WHERE idx = @idx AND gcode = @gcode";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
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("@jdate", jdate ?? DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
cmd.Parameters.AddWithValue("@cate", cate ?? "");
|
||||
cmd.Parameters.AddWithValue("@title", title ?? "");
|
||||
cmd.Parameters.AddWithValue("@doit", doit ?? "");
|
||||
cmd.Parameters.AddWithValue("@remark", remark ?? "");
|
||||
cmd.Parameters.AddWithValue("@jfrom", jfrom ?? "");
|
||||
cmd.Parameters.AddWithValue("@jto", jto ?? "");
|
||||
cmd.Parameters.AddWithValue("@pdate", pdate);
|
||||
cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
|
||||
cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
|
||||
cmd.Parameters.AddWithValue("@package", package ?? "");
|
||||
cmd.Parameters.AddWithValue("@type", type ?? "");
|
||||
cmd.Parameters.AddWithValue("@process", process ?? "");
|
||||
cmd.Parameters.AddWithValue("@status", status ?? "");
|
||||
cmd.Parameters.AddWithValue("@description", description ?? "");
|
||||
cmd.Parameters.AddWithValue("@hrs", hrs);
|
||||
cmd.Parameters.AddWithValue("@ot", ot);
|
||||
cmd.Parameters.AddWithValue("@jobgrp", jobgrp ?? "");
|
||||
cmd.Parameters.AddWithValue("@tag", tag ?? "");
|
||||
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||
|
||||
cn.Open();
|
||||
var result = cmd.ExecuteNonQuery();
|
||||
cn.Close();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "수정되었습니다." : "수정에 실패했습니다." });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
@@ -233,22 +268,129 @@ namespace Project.Web
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = "DELETE FROM EETGW_Jobreport WHERE idx = @idx AND gcode = @gcode";
|
||||
// 권한 체크
|
||||
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.jobreport));
|
||||
|
||||
// 본인 자료인지 체크 (관리자가 아닌 경우)
|
||||
if (curLevel < 5)
|
||||
{
|
||||
var checkSql = "SELECT uid, pdate FROM JobReport WHERE idx = @idx AND gcode = @gcode";
|
||||
var cs2 = Properties.Settings.Default.gwcs;
|
||||
using (var cn2 = new SqlConnection(cs2))
|
||||
using (var cmd2 = new SqlCommand(checkSql, cn2))
|
||||
{
|
||||
cmd2.Parameters.AddWithValue("@idx", idx);
|
||||
cmd2.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cn2.Open();
|
||||
using (var reader = cmd2.ExecuteReader())
|
||||
{
|
||||
if (reader.Read())
|
||||
{
|
||||
var ownerUid = reader["uid"]?.ToString();
|
||||
var pdate = reader["pdate"]?.ToString();
|
||||
|
||||
if (ownerUid != info.Login.no)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "타인의 자료는 삭제할 수 없습니다." });
|
||||
}
|
||||
|
||||
// 마감 체크
|
||||
if (!string.IsNullOrEmpty(pdate) && pdate.Length >= 7)
|
||||
{
|
||||
var smon = pdate.Substring(0, 7);
|
||||
if (DBM.GetMagamStatus(smon))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = $"등록일이 속한 월({smon})이 마감되었습니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sql = "DELETE FROM JobReport WHERE idx = @idx AND gcode = @gcode";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
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();
|
||||
cn.Close();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "삭제되었습니다." : "삭제에 실패했습니다." });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 업무형태 목록 조회 (Common 테이블, grp='15')
|
||||
/// 트리뷰 형태: process > jobgrp > type
|
||||
/// </summary>
|
||||
public string Jobreport_GetJobTypes(string process = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"SELECT idx, code, memo as [type], svalue as jobgrp, svalue2 as process
|
||||
FROM Common WITH (nolock)
|
||||
WHERE gcode = @gcode AND grp = '15'";
|
||||
|
||||
var parameters = new List<SqlParameter>();
|
||||
parameters.Add(new SqlParameter("@gcode", info.Login.gcode));
|
||||
|
||||
if (!string.IsNullOrEmpty(process))
|
||||
{
|
||||
sql += " AND svalue2 = @process";
|
||||
parameters.Add(new SqlParameter("@process", process));
|
||||
}
|
||||
|
||||
sql += " ORDER BY svalue2, svalue, memo";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
using (var da = new SqlDataAdapter(cmd))
|
||||
{
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = dt });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 업무일지 권한 정보 조회
|
||||
/// 본인이거나 권한 레벨 5 이상이면 OT 열을 볼 수 있음
|
||||
/// </summary>
|
||||
public string Jobreport_GetPermission(string targetUserId)
|
||||
{
|
||||
try
|
||||
{
|
||||
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.jobreport));
|
||||
bool canViewOT = string.IsNullOrEmpty(targetUserId) ||
|
||||
targetUserId == info.Login.no ||
|
||||
curLevel >= 5;
|
||||
|
||||
return JsonConvert.SerializeObject(new
|
||||
{
|
||||
Success = true,
|
||||
CurrentUserId = info.Login.no,
|
||||
Level = curLevel,
|
||||
CanViewOT = canViewOT
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
|
||||
@@ -191,8 +191,19 @@ namespace Project.Web
|
||||
{
|
||||
mainForm.OnLoginCompleted();
|
||||
}
|
||||
break;
|
||||
}
|
||||
else if (form is Dialog.fDashboard dashForm)
|
||||
{
|
||||
if (dashForm.InvokeRequired)
|
||||
{
|
||||
dashForm.Invoke(new Action(() => dashForm.RefreshPage()));
|
||||
}
|
||||
else
|
||||
{
|
||||
dashForm.RefreshPage();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -258,6 +269,30 @@ namespace Project.Web
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 로그인 상태 확인
|
||||
/// </summary>
|
||||
public string CheckLoginStatus()
|
||||
{
|
||||
var isLoggedIn = !string.IsNullOrEmpty(info.Login.no);
|
||||
var result = new
|
||||
{
|
||||
Success = true,
|
||||
IsLoggedIn = isLoggedIn,
|
||||
User = isLoggedIn ? new
|
||||
{
|
||||
Id = info.Login.no,
|
||||
Name = info.Login.nameK,
|
||||
NameE = info.Login.nameE,
|
||||
Dept = info.Login.dept,
|
||||
Email = info.Login.email,
|
||||
Level = info.Login.level,
|
||||
Gcode = info.Login.gcode
|
||||
} : null
|
||||
};
|
||||
return JsonConvert.SerializeObject(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 그룹 목록 조회
|
||||
/// </summary>
|
||||
@@ -288,6 +323,68 @@ namespace Project.Web
|
||||
return JsonConvert.SerializeObject(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 로그아웃 처리
|
||||
/// </summary>
|
||||
public string Logout()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 로그인 정보 초기화
|
||||
info.Login.no = "";
|
||||
info.Login.nameK = "";
|
||||
info.Login.nameE = "";
|
||||
info.Login.dept = "";
|
||||
info.Login.email = "";
|
||||
info.Login.level = 0;
|
||||
info.Login.gcode = "";
|
||||
info.Login.hp = "";
|
||||
info.Login.tel = "";
|
||||
info.Login.title = "";
|
||||
info.Login.process = "";
|
||||
info.Login.permission = 0;
|
||||
info.Login.gpermission = 0;
|
||||
|
||||
// fMain의 CloseAllForm 호출
|
||||
CallMainFormCloseAllForm();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "로그아웃 되었습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// fMain의 CloseAllForm() 호출
|
||||
/// </summary>
|
||||
private void CallMainFormCloseAllForm()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (Form form in Application.OpenForms)
|
||||
{
|
||||
if (form is fMain mainForm)
|
||||
{
|
||||
if (mainForm.InvokeRequired)
|
||||
{
|
||||
mainForm.Invoke(new Action(() => mainForm.CloseAllFormPublic()));
|
||||
}
|
||||
else
|
||||
{
|
||||
mainForm.CloseAllFormPublic();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"CloseAllForm 호출 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
200
Project/Web/MachineBridge/MachineBridge.MailForm.cs
Normal file
200
Project/Web/MachineBridge/MachineBridge.MailForm.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using FCOMMON;
|
||||
|
||||
namespace Project.Web
|
||||
{
|
||||
public partial class MachineBridge
|
||||
{
|
||||
#region MailForm API (메일양식)
|
||||
|
||||
/// <summary>
|
||||
/// 메일양식 목록 조회
|
||||
/// </summary>
|
||||
public string MailForm_GetList()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"SELECT idx, gcode, cate, title, tolist, bcc, cc, subject, tail, body,
|
||||
selfTo, selfCC, selfBCC, wuid, wdate, exceptmail, exceptmailcc
|
||||
FROM MailForm WITH (nolock)
|
||||
WHERE gcode = @gcode
|
||||
ORDER BY cate, title";
|
||||
|
||||
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 MailForm_GetDetail(int idx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"SELECT idx, gcode, cate, title, tolist, bcc, cc, subject, tail, body,
|
||||
selfTo, selfCC, selfBCC, wuid, wdate, exceptmail, exceptmailcc
|
||||
FROM MailForm WITH (nolock)
|
||||
WHERE idx = @idx";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@idx", idx);
|
||||
using (var da = new SqlDataAdapter(cmd))
|
||||
{
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
if (dt.Rows.Count > 0)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = dt.Rows[0] });
|
||||
}
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 메일양식 추가
|
||||
/// </summary>
|
||||
public string MailForm_Add(string cate, string title, string tolist, string bcc, string cc,
|
||||
string subject, string tail, string body, bool selfTo, bool selfCC, bool selfBCC,
|
||||
string exceptmail, string exceptmailcc)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"INSERT INTO MailForm (gcode, cate, title, tolist, bcc, cc, subject, tail, body,
|
||||
selfTo, selfCC, selfBCC, wuid, wdate, exceptmail, exceptmailcc)
|
||||
VALUES (@gcode, @cate, @title, @tolist, @bcc, @cc, @subject, @tail, @body,
|
||||
@selfTo, @selfCC, @selfBCC, @wuid, GETDATE(), @exceptmail, @exceptmailcc);
|
||||
SELECT SCOPE_IDENTITY();";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@cate", cate ?? "");
|
||||
cmd.Parameters.AddWithValue("@title", title ?? "");
|
||||
cmd.Parameters.AddWithValue("@tolist", tolist ?? "");
|
||||
cmd.Parameters.AddWithValue("@bcc", bcc ?? "");
|
||||
cmd.Parameters.AddWithValue("@cc", cc ?? "");
|
||||
cmd.Parameters.AddWithValue("@subject", subject ?? "");
|
||||
cmd.Parameters.AddWithValue("@tail", tail ?? "");
|
||||
cmd.Parameters.AddWithValue("@body", body ?? "");
|
||||
cmd.Parameters.AddWithValue("@selfTo", selfTo);
|
||||
cmd.Parameters.AddWithValue("@selfCC", selfCC);
|
||||
cmd.Parameters.AddWithValue("@selfBCC", selfBCC);
|
||||
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||
cmd.Parameters.AddWithValue("@exceptmail", exceptmail ?? "");
|
||||
cmd.Parameters.AddWithValue("@exceptmailcc", exceptmailcc ?? "");
|
||||
|
||||
cn.Open();
|
||||
var newIdx = cmd.ExecuteScalar();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "등록되었습니다.", idx = newIdx });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 메일양식 수정
|
||||
/// </summary>
|
||||
public string MailForm_Edit(int idx, string cate, string title, string tolist, string bcc, string cc,
|
||||
string subject, string tail, string body, bool selfTo, bool selfCC, bool selfBCC,
|
||||
string exceptmail, string exceptmailcc)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"UPDATE MailForm SET
|
||||
cate = @cate, title = @title, tolist = @tolist, bcc = @bcc, cc = @cc,
|
||||
subject = @subject, tail = @tail, body = @body,
|
||||
selfTo = @selfTo, selfCC = @selfCC, selfBCC = @selfBCC,
|
||||
wuid = @wuid, wdate = GETDATE(), exceptmail = @exceptmail, exceptmailcc = @exceptmailcc
|
||||
WHERE idx = @idx";
|
||||
|
||||
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("@cate", cate ?? "");
|
||||
cmd.Parameters.AddWithValue("@title", title ?? "");
|
||||
cmd.Parameters.AddWithValue("@tolist", tolist ?? "");
|
||||
cmd.Parameters.AddWithValue("@bcc", bcc ?? "");
|
||||
cmd.Parameters.AddWithValue("@cc", cc ?? "");
|
||||
cmd.Parameters.AddWithValue("@subject", subject ?? "");
|
||||
cmd.Parameters.AddWithValue("@tail", tail ?? "");
|
||||
cmd.Parameters.AddWithValue("@body", body ?? "");
|
||||
cmd.Parameters.AddWithValue("@selfTo", selfTo);
|
||||
cmd.Parameters.AddWithValue("@selfCC", selfCC);
|
||||
cmd.Parameters.AddWithValue("@selfBCC", selfBCC);
|
||||
cmd.Parameters.AddWithValue("@wuid", info.Login.no);
|
||||
cmd.Parameters.AddWithValue("@exceptmail", exceptmail ?? "");
|
||||
cmd.Parameters.AddWithValue("@exceptmailcc", exceptmailcc ?? "");
|
||||
|
||||
cn.Open();
|
||||
cmd.ExecuteNonQuery();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "수정되었습니다." });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 메일양식 삭제
|
||||
/// </summary>
|
||||
public string MailForm_Delete(int idx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = "DELETE FROM MailForm WHERE idx = @idx";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
using (var cn = new SqlConnection(cs))
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@idx", idx);
|
||||
cn.Open();
|
||||
cmd.ExecuteNonQuery();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "삭제되었습니다." });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ namespace Project.Web
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 할일 추가
|
||||
/// </summary>
|
||||
|
||||
290
Project/Web/MachineBridge/MachineBridge.User.cs
Normal file
290
Project/Web/MachineBridge/MachineBridge.User.cs
Normal file
@@ -0,0 +1,290 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using FCOMMON;
|
||||
|
||||
namespace Project.Web
|
||||
{
|
||||
public partial class MachineBridge
|
||||
{
|
||||
#region User API
|
||||
|
||||
/// <summary>
|
||||
/// 현재 로그인한 사용자 정보 조회
|
||||
/// </summary>
|
||||
public string GetCurrentUserInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(info.Login.no))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "로그인이 필요합니다." });
|
||||
}
|
||||
|
||||
var taUser = new dsMSSQLTableAdapters.UsersTableAdapter();
|
||||
var taGUser = new dsMSSQLTableAdapters.EETGW_GroupUserTableAdapter();
|
||||
|
||||
var drUser = taUser.GetID(info.Login.no).FirstOrDefault();
|
||||
var drGUser = taGUser.GetbyID(info.Login.gcode, info.Login.no).FirstOrDefault();
|
||||
|
||||
if (drUser == null)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 정보를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
var userInfo = new
|
||||
{
|
||||
Id = drUser.id,
|
||||
NameK = drUser.name,
|
||||
NameE = drUser.nameE,
|
||||
Dept = drUser.dept,
|
||||
Grade = drUser.grade,
|
||||
Email = drUser.email,
|
||||
Tel = drUser.tel,
|
||||
Hp = drUser.hp,
|
||||
DateIn = drUser.indate,
|
||||
DateO = drUser.outdate,
|
||||
Memo = drUser.memo,
|
||||
Process = drGUser?.Process ?? "",
|
||||
State = drGUser?.state ?? "",
|
||||
UseJobReport = drGUser != null && !drGUser.IsuseJobReportNull() && drGUser.useJobReport,
|
||||
UseUserState = drGUser != null && !drGUser.IsuseUserStateNull() && drGUser.useUserState,
|
||||
ExceptHoly = drGUser != null && !drGUser.IsexceptHolyNull() && drGUser.exceptHoly,
|
||||
Level = drGUser?.level ?? 0
|
||||
};
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = userInfo });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 정보 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 정보 조회 (ID로)
|
||||
/// </summary>
|
||||
public string GetUserInfoById(string userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 ID를 입력하세요." });
|
||||
}
|
||||
|
||||
var taUser = new dsMSSQLTableAdapters.UsersTableAdapter();
|
||||
var taGUser = new dsMSSQLTableAdapters.EETGW_GroupUserTableAdapter();
|
||||
|
||||
var drUser = taUser.GetID(userId).FirstOrDefault();
|
||||
var drGUser = taGUser.GetbyID(info.Login.gcode, userId).FirstOrDefault();
|
||||
|
||||
if (drUser == null)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "등록된 사용자가 없습니다." });
|
||||
}
|
||||
|
||||
var userInfo = new
|
||||
{
|
||||
Id = drUser.id,
|
||||
NameK = drUser.name,
|
||||
NameE = drUser.nameE,
|
||||
Dept = drUser.dept,
|
||||
Grade = drUser.grade,
|
||||
Email = drUser.email,
|
||||
Tel = drUser.tel,
|
||||
Hp = drUser.hp,
|
||||
DateIn = drUser.indate,
|
||||
DateO = drUser.outdate,
|
||||
Memo = drUser.memo,
|
||||
Process = drGUser?.Process ?? "",
|
||||
State = drGUser?.state ?? "",
|
||||
UseJobReport = drGUser != null && !drGUser.IsuseJobReportNull() && drGUser.useJobReport,
|
||||
UseUserState = drGUser != null && !drGUser.IsuseUserStateNull() && drGUser.useUserState,
|
||||
ExceptHoly = drGUser != null && !drGUser.IsexceptHolyNull() && drGUser.exceptHoly,
|
||||
Level = drGUser?.level ?? 0
|
||||
};
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = userInfo });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 정보 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 정보 저장
|
||||
/// </summary>
|
||||
public string SaveUserInfo(string jsonData)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userData = JsonConvert.DeserializeObject<UserInfoData>(jsonData);
|
||||
|
||||
if (userData == null)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "잘못된 데이터 형식입니다." });
|
||||
}
|
||||
|
||||
var gcode = info.Login.gcode;
|
||||
var uid = userData.Id;
|
||||
|
||||
// 현재 사용자 권한 확인
|
||||
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.account));
|
||||
|
||||
// 그룹 사용자 정보 처리
|
||||
var taUserGrp = new dsMSSQLTableAdapters.EETGW_GroupUserTableAdapter();
|
||||
var dtUserGrp = taUserGrp.GetData(gcode);
|
||||
var drGuser = dtUserGrp.Where(t => t.uid == uid).FirstOrDefault();
|
||||
|
||||
if (drGuser != null)
|
||||
{
|
||||
drGuser.Process = userData.Process ?? "";
|
||||
drGuser.state = userData.State ?? "";
|
||||
|
||||
if (curLevel > 4)
|
||||
{
|
||||
drGuser.useJobReport = userData.UseJobReport;
|
||||
drGuser.useUserState = userData.UseUserState;
|
||||
drGuser.exceptHoly = userData.ExceptHoly;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
drGuser = dtUserGrp.NewEETGW_GroupUserRow();
|
||||
drGuser.wuid = info.Login.no;
|
||||
drGuser.wdate = DateTime.Now;
|
||||
drGuser.gcode = gcode;
|
||||
drGuser.level = 1;
|
||||
drGuser.uid = uid;
|
||||
drGuser.state = userData.State ?? "";
|
||||
drGuser.Process = userData.Process ?? "";
|
||||
drGuser.useJobReport = userData.UseJobReport;
|
||||
drGuser.useUserState = userData.UseUserState;
|
||||
drGuser.exceptHoly = userData.ExceptHoly;
|
||||
dtUserGrp.AddEETGW_GroupUserRow(drGuser);
|
||||
}
|
||||
|
||||
// 사용자 정보 처리
|
||||
var tauser = new dsMSSQLTableAdapters.UsersTableAdapter();
|
||||
var dtuser = tauser.GetID(uid);
|
||||
var drUser = dtuser.FirstOrDefault();
|
||||
|
||||
if (drUser == null)
|
||||
{
|
||||
drUser = dtuser.NewUsersRow();
|
||||
drUser.wuid = info.Login.no;
|
||||
drUser.wdate = DateTime.Now;
|
||||
drUser.gcode = gcode;
|
||||
drUser.level = 1;
|
||||
drUser.id = uid;
|
||||
drUser.password = "B6589FC6AB0DC82CF12099D1C2D40AB994E8410C"; // 기본값 0
|
||||
dtuser.AddUsersRow(drUser);
|
||||
}
|
||||
|
||||
drUser.name = userData.NameK ?? "";
|
||||
drUser.nameE = userData.NameE ?? "";
|
||||
drUser.dept = userData.Dept ?? "";
|
||||
drUser.email = userData.Email ?? "";
|
||||
drUser.tel = userData.Tel ?? "";
|
||||
drUser.hp = userData.Hp ?? "";
|
||||
drUser.indate = userData.DateIn ?? "";
|
||||
drUser.outdate = userData.DateO ?? "";
|
||||
drUser.memo = userData.Memo ?? "";
|
||||
drUser.processs = userData.Process ?? "";
|
||||
drUser.grade = userData.Grade ?? "";
|
||||
drUser.EndEdit();
|
||||
|
||||
var cnt1 = taUserGrp.Update(dtUserGrp);
|
||||
var cnt2 = tauser.Update(dtuser);
|
||||
|
||||
taUserGrp.Dispose();
|
||||
tauser.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "저장 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 비밀번호 변경
|
||||
/// </summary>
|
||||
public string ChangePassword(string oldPassword, string newPassword)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(info.Login.no))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "로그인이 필요합니다." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(newPassword))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "새 비밀번호를 입력하세요." });
|
||||
}
|
||||
|
||||
var uid = info.Login.no;
|
||||
int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.account));
|
||||
|
||||
var taUser = new dsMSSQLTableAdapters.UsersTableAdapter();
|
||||
var dtUser = taUser.GetID(uid);
|
||||
var drUser = dtUser.FirstOrDefault();
|
||||
|
||||
if (drUser == null)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 정보를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// 관리자가 아니면 기존 암호 확인
|
||||
if (curLevel < 5)
|
||||
{
|
||||
var encOldPass = Pub.MakePasswordEnc(oldPassword);
|
||||
if (!encOldPass.Equals(drUser.password))
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "기존 암호가 일치하지 않습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
drUser.password = Pub.MakePasswordEnc(newPassword);
|
||||
drUser.EndEdit();
|
||||
taUser.Update(dtUser);
|
||||
taUser.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "비밀번호가 변경되었습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "비밀번호 변경 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 정보 데이터 클래스
|
||||
/// </summary>
|
||||
public class UserInfoData
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string NameK { get; set; }
|
||||
public string NameE { get; set; }
|
||||
public string Dept { get; set; }
|
||||
public string Grade { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Tel { get; set; }
|
||||
public string Hp { get; set; }
|
||||
public string DateIn { get; set; }
|
||||
public string DateO { get; set; }
|
||||
public string Memo { get; set; }
|
||||
public string Process { get; set; }
|
||||
public string State { get; set; }
|
||||
public bool UseJobReport { get; set; }
|
||||
public bool UseUserState { get; set; }
|
||||
public bool ExceptHoly { get; set; }
|
||||
}
|
||||
}
|
||||
228
Project/Web/MachineBridge/MachineBridge.UserGroup.cs
Normal file
228
Project/Web/MachineBridge/MachineBridge.UserGroup.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using FCOMMON;
|
||||
|
||||
namespace Project.Web
|
||||
{
|
||||
public partial class MachineBridge
|
||||
{
|
||||
#region UserGroup API (그룹정보/권한설정)
|
||||
|
||||
/// <summary>
|
||||
/// 그룹 목록 조회
|
||||
/// </summary>
|
||||
public string UserGroup_GetList()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"SELECT dept, gcode, path_kj, permission, advpurchase, advkisul,
|
||||
managerinfo, devinfo, usemail
|
||||
FROM UserGroup WITH (nolock)
|
||||
WHERE gcode = @gcode
|
||||
ORDER BY dept";
|
||||
|
||||
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 UserGroup_Add(string dept, string path_kj, int permission,
|
||||
bool advpurchase, bool advkisul, string managerinfo, string devinfo, bool usemail)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 중복 체크
|
||||
var checkSql = "SELECT COUNT(*) FROM UserGroup WHERE gcode = @gcode AND dept = @dept";
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
|
||||
using (var cn = new SqlConnection(cs))
|
||||
{
|
||||
cn.Open();
|
||||
using (var checkCmd = new SqlCommand(checkSql, cn))
|
||||
{
|
||||
checkCmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
checkCmd.Parameters.AddWithValue("@dept", dept);
|
||||
var count = (int)checkCmd.ExecuteScalar();
|
||||
if (count > 0)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "이미 존재하는 부서명입니다." });
|
||||
}
|
||||
}
|
||||
|
||||
var sql = @"INSERT INTO UserGroup (dept, gcode, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail)
|
||||
VALUES (@dept, @gcode, @path_kj, @permission, @advpurchase, @advkisul, @managerinfo, @devinfo, @usemail)";
|
||||
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@dept", dept ?? "");
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@path_kj", path_kj ?? "");
|
||||
cmd.Parameters.AddWithValue("@permission", permission);
|
||||
cmd.Parameters.AddWithValue("@advpurchase", advpurchase);
|
||||
cmd.Parameters.AddWithValue("@advkisul", advkisul);
|
||||
cmd.Parameters.AddWithValue("@managerinfo", managerinfo ?? "");
|
||||
cmd.Parameters.AddWithValue("@devinfo", devinfo ?? "");
|
||||
cmd.Parameters.AddWithValue("@usemail", usemail);
|
||||
cmd.ExecuteNonQuery();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "등록되었습니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 그룹 정보 수정
|
||||
/// </summary>
|
||||
public string UserGroup_Edit(string originalDept, string dept, string path_kj, int permission,
|
||||
bool advpurchase, bool advkisul, string managerinfo, string devinfo, bool usemail)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
using (var cn = new SqlConnection(cs))
|
||||
{
|
||||
cn.Open();
|
||||
|
||||
// 부서명이 변경되었을 경우 중복 체크
|
||||
if (originalDept != dept)
|
||||
{
|
||||
var checkSql = "SELECT COUNT(*) FROM UserGroup WHERE gcode = @gcode AND dept = @dept";
|
||||
using (var checkCmd = new SqlCommand(checkSql, cn))
|
||||
{
|
||||
checkCmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
checkCmd.Parameters.AddWithValue("@dept", dept);
|
||||
var count = (int)checkCmd.ExecuteScalar();
|
||||
if (count > 0)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "이미 존재하는 부서명입니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sql = @"UPDATE UserGroup SET
|
||||
dept = @dept, path_kj = @path_kj, permission = @permission,
|
||||
advpurchase = @advpurchase, advkisul = @advkisul,
|
||||
managerinfo = @managerinfo, devinfo = @devinfo, usemail = @usemail
|
||||
WHERE gcode = @gcode AND dept = @originalDept";
|
||||
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@originalDept", originalDept);
|
||||
cmd.Parameters.AddWithValue("@dept", dept ?? "");
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@path_kj", path_kj ?? "");
|
||||
cmd.Parameters.AddWithValue("@permission", permission);
|
||||
cmd.Parameters.AddWithValue("@advpurchase", advpurchase);
|
||||
cmd.Parameters.AddWithValue("@advkisul", advkisul);
|
||||
cmd.Parameters.AddWithValue("@managerinfo", managerinfo ?? "");
|
||||
cmd.Parameters.AddWithValue("@devinfo", devinfo ?? "");
|
||||
cmd.Parameters.AddWithValue("@usemail", usemail);
|
||||
cmd.ExecuteNonQuery();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "수정되었습니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 그룹 삭제
|
||||
/// </summary>
|
||||
public string UserGroup_Delete(string dept)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 해당 그룹에 소속된 사용자가 있는지 확인
|
||||
var checkSql = "SELECT COUNT(*) FROM GroupUser WHERE gcode = @gcode AND dept = @dept";
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
|
||||
using (var cn = new SqlConnection(cs))
|
||||
{
|
||||
cn.Open();
|
||||
|
||||
using (var checkCmd = new SqlCommand(checkSql, cn))
|
||||
{
|
||||
checkCmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
checkCmd.Parameters.AddWithValue("@dept", dept);
|
||||
var count = (int)checkCmd.ExecuteScalar();
|
||||
if (count > 0)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = $"해당 그룹에 {count}명의 사용자가 소속되어 있어 삭제할 수 없습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
var sql = "DELETE FROM UserGroup WHERE gcode = @gcode AND dept = @dept";
|
||||
using (var cmd = new SqlCommand(sql, cn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@dept", dept);
|
||||
cmd.ExecuteNonQuery();
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "삭제되었습니다." });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 권한 정보 목록 (프론트엔드용)
|
||||
/// </summary>
|
||||
public string UserGroup_GetPermissionInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var permissions = new[]
|
||||
{
|
||||
new { index = 0, name = "menu_purchase", label = "구매신청", description = "구매신청 메뉴 표시" },
|
||||
new { index = 1, name = "menu_project", label = "프로젝트", description = "프로젝트 메뉴 표시" },
|
||||
new { index = 2, name = "menu_history", label = "업무일지", description = "업무일지 메뉴 표시" },
|
||||
new { index = 3, name = "menu_jago", label = "품목재고", description = "품목재고 메뉴 표시" },
|
||||
new { index = 4, name = "menu_equipment", label = "장비목록", description = "장비목록 메뉴 표시" },
|
||||
new { index = 5, name = "menu_workday", label = "근태관리", description = "근태관리 메뉴 표시" },
|
||||
new { index = 6, name = "purchase_adv", label = "(구매)상세입력", description = "구매신청 상세입력 권한" },
|
||||
new { index = 7, name = "menu_docu", label = "문서", description = "문서 메뉴 표시" },
|
||||
new { index = 8, name = "menu_logdata", label = "운영기록", description = "운영기록 메뉴 표시" },
|
||||
new { index = 9, name = "jobreport_kisul", label = "업무일지-기술료", description = "업무일지 기술료 보기 권한" },
|
||||
new { index = 10, name = "jobreport_editblock", label = "업무일지-편집제한", description = "업무일지 편집 제한" },
|
||||
};
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = permissions });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
378
Project/Web/MachineBridge/MachineBridge.UserList.cs
Normal file
378
Project/Web/MachineBridge/MachineBridge.UserList.cs
Normal file
@@ -0,0 +1,378 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using FCOMMON;
|
||||
|
||||
namespace Project.Web
|
||||
{
|
||||
public partial class MachineBridge
|
||||
{
|
||||
#region UserList API
|
||||
|
||||
/// <summary>
|
||||
/// 현재 사용자 권한 레벨 조회 (로그인 레벨 + account 권한 중 높은 값)
|
||||
/// </summary>
|
||||
public string UserList_GetCurrentLevel()
|
||||
{
|
||||
try
|
||||
{
|
||||
int curLevel = Math.Max(info.Login.level, FCOMMON.DBM.getAuth(FCOMMON.DBM.eAuthType.account));
|
||||
return JsonConvert.SerializeObject(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
Level = curLevel,
|
||||
CurrentUserId = info.Login.no,
|
||||
CanEdit = curLevel >= 5
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "권한 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 부서 목록 조회
|
||||
/// </summary>
|
||||
public string UserList_GetDepts()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = "SELECT DISTINCT dept FROM UserGroup WITH (NOLOCK) WHERE gcode = @gcode AND ISNULL(dept,'') <> '' ORDER BY dept";
|
||||
|
||||
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<string>();
|
||||
foreach (DataRow dr in dt.Rows)
|
||||
{
|
||||
result.Add(dr["dept"]?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "부서 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 목록 조회
|
||||
/// </summary>
|
||||
public string UserList_GetList(string process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(process) || process == "%") process = "%";
|
||||
else process = "%" + process + "%";
|
||||
|
||||
var gcode = info.Login.gcode;
|
||||
System.Diagnostics.Debug.WriteLine($"[UserList_GetList] gcode={gcode}, process={process}");
|
||||
|
||||
var sql = @"SELECT gcode, dept, level, name, nameE, grade, email, tel, indate, outdate, hp,
|
||||
memo, processs, id, state, useJobReport, useUserState, exceptHoly
|
||||
FROM vGroupUser WITH (NOLOCK)
|
||||
WHERE gcode = @gcode
|
||||
AND ISNULL(processs,'') LIKE @process
|
||||
ORDER BY useUserState DESC, useJobReport DESC, name";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
cmd.Parameters.AddWithValue("@gcode", gcode);
|
||||
cmd.Parameters.AddWithValue("@process", process);
|
||||
|
||||
var da = new SqlDataAdapter(cmd);
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
|
||||
System.Diagnostics.Debug.WriteLine($"[UserList_GetList] 결과 행 수: {dt.Rows.Count}");
|
||||
|
||||
da.Dispose();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(dt, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 목록 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 상세 정보 조회
|
||||
/// </summary>
|
||||
public string UserList_GetUser(string userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"SELECT
|
||||
u.id,
|
||||
u.name,
|
||||
u.nameE,
|
||||
u.grade,
|
||||
u.email,
|
||||
u.tel,
|
||||
u.indate,
|
||||
u.outdate,
|
||||
u.hp,
|
||||
u.processs,
|
||||
u.state,
|
||||
u.memo,
|
||||
gu.level,
|
||||
gu.useUserState,
|
||||
gu.useJobReport,
|
||||
gu.exceptHoly,
|
||||
gu.dept,
|
||||
gu.gcode
|
||||
FROM EETGW_GroupUser gu WITH (NOLOCK)
|
||||
INNER JOIN Users u WITH (NOLOCK) ON gu.uid = u.id
|
||||
WHERE gu.gcode = @gcode AND gu.uid = @uid";
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@uid", userId);
|
||||
|
||||
var da = new SqlDataAdapter(cmd);
|
||||
var dt = new DataTable();
|
||||
da.Fill(dt);
|
||||
da.Dispose();
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
if (dt.Rows.Count > 0)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = true, Data = dt.Rows[0] },
|
||||
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
}
|
||||
else
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자를 찾을 수 없습니다." });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 조회 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 전체 정보 저장 (Users + GroupUser)
|
||||
/// </summary>
|
||||
public string UserList_SaveUserFull(string jsonData)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userData = JsonConvert.DeserializeObject<UserListFullData>(jsonData);
|
||||
if (userData == null)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "잘못된 데이터 형식입니다." });
|
||||
}
|
||||
|
||||
// 권한 체크
|
||||
int curLevel = Math.Max(info.Login.level, FCOMMON.DBM.getAuth(FCOMMON.DBM.eAuthType.account));
|
||||
bool isSelf = info.Login.no == userData.id;
|
||||
|
||||
// 본인이 아니고 권한이 없으면 거부
|
||||
if (!isSelf && curLevel < 5)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "타인의 계정은 편집할 수 없습니다." });
|
||||
}
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
using (var cn = new SqlConnection(cs))
|
||||
{
|
||||
cn.Open();
|
||||
|
||||
// Users 테이블 업데이트
|
||||
var sqlUser = @"UPDATE Users SET
|
||||
name = @name,
|
||||
nameE = @nameE,
|
||||
grade = @grade,
|
||||
email = @email,
|
||||
tel = @tel,
|
||||
hp = @hp,
|
||||
indate = @indate,
|
||||
outdate = @outdate,
|
||||
memo = @memo,
|
||||
processs = @processs,
|
||||
state = @state
|
||||
WHERE id = @id";
|
||||
|
||||
using (var cmdUser = new SqlCommand(sqlUser, cn))
|
||||
{
|
||||
cmdUser.Parameters.AddWithValue("@id", userData.id);
|
||||
cmdUser.Parameters.AddWithValue("@name", userData.name ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@nameE", userData.nameE ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@grade", userData.grade ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@email", userData.email ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@tel", userData.tel ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@hp", userData.hp ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@indate", userData.indate ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@outdate", userData.outdate ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@memo", userData.memo ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@processs", userData.processs ?? "");
|
||||
cmdUser.Parameters.AddWithValue("@state", userData.state ?? "");
|
||||
cmdUser.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// EETGW_GroupUser 테이블 업데이트 (관리자만)
|
||||
if (curLevel >= 5)
|
||||
{
|
||||
var sqlGroup = @"UPDATE EETGW_GroupUser SET
|
||||
level = @level,
|
||||
useUserState = @useUserState,
|
||||
useJobReport = @useJobReport,
|
||||
exceptHoly = @exceptHoly
|
||||
WHERE gcode = @gcode AND uid = @uid";
|
||||
|
||||
using (var cmdGroup = new SqlCommand(sqlGroup, cn))
|
||||
{
|
||||
cmdGroup.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmdGroup.Parameters.AddWithValue("@uid", userData.id);
|
||||
cmdGroup.Parameters.AddWithValue("@level", userData.level);
|
||||
cmdGroup.Parameters.AddWithValue("@useUserState", userData.useUserState);
|
||||
cmdGroup.Parameters.AddWithValue("@useJobReport", userData.useJobReport);
|
||||
cmdGroup.Parameters.AddWithValue("@exceptHoly", userData.exceptHoly);
|
||||
cmdGroup.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "저장 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 저장 (그룹 설정만)
|
||||
/// </summary>
|
||||
public string UserList_SaveGroupUser(string userId, string dept, int level, bool useUserState, bool useJobReport, bool exceptHoly)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 권한 체크
|
||||
int curLevel = Math.Max(info.Login.level, FCOMMON.DBM.getAuth(FCOMMON.DBM.eAuthType.account));
|
||||
if (curLevel < 5)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "사용자 관리 권한이 없습니다." });
|
||||
}
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
|
||||
var sql = @"UPDATE EETGW_GroupUser SET
|
||||
dept = @dept,
|
||||
level = @level,
|
||||
useUserState = @useUserState,
|
||||
useJobReport = @useJobReport,
|
||||
exceptHoly = @exceptHoly
|
||||
WHERE gcode = @gcode AND uid = @uid";
|
||||
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@uid", userId);
|
||||
cmd.Parameters.AddWithValue("@dept", dept ?? "");
|
||||
cmd.Parameters.AddWithValue("@level", level);
|
||||
cmd.Parameters.AddWithValue("@useUserState", useUserState);
|
||||
cmd.Parameters.AddWithValue("@useJobReport", useJobReport);
|
||||
cmd.Parameters.AddWithValue("@exceptHoly", exceptHoly);
|
||||
|
||||
cn.Open();
|
||||
var result = cmd.ExecuteNonQuery();
|
||||
cn.Close();
|
||||
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
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 UserList_DeleteGroupUser(string userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 권한 체크
|
||||
int curLevel = Math.Max(info.Login.level, FCOMMON.DBM.getAuth(FCOMMON.DBM.eAuthType.account));
|
||||
if (curLevel < 5)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "계정 관리자만 사용할 수 있습니다." });
|
||||
}
|
||||
|
||||
var cs = Properties.Settings.Default.gwcs;
|
||||
var cn = new SqlConnection(cs);
|
||||
var sql = "DELETE FROM EETGW_GroupUser WHERE gcode = @gcode AND uid = @uid";
|
||||
var cmd = new SqlCommand(sql, cn);
|
||||
|
||||
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
|
||||
cmd.Parameters.AddWithValue("@uid", userId);
|
||||
|
||||
cn.Open();
|
||||
var result = cmd.ExecuteNonQuery();
|
||||
cn.Close();
|
||||
|
||||
cmd.Dispose();
|
||||
cn.Dispose();
|
||||
|
||||
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "삭제되었습니다." : "삭제에 실패했습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = "삭제 실패: " + ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 전체 정보 데이터 클래스
|
||||
/// </summary>
|
||||
public class UserListFullData
|
||||
{
|
||||
public string id { get; set; }
|
||||
public string name { get; set; }
|
||||
public string nameE { get; set; }
|
||||
public string grade { get; set; }
|
||||
public string email { get; set; }
|
||||
public string tel { get; set; }
|
||||
public string hp { get; set; }
|
||||
public string indate { get; set; }
|
||||
public string outdate { get; set; }
|
||||
public string memo { get; set; }
|
||||
public string processs { get; set; }
|
||||
public string state { get; set; }
|
||||
public int level { get; set; }
|
||||
public bool useUserState { get; set; }
|
||||
public bool useJobReport { get; set; }
|
||||
public bool exceptHoly { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,102 @@ namespace Project.Web
|
||||
public partial class MachineBridge
|
||||
{
|
||||
// Reference to the main form to update logic
|
||||
private Dialog.fDashboardNew _host;
|
||||
private Dialog.fDashboard _host;
|
||||
|
||||
public MachineBridge(Dialog.fDashboardNew host)
|
||||
// WebSocket 서버 인스턴스
|
||||
private static Project.Web.WebSocketServer _wsServer;
|
||||
private static readonly object _wsLock = new object();
|
||||
|
||||
private const int WS_PORT = 8082;
|
||||
|
||||
public MachineBridge(Dialog.fDashboard host)
|
||||
{
|
||||
_host = host;
|
||||
StartWebSocketServer();
|
||||
}
|
||||
|
||||
#region WebSocket Server Control
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket 서버 시작
|
||||
/// </summary>
|
||||
private void StartWebSocketServer()
|
||||
{
|
||||
lock (_wsLock)
|
||||
{
|
||||
if (_wsServer != null)
|
||||
{
|
||||
Console.WriteLine("[WS] WebSocket server already running");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string url = $"http://localhost:{WS_PORT}/";
|
||||
_wsServer = new Project.Web.WebSocketServer(url, this);
|
||||
_wsServer.Start();
|
||||
Console.WriteLine($"[WS] WebSocket server started on port {WS_PORT}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WS] Failed to start WebSocket server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket 서버 중지
|
||||
/// </summary>
|
||||
public static void StopWebSocketServer()
|
||||
{
|
||||
lock (_wsLock)
|
||||
{
|
||||
if (_wsServer != null)
|
||||
{
|
||||
_wsServer.Stop();
|
||||
_wsServer = null;
|
||||
Console.WriteLine("[WS] WebSocket server stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket 서버 실행 여부 확인
|
||||
/// </summary>
|
||||
public static bool IsWebSocketServerRunning()
|
||||
{
|
||||
lock (_wsLock)
|
||||
{
|
||||
return _wsServer != null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region App Info
|
||||
|
||||
/// <summary>
|
||||
/// 애플리케이션 버전 정보 반환
|
||||
/// </summary>
|
||||
public string GetAppVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonConvert.SerializeObject(new
|
||||
{
|
||||
Success = true,
|
||||
ProductName = Application.ProductName,
|
||||
ProductVersion = Application.ProductVersion,
|
||||
DisplayVersion = $"{Application.ProductName} v{Application.ProductVersion}"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
705
Project/Web/MachineBridge/WebSocketServer.cs
Normal file
705
Project/Web/MachineBridge/WebSocketServer.cs
Normal file
@@ -0,0 +1,705 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Project.Web
|
||||
{
|
||||
/// <summary>
|
||||
/// GroupWare WebSocket 서버
|
||||
/// npm run dev 환경에서 핫 리로드 개발을 위한 WebSocket 통신 지원
|
||||
/// </summary>
|
||||
public class WebSocketServer
|
||||
{
|
||||
private HttpListener _httpListener;
|
||||
private List<WebSocket> _clients = new List<WebSocket>();
|
||||
private ConcurrentDictionary<WebSocket, SemaphoreSlim> _socketLocks = new ConcurrentDictionary<WebSocket, SemaphoreSlim>();
|
||||
private MachineBridge _bridge;
|
||||
private bool _isRunning = false;
|
||||
|
||||
public WebSocketServer(string url, MachineBridge bridge)
|
||||
{
|
||||
_bridge = bridge;
|
||||
_httpListener = new HttpListener();
|
||||
_httpListener.Prefixes.Add(url);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning) return;
|
||||
|
||||
try
|
||||
{
|
||||
_httpListener.Start();
|
||||
_isRunning = true;
|
||||
Console.WriteLine($"[WS] GroupWare WebSocket Server Started");
|
||||
Task.Run(AcceptConnections);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WS] Start Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_isRunning = false;
|
||||
try
|
||||
{
|
||||
_httpListener.Stop();
|
||||
_httpListener.Close();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task AcceptConnections()
|
||||
{
|
||||
while (_httpListener.IsListening && _isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _httpListener.GetContextAsync();
|
||||
if (context.Request.IsWebSocketRequest)
|
||||
{
|
||||
ProcessRequest(context);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
context.Response.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_isRunning)
|
||||
Console.WriteLine($"[WS] Accept Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void ProcessRequest(HttpListenerContext context)
|
||||
{
|
||||
WebSocketContext wsContext = null;
|
||||
try
|
||||
{
|
||||
wsContext = await context.AcceptWebSocketAsync(subProtocol: null);
|
||||
WebSocket socket = wsContext.WebSocket;
|
||||
_socketLocks.TryAdd(socket, new SemaphoreSlim(1, 1));
|
||||
|
||||
lock (_clients) { _clients.Add(socket); }
|
||||
Console.WriteLine("[WS] Client Connected");
|
||||
|
||||
await ReceiveLoop(socket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WS] Process Error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (wsContext != null)
|
||||
{
|
||||
WebSocket socket = wsContext.WebSocket;
|
||||
lock (_clients) { _clients.Remove(socket); }
|
||||
|
||||
if (_socketLocks.TryRemove(socket, out var semaphore))
|
||||
{
|
||||
semaphore.Dispose();
|
||||
}
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoop(WebSocket socket)
|
||||
{
|
||||
var buffer = new byte[1024 * 4];
|
||||
while (socket.State == WebSocketState.Open && _isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
|
||||
}
|
||||
else if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
string msg = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
await HandleMessage(msg, socket);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessage(string msg, WebSocket socket)
|
||||
{
|
||||
try
|
||||
{
|
||||
dynamic json = JsonConvert.DeserializeObject(msg);
|
||||
string type = json.type;
|
||||
|
||||
Console.WriteLine($"[WS] Message: {type}");
|
||||
|
||||
switch (type)
|
||||
{
|
||||
// ===== Todo API =====
|
||||
case "GET_TODOS":
|
||||
{
|
||||
string result = _bridge.Todo_GetTodos();
|
||||
var response = new { type = "TODOS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_TODO":
|
||||
{
|
||||
int id = json.id;
|
||||
string result = _bridge.GetTodo(id);
|
||||
var response = new { type = "TODO_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "CREATE_TODO":
|
||||
{
|
||||
string title = json.title ?? "";
|
||||
string remark = json.remark ?? "";
|
||||
string expire = json.expire;
|
||||
int seqno = json.seqno ?? 0;
|
||||
bool flag = json.flag ?? false;
|
||||
string request = json.request;
|
||||
string status = json.status ?? "0";
|
||||
|
||||
string result = _bridge.CreateTodo(title, remark, expire, seqno, flag, request, status);
|
||||
var response = new { type = "TODO_CREATED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "UPDATE_TODO":
|
||||
{
|
||||
int idx = json.idx;
|
||||
string title = json.title ?? "";
|
||||
string remark = json.remark ?? "";
|
||||
string expire = json.expire;
|
||||
int seqno = json.seqno ?? 0;
|
||||
bool flag = json.flag ?? false;
|
||||
string request = json.request;
|
||||
string status = json.status ?? "0";
|
||||
|
||||
string result = _bridge.Todo_UpdateTodo(idx, title, remark, expire, seqno, flag, request, status);
|
||||
var response = new { type = "TODO_UPDATED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "DELETE_TODO":
|
||||
{
|
||||
int id = json.id;
|
||||
string result = _bridge.Todo_DeleteTodo(id);
|
||||
var response = new { type = "TODO_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_URGENT_TODOS":
|
||||
{
|
||||
string result = _bridge.GetUrgentTodos();
|
||||
var response = new { type = "URGENT_TODOS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
// ===== Dashboard API =====
|
||||
case "GET_PURCHASE_WAIT_COUNT":
|
||||
{
|
||||
string result = _bridge.GetPurchaseWaitCount();
|
||||
var response = new { type = "PURCHASE_WAIT_COUNT_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_TODAY_COUNT_H":
|
||||
{
|
||||
string result = _bridge.TodayCountH();
|
||||
var response = new { type = "TODAY_COUNT_H_DATA", count = int.Parse(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_HOLY_USER":
|
||||
{
|
||||
string result = _bridge.GetHolyUser();
|
||||
var response = new { type = "HOLY_USER_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_HOLY_REQUEST_USER":
|
||||
{
|
||||
string result = _bridge.GetHolyRequestUser();
|
||||
var response = new { type = "HOLY_REQUEST_USER_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_PURCHASE_NR_LIST":
|
||||
{
|
||||
string result = _bridge.GetPurchaseNRList();
|
||||
var response = new { type = "PURCHASE_NR_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_PURCHASE_CR_LIST":
|
||||
{
|
||||
string result = _bridge.GetPurchaseCRList();
|
||||
var response = new { type = "PURCHASE_CR_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_HOLYDAY_REQUEST_COUNT":
|
||||
{
|
||||
string result = _bridge.GetHolydayRequestCount();
|
||||
var response = new { type = "HOLYDAY_REQUEST_COUNT_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_CURRENT_USER_COUNT":
|
||||
{
|
||||
string result = _bridge.GetCurrentUserCount();
|
||||
var response = new { type = "CURRENT_USER_COUNT_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
// ===== Login API =====
|
||||
case "CHECK_LOGIN_STATUS":
|
||||
{
|
||||
string result = _bridge.CheckLoginStatus();
|
||||
var response = new { type = "LOGIN_STATUS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "LOGIN":
|
||||
{
|
||||
string gcode = json.gcode ?? "";
|
||||
string id = json.id ?? "";
|
||||
string password = json.password ?? "";
|
||||
bool rememberMe = json.rememberMe ?? false;
|
||||
string result = _bridge.Login(gcode, id, password, rememberMe);
|
||||
var response = new { type = "LOGIN_RESULT", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "LOGOUT":
|
||||
{
|
||||
string result = _bridge.Logout();
|
||||
var response = new { type = "LOGOUT_RESULT", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_USER_GROUPS":
|
||||
{
|
||||
string result = _bridge.GetUserGroups();
|
||||
var response = new { type = "USER_GROUPS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_PREVIOUS_LOGIN_INFO":
|
||||
{
|
||||
string result = _bridge.GetPreviousLoginInfo();
|
||||
var response = new { type = "PREVIOUS_LOGIN_INFO_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
// ===== User API =====
|
||||
case "GET_CURRENT_USER_INFO":
|
||||
{
|
||||
string result = _bridge.GetCurrentUserInfo();
|
||||
var response = new { type = "CURRENT_USER_INFO_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_USER_INFO_BY_ID":
|
||||
{
|
||||
string userId = json.userId ?? "";
|
||||
string result = _bridge.GetUserInfoById(userId);
|
||||
var response = new { type = "USER_INFO_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "SAVE_USER_INFO":
|
||||
{
|
||||
string userData = JsonConvert.SerializeObject(json.userData);
|
||||
string result = _bridge.SaveUserInfo(userData);
|
||||
var response = new { type = "USER_INFO_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "CHANGE_PASSWORD":
|
||||
{
|
||||
string oldPassword = json.oldPassword ?? "";
|
||||
string newPassword = json.newPassword ?? "";
|
||||
string result = _bridge.ChangePassword(oldPassword, newPassword);
|
||||
var response = new { type = "PASSWORD_CHANGED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
// ===== Common Code API =====
|
||||
case "COMMON_GET_GROUPS":
|
||||
{
|
||||
string result = _bridge.Common_GetGroups();
|
||||
var response = new { type = "COMMON_GROUPS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "COMMON_GET_LIST":
|
||||
{
|
||||
string grp = json.grp ?? "99";
|
||||
string result = _bridge.Common_GetList(grp);
|
||||
var response = new { type = "COMMON_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "COMMON_SAVE":
|
||||
{
|
||||
int idx = json.idx ?? 0;
|
||||
string grp = json.grp ?? "";
|
||||
string code = json.code ?? "";
|
||||
string svalue = json.svalue ?? "";
|
||||
int ivalue = json.ivalue ?? 0;
|
||||
float fvalue = json.fvalue ?? 0f;
|
||||
string svalue2 = json.svalue2 ?? "";
|
||||
string memo = json.memo ?? "";
|
||||
string result = _bridge.Common_Save(idx, grp, code, svalue, ivalue, fvalue, svalue2, memo);
|
||||
var response = new { type = "COMMON_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "COMMON_DELETE":
|
||||
{
|
||||
int idx = json.idx ?? 0;
|
||||
string result = _bridge.Common_Delete(idx);
|
||||
var response = new { type = "COMMON_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
// ===== Items API =====
|
||||
case "ITEMS_GET_CATEGORIES":
|
||||
{
|
||||
string result = _bridge.Items_GetCategories();
|
||||
var response = new { type = "ITEMS_CATEGORIES_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "ITEMS_GET_LIST":
|
||||
{
|
||||
string category = json.category ?? "";
|
||||
string searchKey = json.searchKey ?? "";
|
||||
string result = _bridge.Items_GetList(category, searchKey);
|
||||
var response = new { type = "ITEMS_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "ITEMS_SAVE":
|
||||
{
|
||||
int idx = json.idx ?? 0;
|
||||
string sid = json.sid ?? "";
|
||||
string cate = json.cate ?? "";
|
||||
string name = json.name ?? "";
|
||||
string model = json.model ?? "";
|
||||
string scale = json.scale ?? "";
|
||||
string unit = json.unit ?? "";
|
||||
decimal price = json.price ?? 0m;
|
||||
string supply = json.supply ?? "";
|
||||
string manu = json.manu ?? "";
|
||||
string storage = json.storage ?? "";
|
||||
bool disable = json.disable ?? false;
|
||||
string memo = json.memo ?? "";
|
||||
string result = _bridge.Items_Save(idx, sid, cate, name, model, scale, unit, price, supply, manu, storage, disable, memo);
|
||||
var response = new { type = "ITEMS_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "ITEMS_DELETE":
|
||||
{
|
||||
int idx = json.idx ?? 0;
|
||||
string result = _bridge.Items_Delete(idx);
|
||||
var response = new { type = "ITEMS_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
// ===== UserList API =====
|
||||
case "USERLIST_GET_CURRENT_LEVEL":
|
||||
{
|
||||
string result = _bridge.UserList_GetCurrentLevel();
|
||||
var response = new { type = "USERLIST_CURRENT_LEVEL_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "USERLIST_GET_DEPTS":
|
||||
{
|
||||
string result = _bridge.UserList_GetDepts();
|
||||
var response = new { type = "USERLIST_DEPTS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "USERLIST_GET_LIST":
|
||||
{
|
||||
string process = json.process ?? "%";
|
||||
string result = _bridge.UserList_GetList(process);
|
||||
var response = new { type = "USERLIST_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "USERLIST_GET_USER":
|
||||
{
|
||||
string userId = json.userId ?? "";
|
||||
string result = _bridge.UserList_GetUser(userId);
|
||||
var response = new { type = "USERLIST_USER_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "USERLIST_SAVE_GROUP_USER":
|
||||
{
|
||||
string userId = json.userId ?? "";
|
||||
string dept = json.dept ?? "";
|
||||
int level = json.level ?? 1;
|
||||
bool useUserState = json.useUserState ?? false;
|
||||
bool useJobReport = json.useJobReport ?? false;
|
||||
bool exceptHoly = json.exceptHoly ?? false;
|
||||
string result = _bridge.UserList_SaveGroupUser(userId, dept, level, useUserState, useJobReport, exceptHoly);
|
||||
var response = new { type = "USERLIST_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "USERLIST_SAVE_USER_FULL":
|
||||
{
|
||||
string userData = JsonConvert.SerializeObject(json.userData);
|
||||
string result = _bridge.UserList_SaveUserFull(userData);
|
||||
var response = new { type = "USERLIST_USER_FULL_SAVED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "USERLIST_DELETE_GROUP_USER":
|
||||
{
|
||||
string userId = json.userId ?? "";
|
||||
string result = _bridge.UserList_DeleteGroupUser(userId);
|
||||
var response = new { type = "USERLIST_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
// ===== JobReport API (JobReport 뷰/테이블) =====
|
||||
case "JOBREPORT_GET_LIST":
|
||||
{
|
||||
string sd = json.sd ?? "";
|
||||
string ed = json.ed ?? "";
|
||||
string uid = json.uid ?? "";
|
||||
string cate = json.cate ?? ""; // 사용안함 (호환성)
|
||||
string searchKey = json.searchKey ?? "";
|
||||
Console.WriteLine($"[WS] JOBREPORT_GET_LIST: sd={sd}, ed={ed}, uid={uid}, searchKey={searchKey}");
|
||||
string result = _bridge.Jobreport_GetList(sd, ed, uid, cate, searchKey);
|
||||
var response = new { type = "JOBREPORT_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "JOBREPORT_GET_USERS":
|
||||
{
|
||||
string result = _bridge.Jobreport_GetUsers();
|
||||
var response = new { type = "JOBREPORT_USERS_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "JOBREPORT_GET_DETAIL":
|
||||
{
|
||||
int idx = json.idx ?? 0;
|
||||
string result = _bridge.Jobreport_GetDetail(idx);
|
||||
var response = new { type = "JOBREPORT_DETAIL_DATA", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "JOBREPORT_ADD":
|
||||
{
|
||||
string pdate = json.pdate ?? "";
|
||||
string projectName = json.projectName ?? "";
|
||||
string requestpart = json.requestpart ?? "";
|
||||
string package = json.package ?? "";
|
||||
string type1 = json.type ?? "";
|
||||
string process = json.process ?? "";
|
||||
string status = json.status ?? "진행 완료";
|
||||
string description = json.description ?? "";
|
||||
double hrs = json.hrs ?? 0.0;
|
||||
double ot = json.ot ?? 0.0;
|
||||
string jobgrp = json.jobgrp ?? "";
|
||||
string tag = json.tag ?? "";
|
||||
string result = _bridge.Jobreport_Add(pdate, projectName, requestpart, package, type1, process, status, description, hrs, ot, jobgrp, tag);
|
||||
var response = new { type = "JOBREPORT_ADDED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "JOBREPORT_EDIT":
|
||||
{
|
||||
int idx = json.idx ?? 0;
|
||||
string pdate = json.pdate ?? "";
|
||||
string projectName = json.projectName ?? "";
|
||||
string requestpart = json.requestpart ?? "";
|
||||
string package = json.package ?? "";
|
||||
string type2 = json.type ?? "";
|
||||
string process = json.process ?? "";
|
||||
string status = json.status ?? "";
|
||||
string description = json.description ?? "";
|
||||
double hrs = json.hrs ?? 0.0;
|
||||
double ot = json.ot ?? 0.0;
|
||||
string jobgrp = json.jobgrp ?? "";
|
||||
string tag = json.tag ?? "";
|
||||
string result = _bridge.Jobreport_Edit(idx, pdate, projectName, requestpart, package, type2, process, status, description, hrs, ot, jobgrp, tag);
|
||||
var response = new { type = "JOBREPORT_EDITED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "JOBREPORT_DELETE":
|
||||
{
|
||||
int idx = json.idx ?? 0;
|
||||
string result = _bridge.Jobreport_Delete(idx);
|
||||
var response = new { type = "JOBREPORT_DELETED", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "JOBREPORT_GET_PERMISSION":
|
||||
{
|
||||
string targetUserId = json.targetUserId ?? "";
|
||||
string result = _bridge.Jobreport_GetPermission(targetUserId);
|
||||
var response = new { type = "JOBREPORT_PERMISSION", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "JOBREPORT_GET_JOBTYPES":
|
||||
{
|
||||
string process = json.process ?? "";
|
||||
string result = _bridge.Jobreport_GetJobTypes(process);
|
||||
var response = new { type = "JOBREPORT_JOBTYPES", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GET_APP_VERSION":
|
||||
{
|
||||
string result = _bridge.GetAppVersion();
|
||||
var response = new { type = "APP_VERSION", data = JsonConvert.DeserializeObject(result) };
|
||||
await Send(socket, JsonConvert.SerializeObject(response));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
Console.WriteLine($"[WS] Unknown message type: {type}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WS] Handle Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Send(WebSocket socket, string message)
|
||||
{
|
||||
if (_socketLocks.TryGetValue(socket, out var semaphore))
|
||||
{
|
||||
await semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (socket.State == WebSocketState.Open)
|
||||
{
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(message);
|
||||
await socket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void Broadcast(string message)
|
||||
{
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(message);
|
||||
WebSocket[] clientsCopy;
|
||||
|
||||
lock (_clients)
|
||||
{
|
||||
clientsCopy = _clients.ToArray();
|
||||
}
|
||||
|
||||
foreach (var client in clientsCopy)
|
||||
{
|
||||
if (client.State == WebSocketState.Open && _socketLocks.TryGetValue(client, out var semaphore))
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (await semaphore.WaitAsync(0))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (client.State == WebSocketState.Open)
|
||||
{
|
||||
await client.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace Project.Web
|
||||
{
|
||||
public class WebSocketServer
|
||||
{
|
||||
private HttpListener _httpListener;
|
||||
private List<WebSocket> _clients = new List<WebSocket>();
|
||||
private Dialog.fDashboardNew _mainForm;
|
||||
|
||||
public WebSocketServer(string url, Dialog.fDashboardNew form)
|
||||
{
|
||||
_mainForm = form;
|
||||
_httpListener = new HttpListener();
|
||||
_httpListener.Prefixes.Add(url);
|
||||
_httpListener.Start();
|
||||
Console.WriteLine($"[WS] Listening on {url}");
|
||||
Task.Run(AcceptConnections);
|
||||
}
|
||||
|
||||
private async Task AcceptConnections()
|
||||
{
|
||||
while (_httpListener.IsListening)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _httpListener.GetContextAsync();
|
||||
if (context.Request.IsWebSocketRequest)
|
||||
{
|
||||
ProcessRequest(context);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
context.Response.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WS] Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private System.Collections.Concurrent.ConcurrentDictionary<WebSocket, SemaphoreSlim> _socketLocks = new System.Collections.Concurrent.ConcurrentDictionary<WebSocket, SemaphoreSlim>();
|
||||
|
||||
private async void ProcessRequest(HttpListenerContext context)
|
||||
{
|
||||
WebSocketContext wsContext = null;
|
||||
try
|
||||
{
|
||||
wsContext = await context.AcceptWebSocketAsync(subProtocol: null);
|
||||
WebSocket socket = wsContext.WebSocket;
|
||||
_socketLocks.TryAdd(socket, new SemaphoreSlim(1, 1));
|
||||
|
||||
lock (_clients) { _clients.Add(socket); }
|
||||
Console.WriteLine("[WS] Client Connected");
|
||||
|
||||
await ReceiveLoop(socket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WS] Accept Error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (wsContext != null)
|
||||
{
|
||||
WebSocket socket = wsContext.WebSocket;
|
||||
lock (_clients) { _clients.Remove(socket); }
|
||||
|
||||
if (_socketLocks.TryRemove(socket, out var semaphore))
|
||||
{
|
||||
semaphore.Dispose();
|
||||
}
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoop(WebSocket socket)
|
||||
{
|
||||
var buffer = new byte[1024 * 4];
|
||||
while (socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
|
||||
}
|
||||
else if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
string msg = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
HandleMessage(msg, socket);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void HandleMessage(string msg, WebSocket socket)
|
||||
{
|
||||
// Simple JSON parsing (manual or Newtonsoft)
|
||||
// Expected format: { "type": "...", "data": ... }
|
||||
try
|
||||
{
|
||||
dynamic json = Newtonsoft.Json.JsonConvert.DeserializeObject(msg);
|
||||
string type = json.type;
|
||||
|
||||
Console.WriteLine($"HandleMessage:{type}");
|
||||
if (type == "GET_CONFIG")
|
||||
{
|
||||
//// Simulate Delay for Loading Screen Test
|
||||
//await Task.Delay(1000);
|
||||
|
||||
//// Send Config back
|
||||
//var bridge = new MachineBridge(_mainForm); // Re-use logic
|
||||
//string configJson = bridge.GetConfig();
|
||||
//var response = new { type = "CONFIG_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(configJson) };
|
||||
//await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||
}
|
||||
else if (type == "GET_IO_LIST")
|
||||
{
|
||||
// var bridge = new MachineBridge(_mainForm);
|
||||
// string ioJson = bridge.GetIOList();
|
||||
// var response = new { type = "IO_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(ioJson) };
|
||||
// await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||
//}
|
||||
//else if (type == "GET_RECIPE_LIST")
|
||||
//{
|
||||
// var bridge = new MachineBridge(_mainForm);
|
||||
// string recipeJson = bridge.GetRecipeList();
|
||||
// var response = new { type = "RECIPE_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(recipeJson) };
|
||||
// await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||
}
|
||||
else if (type == "SAVE_CONFIG")
|
||||
{
|
||||
//string configJson = Newtonsoft.Json.JsonConvert.SerializeObject(json.data);
|
||||
//var bridge = new MachineBridge(_mainForm);
|
||||
//bridge.SaveConfig(configJson);
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WS] Msg Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Send(WebSocket socket, string message)
|
||||
{
|
||||
if (_socketLocks.TryGetValue(socket, out var semaphore))
|
||||
{
|
||||
await semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (socket.State == WebSocketState.Open)
|
||||
{
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(message);
|
||||
await socket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void Broadcast(string message)
|
||||
{
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(message);
|
||||
WebSocket[] clientsCopy;
|
||||
|
||||
lock (_clients)
|
||||
{
|
||||
clientsCopy = _clients.ToArray();
|
||||
}
|
||||
|
||||
foreach (var client in clientsCopy)
|
||||
{
|
||||
if (client.State == WebSocketState.Open && _socketLocks.TryGetValue(client, out var semaphore))
|
||||
{
|
||||
// Fire and forget, but safely
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
// Try to get lock immediately. If busy (sending previous frame), skip this frame to prevent lag.
|
||||
if (await semaphore.WaitAsync(0))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (client.State == WebSocketState.Open)
|
||||
{
|
||||
await client.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch { /* Ignore send errors */ }
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,7 +706,7 @@
|
||||
|
||||
showLoading();
|
||||
try {
|
||||
const jsonStr = await machine.Todo_CreateTodo(title, remark, expire, seqno, flag, request, status);
|
||||
const jsonStr = await machine.CreateTodo(title, remark, expire, seqno, flag, request, status);
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
if (data.Success) {
|
||||
|
||||
11
Project/fMain.Designer.cs
generated
11
Project/fMain.Designer.cs
generated
@@ -44,7 +44,6 @@
|
||||
this.sbChat = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
this.menuStrip1 = new System.Windows.Forms.MenuStrip();
|
||||
this.btSetting = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.로그인ToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.commonToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.codesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.itemsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
@@ -245,7 +244,6 @@
|
||||
this.menuStrip1.Font = new System.Drawing.Font("맑은 고딕", 10F);
|
||||
this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||
this.btSetting,
|
||||
this.로그인ToolStripMenuItem,
|
||||
this.commonToolStripMenuItem,
|
||||
this.managementToolStripMenuItem,
|
||||
this.mn_docu,
|
||||
@@ -269,14 +267,6 @@
|
||||
this.btSetting.Text = "설정";
|
||||
this.btSetting.Click += new System.EventHandler(this.settingToolStripMenuItem_Click);
|
||||
//
|
||||
// 로그인ToolStripMenuItem
|
||||
//
|
||||
this.로그인ToolStripMenuItem.Image = ((System.Drawing.Image)(resources.GetObject("로그인ToolStripMenuItem.Image")));
|
||||
this.로그인ToolStripMenuItem.Name = "로그인ToolStripMenuItem";
|
||||
this.로그인ToolStripMenuItem.Size = new System.Drawing.Size(79, 23);
|
||||
this.로그인ToolStripMenuItem.Text = "로그인";
|
||||
this.로그인ToolStripMenuItem.Click += new System.EventHandler(this.로그인ToolStripMenuItem_Click);
|
||||
//
|
||||
// commonToolStripMenuItem
|
||||
//
|
||||
this.commonToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||
@@ -1269,7 +1259,6 @@
|
||||
private System.Windows.Forms.ToolStripMenuItem mn_project;
|
||||
private System.Windows.Forms.ToolStripMenuItem projectImportCompleteToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem purchaseOrderImportToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem 로그인ToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem 메일전송ToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem mn_dailyhistory;
|
||||
private System.Windows.Forms.ToolStripMenuItem 패치내역ToolStripMenuItem1;
|
||||
|
||||
@@ -158,14 +158,15 @@ namespace Project
|
||||
|
||||
Console.WriteLine($"WebView2 초기화 상태: {Pub.InitWebView}");
|
||||
|
||||
Func_Login();
|
||||
|
||||
|
||||
// WebView2 로그인이 아닌 경우에만 여기서 후처리 실행
|
||||
// WebView2 로그인의 경우 OnLoginCompleted()에서 호출됨
|
||||
if (Pub.InitWebView != 1)
|
||||
{
|
||||
OnLoginCompleted();
|
||||
}
|
||||
//if (Pub.InitWebView != 1)
|
||||
//{
|
||||
Menu_Dashboard();
|
||||
//OnLoginCompleted();
|
||||
//}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -236,22 +237,22 @@ namespace Project
|
||||
Util.RunExplorer(cmd);
|
||||
}
|
||||
|
||||
void Func_Login()
|
||||
{
|
||||
this.sbWeb.Text = $"WebView:{Pub.InitWebView}";
|
||||
if (Pub.InitWebView == 1)
|
||||
{
|
||||
// WebView2 기반 대시보드 로그인
|
||||
Menu_Dashboard();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 기존 WinForms 로그인
|
||||
using (var f = new Dialog.fLogin())
|
||||
if (f.ShowDialog() != System.Windows.Forms.DialogResult.OK)
|
||||
Application.ExitThread();
|
||||
}
|
||||
}
|
||||
//void Func_Login()
|
||||
//{
|
||||
// this.sbWeb.Text = $"WebView:{Pub.InitWebView}";
|
||||
// if (Pub.InitWebView == 1)
|
||||
// {
|
||||
// // WebView2 기반 대시보드 로그인
|
||||
// Menu_Dashboard();
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// // 기존 WinForms 로그인
|
||||
// using (var f = new Dialog.fLogin())
|
||||
// if (f.ShowDialog() != System.Windows.Forms.DialogResult.OK)
|
||||
// Application.ExitThread();
|
||||
// }
|
||||
//}
|
||||
void Func_RunStartForm()
|
||||
{
|
||||
var menu_purchaseVisible = FCOMMON.Util.getBit(FCOMMON.info.Login.gpermission, (int)FCOMMON.eGroupPermission.menu_purchase);
|
||||
@@ -658,11 +659,11 @@ namespace Project
|
||||
f.Show();
|
||||
}
|
||||
|
||||
private void 로그인ToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
{
|
||||
CloseAllForm();
|
||||
Func_Login();
|
||||
}
|
||||
//private void 로그인ToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
//{
|
||||
// CloseAllForm();
|
||||
// Func_Login();
|
||||
//}
|
||||
|
||||
void CloseAllForm()
|
||||
{
|
||||
@@ -675,7 +676,25 @@ namespace Project
|
||||
tabControl1.TabPages.Remove(tab);
|
||||
this.tabControl1.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MachineBridge에서 호출 가능한 public 메서드
|
||||
/// </summary>
|
||||
public void CloseAllFormPublic()
|
||||
{
|
||||
CloseAllForm();
|
||||
|
||||
// 상태바 정보 초기화
|
||||
sbLogin.Text = "";
|
||||
sbLoginUseTime.Text = "";
|
||||
|
||||
// 메뉴/툴바 비활성화
|
||||
menuStrip1.Enabled = false;
|
||||
toolStrip1.Enabled = false;
|
||||
btDev.Visible = false;
|
||||
|
||||
Menu_Dashboard();
|
||||
}
|
||||
|
||||
private void 메일전송ToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
@@ -1463,14 +1482,15 @@ namespace Project
|
||||
|
||||
}
|
||||
|
||||
Dialog.fDashboardNew fdashboard = null;
|
||||
//Dialog.fDashboard fdashboard = null;
|
||||
void Menu_Dashboard()
|
||||
{
|
||||
string formkey = "DASHBOARD";
|
||||
if (!ShowForm(formkey))
|
||||
{
|
||||
if (fdashboard == null || fdashboard.IsDisposed)
|
||||
fdashboard = new Dialog.fDashboardNew();
|
||||
fdashboard = new Dialog.fDashboard();
|
||||
|
||||
AddForm(formkey, fdashboard);
|
||||
}
|
||||
}
|
||||
@@ -1528,6 +1548,7 @@ namespace Project
|
||||
f.ShowDialog();
|
||||
}
|
||||
|
||||
Dialog.fDashboard fdashboard = null;
|
||||
private void tabControl1_SelectedIndexChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (this.tabControl1.SelectedIndex == 0)
|
||||
|
||||
@@ -12,7 +12,6 @@ using System.Net;
|
||||
using System.IO.Compression;
|
||||
using System.IO;
|
||||
using FCOMMON;
|
||||
using Microsoft.Owin.Hosting;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Project.Dialog
|
||||
|
||||
28
Project/frontend/.gitignore
vendored
Normal file
28
Project/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
148
Project/frontend/README.md
Normal file
148
Project/frontend/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# GroupWare React Frontend
|
||||
|
||||
GroupWare 시스템의 React 기반 프론트엔드입니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **React 18** - UI 라이브러리
|
||||
- **TypeScript** - 타입 안정성
|
||||
- **Vite** - 빌드 도구 및 개발 서버
|
||||
- **Tailwind CSS** - 스타일링
|
||||
- **React Router v7** - 라우팅
|
||||
- **Lucide React** - 아이콘
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── layout/ # 레이아웃 컴포넌트
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ └── Navigation.tsx
|
||||
│ ├── pages/ # 페이지 컴포넌트
|
||||
│ │ ├── Dashboard.tsx
|
||||
│ │ ├── Todo.tsx
|
||||
│ │ └── Placeholder.tsx
|
||||
│ ├── communication.ts # 통신 레이어 (WebView2 + WebSocket)
|
||||
│ ├── types.ts # TypeScript 타입 정의
|
||||
│ ├── App.tsx # 메인 앱 컴포넌트
|
||||
│ ├── main.tsx # 엔트리 포인트
|
||||
│ └── index.css # 글로벌 스타일
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.js
|
||||
├── tsconfig.json
|
||||
├── build.bat # 프로덕션 빌드 스크립트
|
||||
└── run-dev.bat # 개발 서버 실행 스크립트
|
||||
```
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
### 1. 의존성 설치
|
||||
|
||||
```bash
|
||||
cd Project/frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 개발 서버 실행
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# 또는
|
||||
run-dev.bat
|
||||
```
|
||||
|
||||
개발 서버는 `http://localhost:5173`에서 실행됩니다.
|
||||
|
||||
### 3. 핫 리로드 개발
|
||||
|
||||
개발 모드에서는 WebSocket(포트 8082)을 통해 C# 백엔드와 통신합니다.
|
||||
GroupWare 애플리케이션에서 WebSocket 서버가 실행 중이어야 합니다.
|
||||
|
||||
## 프로덕션 빌드
|
||||
|
||||
### 빌드 실행
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# 또는
|
||||
build.bat
|
||||
```
|
||||
|
||||
빌드 결과물은 `dist/` 폴더에 생성되고,
|
||||
`build.bat`을 사용하면 자동으로 `wwwroot/react-app/`으로 복사됩니다.
|
||||
|
||||
### 접근 URL
|
||||
|
||||
- **개발**: `http://localhost:5173`
|
||||
- **프로덕션**: `http://localhost:7979/react-app/`
|
||||
|
||||
## 통신 방식
|
||||
|
||||
### 듀얼 모드 통신
|
||||
|
||||
이 프로젝트는 두 가지 통신 방식을 지원합니다:
|
||||
|
||||
| 환경 | 통신 방식 | 포트 |
|
||||
|------|-----------|------|
|
||||
| WebView2 (프로덕션) | HostObject 직접 호출 | - |
|
||||
| Browser (개발) | WebSocket | 8082 |
|
||||
|
||||
### 자동 감지
|
||||
|
||||
`communication.ts`에서 실행 환경을 자동으로 감지하여 적절한 통신 방식을 사용합니다:
|
||||
|
||||
```typescript
|
||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||
|
||||
if (isWebView) {
|
||||
// WebView2 HostObject 사용
|
||||
const result = await machine.Todo_GetTodos();
|
||||
} else {
|
||||
// WebSocket 사용
|
||||
ws.send(JSON.stringify({ type: 'GET_TODOS' }));
|
||||
}
|
||||
```
|
||||
|
||||
## 페이지 목록
|
||||
|
||||
| 경로 | 페이지 | 상태 |
|
||||
|------|--------|------|
|
||||
| `/` | 대시보드 | 완료 |
|
||||
| `/todo` | 할일 관리 | 완료 |
|
||||
| `/kuntae` | 근태 관리 | 개발 예정 |
|
||||
| `/jobreport` | 업무 일지 | 개발 예정 |
|
||||
| `/project` | 프로젝트 | 개발 예정 |
|
||||
| `/common` | 공용 코드 | 개발 예정 |
|
||||
|
||||
## 스타일 가이드
|
||||
|
||||
### 색상 팔레트
|
||||
|
||||
- **Primary**: Blue (`#3b82f6`)
|
||||
- **Success**: Green (`#22c55e`)
|
||||
- **Warning**: Amber (`#f59e0b`)
|
||||
- **Danger**: Red (`#ef4444`)
|
||||
|
||||
### Glass Effect
|
||||
|
||||
```css
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
```
|
||||
|
||||
## C# WebSocket 서버 설정
|
||||
|
||||
`MachineBridge/WebSocketServer.cs`를 참고하여 WebSocket 서버를 시작하세요:
|
||||
|
||||
```csharp
|
||||
// 예시 코드
|
||||
var wsServer = new GroupWareWebSocketServer("http://localhost:8082/", bridge);
|
||||
wsServer.Start();
|
||||
```
|
||||
50
Project/frontend/build.bat
Normal file
50
Project/frontend/build.bat
Normal file
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo GroupWare Frontend Build Script
|
||||
echo ========================================
|
||||
|
||||
REM 현재 디렉토리 저장
|
||||
set CURRENT_DIR=%CD%
|
||||
|
||||
REM frontend 폴더로 이동
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo [1/3] Installing dependencies...
|
||||
call npm install
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo ERROR: npm install failed
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/3] Building production...
|
||||
call npm run build
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo ERROR: npm build failed
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [3/3] Copying dist to wwwroot...
|
||||
REM 기존 react 폴더 삭제 (있으면)
|
||||
if exist "..\Web\wwwroot\react-app" rmdir /s /q "..\Web\wwwroot\react-app"
|
||||
|
||||
REM dist 폴더를 wwwroot\react-app으로 복사
|
||||
xcopy /e /i /y "dist" "..\Web\wwwroot\react-app"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Build completed successfully!
|
||||
echo Output: Project\Web\wwwroot\react-app
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Access via: http://localhost:7979/react-app/
|
||||
echo.
|
||||
|
||||
REM 원래 디렉토리로 복귀
|
||||
cd /d "%CURRENT_DIR%"
|
||||
|
||||
pause
|
||||
16
Project/frontend/index.html
Normal file
16
Project/frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>GroupWare</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2718
Project/frontend/package-lock.json
generated
Normal file
2718
Project/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
Project/frontend/package.json
Normal file
29
Project/frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "groupware-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
6
Project/frontend/postcss.config.js
Normal file
6
Project/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
18
Project/frontend/run-dev.bat
Normal file
18
Project/frontend/run-dev.bat
Normal file
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo GroupWare Frontend Development Server
|
||||
echo ========================================
|
||||
|
||||
REM frontend 폴더로 이동
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo Starting Vite development server...
|
||||
echo.
|
||||
echo [INFO] Make sure GroupWare application is running
|
||||
echo [INFO] WebSocket server should be on port 8082
|
||||
echo.
|
||||
echo Press Ctrl+C to stop the server
|
||||
echo.
|
||||
|
||||
call npm run dev
|
||||
99
Project/frontend/src/App.tsx
Normal file
99
Project/frontend/src/App.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from '@/components/layout';
|
||||
import { Dashboard, Todo, Kuntae, Jobreport, PlaceholderPage, Login, CommonCodePage, ItemsPage, UserListPage, MonthlyWorkPage, MailFormPage, UserGroupPage } from '@/pages';
|
||||
import { comms } from '@/communication';
|
||||
import { UserInfo } from '@/types';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function App() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null); // null = 체크 중
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 통신 상태 구독
|
||||
const unsubscribe = comms.subscribe((msg: unknown) => {
|
||||
const message = msg as { type?: string; connected?: boolean };
|
||||
if (message?.type === 'CONNECTION_STATE') {
|
||||
setIsConnected(message.connected ?? false);
|
||||
// 연결되면 로그인 상태 체크
|
||||
if (message.connected) {
|
||||
checkLoginStatus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 초기 연결 상태 설정
|
||||
setIsConnected(comms.getConnectionState());
|
||||
|
||||
// 연결되어 있으면 바로 로그인 상태 체크
|
||||
if (comms.getConnectionState()) {
|
||||
checkLoginStatus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkLoginStatus = async () => {
|
||||
try {
|
||||
const result = await comms.checkLoginStatus();
|
||||
if (result.Success) {
|
||||
setIsLoggedIn(result.IsLoggedIn);
|
||||
setUser(result.User);
|
||||
} else {
|
||||
setIsLoggedIn(false);
|
||||
setUser(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그인 상태 체크 실패:', err);
|
||||
setIsLoggedIn(false);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
checkLoginStatus();
|
||||
};
|
||||
|
||||
// 로그인 상태 체크 중
|
||||
if (isLoggedIn === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-10 h-10 text-white animate-spin mx-auto mb-4" />
|
||||
<p className="text-white/70">로그인 상태 확인 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로그인 안됨 → 로그인 화면 표시
|
||||
if (!isLoggedIn) {
|
||||
return <Login onLoginSuccess={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
// 로그인 됨 → 메인 앱 표시
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout isConnected={isConnected} user={user} />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/todo" element={<Todo />} />
|
||||
<Route path="/kuntae" element={<Kuntae />} />
|
||||
<Route path="/jobreport" element={<Jobreport />} />
|
||||
<Route path="/project" element={<PlaceholderPage title="프로젝트" />} />
|
||||
<Route path="/common" element={<CommonCodePage />} />
|
||||
<Route path="/items" element={<ItemsPage />} />
|
||||
<Route path="/user/list" element={<UserListPage />} />
|
||||
<Route path="/monthly-work" element={<MonthlyWorkPage />} />
|
||||
<Route path="/mail-form" element={<MailFormPage />} />
|
||||
<Route path="/user-group" element={<UserGroupPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
755
Project/frontend/src/communication.ts
Normal file
755
Project/frontend/src/communication.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
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';
|
||||
|
||||
// WebView2 환경인지 체크
|
||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||
|
||||
// 비동기 프록시 캐싱 (한 번만 초기화)
|
||||
const machine: MachineBridgeInterface | null = isWebView
|
||||
? window.chrome!.webview!.hostObjects.machine
|
||||
: null;
|
||||
|
||||
type MessageCallback = (data: unknown) => void;
|
||||
|
||||
class CommunicationLayer {
|
||||
private listeners: MessageCallback[] = [];
|
||||
private ws: WebSocket | null = null;
|
||||
private isConnected = false;
|
||||
private wsUrl = 'ws://localhost:8082'; // GroupWare WebSocket 포트
|
||||
|
||||
constructor() {
|
||||
if (isWebView) {
|
||||
console.log("[COMM] Running in WebView2 Mode (HostObject)");
|
||||
this.isConnected = true;
|
||||
window.chrome!.webview!.addEventListener('message', (event: MessageEvent) => {
|
||||
this.notifyListeners(event.data);
|
||||
});
|
||||
// WebView2 환경에서도 연결 상태 알림
|
||||
setTimeout(() => {
|
||||
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
|
||||
}, 0);
|
||||
} else {
|
||||
console.log("[COMM] Running in Browser Mode (WebSocket)");
|
||||
this.connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
public isWebViewMode(): boolean {
|
||||
return isWebView;
|
||||
}
|
||||
|
||||
private connectWebSocket() {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[COMM] WebSocket Connected");
|
||||
this.isConnected = true;
|
||||
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.notifyListeners(data);
|
||||
} catch (e) {
|
||||
console.error("[COMM] JSON Parse Error", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log("[COMM] WebSocket Closed. Reconnecting...");
|
||||
this.isConnected = false;
|
||||
this.notifyListeners({ type: 'CONNECTION_STATE', connected: false });
|
||||
setTimeout(() => this.connectWebSocket(), 2000);
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
console.error("[COMM] WebSocket Error", err);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners(data: unknown) {
|
||||
this.listeners.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
public subscribe(callback: MessageCallback) {
|
||||
this.listeners.push(callback);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
public getConnectionState(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
// WebSocket 요청-응답 헬퍼
|
||||
private wsRequest<T>(requestType: string, responseType: string, params?: Record<string, unknown>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected) {
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) reject(new Error("WebSocket connection timeout"));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
reject(new Error(`${requestType} timeout`));
|
||||
}, 10000);
|
||||
|
||||
const handler = (data: unknown) => {
|
||||
const msg = data as { type: string; data?: T; Success?: boolean; Message?: string };
|
||||
if (msg.type === responseType) {
|
||||
clearTimeout(timeoutId);
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
resolve(msg.data as T);
|
||||
}
|
||||
};
|
||||
this.listeners.push(handler);
|
||||
this.ws?.send(JSON.stringify({ ...params, type: requestType }));
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Todo API =====
|
||||
|
||||
public async getTodos(): Promise<ApiResponse<TodoModel[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Todo_GetTodos();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<TodoModel[]>>('GET_TODOS', 'TODOS_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getTodo(id: number): Promise<ApiResponse<TodoModel>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Todo_GetTodo(id);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<TodoModel>>('GET_TODO', 'TODO_DATA', { id });
|
||||
}
|
||||
}
|
||||
|
||||
public async createTodo(
|
||||
title: string,
|
||||
remark: string,
|
||||
expire: string | null,
|
||||
seqno: number,
|
||||
flag: boolean,
|
||||
request: string | null,
|
||||
status: string
|
||||
): Promise<ApiResponse<{ idx: number }>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.CreateTodo(title, remark, expire, seqno, flag, request, status);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<{ idx: number }>>('CREATE_TODO', 'TODO_CREATED', {
|
||||
title, remark, expire, seqno, flag, request, status
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async updateTodo(
|
||||
idx: number,
|
||||
title: string,
|
||||
remark: string,
|
||||
expire: string | null,
|
||||
seqno: number,
|
||||
flag: boolean,
|
||||
request: string | null,
|
||||
status: string
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Todo_UpdateTodo(idx, title, remark, expire, seqno, flag, request, status);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('UPDATE_TODO', 'TODO_UPDATED', {
|
||||
idx, title, remark, expire, seqno, flag, request, status
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteTodo(id: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Todo_DeleteTodo(id);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('DELETE_TODO', 'TODO_DELETED', { id });
|
||||
}
|
||||
}
|
||||
|
||||
public async getUrgentTodos(): Promise<ApiResponse<TodoModel[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetUrgentTodos();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<TodoModel[]>>('GET_URGENT_TODOS', 'URGENT_TODOS_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Dashboard API =====
|
||||
|
||||
public async getPurchaseWaitCount(): Promise<PurchaseCount> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetPurchaseWaitCount();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<PurchaseCount>('GET_PURCHASE_WAIT_COUNT', 'PURCHASE_WAIT_COUNT_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getTodayCountH(): Promise<number> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.TodayCountH();
|
||||
return parseInt(result, 10);
|
||||
} else {
|
||||
const response = await this.wsRequest<{ count: number }>('GET_TODAY_COUNT_H', 'TODAY_COUNT_H_DATA');
|
||||
return response.count;
|
||||
}
|
||||
}
|
||||
|
||||
public async getHolyUser(): Promise<HolyUser[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetHolyUser();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<HolyUser[]>('GET_HOLY_USER', 'HOLY_USER_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getHolyRequestUser(): Promise<HolyRequestUser[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetHolyRequestUser();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<HolyRequestUser[]>('GET_HOLY_REQUEST_USER', 'HOLY_REQUEST_USER_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getPurchaseNRList(): Promise<PurchaseItem[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetPurchaseNRList();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<PurchaseItem[]>('GET_PURCHASE_NR_LIST', 'PURCHASE_NR_LIST_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getPurchaseCRList(): Promise<PurchaseItem[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetPurchaseCRList();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<PurchaseItem[]>('GET_PURCHASE_CR_LIST', 'PURCHASE_CR_LIST_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getHolydayRequestCount(): Promise<{ HOLY: number; Message?: string }> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetHolydayRequestCount();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<{ HOLY: number; Message?: string }>('GET_HOLYDAY_REQUEST_COUNT', 'HOLYDAY_REQUEST_COUNT_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getCurrentUserCount(): Promise<{ Count: number; Message?: string }> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetCurrentUserCount();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<{ Count: number; Message?: string }>('GET_CURRENT_USER_COUNT', 'CURRENT_USER_COUNT_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Kuntae API =====
|
||||
|
||||
public async getKuntaeList(sd: string, ed: string): Promise<ApiResponse<KuntaeModel[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Kuntae_GetList(sd, ed);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<KuntaeModel[]>>('GET_KUNTAE_LIST', 'KUNTAE_LIST_DATA', { sd, ed });
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteKuntae(id: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Kuntae_Delete(id);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('DELETE_KUNTAE', 'KUNTAE_DELETED', { id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ===== Login API =====
|
||||
|
||||
public async checkLoginStatus(): Promise<LoginStatusResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.CheckLoginStatus();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<LoginStatusResponse>('CHECK_LOGIN_STATUS', 'LOGIN_STATUS_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async login(gcode: string, id: string, password: string, rememberMe: boolean): Promise<LoginResult> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Login(gcode, id, password, rememberMe);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<LoginResult>('LOGIN', 'LOGIN_RESULT', { gcode, id, password, rememberMe });
|
||||
}
|
||||
}
|
||||
|
||||
public async logout(): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Logout();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('LOGOUT', 'LOGOUT_RESULT');
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserGroups(): Promise<UserGroup[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetUserGroups();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<UserGroup[]>('GET_USER_GROUPS', 'USER_GROUPS_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getPreviousLoginInfo(): Promise<PreviousLoginInfo> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetPreviousLoginInfo();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<PreviousLoginInfo>('GET_PREVIOUS_LOGIN_INFO', 'PREVIOUS_LOGIN_INFO_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== User API =====
|
||||
|
||||
public async getCurrentUserInfo(): Promise<ApiResponse<UserInfoDetail>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetCurrentUserInfo();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<UserInfoDetail>>('GET_CURRENT_USER_INFO', 'CURRENT_USER_INFO_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserInfoById(userId: string): Promise<ApiResponse<UserInfoDetail>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetUserInfoById(userId);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<UserInfoDetail>>('GET_USER_INFO_BY_ID', 'USER_INFO_DATA', { userId });
|
||||
}
|
||||
}
|
||||
|
||||
public async saveUserInfo(userData: UserInfoDetail): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.SaveUserInfo(JSON.stringify(userData));
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('SAVE_USER_INFO', 'USER_INFO_SAVED', { userData });
|
||||
}
|
||||
}
|
||||
|
||||
public async changePassword(oldPassword: string, newPassword: string): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.ChangePassword(oldPassword, newPassword);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('CHANGE_PASSWORD', 'PASSWORD_CHANGED', { oldPassword, newPassword });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Common Code API =====
|
||||
|
||||
public async getCommonGroups(): Promise<CommonCodeGroup[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Common_GetGroups();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<CommonCodeGroup[]>('COMMON_GET_GROUPS', 'COMMON_GROUPS_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getCommonList(grp: string): Promise<CommonCode[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Common_GetList(grp);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<CommonCode[]>('COMMON_GET_LIST', 'COMMON_LIST_DATA', { grp });
|
||||
}
|
||||
}
|
||||
|
||||
public async saveCommon(data: CommonCode): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Common_Save(
|
||||
data.idx, data.grp, data.code, data.svalue,
|
||||
data.ivalue, data.fvalue, data.svalue2 || '', data.memo
|
||||
);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('COMMON_SAVE', 'COMMON_SAVED', data as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteCommon(idx: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Common_Delete(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('COMMON_DELETE', 'COMMON_DELETED', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Items API =====
|
||||
|
||||
public async getItemCategories(): Promise<ApiResponse<string[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Items_GetCategories();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<string[]>>('ITEMS_GET_CATEGORIES', 'ITEMS_CATEGORIES_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getItemList(category: string, searchKey: string): Promise<ItemInfo[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Items_GetList(category, searchKey);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ItemInfo[]>('ITEMS_GET_LIST', 'ITEMS_LIST_DATA', { category, searchKey });
|
||||
}
|
||||
}
|
||||
|
||||
public async saveItem(data: ItemInfo): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Items_Save(
|
||||
data.idx, data.sid, data.cate, data.name, data.model,
|
||||
data.scale, data.unit, data.price, data.supply,
|
||||
data.manu, data.storage, data.disable, data.memo
|
||||
);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('ITEMS_SAVE', 'ITEMS_SAVED', data as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteItem(idx: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Items_Delete(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('ITEMS_DELETE', 'ITEMS_DELETED', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UserList API =====
|
||||
|
||||
public async getCurrentUserLevel(): Promise<ApiResponse<UserLevelInfo>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserList_GetCurrentLevel();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<UserLevelInfo>>('USERLIST_GET_CURRENT_LEVEL', 'USERLIST_CURRENT_LEVEL_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserList(process: string): Promise<GroupUser[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserList_GetList(process);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<GroupUser[]>('USERLIST_GET_LIST', 'USERLIST_LIST_DATA', { process });
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserListUser(userId: string): Promise<ApiResponse<GroupUser>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserList_GetUser(userId);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<GroupUser>>('USERLIST_GET_USER', 'USERLIST_USER_DATA', { userId });
|
||||
}
|
||||
}
|
||||
|
||||
public async saveGroupUser(
|
||||
userId: string, dept: string, level: number,
|
||||
useUserState: boolean, useJobReport: boolean, exceptHoly: boolean
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserList_SaveGroupUser(userId, dept, level, useUserState, useJobReport, exceptHoly);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('USERLIST_SAVE_GROUP_USER', 'USERLIST_SAVED', {
|
||||
userId, dept, level, useUserState, useJobReport, exceptHoly
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async saveUserFull(userData: UserFullData): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserList_SaveUserFull(JSON.stringify(userData));
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('USERLIST_SAVE_USER_FULL', 'USERLIST_USER_FULL_SAVED', { userData });
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteGroupUser(userId: string): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserList_DeleteGroupUser(userId);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('USERLIST_DELETE_GROUP_USER', 'USERLIST_DELETED', { userId });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== JobReport API (JobReport 뷰/테이블) =====
|
||||
|
||||
public async getJobReportList(sd: string, ed: string, uid: string = '', searchKey: string = ''): Promise<ApiResponse<JobReportItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_GetList(sd, ed, uid, '', searchKey);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<JobReportItem[]>>('JOBREPORT_GET_LIST', 'JOBREPORT_LIST_DATA', { sd, ed, uid, searchKey });
|
||||
}
|
||||
}
|
||||
|
||||
public async getJobReportUsers(): Promise<JobReportUser[]> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_GetUsers();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<JobReportUser[]>('JOBREPORT_GET_USERS', 'JOBREPORT_USERS_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getJobReportDetail(idx: number): Promise<ApiResponse<JobReportItem>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_GetDetail(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<JobReportItem>>('JOBREPORT_GET_DETAIL', 'JOBREPORT_DETAIL_DATA', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
public async addJobReport(
|
||||
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<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_Add(pdate, projectName, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('JOBREPORT_ADD', 'JOBREPORT_ADDED', {
|
||||
pdate, projectName, requestpart, package: package_, type, process, status, description, hrs, ot, jobgrp, tag
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async editJobReport(
|
||||
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<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_Edit(idx, pdate, projectName, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('JOBREPORT_EDIT', 'JOBREPORT_EDITED', {
|
||||
idx, pdate, projectName, requestpart, package: package_, type, process, status, description, hrs, ot, jobgrp, tag
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteJobReport(idx: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_Delete(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('JOBREPORT_DELETE', 'JOBREPORT_DELETED', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
public async getJobReportPermission(targetUserId: string): Promise<JobReportPermission> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_GetPermission(targetUserId);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<JobReportPermission>('JOBREPORT_GET_PERMISSION', 'JOBREPORT_PERMISSION', { targetUserId });
|
||||
}
|
||||
}
|
||||
|
||||
public async getJobTypes(process: string = ''): Promise<ApiResponse<JobTypeItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Jobreport_GetJobTypes(process);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<JobTypeItem[]>>('JOBREPORT_GET_JOBTYPES', 'JOBREPORT_JOBTYPES', { process });
|
||||
}
|
||||
}
|
||||
|
||||
public async getAppVersion(): Promise<AppVersionInfo> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.GetAppVersion();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<AppVersionInfo>('GET_APP_VERSION', 'APP_VERSION', {});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Holiday API (월별근무표) =====
|
||||
|
||||
public async getHolidayList(month: string): Promise<ApiResponse<HolidayItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Holiday_GetList(month);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<HolidayItem[]>>('HOLIDAY_GET_LIST', 'HOLIDAY_LIST_DATA', { month });
|
||||
}
|
||||
}
|
||||
|
||||
public async saveHolidays(month: string, holidays: HolidayItem[]): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Holiday_Save(month, JSON.stringify(holidays));
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('HOLIDAY_SAVE', 'HOLIDAY_SAVED', { month, holidays });
|
||||
}
|
||||
}
|
||||
|
||||
public async initializeHoliday(month: string): Promise<ApiResponse<{ Created: boolean }>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Holiday_Initialize(month);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<{ Created: boolean }>>('HOLIDAY_INITIALIZE', 'HOLIDAY_INITIALIZED', { month });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MailForm API (메일양식) =====
|
||||
|
||||
public async getMailFormList(): Promise<ApiResponse<MailFormItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.MailForm_GetList();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<MailFormItem[]>>('MAILFORM_GET_LIST', 'MAILFORM_LIST_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async getMailFormDetail(idx: number): Promise<ApiResponse<MailFormItem>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.MailForm_GetDetail(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<MailFormItem>>('MAILFORM_GET_DETAIL', 'MAILFORM_DETAIL_DATA', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
public async addMailForm(
|
||||
cate: string, title: string, tolist: string, bcc: string, cc: string,
|
||||
subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean,
|
||||
selfBCC: boolean, exceptmail: string, exceptmailcc: string
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.MailForm_Add(cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('MAILFORM_ADD', 'MAILFORM_ADDED', {
|
||||
cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async editMailForm(
|
||||
idx: number, cate: string, title: string, tolist: string, bcc: string, cc: string,
|
||||
subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean,
|
||||
selfBCC: boolean, exceptmail: string, exceptmailcc: string
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.MailForm_Edit(idx, cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('MAILFORM_EDIT', 'MAILFORM_EDITED', {
|
||||
idx, cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteMailForm(idx: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.MailForm_Delete(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('MAILFORM_DELETE', 'MAILFORM_DELETED', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UserGroup API (그룹정보/권한설정) =====
|
||||
|
||||
public async getUserGroupList(): Promise<ApiResponse<UserGroupItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserGroup_GetList();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<UserGroupItem[]>>('USERGROUP_GET_LIST', 'USERGROUP_LIST_DATA');
|
||||
}
|
||||
}
|
||||
|
||||
public async addUserGroup(
|
||||
dept: string, path_kj: string, permission: number, advpurchase: boolean,
|
||||
advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserGroup_Add(dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('USERGROUP_ADD', 'USERGROUP_ADDED', {
|
||||
dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async editUserGroup(
|
||||
originalDept: string, dept: string, path_kj: string, permission: number,
|
||||
advpurchase: boolean, advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserGroup_Edit(originalDept, dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('USERGROUP_EDIT', 'USERGROUP_EDITED', {
|
||||
originalDept, dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteUserGroup(dept: string): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserGroup_Delete(dept);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('USERGROUP_DELETE', 'USERGROUP_DELETED', { dept });
|
||||
}
|
||||
}
|
||||
|
||||
public async getPermissionInfo(): Promise<ApiResponse<PermissionInfo[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.UserGroup_GetPermissionInfo();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<PermissionInfo[]>>('USERGROUP_GET_PERMISSION_INFO', 'USERGROUP_PERMISSION_INFO');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comms = new CommunicationLayer();
|
||||
244
Project/frontend/src/components/items/ItemEditDialog.tsx
Normal file
244
Project/frontend/src/components/items/ItemEditDialog.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Trash2 } from 'lucide-react';
|
||||
import { ItemInfo } from '@/types';
|
||||
|
||||
interface ItemEditDialogProps {
|
||||
item: ItemInfo | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (item: ItemInfo) => Promise<void>;
|
||||
onDelete: (idx: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: ItemEditDialogProps) {
|
||||
const [editData, setEditData] = useState<ItemInfo | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setEditData({ ...item });
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (!isOpen || !editData) return null;
|
||||
|
||||
const isNew = editData.idx === 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editData) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(editData);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editData || isNew) return;
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onDelete(editData.idx);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 border 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">
|
||||
{isNew ? '품목 추가' : '품목 편집'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-4 space-y-4 max-h-[60vh] overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* SID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">SID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.sid}
|
||||
onChange={(e) => setEditData({ ...editData, sid: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 분류 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">분류</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.cate}
|
||||
onChange={(e) => setEditData({ ...editData, cate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">품명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.name}
|
||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 모델 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">모델</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.model}
|
||||
onChange={(e) => setEditData({ ...editData, model: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 규격 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">규격</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.scale}
|
||||
onChange={(e) => setEditData({ ...editData, scale: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 단위 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">단위</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.unit}
|
||||
onChange={(e) => setEditData({ ...editData, unit: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 단가 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">단가</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editData.price}
|
||||
onChange={(e) => setEditData({ ...editData, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 공급처 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">공급처</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.supply}
|
||||
onChange={(e) => setEditData({ ...editData, supply: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 제조사 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">제조사</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.manu}
|
||||
onChange={(e) => setEditData({ ...editData, manu: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보관장소 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">보관장소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.storage}
|
||||
onChange={(e) => setEditData({ ...editData, storage: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">메모</label>
|
||||
<textarea
|
||||
value={editData.memo}
|
||||
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disable"
|
||||
checked={editData.disable}
|
||||
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10"
|
||||
/>
|
||||
<label htmlFor="disable" className="text-sm text-white/70">비활성화</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-white/10">
|
||||
<div>
|
||||
{!isNew && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
Project/frontend/src/components/items/index.ts
Normal file
1
Project/frontend/src/components/items/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ItemEditDialog } from './ItemEditDialog';
|
||||
336
Project/frontend/src/components/jobreport/JobTypeSelectModal.tsx
Normal file
336
Project/frontend/src/components/jobreport/JobTypeSelectModal.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { X, ChevronRight, ChevronDown, Search } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { comms } from '@/communication';
|
||||
import { JobTypeItem } from '@/types';
|
||||
|
||||
interface JobTypeSelectModalProps {
|
||||
isOpen: boolean;
|
||||
currentProcess?: string;
|
||||
currentJobgrp?: string;
|
||||
currentType?: string;
|
||||
onClose: () => void;
|
||||
onSelect: (process: string, jobgrp: string, type: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
children?: TreeNode[];
|
||||
item?: JobTypeItem;
|
||||
}
|
||||
|
||||
export function JobTypeSelectModal({
|
||||
isOpen,
|
||||
currentProcess,
|
||||
currentJobgrp,
|
||||
currentType,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: JobTypeSelectModalProps) {
|
||||
const [jobTypes, setJobTypes] = useState<JobTypeItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string>('');
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const loadJobTypes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getJobTypes('');
|
||||
if (response.Success && response.Data) {
|
||||
setJobTypes(response.Data);
|
||||
|
||||
// 모든 노드 확장
|
||||
const allExpanded = new Set<string>();
|
||||
response.Data.forEach((item) => {
|
||||
const process = item.process || 'N/A';
|
||||
const jobgrp = item.jobgrp || 'N/A';
|
||||
allExpanded.add(process);
|
||||
allExpanded.add(`${process}|${jobgrp}`);
|
||||
});
|
||||
setExpandedNodes(allExpanded);
|
||||
|
||||
// 현재 선택된 항목이 있으면 선택 표시
|
||||
if (currentType) {
|
||||
setSelectedPath(`${currentProcess || 'N/A'}|${currentJobgrp || 'N/A'}|${currentType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무형태 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadJobTypes();
|
||||
}, [isOpen, currentProcess, currentJobgrp, currentType]);
|
||||
|
||||
// 트리 구조 생성
|
||||
const treeData = useMemo(() => {
|
||||
const processMap = new Map<string, Map<string, JobTypeItem[]>>();
|
||||
|
||||
// 검색 필터 적용
|
||||
const filteredTypes = searchKey
|
||||
? jobTypes.filter(
|
||||
(item) =>
|
||||
item.type?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.jobgrp?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.process?.toLowerCase().includes(searchKey.toLowerCase())
|
||||
)
|
||||
: jobTypes;
|
||||
|
||||
// 그룹핑
|
||||
filteredTypes.forEach((item) => {
|
||||
const process = item.process || 'N/A';
|
||||
const jobgrp = item.jobgrp || 'N/A';
|
||||
|
||||
if (!processMap.has(process)) {
|
||||
processMap.set(process, new Map());
|
||||
}
|
||||
const grpMap = processMap.get(process)!;
|
||||
if (!grpMap.has(jobgrp)) {
|
||||
grpMap.set(jobgrp, []);
|
||||
}
|
||||
grpMap.get(jobgrp)!.push(item);
|
||||
});
|
||||
|
||||
// 트리 노드 생성
|
||||
const tree: TreeNode[] = [];
|
||||
processMap.forEach((grpMap, processName) => {
|
||||
const processNode: TreeNode = {
|
||||
name: processName,
|
||||
children: [],
|
||||
};
|
||||
|
||||
grpMap.forEach((items, grpName) => {
|
||||
const grpNode: TreeNode = {
|
||||
name: grpName,
|
||||
children: items.map((item) => ({
|
||||
name: item.type,
|
||||
item,
|
||||
})),
|
||||
};
|
||||
processNode.children!.push(grpNode);
|
||||
});
|
||||
|
||||
tree.push(processNode);
|
||||
});
|
||||
|
||||
// 정렬
|
||||
tree.sort((a, b) => a.name.localeCompare(b.name));
|
||||
tree.forEach((p) => {
|
||||
p.children?.sort((a, b) => a.name.localeCompare(b.name));
|
||||
p.children?.forEach((g) => {
|
||||
g.children?.sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
});
|
||||
|
||||
return tree;
|
||||
}, [jobTypes, searchKey]);
|
||||
|
||||
// 검색 시 모든 노드 확장
|
||||
useEffect(() => {
|
||||
if (searchKey && treeData.length > 0) {
|
||||
const allExpanded = new Set<string>();
|
||||
treeData.forEach((processNode) => {
|
||||
allExpanded.add(processNode.name);
|
||||
processNode.children?.forEach((grpNode) => {
|
||||
allExpanded.add(`${processNode.name}|${grpNode.name}`);
|
||||
});
|
||||
});
|
||||
setExpandedNodes(allExpanded);
|
||||
}
|
||||
}, [searchKey, treeData]);
|
||||
|
||||
// 노드 토글
|
||||
const toggleNode = (path: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path);
|
||||
} else {
|
||||
newSet.add(path);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 항목 더블클릭
|
||||
const handleDoubleClick = (item: JobTypeItem) => {
|
||||
const process = item.process || 'N/A';
|
||||
const jobgrp = item.jobgrp || 'N/A';
|
||||
onSelect(process, jobgrp, item.type);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 선택 버튼 클릭
|
||||
const handleSelectClick = () => {
|
||||
if (selectedPath) {
|
||||
const parts = selectedPath.split('|');
|
||||
if (parts.length === 3) {
|
||||
onSelect(parts[0], parts[1], parts[2]);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div
|
||||
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up max-h-[85vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">업무형태 선택</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="px-6 py-3 border-b border-white/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -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 bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 트리뷰 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="text-white/50 text-center py-8">로딩 중...</div>
|
||||
) : treeData.length === 0 ? (
|
||||
<div className="text-white/50 text-center py-8">
|
||||
{searchKey ? '검색 결과가 없습니다' : '업무형태가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{treeData.map((processNode) => (
|
||||
<div key={processNode.name} className="border border-white/10 rounded-lg overflow-hidden mb-2">
|
||||
{/* 공정 레벨 */}
|
||||
<button
|
||||
className="flex items-center w-full px-3 py-2 text-left bg-white/5 hover:bg-white/10 transition-colors"
|
||||
onClick={() => toggleNode(processNode.name)}
|
||||
>
|
||||
{expandedNodes.has(processNode.name) ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2 text-primary-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 mr-2 text-white/50" />
|
||||
)}
|
||||
<span className="font-semibold text-primary-300">{processNode.name}</span>
|
||||
<span className="ml-2 text-xs text-white/40">
|
||||
({processNode.children?.reduce((acc, g) => acc + (g.children?.length || 0), 0) || 0}건)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 업무분류 레벨 */}
|
||||
{expandedNodes.has(processNode.name) && processNode.children && (
|
||||
<div className="border-t border-white/10">
|
||||
{processNode.children.map((grpNode) => {
|
||||
const grpPath = `${processNode.name}|${grpNode.name}`;
|
||||
return (
|
||||
<div key={grpPath} className="border-b border-white/5 last:border-b-0">
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-1.5 text-left bg-white/5 hover:bg-white/10 transition-colors"
|
||||
onClick={() => toggleNode(grpPath)}
|
||||
>
|
||||
{expandedNodes.has(grpPath) ? (
|
||||
<ChevronDown className="w-3 h-3 mr-2 text-white/50" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 mr-2 text-white/40" />
|
||||
)}
|
||||
<span className="text-white/80">{grpNode.name}</span>
|
||||
<span className="ml-2 text-xs text-white/40">
|
||||
({grpNode.children?.length || 0})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 업무형태 레벨 */}
|
||||
{expandedNodes.has(grpPath) && grpNode.children && (
|
||||
<div className="bg-black/20 py-1">
|
||||
{grpNode.children.map((typeNode) => {
|
||||
const typePath = `${grpPath}|${typeNode.name}`;
|
||||
const isSelected = selectedPath === typePath;
|
||||
return (
|
||||
<button
|
||||
key={typePath}
|
||||
className={`w-full px-8 py-1.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary-500/40 text-primary-200'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setSelectedPath(typePath)}
|
||||
onDoubleClick={() => typeNode.item && handleDoubleClick(typeNode.item)}
|
||||
>
|
||||
• {typeNode.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 항목 표시 */}
|
||||
{selectedPath && (
|
||||
<div className="px-6 py-3 border-t border-white/10 bg-primary-500/10">
|
||||
<div className="text-sm">
|
||||
<span className="text-white/50">선택: </span>
|
||||
<span className="text-primary-300 font-medium">
|
||||
{selectedPath.split('|').reverse().join(' ← ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectClick}
|
||||
disabled={!selectedPath}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
372
Project/frontend/src/components/jobreport/JobreportEditModal.tsx
Normal file
372
Project/frontend/src/components/jobreport/JobreportEditModal.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Plus, Trash2, X, Loader2, ChevronDown } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { JobReportItem } from '@/types';
|
||||
import { JobTypeSelectModal } from './JobTypeSelectModal';
|
||||
|
||||
export interface JobreportFormData {
|
||||
pdate: string;
|
||||
projectName: string;
|
||||
requestpart: string;
|
||||
package: string;
|
||||
type: string;
|
||||
process: string;
|
||||
status: string;
|
||||
description: string;
|
||||
hrs: number;
|
||||
ot: number;
|
||||
jobgrp: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
|
||||
const formatDateLocal = (date: Date) => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const initialFormData: JobreportFormData = {
|
||||
pdate: formatDateLocal(new Date()),
|
||||
projectName: '',
|
||||
requestpart: '',
|
||||
package: '',
|
||||
type: '',
|
||||
process: '',
|
||||
status: '진행 완료',
|
||||
description: '',
|
||||
hrs: 8,
|
||||
ot: 0,
|
||||
jobgrp: '',
|
||||
tag: '',
|
||||
};
|
||||
|
||||
interface JobreportEditModalProps {
|
||||
isOpen: boolean;
|
||||
editingItem: JobReportItem | null;
|
||||
formData: JobreportFormData;
|
||||
processing: boolean;
|
||||
onClose: () => void;
|
||||
onFormChange: (data: JobreportFormData) => void;
|
||||
onSave: () => void;
|
||||
onDelete: (idx: number) => void;
|
||||
}
|
||||
|
||||
export function JobreportEditModal({
|
||||
isOpen,
|
||||
editingItem,
|
||||
formData,
|
||||
processing,
|
||||
onClose,
|
||||
onFormChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: JobreportEditModalProps) {
|
||||
const [showJobTypeModal, setShowJobTypeModal] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFieldChange = <K extends keyof JobreportFormData>(
|
||||
field: K,
|
||||
value: JobreportFormData[K]
|
||||
) => {
|
||||
onFormChange({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
// 업무형태 선택 처리
|
||||
const handleJobTypeSelect = (process: string, jobgrp: string, type: string) => {
|
||||
onFormChange({
|
||||
...formData,
|
||||
process,
|
||||
jobgrp,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
// 업무형태 표시 텍스트
|
||||
const getJobTypeDisplayText = () => {
|
||||
if (!formData.type) {
|
||||
return '업무형태를 선택하세요';
|
||||
}
|
||||
if (formData.jobgrp && formData.jobgrp !== 'N/A') {
|
||||
return `${formData.type} ← ${formData.jobgrp}`;
|
||||
}
|
||||
return formData.type;
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
const handleSaveWithValidation = () => {
|
||||
// 프로젝트명 필수
|
||||
if (!formData.projectName.trim()) {
|
||||
alert('프로젝트(아이템) 명칭이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 업무형태가 '휴가'가 아니면 업무내용 필수
|
||||
if (formData.type !== '휴가' && !formData.description.trim()) {
|
||||
alert('진행 내용이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 근무시간 + 초과시간이 0이면 등록 불가
|
||||
const totalHours = (formData.hrs || 0) + (formData.ot || 0);
|
||||
if (totalHours === 0) {
|
||||
alert('근무시간/초과시간이 입력되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 상태 필수
|
||||
if (!formData.status.trim()) {
|
||||
alert('상태를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 업무형태 필수
|
||||
if (!formData.type.trim()) {
|
||||
alert('업무형태를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 공정 필수
|
||||
if (!formData.process.trim()) {
|
||||
alert('업무프로세스를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효성 검사 통과, 저장 진행
|
||||
onSave();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div
|
||||
className="glass-effect rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto"
|
||||
onClick={(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">
|
||||
<h2 className="text-xl font-semibold text-white flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
{editingItem ? '업무일지 수정' : '업무일지 등록'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 1행: 날짜, 프로젝트명 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
날짜 *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.pdate}
|
||||
onChange={(e) => handleFieldChange('pdate', 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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
프로젝트명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.projectName}
|
||||
onChange={(e) => handleFieldChange('projectName', 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"
|
||||
placeholder="프로젝트 또는 아이템명"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2행: 요청부서, 패키지 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
요청부서
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.requestpart}
|
||||
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"
|
||||
placeholder="요청부서"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
패키지
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.package}
|
||||
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"
|
||||
placeholder="패키지"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3행: 업무형태 선택 버튼 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
업무형태 *
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowJobTypeModal(true)}
|
||||
className={`w-full border rounded-lg px-4 py-2 text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-primary-400 transition-colors ${
|
||||
formData.type
|
||||
? 'bg-white/20 border-white/30 text-white'
|
||||
: 'bg-pink-500/30 border-pink-400/50 text-pink-200'
|
||||
}`}
|
||||
>
|
||||
<span>{getJobTypeDisplayText()}</span>
|
||||
<ChevronDown className="w-4 h-4 text-white/50" />
|
||||
</button>
|
||||
{formData.process && (
|
||||
<div className="mt-1 text-xs text-white/50">
|
||||
공정: {formData.process}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4행: 상태, 근무시간, 초과시간 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
상태
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
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"
|
||||
>
|
||||
<option value="진행 완료" className="bg-gray-800">
|
||||
진행 완료
|
||||
</option>
|
||||
<option value="진행 중" className="bg-gray-800">
|
||||
진행 중
|
||||
</option>
|
||||
<option value="대기" className="bg-gray-800">
|
||||
대기
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
근무시간(h)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="24"
|
||||
value={formData.hrs}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('hrs', parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
초과시간(h)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="24"
|
||||
value={formData.ot}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('ot', parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업무내용 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
업무내용
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFieldChange('description', e.target.value)}
|
||||
rows={6}
|
||||
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 resize-none"
|
||||
placeholder="업무 내용을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between sticky bottom-0 bg-slate-800/95 backdrop-blur">
|
||||
{/* 좌측: 삭제 버튼 (편집 모드일 때만) */}
|
||||
<div>
|
||||
{editingItem && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (editingItem) {
|
||||
onDelete(editingItem.idx);
|
||||
}
|
||||
}}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 우측: 취소, 저장 버튼 */}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveWithValidation}
|
||||
disabled={processing}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{editingItem ? '수정' : '등록'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업무형태 선택 모달 */}
|
||||
<JobTypeSelectModal
|
||||
isOpen={showJobTypeModal}
|
||||
currentProcess={formData.process}
|
||||
currentJobgrp={formData.jobgrp}
|
||||
currentType={formData.type}
|
||||
onClose={() => setShowJobTypeModal(false)}
|
||||
onSelect={handleJobTypeSelect}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
22
Project/frontend/src/components/layout/AmkorLogo.tsx
Normal file
22
Project/frontend/src/components/layout/AmkorLogo.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface AmkorLogoProps {
|
||||
className?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function AmkorLogo({ className = '', height = 48 }: AmkorLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 50 50"
|
||||
height={height}
|
||||
className={className}
|
||||
>
|
||||
{/* 흰색 원 배경 */}
|
||||
<circle cx="25" cy="25" r="23" fill="white" />
|
||||
{/* 파란색 A */}
|
||||
<path
|
||||
d="M25 8 L38 40 L32 40 L29 32 L16 32 L16 26 L27 26 L22.5 14 L17 28 L11 40 L5 40 L18 8 Z"
|
||||
fill="#1a5091"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
450
Project/frontend/src/components/layout/Header.tsx
Normal file
450
Project/frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CheckSquare,
|
||||
Clock as ClockIcon,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
Code,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Package,
|
||||
User,
|
||||
Users,
|
||||
CalendarDays,
|
||||
Mail,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
||||
import { AmkorLogo } from './AmkorLogo';
|
||||
|
||||
interface HeaderProps {
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path?: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface SubMenu {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
type: 'link' | 'submenu' | 'action';
|
||||
path?: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
submenu?: SubMenu;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface DropdownMenuConfig {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
// 일반 메뉴 항목
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/jobreport', icon: FileText, label: '업무일지' },
|
||||
{ path: '/project', icon: FolderKanban, label: '프로젝트' },
|
||||
{ path: '/todo', icon: CheckSquare, label: '할일' },
|
||||
{ path: '/kuntae', icon: ClockIcon, label: '근태' },
|
||||
];
|
||||
|
||||
// 드롭다운 메뉴 (2단계 지원)
|
||||
const dropdownMenus: DropdownMenuConfig[] = [
|
||||
{
|
||||
label: '공용정보',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ type: 'link', path: '/common', icon: Code, label: '공용코드' },
|
||||
{ type: 'link', path: '/items', icon: Package, label: '품목정보' },
|
||||
{
|
||||
type: 'submenu',
|
||||
icon: Users,
|
||||
label: '사용자',
|
||||
submenu: {
|
||||
label: '사용자',
|
||||
icon: Users,
|
||||
items: [
|
||||
{ icon: User, label: '정보', action: 'userInfo' },
|
||||
{ path: '/user/list', icon: Users, label: '목록' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
||||
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
||||
{ type: 'link', path: '/user-group', icon: Shield, label: '그룹정보' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function DropdownNavMenu({
|
||||
menu,
|
||||
onItemClick,
|
||||
onAction
|
||||
}: {
|
||||
menu: DropdownMenuConfig;
|
||||
onItemClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSubItemClick = (subItem: NavItem) => {
|
||||
setIsOpen(false);
|
||||
setActiveSubmenu(null);
|
||||
if (subItem.action) {
|
||||
onAction?.(subItem.action);
|
||||
}
|
||||
onItemClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium',
|
||||
isOpen
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<menu.icon className="w-4 h-4" />
|
||||
<span>{menu.label}</span>
|
||||
<ChevronDown className={clsx('w-3 h-3 transition-transform', isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 min-w-[160px] glass-effect-solid rounded-lg py-1 z-[9999]">
|
||||
{menu.items.map((item) => (
|
||||
item.type === 'link' ? (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onItemClick?.();
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<div
|
||||
key={item.label}
|
||||
className="relative"
|
||||
onMouseEnter={() => setActiveSubmenu(item.label)}
|
||||
onMouseLeave={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-4 py-2 text-sm transition-colors cursor-pointer',
|
||||
activeSubmenu === item.label
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
{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]">
|
||||
{item.submenu.items.map((subItem) => (
|
||||
subItem.path ? (
|
||||
<NavLink
|
||||
key={subItem.path}
|
||||
to={subItem.path}
|
||||
onClick={() => handleSubItemClick(subItem)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<button
|
||||
key={subItem.label}
|
||||
onClick={() => handleSubItemClick(subItem)}
|
||||
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"
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일용 드롭다운 (펼쳐진 형태)
|
||||
function MobileDropdownMenu({
|
||||
menu,
|
||||
onItemClick,
|
||||
onAction
|
||||
}: {
|
||||
menu: DropdownMenuConfig;
|
||||
onItemClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
|
||||
const handleSubItemClick = (subItem: NavItem) => {
|
||||
if (subItem.action) {
|
||||
onAction?.(subItem.action);
|
||||
}
|
||||
onItemClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<menu.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{menu.label}</span>
|
||||
</div>
|
||||
<ChevronDown className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{menu.items.map((item) => (
|
||||
item.type === 'link' ? (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={onItemClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-3 px-4 py-2 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-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<div key={item.label}>
|
||||
<button
|
||||
onClick={() => setActiveSubmenu(activeSubmenu === item.label ? null : item.label)}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<ChevronDown className={clsx('w-3 h-3 transition-transform', activeSubmenu === item.label && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{activeSubmenu === item.label && item.submenu && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{item.submenu.items.map((subItem) => (
|
||||
subItem.path ? (
|
||||
<NavLink
|
||||
key={subItem.path}
|
||||
to={subItem.path}
|
||||
onClick={onItemClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)
|
||||
}
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<button
|
||||
key={subItem.label}
|
||||
onClick={() => handleSubItemClick(subItem)}
|
||||
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"
|
||||
>
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
<span>{subItem.label}</span>
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({ isConnected }: HeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [showUserInfoDialog, setShowUserInfoDialog] = useState(false);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'userInfo') {
|
||||
setShowUserInfoDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="glass-effect relative z-[9999]">
|
||||
{/* Main Header Bar */}
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
{/* Logo & Mobile Menu Button */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="lg:hidden text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<AmkorLogo height={36} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex items-center space-x-1">
|
||||
{/* 드롭다운 메뉴들 */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||
))}
|
||||
|
||||
{/* 일반 메뉴들 */}
|
||||
{navItems.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>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Mobile Navigation Dropdown */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="lg:hidden border-t border-white/10">
|
||||
<nav className="px-4 py-2 space-y-1">
|
||||
{/* 드롭다운 메뉴들 */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
<MobileDropdownMenu
|
||||
key={menu.label}
|
||||
menu={menu}
|
||||
onItemClick={() => setIsMobileMenuOpen(false)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 일반 메뉴들 */}
|
||||
{navItems.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>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* User Info Dialog */}
|
||||
<UserInfoDialog
|
||||
isOpen={showUserInfoDialog}
|
||||
onClose={() => setShowUserInfoDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
Project/frontend/src/components/layout/Layout.tsx
Normal file
28
Project/frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { UserInfo } from '@/types';
|
||||
|
||||
interface LayoutProps {
|
||||
isConnected: boolean;
|
||||
user?: UserInfo | null;
|
||||
}
|
||||
|
||||
export function Layout({ isConnected, user }: LayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900">
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
{/* Top Navigation Header */}
|
||||
<Header isConnected={isConnected} />
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Bottom Status Bar */}
|
||||
<StatusBar userName={user?.Name} userDept={user?.Dept} isConnected={isConnected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
Project/frontend/src/components/layout/StatusBar.tsx
Normal file
79
Project/frontend/src/components/layout/StatusBar.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock, Wifi, WifiOff } from 'lucide-react';
|
||||
import { UserInfoButton } from './UserInfoButton';
|
||||
import { comms } from '@/communication';
|
||||
|
||||
interface StatusBarProps {
|
||||
userName?: string;
|
||||
userDept?: string;
|
||||
isConnected?: boolean;
|
||||
}
|
||||
|
||||
export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [versionDisplay, setVersionDisplay] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 앱 버전 로드
|
||||
useEffect(() => {
|
||||
const loadVersion = async () => {
|
||||
try {
|
||||
const result = await comms.getAppVersion();
|
||||
if (result.Success) {
|
||||
setVersionDisplay(result.DisplayVersion);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('버전 정보 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
loadVersion();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="glass-effect px-4 py-2 flex items-center justify-between text-sm">
|
||||
{/* Left: User Info */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserInfoButton userName={userName} userDept={userDept} />
|
||||
</div>
|
||||
|
||||
{/* Center: App Version */}
|
||||
<div className="text-white/50">
|
||||
{versionDisplay || 'Loading...'}
|
||||
</div>
|
||||
|
||||
{/* Right: Connection Status & Time */}
|
||||
<div className="flex items-center space-x-4 text-white/70">
|
||||
{/* Connection Status */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-4 h-4 text-success-400" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4 text-danger-400" />
|
||||
)}
|
||||
<span className={isConnected ? 'text-success-400' : 'text-danger-400'}>
|
||||
{isConnected ? '연결됨' : '연결 끊김'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Current Time */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{currentTime.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
107
Project/frontend/src/components/layout/UserInfoButton.tsx
Normal file
107
Project/frontend/src/components/layout/UserInfoButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { User, LogOut, X } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
|
||||
interface UserInfoButtonProps {
|
||||
userName?: string;
|
||||
userDept?: string;
|
||||
}
|
||||
|
||||
export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) {
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
const result = await comms.logout();
|
||||
if (result.Success) {
|
||||
// 로그아웃 성공 - 페이지 새로고침으로 로그인 화면 표시
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(result.Message || '로그아웃에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그아웃 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
setShowLogoutDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!userName) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 사용자 정보 버튼 */}
|
||||
<button
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
className="flex items-center space-x-2 text-white/70 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>{userName}</span>
|
||||
{userDept && <span className="text-white/50">({userDept})</span>}
|
||||
</button>
|
||||
|
||||
{/* 로그아웃 다이얼로그 - Portal로 body에 직접 렌더링 */}
|
||||
{showLogoutDialog && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setShowLogoutDialog(false)}
|
||||
>
|
||||
<div
|
||||
className="glass-effect rounded-2xl w-full max-w-sm animate-slide-up mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center">
|
||||
<LogOut className="w-5 h-5 mr-2" />
|
||||
로그아웃
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowLogoutDialog(false)}
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<User className="w-8 h-8 text-white/70" />
|
||||
</div>
|
||||
<p className="text-white font-medium">{userName}</p>
|
||||
{userDept && <p className="text-white/50 text-sm">{userDept}</p>}
|
||||
</div>
|
||||
<p className="text-white/70 text-center text-sm">
|
||||
로그아웃 하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-center">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<span className="animate-spin mr-2">⏳</span>
|
||||
) : (
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
Project/frontend/src/components/layout/index.ts
Normal file
3
Project/frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Layout } from './Layout';
|
||||
export { Header } from './Header';
|
||||
export { StatusBar } from './StatusBar';
|
||||
449
Project/frontend/src/components/user/UserInfoDialog.tsx
Normal file
449
Project/frontend/src/components/user/UserInfoDialog.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Key, User, Mail, Building2, Briefcase, Calendar, FileText } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { UserInfoDetail } from '@/types';
|
||||
|
||||
interface UserInfoDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userId?: string;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
interface PasswordDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (oldPassword: string, newPassword: string) => void;
|
||||
}
|
||||
|
||||
function PasswordDialog({ isOpen, onClose, onConfirm }: PasswordDialogProps) {
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!newPassword) {
|
||||
setError('새 비밀번호를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
onConfirm(oldPassword, newPassword);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10001]">
|
||||
<div className="glass-effect-solid rounded-xl p-6 w-full max-w-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
비밀번호 변경
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">기존 비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="기존 비밀번호"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">새 비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="새 비밀번호"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">새 비밀번호 확인</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="새 비밀번호 확인"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<UserInfoDetail>({
|
||||
Id: '',
|
||||
NameK: '',
|
||||
NameE: '',
|
||||
Dept: '',
|
||||
Grade: '',
|
||||
Email: '',
|
||||
Tel: '',
|
||||
Hp: '',
|
||||
DateIn: '',
|
||||
DateO: '',
|
||||
Memo: '',
|
||||
Process: '',
|
||||
State: '',
|
||||
UseJobReport: false,
|
||||
UseUserState: false,
|
||||
ExceptHoly: false,
|
||||
Level: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadUserInfo();
|
||||
}
|
||||
}, [isOpen, userId]);
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = userId
|
||||
? await comms.getUserInfoById(userId)
|
||||
: await comms.getCurrentUserInfo();
|
||||
|
||||
if (response.Success && response.Data) {
|
||||
setFormData(response.Data);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: response.Message || '사용자 정보를 불러올 수 없습니다.' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: '사용자 정보 조회 중 오류가 발생했습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = await comms.saveUserInfo(formData);
|
||||
if (response.Success) {
|
||||
setMessage({ type: 'success', text: '저장되었습니다.' });
|
||||
onSave?.();
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1000);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: response.Message || '저장에 실패했습니다.' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: '저장 중 오류가 발생했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (oldPassword: string, newPassword: string) => {
|
||||
try {
|
||||
const response = await comms.changePassword(oldPassword, newPassword);
|
||||
if (response.Success) {
|
||||
setMessage({ type: 'success', text: '비밀번호가 변경되었습니다.' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: response.Message || '비밀번호 변경에 실패했습니다.' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: '비밀번호 변경 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof UserInfoDetail, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
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-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
||||
<User className="w-6 h-6" />
|
||||
사용자 정보
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)] custom-scrollbar">
|
||||
{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"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
|
||||
<User className="w-4 h-4" />
|
||||
사원번호
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.Id}
|
||||
disabled
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.NameK}
|
||||
onChange={(e) => handleInputChange('NameK', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="이름"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">영문이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.NameE}
|
||||
onChange={(e) => handleInputChange('NameE', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="English Name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
|
||||
<Building2 className="w-4 h-4" />
|
||||
부서
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.Dept}
|
||||
onChange={(e) => handleInputChange('Dept', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="부서"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
직책
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.Grade}
|
||||
onChange={(e) => handleInputChange('Grade', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="직책"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">공정</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.Process}
|
||||
onChange={(e) => handleInputChange('Process', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="공정"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
|
||||
<Mail className="w-4 h-4" />
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.Email}
|
||||
onChange={(e) => handleInputChange('Email', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 입/퇴사 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
입사일
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.DateIn}
|
||||
onChange={(e) => handleInputChange('DateIn', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
퇴사일
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.DateO}
|
||||
onChange={(e) => handleInputChange('DateO', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
비고
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.Memo}
|
||||
onChange={(e) => handleInputChange('Memo', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 resize-none"
|
||||
placeholder="비고"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 체크박스 */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.UseJobReport}
|
||||
onChange={(e) => handleInputChange('UseJobReport', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
|
||||
/>
|
||||
<span className="text-white/70">업무일지 사용</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.UseUserState}
|
||||
onChange={(e) => handleInputChange('UseUserState', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
|
||||
/>
|
||||
<span className="text-white/70">계정 사용</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ExceptHoly}
|
||||
onChange={(e) => handleInputChange('ExceptHoly', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
|
||||
/>
|
||||
<span className="text-white/70">휴가 제외</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 메시지 */}
|
||||
{message && (
|
||||
<div
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg text-sm',
|
||||
message.type === 'success' ? 'bg-green-600/20 text-green-400' : 'bg-red-600/20 text-red-400'
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setShowPasswordDialog(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-yellow-600/20 hover:bg-yellow-600/30 text-yellow-400 rounded-lg transition-colors"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-colors',
|
||||
saving
|
||||
? 'bg-blue-600/50 text-white/50 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
)}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordDialog
|
||||
isOpen={showPasswordDialog}
|
||||
onClose={() => setShowPasswordDialog(false)}
|
||||
onConfirm={handleChangePassword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
Project/frontend/src/components/user/index.ts
Normal file
1
Project/frontend/src/components/user/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UserInfoDialog } from './UserInfoDialog';
|
||||
62
Project/frontend/src/index.css
Normal file
62
Project/frontend/src/index.css
Normal file
@@ -0,0 +1,62 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* 드롭다운 메뉴용 불투명 배경 */
|
||||
.glass-effect-solid {
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 드롭다운 스타일 */
|
||||
select option {
|
||||
background-color: #1f2937;
|
||||
color: white;
|
||||
}
|
||||
|
||||
select:focus option:checked {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
select option:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
10
Project/frontend/src/main.tsx
Normal file
10
Project/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
341
Project/frontend/src/pages/CommonCode.tsx
Normal file
341
Project/frontend/src/pages/CommonCode.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Save, Trash2, RefreshCw, FolderOpen, Edit2 } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { CommonCodeGroup, CommonCode } from '@/types';
|
||||
|
||||
export function CommonCodePage() {
|
||||
const [groups, setGroups] = useState<CommonCodeGroup[]>([]);
|
||||
const [codes, setCodes] = useState<CommonCode[]>([]);
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
const [selectedCode, setSelectedCode] = useState<CommonCode | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<CommonCode>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup) {
|
||||
loadCodes();
|
||||
setSelectedCode(null);
|
||||
}
|
||||
}, [selectedGroup]);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const result = await comms.getCommonGroups();
|
||||
setGroups(result);
|
||||
} catch (error) {
|
||||
console.error('그룹 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCodes = async () => {
|
||||
if (!selectedGroup) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getCommonList(selectedGroup);
|
||||
setCodes(result);
|
||||
} catch (error) {
|
||||
console.error('코드 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedCode) return;
|
||||
try {
|
||||
const saveData = { ...selectedCode, ...editData } as CommonCode;
|
||||
const response = await comms.saveCommon(saveData);
|
||||
if (response.Success) {
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
loadCodes();
|
||||
} else {
|
||||
alert(response.Message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedCode || selectedCode.idx === 0) return;
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const response = await comms.deleteCommon(selectedCode.idx);
|
||||
if (response.Success) {
|
||||
setSelectedCode(null);
|
||||
setIsEditing(false);
|
||||
loadCodes();
|
||||
} else {
|
||||
alert(response.Message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
if (!selectedGroup) {
|
||||
alert('그룹을 먼저 선택하세요.');
|
||||
return;
|
||||
}
|
||||
const newCode: CommonCode = {
|
||||
idx: 0,
|
||||
grp: selectedGroup,
|
||||
code: '',
|
||||
svalue: '',
|
||||
ivalue: 0,
|
||||
fvalue: 0,
|
||||
memo: '',
|
||||
};
|
||||
setSelectedCode(newCode);
|
||||
setEditData(newCode);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (selectedCode?.idx === 0) {
|
||||
setSelectedCode(null);
|
||||
}
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
};
|
||||
|
||||
const selectedGroupName = groups.find((g) => g.code === selectedGroup)?.memo || '';
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-4">
|
||||
{/* 좌측: 그룹 목록 */}
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
{/* 그룹 목록 */}
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-3 border-b border-white/10">
|
||||
<h2 className="text-sm font-semibold text-white/70">코드 그룹</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.code}
|
||||
onClick={() => setSelectedGroup(g.code)}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 text-left text-sm flex items-center gap-2 transition-colors',
|
||||
selectedGroup === g.code
|
||||
? 'bg-blue-600/30 text-white border-l-2 border-blue-500'
|
||||
: 'text-white/70 hover:bg-white/5 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">[{g.code}] {g.memo}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 코드 목록 */}
|
||||
<div className="w-72 flex flex-col">
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">{selectedGroupName || '그룹 선택'}</h2>
|
||||
<p className="text-xs text-white/50">{codes.length}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={loadCodes}
|
||||
className="p-1.5 bg-white/10 hover:bg-white/20 rounded text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="p-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : selectedGroup ? (
|
||||
codes.length > 0 ? (
|
||||
codes.map((code) => (
|
||||
<button
|
||||
key={code.idx || 'new'}
|
||||
onClick={() => {
|
||||
setSelectedCode(code);
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
}}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 text-left border-b border-white/5 transition-colors',
|
||||
selectedCode?.idx === code.idx
|
||||
? 'bg-blue-600/20 border-l-2 border-l-blue-500'
|
||||
: 'hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="text-sm text-white font-medium">{code.code}</div>
|
||||
<div className="text-xs text-white/50 truncate">{code.memo || code.svalue}</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-white/50 text-sm">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="p-4 text-center text-white/50 text-sm">
|
||||
그룹을 선택하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 정보 */}
|
||||
<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 justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{selectedCode ? (isEditing ? '코드 편집' : '코드 상세') : '코드 선택'}
|
||||
</h2>
|
||||
{selectedCode && !isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
setEditData(selectedCode);
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-sm transition-colors"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-red-600/20 hover:bg-red-600/40 rounded text-red-400 text-sm transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{selectedCode ? (
|
||||
<div className="space-y-4">
|
||||
{/* 코드 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">코드</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.code ?? selectedCode.code}
|
||||
onChange={(e) => setEditData({ ...editData, code: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.code}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 값(S) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">값 (String)</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.svalue ?? selectedCode.svalue}
|
||||
onChange={(e) => setEditData({ ...editData, svalue: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.svalue || '-'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 값(I) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">값 (Integer)</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.ivalue ?? selectedCode.ivalue}
|
||||
onChange={(e) => setEditData({ ...editData, ivalue: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.ivalue}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 값(F) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">값 (Float)</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editData.fvalue ?? selectedCode.fvalue}
|
||||
onChange={(e) => setEditData({ ...editData, fvalue: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.fvalue}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-1">설명</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editData.memo ?? selectedCode.memo}
|
||||
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-white/5 rounded-lg text-white min-h-[80px]">{selectedCode.memo || '-'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 편집 모드 버튼 */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-white/50">
|
||||
좌측에서 코드를 선택하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
Project/frontend/src/pages/Dashboard.tsx
Normal file
402
Project/frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShoppingCart,
|
||||
FileCheck,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Flag,
|
||||
RefreshCw,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { TodoModel, PurchaseItem } from '@/types';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color, onClick }: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`glass-effect rounded-2xl p-6 card-hover ${onClick ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white/60 text-sm font-medium">{title}</p>
|
||||
<p className={`text-3xl font-bold mt-2 ${color}`}>{value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [purchaseNR, setPurchaseNR] = useState(0);
|
||||
const [purchaseCR, setPurchaseCR] = useState(0);
|
||||
const [todoCount, setTodoCount] = useState(0);
|
||||
const [todayWorkHrs, setTodayWorkHrs] = useState(0);
|
||||
|
||||
// 목록 데이터
|
||||
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
|
||||
const [purchaseNRList, setPurchaseNRList] = useState<PurchaseItem[]>([]);
|
||||
const [purchaseCRList, setPurchaseCRList] = useState<PurchaseItem[]>([]);
|
||||
|
||||
// 모달 상태
|
||||
const [showNRModal, setShowNRModal] = useState(false);
|
||||
const [showCRModal, setShowCRModal] = useState(false);
|
||||
|
||||
const loadDashboardData = useCallback(async () => {
|
||||
try {
|
||||
// 오늘 날짜 (로컬 시간 기준)
|
||||
const now = new Date();
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 현재 로그인 사용자 ID 가져오기
|
||||
let currentUserId = '';
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
currentUserId = loginStatus.User.Id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 정보 로드 오류:', error);
|
||||
}
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
const [
|
||||
purchaseCount,
|
||||
urgentTodosResponse,
|
||||
allTodosResponse,
|
||||
jobreportResponse,
|
||||
] = await Promise.all([
|
||||
comms.getPurchaseWaitCount(),
|
||||
comms.getUrgentTodos(),
|
||||
comms.getTodos(),
|
||||
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
|
||||
]);
|
||||
|
||||
setPurchaseNR(purchaseCount.NR);
|
||||
setPurchaseCR(purchaseCount.CR);
|
||||
|
||||
if (urgentTodosResponse.Success && urgentTodosResponse.Data) {
|
||||
setUrgentTodos(urgentTodosResponse.Data.slice(0, 5));
|
||||
}
|
||||
|
||||
if (allTodosResponse.Success && allTodosResponse.Data) {
|
||||
// 진행, 대기 상태의 할일만 카운트 (보류, 취소 제외)
|
||||
const pendingCount = allTodosResponse.Data.filter(t => t.status === '0' || t.status === '1').length;
|
||||
setTodoCount(pendingCount);
|
||||
}
|
||||
|
||||
// 오늘 업무일지 작성시간 계산
|
||||
if (jobreportResponse.Success && jobreportResponse.Data) {
|
||||
const totalHrs = jobreportResponse.Data.reduce((acc, item) => acc + (item.hrs || 0), 0);
|
||||
setTodayWorkHrs(totalHrs);
|
||||
} else {
|
||||
setTodayWorkHrs(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNRList = async () => {
|
||||
try {
|
||||
const list = await comms.getPurchaseNRList();
|
||||
setPurchaseNRList(list);
|
||||
setShowNRModal(true);
|
||||
} catch (error) {
|
||||
console.error('NR 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCRList = async () => {
|
||||
try {
|
||||
const list = await comms.getPurchaseCRList();
|
||||
setPurchaseCRList(list);
|
||||
setShowCRModal(true);
|
||||
} catch (error) {
|
||||
console.error('CR 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
|
||||
// 30초마다 자동 새로고침
|
||||
const interval = setInterval(loadDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadDashboardData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadDashboardData();
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case '0': return '대기';
|
||||
case '1': return '진행';
|
||||
case '2': return '취소';
|
||||
case '3': return '보류';
|
||||
case '5': return '완료';
|
||||
default: return '대기';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case '0': return 'bg-gray-500/20 text-gray-300';
|
||||
case '1': return 'bg-primary-500/20 text-primary-300';
|
||||
case '2': return 'bg-danger-500/20 text-danger-300';
|
||||
case '3': return 'bg-warning-500/20 text-warning-300';
|
||||
case '5': return 'bg-success-500/20 text-success-300';
|
||||
default: return 'bg-white/10 text-white/50';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityText = (seqno: number) => {
|
||||
switch (seqno) {
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
default: return '보통';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityClass = (seqno: number) => {
|
||||
switch (seqno) {
|
||||
case 1: return 'bg-primary-500/20 text-primary-300';
|
||||
case 2: return 'bg-warning-500/20 text-warning-300';
|
||||
case 3: return 'bg-danger-500/20 text-danger-300';
|
||||
default: return 'bg-white/10 text-white/50';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-white">오늘의 현황</h2>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center space-x-2 px-4 py-2 glass-effect rounded-lg text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="구매요청 (NR)"
|
||||
value={purchaseNR}
|
||||
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
|
||||
color="text-primary-400"
|
||||
onClick={loadNRList}
|
||||
/>
|
||||
<StatCard
|
||||
title="구매요청 (CR)"
|
||||
value={purchaseCR}
|
||||
icon={<FileCheck className="w-6 h-6 text-success-400" />}
|
||||
color="text-success-400"
|
||||
onClick={loadCRList}
|
||||
/>
|
||||
<StatCard
|
||||
title="미완료 할일"
|
||||
value={todoCount}
|
||||
icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
|
||||
color="text-warning-400"
|
||||
onClick={() => navigate('/todo')}
|
||||
/>
|
||||
<StatCard
|
||||
title="금일 업무일지"
|
||||
value={`${todayWorkHrs}시간`}
|
||||
icon={<Clock className="w-6 h-6 text-cyan-400" />}
|
||||
color="text-cyan-400"
|
||||
onClick={() => navigate('/jobreport')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 급한 할일 목록 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
|
||||
급한 할일
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => navigate('/todo')}
|
||||
className="text-sm text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
전체보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/10">
|
||||
{urgentTodos.length > 0 ? (
|
||||
urgentTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.idx}
|
||||
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={() => navigate('/todo')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
{todo.flag && (
|
||||
<Flag className="w-4 h-4 text-warning-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-white font-medium">
|
||||
{todo.title || '제목 없음'}
|
||||
</p>
|
||||
<p className="text-white/60 text-sm line-clamp-1">
|
||||
{todo.remark}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
||||
{getPriorityText(todo.seqno)}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
||||
{getStatusText(todo.status)}
|
||||
</span>
|
||||
{todo.expire && (
|
||||
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
|
||||
{new Date(todo.expire).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-6 py-8 text-center text-white/50">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
|
||||
<p>급한 할일이 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NR 모달 */}
|
||||
{showNRModal && (
|
||||
<Modal title="구매요청 (NR) 목록" onClose={() => setShowNRModal(false)}>
|
||||
<PurchaseTable data={purchaseNRList} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* CR 모달 */}
|
||||
{showCRModal && (
|
||||
<Modal title="구매요청 (CR) 목록" onClose={() => setShowCRModal(false)}>
|
||||
<PurchaseTable data={purchaseCRList} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모달 컴포넌트
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Modal({ title, onClose, children }: ModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-effect rounded-2xl w-full max-w-4xl max-h-[80vh] overflow-hidden animate-slide-up">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[calc(80vh-80px)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 구매 테이블 컴포넌트
|
||||
function PurchaseTable({ data }: { data: PurchaseItem[] }) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="px-6 py-8 text-center text-white/50">
|
||||
대기 중인 구매요청이 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">요청일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">공정</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">품명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">규격</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">수량</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">단가</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{data.map((item, idx) => (
|
||||
<tr key={idx} className="hover:bg-white/5">
|
||||
<td className="px-4 py-3 text-white/80 text-sm">{item.pdate}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm">{item.process}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.pumname}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm">{item.pumscale}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm text-right">
|
||||
{item.pumqtyreq?.toLocaleString()} {item.pumunit}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm text-right">
|
||||
{item.pumprice?.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm text-right font-medium">
|
||||
{item.pumamt?.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
230
Project/frontend/src/pages/Items.tsx
Normal file
230
Project/frontend/src/pages/Items.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, Plus, Package } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { ItemInfo } from '@/types';
|
||||
import { ItemEditDialog } from '@/components/items';
|
||||
|
||||
export function ItemsPage() {
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<ItemInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const result = await comms.getItemCategories();
|
||||
if (result.Success && result.Data) {
|
||||
setCategories(result.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadItems = async () => {
|
||||
if (!searchKey.trim()) {
|
||||
alert('검색어를 입력하세요');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getItemList(selectedCategory, searchKey);
|
||||
setItems(result);
|
||||
} catch (error) {
|
||||
console.error('품목 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (item: ItemInfo) => {
|
||||
const response = await comms.saveItem(item);
|
||||
if (response.Success) {
|
||||
setDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
if (searchKey) loadItems();
|
||||
} else {
|
||||
alert(response.Message || '저장 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (idx: number) => {
|
||||
const response = await comms.deleteItem(idx);
|
||||
if (response.Success) {
|
||||
setDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
setItems(items.filter((i) => i.idx !== idx));
|
||||
} else {
|
||||
alert(response.Message || '삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
const newItem: ItemInfo = {
|
||||
idx: 0,
|
||||
sid: '',
|
||||
cate: selectedCategory !== 'all' ? selectedCategory : '',
|
||||
name: '',
|
||||
model: '',
|
||||
scale: '',
|
||||
unit: '',
|
||||
price: 0,
|
||||
supply: '',
|
||||
manu: '',
|
||||
storage: '',
|
||||
disable: false,
|
||||
memo: '',
|
||||
};
|
||||
setSelectedItem(newItem);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRowClick = (item: ItemInfo) => {
|
||||
setSelectedItem(item);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const filteredItems = items.filter(
|
||||
(i) =>
|
||||
(i.sid ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(i.name ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(i.model ?? '').toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white min-w-[150px]"
|
||||
>
|
||||
<option value="all" className="bg-slate-800">-- 전체 --</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c} className="bg-slate-800">{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && loadItems()}
|
||||
placeholder="품목명/SID/모델 검색..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadItems}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
검색
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="필터..."
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-32"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="flex items-center gap-1 px-3 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">품목 목록</h2>
|
||||
<span className="text-sm text-white/50">({filteredItems.length}건)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">분류</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">품명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">모델</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-white/70 w-24">단가</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">공급처</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">제조사</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item) => (
|
||||
<tr
|
||||
key={item.idx || 'new'}
|
||||
onClick={() => handleRowClick(item)}
|
||||
className={clsx(
|
||||
'hover:bg-white/10 transition-colors cursor-pointer',
|
||||
item.disable && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.cate}</td>
|
||||
<td className="px-3 py-2 text-white">{item.name}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.model}</td>
|
||||
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.supply}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.manu}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-white/50">
|
||||
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 편집 다이얼로그 */}
|
||||
<ItemEditDialog
|
||||
item={selectedItem}
|
||||
isOpen={dialogOpen}
|
||||
onClose={() => {
|
||||
setDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
603
Project/frontend/src/pages/Jobreport.tsx
Normal file
603
Project/frontend/src/pages/Jobreport.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { JobReportItem, JobReportUser } from '@/types';
|
||||
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
|
||||
|
||||
export function Jobreport() {
|
||||
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
|
||||
const [users, setUsers] = useState<JobReportUser[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// 검색 조건
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState('');
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
|
||||
// 모달 상태
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<JobReportItem | null>(null);
|
||||
const [formData, setFormData] = useState<JobreportFormData>(initialFormData);
|
||||
|
||||
// 페이징 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
|
||||
// 권한 상태
|
||||
const [canViewOT, setCanViewOT] = useState(false);
|
||||
|
||||
// 오늘 근무시간 상태
|
||||
const [todayWork, setTodayWork] = useState({ hrs: 0, ot: 0 });
|
||||
|
||||
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
|
||||
const formatDateLocal = (date: Date) => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 오늘 근무시간 로드
|
||||
const loadTodayWork = useCallback(async (userId: string) => {
|
||||
const todayStr = formatDateLocal(new Date());
|
||||
try {
|
||||
const response = await comms.getJobReportList(todayStr, todayStr, userId, '');
|
||||
if (response.Success && response.Data) {
|
||||
// 웹소켓 모드에서 응답 혼선 방지를 위해 오늘 날짜 데이터만 필터링
|
||||
const todayData = response.Data.filter(item => {
|
||||
const itemDate = item.pdate?.substring(0, 10);
|
||||
return itemDate === todayStr;
|
||||
});
|
||||
const work = todayData.reduce((acc, item) => ({
|
||||
hrs: acc.hrs + (item.hrs || 0),
|
||||
ot: acc.ot + (item.ot || 0)
|
||||
}), { hrs: 0, ot: 0 });
|
||||
setTodayWork(work);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘 근무시간 로드 오류:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기화 완료 플래그
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// 날짜 및 사용자 정보 초기화
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const sd = formatDateLocal(startOfMonth);
|
||||
const ed = formatDateLocal(endOfMonth);
|
||||
|
||||
setStartDate(sd);
|
||||
setEndDate(ed);
|
||||
|
||||
// 현재 로그인 사용자 정보 로드
|
||||
let userId = '';
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
userId = loginStatus.User.Id;
|
||||
setSelectedUser(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 정보 로드 오류:', error);
|
||||
}
|
||||
|
||||
// 사용자 목록 로드
|
||||
loadUsers();
|
||||
|
||||
// 권한 로드 (본인 조회이므로 canViewOT = true)
|
||||
try {
|
||||
const perm = await comms.getJobReportPermission(userId);
|
||||
if (perm.Success) {
|
||||
setCanViewOT(perm.CanViewOT);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 정보 로드 오류:', error);
|
||||
}
|
||||
|
||||
// 초기화 완료 표시
|
||||
setInitialized(true);
|
||||
};
|
||||
|
||||
initialize();
|
||||
}, []);
|
||||
|
||||
// 초기화 완료 후 조회 실행
|
||||
useEffect(() => {
|
||||
if (initialized && startDate && endDate && selectedUser) {
|
||||
handleSearchAndLoadToday();
|
||||
}
|
||||
}, [initialized, startDate, endDate, selectedUser]);
|
||||
|
||||
// 검색 + 오늘 근무시간 로드 (순차 실행)
|
||||
const handleSearchAndLoadToday = async () => {
|
||||
await handleSearch();
|
||||
loadTodayWork(selectedUser);
|
||||
};
|
||||
|
||||
// 사용자 목록 로드
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const result = await comms.getJobReportUsers();
|
||||
setUsers(result || []);
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getJobReportList(startDate, endDate, selectedUser, searchKey);
|
||||
if (response.Success && response.Data) {
|
||||
setJobreportList(response.Data);
|
||||
} else {
|
||||
setJobreportList([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무일지 목록 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startDate, endDate, selectedUser, searchKey]);
|
||||
|
||||
// 검색
|
||||
const handleSearch = async () => {
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작일은 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
// 선택된 담당자에 따라 권한 재확인
|
||||
try {
|
||||
const perm = await comms.getJobReportPermission(selectedUser);
|
||||
if (perm.Success) {
|
||||
setCanViewOT(perm.CanViewOT);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 정보 로드 오류:', error);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
// 새 업무일지 추가 모달
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// 복사하여 새 업무일지 생성 모달
|
||||
const openCopyModal = async (item: JobReportItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 행 클릭 이벤트 방지
|
||||
try {
|
||||
const response = await comms.getJobReportDetail(item.idx);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data;
|
||||
setEditingItem(null); // 새로 추가하는 것이므로 null
|
||||
setFormData({
|
||||
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
||||
projectName: data.projectName || '',
|
||||
requestpart: data.requestpart || '',
|
||||
package: data.package || '',
|
||||
type: data.type || '',
|
||||
process: data.process || '',
|
||||
status: data.status || '진행 완료',
|
||||
description: data.description || '',
|
||||
hrs: 0, // 시간 초기화
|
||||
ot: 0, // OT 초기화
|
||||
jobgrp: '',
|
||||
tag: '',
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무일지 조회 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 편집 모달
|
||||
const openEditModal = async (item: JobReportItem) => {
|
||||
try {
|
||||
const response = await comms.getJobReportDetail(item.idx);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data;
|
||||
setEditingItem(data);
|
||||
setFormData({
|
||||
pdate: data.pdate ? data.pdate.split('T')[0] : '',
|
||||
projectName: data.projectName || '',
|
||||
requestpart: data.requestpart || '',
|
||||
package: data.package || '',
|
||||
type: data.type || '',
|
||||
process: data.process || '',
|
||||
status: data.status || '진행 완료',
|
||||
description: data.description || '',
|
||||
hrs: data.hrs || 0,
|
||||
ot: data.ot || 0,
|
||||
jobgrp: '', // 뷰에 없는 필드
|
||||
tag: '', // 뷰에 없는 필드
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업무일지 조회 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.pdate) {
|
||||
alert('날짜를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.projectName.trim()) {
|
||||
alert('프로젝트명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingItem) {
|
||||
const itemIdx = editingItem.idx ?? (editingItem as unknown as Record<string, unknown>)['Idx'] as number;
|
||||
if (!itemIdx) {
|
||||
alert('수정할 항목의 ID를 찾을 수 없습니다.');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
response = await comms.editJobReport(
|
||||
itemIdx,
|
||||
formData.pdate || '',
|
||||
formData.projectName || '',
|
||||
formData.requestpart || '',
|
||||
formData.package || '',
|
||||
formData.type || '',
|
||||
formData.process || '',
|
||||
formData.status || '진행 완료',
|
||||
formData.description || '',
|
||||
formData.hrs || 0,
|
||||
formData.ot || 0,
|
||||
formData.jobgrp || '',
|
||||
formData.tag || ''
|
||||
);
|
||||
} else {
|
||||
response = await comms.addJobReport(
|
||||
formData.pdate || '',
|
||||
formData.projectName || '',
|
||||
formData.requestpart || '',
|
||||
formData.package || '',
|
||||
formData.type || '',
|
||||
formData.process || '',
|
||||
formData.status || '진행 완료',
|
||||
formData.description || '',
|
||||
formData.hrs || 0,
|
||||
formData.ot || 0,
|
||||
formData.jobgrp || '',
|
||||
formData.tag || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
loadTodayWork(selectedUser);
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('정말로 이 업무일지를 삭제하시겠습니까?')) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteJobReport(id);
|
||||
if (response.Success) {
|
||||
alert('삭제되었습니다.');
|
||||
loadData();
|
||||
loadTodayWork(selectedUser);
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷 (YY.MM.DD)
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const yy = String(date.getFullYear()).slice(-2);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${yy}.${mm}.${dd}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// 페이징 계산
|
||||
const totalPages = Math.ceil(jobreportList.length / pageSize);
|
||||
const paginatedList = jobreportList.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
|
||||
// 검색 시 페이지 초기화
|
||||
const handleSearchWithReset = () => {
|
||||
setCurrentPage(1);
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 검색 필터 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex gap-6">
|
||||
{/* 좌측: 필터 영역 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 필터 입력 영역: 2행 2열 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{/* 1행: 시작일, 담당자 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">담당자</label>
|
||||
<select
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
<option value="" className="bg-gray-800">전체</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id} className="bg-gray-800">
|
||||
{user.name}({user.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* 2행: 종료일, 검색어 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">검색어</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
|
||||
placeholder="프로젝트, 내용 등"
|
||||
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역: 우측 수직 배치 */}
|
||||
<div className="grid grid-rows-2 gap-y-3">
|
||||
<button
|
||||
onClick={handleSearchWithReset}
|
||||
disabled={loading}
|
||||
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 업무일지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 오늘 근무시간 */}
|
||||
<div className="flex-shrink-0 w-48">
|
||||
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center">
|
||||
<div className="text-white/70 text-sm font-medium mb-2 text-center">오늘 근무시간</div>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl font-bold text-white">{todayWork.hrs}</span>
|
||||
<span className="text-white/70 text-lg ml-1">시간</span>
|
||||
</div>
|
||||
{todayWork.ot > 0 && (
|
||||
<div className="text-center mt-1">
|
||||
<span className="text-warning-400 text-sm">OT: {todayWork.ot}시간</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
업무일지 목록
|
||||
</h3>
|
||||
<span className="text-white/60 text-sm">{jobreportList.length}건</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-center text-xs font-medium text-white/70 uppercase w-10"></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">날짜</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">프로젝트</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">업무형태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">시간</th>
|
||||
{canViewOT && <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={canViewOT ? 8 : 7} className="px-4 py-8 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : jobreportList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={canViewOT ? 8 : 7} className="px-4 py-8 text-center text-white/50">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedList.map((item) => (
|
||||
<tr
|
||||
key={item.idx}
|
||||
className={`hover:bg-white/5 transition-colors cursor-pointer ${item.type === '휴가' ? 'bg-gradient-to-r from-lime-400/30 via-emerald-400/20 to-teal-400/30' : ''}`}
|
||||
onClick={() => openEditModal(item)}
|
||||
>
|
||||
<td className="px-2 py-3 text-center">
|
||||
<button
|
||||
onClick={(e) => openCopyModal(item, e)}
|
||||
className="text-white/40 hover:text-primary-400 transition-colors"
|
||||
title="복사하여 새로 작성"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.pdate)}</td>
|
||||
<td className={`px-4 py-3 text-sm font-medium max-w-xs truncate ${item.pidx && item.pidx > 0 ? 'text-white' : 'text-white/50'}`} title={item.projectName}>
|
||||
{item.projectName || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
item.status?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70'
|
||||
}`}>
|
||||
{item.status || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
{item.hrs || 0}h
|
||||
</td>
|
||||
{canViewOT && (
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
{item.ot ? <span className="text-warning-400">{item.ot}h</span> : '-'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-white text-sm">{item.name || item.id || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div className="text-white/50 text-sm">
|
||||
총 {jobreportList.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, jobreportList.length)}건
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-white/70 px-3">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<JobreportEditModal
|
||||
isOpen={showModal}
|
||||
editingItem={editingItem}
|
||||
formData={formData}
|
||||
processing={processing}
|
||||
onClose={() => setShowModal(false)}
|
||||
onFormChange={setFormData}
|
||||
onSave={handleSave}
|
||||
onDelete={(idx) => {
|
||||
handleDelete(idx);
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
Project/frontend/src/pages/Kuntae.tsx
Normal file
286
Project/frontend/src/pages/Kuntae.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Search,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { KuntaeModel } from '@/types';
|
||||
|
||||
export function Kuntae() {
|
||||
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// 검색 조건
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// 통계
|
||||
const [stats, setStats] = useState({
|
||||
holyUsed: 0,
|
||||
alternateUsed: 0,
|
||||
holyRemain: 0,
|
||||
alternateRemain: 0,
|
||||
});
|
||||
|
||||
// 날짜 초기화 (현재 월)
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
setStartDate(startOfMonth.toISOString().split('T')[0]);
|
||||
setEndDate(endOfMonth.toISOString().split('T')[0]);
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getKuntaeList(startDate, endDate);
|
||||
if (response.Success && response.Data) {
|
||||
setKuntaeList(response.Data);
|
||||
updateStats(response.Data);
|
||||
} else {
|
||||
setKuntaeList([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('근태 목록 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 통계 업데이트
|
||||
const updateStats = (data: KuntaeModel[]) => {
|
||||
const holyUsed = data.filter(item => item.cate === '연차' || item.cate === '휴가').length;
|
||||
const alternateUsed = data.filter(item => item.cate === '대체').length;
|
||||
|
||||
setStats({
|
||||
holyUsed,
|
||||
alternateUsed,
|
||||
holyRemain: 15 - holyUsed, // 예시 값
|
||||
alternateRemain: 5 - alternateUsed, // 예시 값
|
||||
});
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = () => {
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작일은 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('정말로 이 근태 데이터를 삭제하시겠습니까?')) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteKuntae(id);
|
||||
if (response.Success) {
|
||||
alert('삭제되었습니다.');
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 개발중 경고 */}
|
||||
<div className="bg-warning-500/20 border border-warning-500/30 rounded-xl p-4 flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 text-warning-400 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-white font-medium">개발중인 기능입니다</p>
|
||||
<p className="text-white/60 text-sm">일부 기능이 정상적으로 동작하지 않을 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="휴가 사용"
|
||||
value={stats.holyUsed}
|
||||
icon={<Calendar className="w-6 h-6 text-primary-400" />}
|
||||
color="text-primary-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="대체 사용"
|
||||
value={stats.alternateUsed}
|
||||
icon={<CheckCircle className="w-6 h-6 text-success-400" />}
|
||||
color="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="잔량 (연차)"
|
||||
value={stats.holyRemain}
|
||||
icon={<Clock className="w-6 h-6 text-warning-400" />}
|
||||
color="text-warning-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="잔량 (대체)"
|
||||
value={stats.alternateRemain}
|
||||
icon={<XCircle className="w-6 h-6 text-danger-400" />}
|
||||
color="text-danger-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
<h3 className="text-lg font-semibold text-white">근태 상세 내역</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">구분</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">시작일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">종료일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사번</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">성명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사용(일)</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">내용</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">소스</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">등록자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">등록일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : kuntaeList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center text-white/50">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
kuntaeList.map((item) => (
|
||||
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-white text-sm">{item.cate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.sdate)}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.edate)}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.uid || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.uname || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.term || '-'}</td>
|
||||
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}>
|
||||
{item.contents || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.extcate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.wuid || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.wdate || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handleDelete(item.idx)}
|
||||
disabled={processing}
|
||||
className="text-danger-400 hover:text-danger-300 transition-colors disabled:opacity-50"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 통계 카드 컴포넌트
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color }: StatCardProps) {
|
||||
return (
|
||||
<div className="glass-effect rounded-xl p-4 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-white/70">{title}</p>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
Project/frontend/src/pages/Login.tsx
Normal file
202
Project/frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LogIn, Building2, User, Lock, Loader2 } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { UserGroup } from '@/types';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
export function Login({ onLoginSuccess }: LoginProps) {
|
||||
const [groups, setGroups] = useState<UserGroup[]>([]);
|
||||
const [gcode, setGcode] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
// 그룹 목록 및 이전 로그인 정보 로드
|
||||
const [groupsData, prevInfo] = await Promise.all([
|
||||
comms.getUserGroups(),
|
||||
comms.getPreviousLoginInfo()
|
||||
]);
|
||||
|
||||
console.log('[Login] 부서 목록:', groupsData);
|
||||
setGroups(groupsData);
|
||||
|
||||
if (prevInfo.Success && prevInfo.Data) {
|
||||
if (prevInfo.Data.LastGcode) {
|
||||
setGcode(prevInfo.Data.LastGcode);
|
||||
}
|
||||
if (prevInfo.Data.LastId) {
|
||||
setUserId(prevInfo.Data.LastId);
|
||||
setRememberMe(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('초기 데이터 로드 실패:', err);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await comms.login(gcode, userId, password, rememberMe);
|
||||
|
||||
if (result.Success) {
|
||||
if (result.VersionWarning) {
|
||||
alert(result.VersionWarning);
|
||||
}
|
||||
onLoginSuccess();
|
||||
} else {
|
||||
setError(result.Message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('서버 연결에 실패했습니다.');
|
||||
console.error('로그인 오류:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">GroupWare</h1>
|
||||
<p className="text-white/60">ATK4-EET</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="glass-effect rounded-2xl p-8">
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">로그인</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* 부서 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
소속 부서
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
|
||||
<select
|
||||
value={gcode}
|
||||
onChange={(e) => setGcode(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-xl pl-10 pr-4 py-3 text-white focus:outline-none focus:border-primary-400 appearance-none"
|
||||
required
|
||||
>
|
||||
<option value="">부서를 선택하세요</option>
|
||||
{groups.filter(g => g.gcode).map((group) => (
|
||||
<option key={group.gcode} value={group.gcode}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
사용자 ID
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="ID를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
비밀번호
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그인 정보 저장 */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
|
||||
로그인 정보 저장
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="bg-danger-500/20 border border-danger-500/50 rounded-lg px-4 py-3 text-danger-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 disabled:bg-primary-500/50 text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-5 h-5" />
|
||||
<span>로그인</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-white/40 text-sm mt-6">
|
||||
© 2024 GroupWare. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
Project/frontend/src/pages/MailForm.tsx
Normal file
474
Project/frontend/src/pages/MailForm.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { MailFormItem } from '@/types';
|
||||
|
||||
const initialFormData: Partial<MailFormItem> = {
|
||||
cate: '',
|
||||
title: '',
|
||||
tolist: '',
|
||||
bcc: '',
|
||||
cc: '',
|
||||
subject: '',
|
||||
tail: '',
|
||||
body: '',
|
||||
selfTo: false,
|
||||
selfCC: false,
|
||||
selfBCC: false,
|
||||
exceptmail: '',
|
||||
exceptmailcc: '',
|
||||
};
|
||||
|
||||
export function MailFormPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mailForms, setMailForms] = useState<MailFormItem[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<MailFormItem | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<MailFormItem>>(initialFormData);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getMailFormList();
|
||||
if (response.Success && response.Data) {
|
||||
setMailForms(response.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const filteredItems = mailForms.filter(item =>
|
||||
!searchKey ||
|
||||
item.title?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.cate?.toLowerCase().includes(searchKey.toLowerCase()) ||
|
||||
item.subject?.toLowerCase().includes(searchKey.toLowerCase())
|
||||
);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item: MailFormItem) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
cate: item.cate || '',
|
||||
title: item.title || '',
|
||||
tolist: item.tolist || '',
|
||||
bcc: item.bcc || '',
|
||||
cc: item.cc || '',
|
||||
subject: item.subject || '',
|
||||
tail: item.tail || '',
|
||||
body: item.body || '',
|
||||
selfTo: item.selfTo || false,
|
||||
selfCC: item.selfCC || false,
|
||||
selfBCC: item.selfBCC || false,
|
||||
exceptmail: item.exceptmail || '',
|
||||
exceptmailcc: item.exceptmailcc || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.title?.trim()) {
|
||||
alert('양식명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingItem) {
|
||||
response = await comms.editMailForm(
|
||||
editingItem.idx,
|
||||
formData.cate || '',
|
||||
formData.title || '',
|
||||
formData.tolist || '',
|
||||
formData.bcc || '',
|
||||
formData.cc || '',
|
||||
formData.subject || '',
|
||||
formData.tail || '',
|
||||
formData.body || '',
|
||||
formData.selfTo || false,
|
||||
formData.selfCC || false,
|
||||
formData.selfBCC || false,
|
||||
formData.exceptmail || '',
|
||||
formData.exceptmailcc || ''
|
||||
);
|
||||
} else {
|
||||
response = await comms.addMailForm(
|
||||
formData.cate || '',
|
||||
formData.title || '',
|
||||
formData.tolist || '',
|
||||
formData.bcc || '',
|
||||
formData.cc || '',
|
||||
formData.subject || '',
|
||||
formData.tail || '',
|
||||
formData.body || '',
|
||||
formData.selfTo || false,
|
||||
formData.selfCC || false,
|
||||
formData.selfBCC || false,
|
||||
formData.exceptmail || '',
|
||||
formData.exceptmailcc || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
setShowModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: MailFormItem) => {
|
||||
if (!confirm(`"${item.title}" 양식을 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const response = await comms.deleteMailForm(item.idx);
|
||||
if (response.Success) {
|
||||
loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-primary-500/20 rounded-xl">
|
||||
<Mail 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-3">
|
||||
{/* 검색 */}
|
||||
<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-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-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 space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>새 양식</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
{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">
|
||||
<Mail className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>등록된 메일양식이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-24">분류</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">양식명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">제목</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">To</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">CC</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">BCC</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-white/70 text-sm">{item.cate || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm font-medium">{item.title}</td>
|
||||
<td className="px-4 py-3 text-white/70 text-sm">{item.subject || '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.selfTo && (
|
||||
<span className="inline-block w-5 h-5 bg-success-500/20 text-success-400 rounded text-xs leading-5">S</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.selfCC && (
|
||||
<span className="inline-block w-5 h-5 bg-warning-500/20 text-warning-400 rounded text-xs leading-5">S</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.selfBCC && (
|
||||
<span className="inline-block w-5 h-5 bg-primary-500/20 text-primary-400 rounded text-xs leading-5">S</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<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>
|
||||
|
||||
{/* 편집 모달 */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-slate-800 rounded-2xl w-full max-w-4xl 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">
|
||||
{/* 1행: 분류, 양식명 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">분류</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, cate: 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">양식명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title || ''}
|
||||
onChange={(e) => setFormData({ ...formData, title: 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>
|
||||
|
||||
{/* 2행: 제목 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">메일 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject || ''}
|
||||
onChange={(e) => setFormData({ ...formData, subject: 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>
|
||||
|
||||
{/* 3행: 수신자 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">To (수신)</label>
|
||||
<textarea
|
||||
value={formData.tolist || ''}
|
||||
onChange={(e) => setFormData({ ...formData, tolist: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="이메일 주소 (줄바꿈으로 구분)"
|
||||
/>
|
||||
<label className="flex items-center mt-1 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selfTo || false}
|
||||
onChange={(e) => setFormData({ ...formData, selfTo: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
본인 포함
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">CC (참조)</label>
|
||||
<textarea
|
||||
value={formData.cc || ''}
|
||||
onChange={(e) => setFormData({ ...formData, cc: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="이메일 주소 (줄바꿈으로 구분)"
|
||||
/>
|
||||
<label className="flex items-center mt-1 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selfCC || false}
|
||||
onChange={(e) => setFormData({ ...formData, selfCC: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
본인 포함
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">BCC (숨은참조)</label>
|
||||
<textarea
|
||||
value={formData.bcc || ''}
|
||||
onChange={(e) => setFormData({ ...formData, bcc: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
|
||||
placeholder="이메일 주소 (줄바꿈으로 구분)"
|
||||
/>
|
||||
<label className="flex items-center mt-1 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selfBCC || false}
|
||||
onChange={(e) => setFormData({ ...formData, selfBCC: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
본인 포함
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4행: 제외 메일 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">To 제외 메일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.exceptmail || ''}
|
||||
onChange={(e) => setFormData({ ...formData, exceptmail: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
placeholder="제외할 이메일 주소"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">CC 제외 메일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.exceptmailcc || ''}
|
||||
onChange={(e) => setFormData({ ...formData, exceptmailcc: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
placeholder="제외할 이메일 주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5행: 본문 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">메일 본문</label>
|
||||
<textarea
|
||||
value={formData.body || ''}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
rows={6}
|
||||
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"
|
||||
placeholder="메일 본문 내용..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 6행: 꼬리말 */}
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm mb-1">꼬리말</label>
|
||||
<textarea
|
||||
value={formData.tail || ''}
|
||||
onChange={(e) => setFormData({ ...formData, tail: e.target.value })}
|
||||
rows={3}
|
||||
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"
|
||||
placeholder="메일 꼬리말..."
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
Project/frontend/src/pages/MonthlyWork.tsx
Normal file
248
Project/frontend/src/pages/MonthlyWork.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Save,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { HolidayItem } from '@/types';
|
||||
|
||||
interface DayInfo extends HolidayItem {
|
||||
dayOfWeek: number;
|
||||
dayName: string;
|
||||
}
|
||||
|
||||
export function MonthlyWorkPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
const [holidays, setHolidays] = useState<DayInfo[]>([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 먼저 초기화 시도 (데이터 없으면 생성)
|
||||
await comms.initializeHoliday(month);
|
||||
|
||||
// 데이터 로드
|
||||
const response = await comms.getHolidayList(month);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data.map(item => {
|
||||
const date = new Date(item.pdate);
|
||||
return {
|
||||
...item,
|
||||
dayOfWeek: date.getDay(),
|
||||
dayName: dayNames[date.getDay()],
|
||||
};
|
||||
});
|
||||
setHolidays(data);
|
||||
setHasChanges(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [month]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleMonthChange = (delta: number) => {
|
||||
const [year, mon] = month.split('-').map(Number);
|
||||
const newDate = new Date(year, mon - 1 + delta, 1);
|
||||
setMonth(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
const handleToggleFree = (idx: number) => {
|
||||
setHolidays(prev => prev.map(h =>
|
||||
h.idx === idx ? { ...h, free: !h.free } : h
|
||||
));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleMemoChange = (idx: number, memo: string) => {
|
||||
setHolidays(prev => prev.map(h =>
|
||||
h.idx === idx ? { ...h, memo } : h
|
||||
));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const dataToSave = holidays.map(({ idx, pdate, free, memo }) => ({
|
||||
idx, pdate, free, memo
|
||||
}));
|
||||
const response = await comms.saveHolidays(month, dataToSave as HolidayItem[]);
|
||||
if (response.Success) {
|
||||
setHasChanges(false);
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 통계 계산
|
||||
const workDays = holidays.filter(h => !h.free).length;
|
||||
const freeDays = holidays.filter(h => h.free).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-primary-500/20 rounded-xl">
|
||||
<Calendar 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-4">
|
||||
{/* 월 선택 */}
|
||||
<div className="flex items-center space-x-2 bg-white/10 rounded-lg px-3 py-2">
|
||||
<button
|
||||
onClick={() => handleMonthChange(-1)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="bg-transparent text-white text-center w-32 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMonthChange(1)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 버튼들 */}
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-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' : ''}`} />
|
||||
<span className="hidden sm:inline">새로고침</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
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 className="mt-4 flex items-center space-x-6 text-sm">
|
||||
<span className="text-white/70">
|
||||
근무일: <span className="text-white font-semibold">{workDays}일</span>
|
||||
</span>
|
||||
<span className="text-white/70">
|
||||
휴일: <span className="text-danger-400 font-semibold">{freeDays}일</span>
|
||||
</span>
|
||||
<span className="text-white/70">
|
||||
총: <span className="text-white font-semibold">{holidays.length}일</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-32">날짜</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">요일</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24">휴일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{holidays.map((day) => (
|
||||
<tr
|
||||
key={day.idx}
|
||||
className={`hover:bg-white/5 transition-colors ${
|
||||
day.dayOfWeek === 0 ? 'bg-danger-500/10' :
|
||||
day.dayOfWeek === 6 ? 'bg-primary-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
{day.pdate}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-center text-sm font-medium ${
|
||||
day.dayOfWeek === 0 ? 'text-danger-400' :
|
||||
day.dayOfWeek === 6 ? 'text-primary-400' : 'text-white/70'
|
||||
}`}>
|
||||
{day.dayName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleFree(day.idx)}
|
||||
className={`w-8 h-8 rounded-lg transition-colors ${
|
||||
day.free
|
||||
? 'bg-danger-500/20 text-danger-400 hover:bg-danger-500/30'
|
||||
: 'bg-white/10 text-white/40 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{day.free ? 'O' : '-'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={day.memo || ''}
|
||||
onChange={(e) => handleMemoChange(day.idx, e.target.value)}
|
||||
placeholder="메모 입력..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
Project/frontend/src/pages/Placeholder.tsx
Normal file
22
Project/frontend/src/pages/Placeholder.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title }: PlaceholderPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full animate-fade-in">
|
||||
<div className="glass-effect rounded-2xl p-12 text-center">
|
||||
<Construction className="w-16 h-16 text-warning-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{title}</h2>
|
||||
<p className="text-white/60">
|
||||
이 페이지는 현재 개발 중입니다.
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
React 버전으로 마이그레이션 예정입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
719
Project/frontend/src/pages/Todo.tsx
Normal file
719
Project/frontend/src/pages/Todo.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Flag,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { TodoModel, TodoStatus, TodoPriority } from '@/types';
|
||||
|
||||
// 상태/중요도 유틸리티 함수들
|
||||
const getStatusText = (status: string): string => {
|
||||
switch (status) {
|
||||
case '0': return '대기';
|
||||
case '1': return '진행';
|
||||
case '2': return '취소';
|
||||
case '3': return '보류';
|
||||
case '5': return '완료';
|
||||
default: return '대기';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string): string => {
|
||||
switch (status) {
|
||||
case '0': return 'bg-gray-500/20 text-gray-300 border-gray-500/30';
|
||||
case '1': return 'bg-primary-500/20 text-primary-300 border-primary-500/30';
|
||||
case '2': return 'bg-danger-500/20 text-danger-300 border-danger-500/30';
|
||||
case '3': return 'bg-warning-500/20 text-warning-300 border-warning-500/30';
|
||||
case '5': return 'bg-success-500/20 text-success-300 border-success-500/30';
|
||||
default: return 'bg-white/10 text-white/50 border-white/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityText = (seqno: number): string => {
|
||||
switch (seqno) {
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
default: return '보통';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityClass = (seqno: number): string => {
|
||||
switch (seqno) {
|
||||
case 1: return 'bg-primary-500/20 text-primary-300';
|
||||
case 2: return 'bg-warning-500/20 text-warning-300';
|
||||
case 3: return 'bg-danger-500/20 text-danger-300';
|
||||
default: return 'bg-white/10 text-white/50';
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 데이터 타입
|
||||
interface TodoFormData {
|
||||
title: string;
|
||||
remark: string;
|
||||
expire: string;
|
||||
seqno: TodoPriority;
|
||||
flag: boolean;
|
||||
request: string;
|
||||
status: TodoStatus;
|
||||
}
|
||||
|
||||
const initialFormData: TodoFormData = {
|
||||
title: '',
|
||||
remark: '',
|
||||
expire: '',
|
||||
seqno: 0,
|
||||
flag: false,
|
||||
request: '',
|
||||
status: '0',
|
||||
};
|
||||
|
||||
export function Todo() {
|
||||
const [todos, setTodos] = useState<TodoModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'hold' | 'completed' | 'cancelled'>('active');
|
||||
|
||||
// 모달 상태
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingTodo, setEditingTodo] = useState<TodoModel | null>(null);
|
||||
const [formData, setFormData] = useState<TodoFormData>(initialFormData);
|
||||
|
||||
// 할일 목록 로드
|
||||
const loadTodos = useCallback(async () => {
|
||||
try {
|
||||
const response = await comms.getTodos();
|
||||
if (response.Success && response.Data) {
|
||||
setTodos(response.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 목록 로드 오류:', error);
|
||||
alert('할일 목록을 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTodos();
|
||||
}, [loadTodos]);
|
||||
|
||||
// 필터링된 할일 목록
|
||||
const activeTodos = todos.filter(todo => todo.status === '0' || todo.status === '1'); // 대기 + 진행
|
||||
const holdTodos = todos.filter(todo => todo.status === '3'); // 보류
|
||||
const completedTodos = todos.filter(todo => todo.status === '5'); // 완료
|
||||
const cancelledTodos = todos.filter(todo => todo.status === '2'); // 취소
|
||||
|
||||
// 새 할일 추가
|
||||
const handleAdd = async () => {
|
||||
if (!formData.remark.trim()) {
|
||||
alert('할일 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.createTodo(
|
||||
formData.title,
|
||||
formData.remark,
|
||||
formData.expire || null,
|
||||
formData.seqno,
|
||||
formData.flag,
|
||||
formData.request || null,
|
||||
formData.status
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
setShowAddModal(false);
|
||||
setFormData(initialFormData);
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '할일 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 추가 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 수정 모달 열기
|
||||
const openEditModal = async (todo: TodoModel) => {
|
||||
try {
|
||||
const response = await comms.getTodo(todo.idx);
|
||||
if (response.Success && response.Data) {
|
||||
const data = response.Data;
|
||||
setEditingTodo(data);
|
||||
setFormData({
|
||||
title: data.title || '',
|
||||
remark: data.remark || '',
|
||||
expire: data.expire ? data.expire.split('T')[0] : '',
|
||||
seqno: data.seqno as TodoPriority,
|
||||
flag: data.flag || false,
|
||||
request: data.request || '',
|
||||
status: data.status as TodoStatus,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 조회 오류:', error);
|
||||
alert('할일 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 수정
|
||||
const handleUpdate = async () => {
|
||||
if (!editingTodo || !formData.remark.trim()) {
|
||||
alert('할일 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.updateTodo(
|
||||
editingTodo.idx,
|
||||
formData.title,
|
||||
formData.remark,
|
||||
formData.expire || null,
|
||||
formData.seqno,
|
||||
formData.flag,
|
||||
formData.request || null,
|
||||
formData.status
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
setFormData(initialFormData);
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '할일 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 수정 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 할일 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteTodo(id);
|
||||
if (response.Success) {
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '할일 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할일 삭제 오류:', error);
|
||||
alert('서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태 빠른 변경
|
||||
const handleQuickStatusChange = async (todo: TodoModel, newStatus: TodoStatus) => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.updateTodo(
|
||||
todo.idx,
|
||||
todo.title,
|
||||
todo.remark,
|
||||
todo.expire,
|
||||
todo.seqno,
|
||||
todo.flag,
|
||||
todo.request,
|
||||
newStatus
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
loadTodos();
|
||||
} else {
|
||||
alert(response.Message || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상태 변경 오류:', error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
내 할일 목록
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFormData(initialFormData);
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
새 할일 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 메뉴 */}
|
||||
<div className="px-6 py-2 border-b border-white/10">
|
||||
<div className="flex space-x-1 bg-white/5 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('active')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'active'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>진행중</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-primary-500/30 text-primary-200 rounded-full">
|
||||
{activeTodos.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('hold')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'hold'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<span>보류</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-warning-500/30 text-warning-200 rounded-full">
|
||||
{holdTodos.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('completed')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'completed'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>완료</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-success-500/30 text-success-200 rounded-full">
|
||||
{completedTodos.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('cancelled')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'cancelled'
|
||||
? 'text-white bg-white/20 shadow-sm'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<X className="w-4 h-4" />
|
||||
<span>취소</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-danger-500/30 text-danger-200 rounded-full">
|
||||
{cancelledTodos.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 할일 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
{activeTab === 'active' && (
|
||||
<th className="px-2 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-10 border-r border-white/10"></th>
|
||||
)}
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10">상태</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10">플래그</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider border-r border-white/10">제목</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24 border-r border-white/10">요청자</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-20 border-r border-white/10">중요도</th>
|
||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24">
|
||||
{activeTab === 'completed' ? '완료일' : '만료일'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).map((todo) => (
|
||||
<TodoRow
|
||||
key={todo.idx}
|
||||
todo={todo}
|
||||
showOkdate={activeTab === 'completed'}
|
||||
showCompleteButton={activeTab === 'active'}
|
||||
onEdit={() => openEditModal(todo)}
|
||||
onComplete={() => handleQuickStatusChange(todo, '5')}
|
||||
/>
|
||||
))}
|
||||
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={activeTab === 'active' ? 7 : 6} className="px-6 py-8 text-center text-white/50">
|
||||
{activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 인디케이터 */}
|
||||
{processing && (
|
||||
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm flex items-center">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
처리 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 모달 */}
|
||||
{showAddModal && (
|
||||
<TodoModal
|
||||
title="새 할일 추가"
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={handleAdd}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
submitText="추가"
|
||||
processing={processing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 수정 모달 */}
|
||||
{showEditModal && editingTodo && (
|
||||
<TodoModal
|
||||
title="할일 수정"
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={handleUpdate}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
}}
|
||||
submitText="수정"
|
||||
processing={processing}
|
||||
isEdit={true}
|
||||
onComplete={() => {
|
||||
handleQuickStatusChange(editingTodo, '5');
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
handleDelete(editingTodo.idx);
|
||||
setShowEditModal(false);
|
||||
setEditingTodo(null);
|
||||
}}
|
||||
currentStatus={editingTodo.status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 할일 행 컴포넌트
|
||||
interface TodoRowProps {
|
||||
todo: TodoModel;
|
||||
showOkdate: boolean;
|
||||
showCompleteButton?: boolean;
|
||||
onEdit: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function TodoRow({ todo, showOkdate, showCompleteButton = true, onEdit, onComplete }: TodoRowProps) {
|
||||
const isExpired = todo.expire && new Date(todo.expire) < new Date();
|
||||
|
||||
const handleComplete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('이 할일을 완료 처리하시겠습니까?')) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{showCompleteButton && (
|
||||
<td className="px-2 py-4 text-center border-r border-white/10">
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="p-1.5 bg-success-500/20 hover:bg-success-500/40 text-success-300 rounded-full transition-colors"
|
||||
title="완료 처리"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
||||
{getStatusText(todo.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50'
|
||||
}`}>
|
||||
{todo.flag ? <Flag className="w-3 h-3 mr-1" /> : null}
|
||||
{todo.flag ? '고정' : '일반'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-left text-white border-r border-white/10">{todo.title || '제목 없음'}</td>
|
||||
<td className="px-3 py-4 text-center text-white/80 border-r border-white/10">{todo.request || '-'}</td>
|
||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
||||
{getPriorityText(todo.seqno)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-3 py-4 text-center whitespace-nowrap ${showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/80')}`}>
|
||||
{showOkdate
|
||||
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
||||
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// 할일 모달 컴포넌트
|
||||
interface TodoModalProps {
|
||||
title: string;
|
||||
formData: TodoFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<TodoFormData>>;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
submitText: string;
|
||||
processing: boolean;
|
||||
isEdit?: boolean;
|
||||
onComplete?: () => void;
|
||||
onDelete?: () => void;
|
||||
currentStatus?: string;
|
||||
}
|
||||
|
||||
function TodoModal({
|
||||
title,
|
||||
formData,
|
||||
setFormData,
|
||||
onSubmit,
|
||||
onClose,
|
||||
submitText,
|
||||
processing,
|
||||
isEdit = false,
|
||||
onComplete,
|
||||
onDelete,
|
||||
currentStatus,
|
||||
}: TodoModalProps) {
|
||||
const statusOptions: { value: TodoStatus; label: string }[] = [
|
||||
{ value: '0', label: '대기' },
|
||||
{ value: '1', label: '진행' },
|
||||
{ value: '3', label: '보류' },
|
||||
{ value: '2', label: '취소' },
|
||||
{ value: '5', label: '완료' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={onClose}>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div
|
||||
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white flex items-center">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
{title}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">제목 (선택사항)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm 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 transition-all"
|
||||
placeholder="할일 제목을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">만료일 (선택사항)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.expire}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expire: e.target.value }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">내용 *</label>
|
||||
<textarea
|
||||
value={formData.remark}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full bg-white/20 backdrop-blur-sm 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 transition-all"
|
||||
placeholder="할일 내용을 입력하세요 (필수)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.request}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, request: e.target.value }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm 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 transition-all"
|
||||
placeholder="업무 요청자를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">진행상태</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
|
||||
formData.status === option.value
|
||||
? getStatusClass(option.value)
|
||||
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
||||
<select
|
||||
value={formData.seqno}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
>
|
||||
<option value={0}>보통</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={3}>긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center text-white/70 text-sm font-medium cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.flag}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, flag: e.target.checked }))}
|
||||
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
|
||||
/>
|
||||
플래그 (상단 고정)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
|
||||
{/* 왼쪽: 삭제 버튼 (편집 모드일 때만) */}
|
||||
<div>
|
||||
{isEdit && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 취소, 완료, 수정 버튼 */}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
{isEdit && onComplete && currentStatus !== '5' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onComplete}
|
||||
disabled={processing}
|
||||
className="bg-success-500 hover:bg-success-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
완료 처리
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={processing}
|
||||
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
533
Project/frontend/src/pages/UserGroup.tsx
Normal file
533
Project/frontend/src/pages/UserGroup.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
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';
|
||||
|
||||
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 UserGroupPage() {
|
||||
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(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
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="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-2xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-primary-500/20 rounded-xl">
|
||||
<Users 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-3">
|
||||
{/* 검색 */}
|
||||
<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-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-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 space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>새 그룹</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||
{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>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">부서명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">경로</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24">권한</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">구매고급</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">기술고급</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">메일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">관리자정보</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-28">작업</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-3 text-white font-medium">{item.dept}</td>
|
||||
<td className="px-4 py-3 text-white/70 text-sm">{item.path_kj || '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => openPermissionModal(item)}
|
||||
className="inline-flex items-center space-x-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-3 text-center">
|
||||
{item.advpurchase && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.advkisul && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{item.usemail && <Check className="w-4 h-4 text-success-400 mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white/70 text-sm truncate max-w-48">{item.managerinfo || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<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>
|
||||
|
||||
{/* 그룹 편집 모달 */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-slate-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-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-slate-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>
|
||||
);
|
||||
}
|
||||
555
Project/frontend/src/pages/UserList.tsx
Normal file
555
Project/frontend/src/pages/UserList.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, RefreshCw, Users, Check, X, User, Save } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { GroupUser, UserLevelInfo, UserFullData } from '@/types';
|
||||
|
||||
// 사용자 상세 다이얼로그 Props
|
||||
interface UserDetailDialogProps {
|
||||
user: GroupUser;
|
||||
levelInfo: UserLevelInfo | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
// 사용자 상세 다이얼로그 컴포넌트
|
||||
function UserDetailDialog({ user, levelInfo, onClose, onSave }: UserDetailDialogProps) {
|
||||
const [formData, setFormData] = useState<UserFullData>({
|
||||
id: user.id,
|
||||
name: user.name || '',
|
||||
nameE: user.nameE || '',
|
||||
grade: user.grade || '',
|
||||
email: user.email || '',
|
||||
tel: user.tel || '',
|
||||
hp: user.hp || '',
|
||||
indate: user.indate || '',
|
||||
outdate: user.outdate || '',
|
||||
memo: user.memo || '',
|
||||
processs: user.processs || '',
|
||||
state: user.state || '',
|
||||
level: user.level || 1,
|
||||
useUserState: user.useUserState || false,
|
||||
useJobReport: user.useJobReport || false,
|
||||
exceptHoly: user.exceptHoly || false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 편집 가능 여부: 관리자(level >= 5) 또는 본인
|
||||
const isSelf = levelInfo?.CurrentUserId === user.id;
|
||||
const canEdit = levelInfo?.CanEdit || isSelf;
|
||||
const canEditAdmin = levelInfo?.CanEdit || false; // 관리자 전용 필드
|
||||
|
||||
const handleChange = (field: keyof UserFullData, value: string | number | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canEdit) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await comms.saveUserFull(formData);
|
||||
if (result.Success) {
|
||||
onSave();
|
||||
onClose();
|
||||
} else {
|
||||
alert(result.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-2xl max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
사용자 정보 {canEdit ? '' : '(읽기 전용)'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white transition-colors text-xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-4 overflow-auto max-h-[calc(90vh-120px)]">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 사번 (읽기 전용) */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">사번</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.id}
|
||||
disabled
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/20 rounded-lg text-white/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 성명 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">성명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">영문명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.nameE}
|
||||
onChange={(e) => handleChange('nameE', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 직책 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">직책</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.grade}
|
||||
onChange={(e) => handleChange('grade', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공정 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">공정</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.processs}
|
||||
onChange={(e) => handleChange('processs', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">상태</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleChange('state', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전화 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">전화</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tel}
|
||||
onChange={(e) => handleChange('tel', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 입사일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">입사일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.indate}
|
||||
onChange={(e) => handleChange('indate', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="YYYY-MM-DD"
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 퇴사일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">퇴사일</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.outdate}
|
||||
onChange={(e) => handleChange('outdate', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="YYYY-MM-DD"
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 레벨 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">레벨</label>
|
||||
<select
|
||||
value={formData.level}
|
||||
onChange={(e) => handleChange('level', parseInt(e.target.value) || 0)}
|
||||
disabled={!canEditAdmin}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
|
||||
canEditAdmin ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((lv) => (
|
||||
<option key={lv} value={lv} className="bg-gray-800 text-white">
|
||||
{lv}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-white/70 mb-1">메모</label>
|
||||
<textarea
|
||||
value={formData.memo}
|
||||
onChange={(e) => handleChange('memo', e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 border border-white/20 rounded-lg text-white resize-none",
|
||||
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 관리자 전용 설정 */}
|
||||
<div className="col-span-3 border-t border-white/10 pt-4 mt-2">
|
||||
<h3 className="text-sm font-medium text-white/80 mb-3">
|
||||
관리자 설정 {!canEditAdmin && '(관리자만 수정 가능)'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* 계정 사용 */}
|
||||
<label className={clsx(
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
!canEditAdmin && "opacity-50 cursor-not-allowed"
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.useUserState}
|
||||
onChange={(e) => handleChange('useUserState', e.target.checked)}
|
||||
disabled={!canEditAdmin}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-sm text-white">계정 사용</span>
|
||||
</label>
|
||||
|
||||
{/* 일지 사용 */}
|
||||
<label className={clsx(
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
!canEditAdmin && "opacity-50 cursor-not-allowed"
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.useJobReport}
|
||||
onChange={(e) => handleChange('useJobReport', e.target.checked)}
|
||||
disabled={!canEditAdmin}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-sm text-white">일지 사용</span>
|
||||
</label>
|
||||
|
||||
{/* 휴가 제외 */}
|
||||
<label className={clsx(
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
!canEditAdmin && "opacity-50 cursor-not-allowed"
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.exceptHoly}
|
||||
onChange={(e) => handleChange('exceptHoly', e.target.checked)}
|
||||
disabled={!canEditAdmin}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-sm text-white">휴가 제외</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-white/10 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserListPage() {
|
||||
const [users, setUsers] = useState<GroupUser[]>([]);
|
||||
const [process, setProcess] = useState('%');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [levelInfo, setLevelInfo] = useState<UserLevelInfo | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<GroupUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadLevelInfo();
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadLevelInfo = async () => {
|
||||
try {
|
||||
const result = await comms.getCurrentUserLevel();
|
||||
if (result.Success && result.Data) {
|
||||
setLevelInfo(result.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 정보 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await comms.getUserList(process);
|
||||
if (Array.isArray(result)) {
|
||||
setUsers(result);
|
||||
} else if (result && typeof result === 'object') {
|
||||
const r = result as { Success?: boolean; Message?: string };
|
||||
if (r.Success === false) {
|
||||
console.error('사용자 목록 조회 실패:', r.Message);
|
||||
}
|
||||
setUsers([]);
|
||||
} else {
|
||||
console.error('사용자 목록 응답이 배열이 아님:', result);
|
||||
setUsers([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 실패:', error);
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleRowClick = (user: GroupUser) => {
|
||||
setSelectedUser(user);
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
(u.id ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(u.name ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(u.email ?? '').toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(u.tel ?? '').toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-white/70">공정</label>
|
||||
<input
|
||||
type="text"
|
||||
value={process}
|
||||
onChange={(e) => setProcess(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRefresh()}
|
||||
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
새로고침
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">사용자 목록</h2>
|
||||
<span className="text-sm text-white/50">({filteredUsers.length}명)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-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-20">직책</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">이메일</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">전화</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">공정</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-16">상태</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-white/70 w-12">Lv</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-white/70 w-16">계정</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-white/70 w-16">일지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredUsers.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
onClick={() => handleRowClick(user)}
|
||||
className={clsx(
|
||||
'hover:bg-white/5 transition-colors cursor-pointer',
|
||||
!user.useUserState && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 text-white font-mono">{user.id}</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{user.name}</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.grade}</td>
|
||||
<td className="px-3 py-2">
|
||||
{user.email ? (
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{user.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-white/70">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.tel}</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.processs}</td>
|
||||
<td className="px-3 py-2 text-white/70">{user.state}</td>
|
||||
<td className="px-3 py-2 text-white text-center">{user.level}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{user.useUserState ? (
|
||||
<Check className="w-4 h-4 text-green-400 mx-auto" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-red-400 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{user.useJobReport ? (
|
||||
<Check className="w-4 h-4 text-green-400 mx-auto" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-red-400 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-white/50">
|
||||
{users.length === 0 ? '공정을 입력하고 새로고침하세요.' : '검색 결과가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 상세 다이얼로그 */}
|
||||
{selectedUser && (
|
||||
<UserDetailDialog
|
||||
user={selectedUser}
|
||||
levelInfo={levelInfo}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
onSave={() => loadUsers()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
Project/frontend/src/pages/index.ts
Normal file
12
Project/frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { Dashboard } from './Dashboard';
|
||||
export { Todo } from './Todo';
|
||||
export { Kuntae } from './Kuntae';
|
||||
export { Jobreport } from './Jobreport';
|
||||
export { PlaceholderPage } from './Placeholder';
|
||||
export { Login } from './Login';
|
||||
export { CommonCodePage } from './CommonCode';
|
||||
export { ItemsPage } from './Items';
|
||||
export { UserListPage } from './UserList';
|
||||
export { MonthlyWorkPage } from './MonthlyWork';
|
||||
export { MailFormPage } from './MailForm';
|
||||
export { UserGroupPage } from './UserGroup';
|
||||
467
Project/frontend/src/types.ts
Normal file
467
Project/frontend/src/types.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
// Todo 관련 타입
|
||||
export interface TodoModel {
|
||||
idx: number;
|
||||
gcode: string;
|
||||
uid: string;
|
||||
title: string;
|
||||
remark: string;
|
||||
flag: boolean;
|
||||
expire: string | null;
|
||||
seqno: number;
|
||||
request: string | null;
|
||||
status: string;
|
||||
okdate: string | null;
|
||||
wuid: string;
|
||||
wdate: string;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface ApiResponse<T = unknown> {
|
||||
Success: boolean;
|
||||
Message?: string;
|
||||
Data?: T;
|
||||
}
|
||||
|
||||
// Dashboard 관련 타입
|
||||
export interface PurchaseCount {
|
||||
NR: number;
|
||||
CR: number;
|
||||
Message?: string;
|
||||
}
|
||||
|
||||
export interface HolyUser {
|
||||
uid: string;
|
||||
type: string;
|
||||
cate: string;
|
||||
sdate: string;
|
||||
edate: string;
|
||||
title: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface HolyRequestUser {
|
||||
uid: string;
|
||||
cate: string;
|
||||
sdate: string;
|
||||
edate: string;
|
||||
HolyReason: string;
|
||||
name: string;
|
||||
holydays: number;
|
||||
holytimes: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface PurchaseItem {
|
||||
pdate: string;
|
||||
process: string;
|
||||
pumname: string;
|
||||
pumscale: string;
|
||||
pumunit: string;
|
||||
pumqtyreq: number;
|
||||
pumprice: number;
|
||||
pumamt: number;
|
||||
}
|
||||
|
||||
// 상태 관련 타입
|
||||
export type TodoStatus = '0' | '1' | '2' | '3' | '5';
|
||||
export type TodoPriority = 0 | 1 | 2 | 3;
|
||||
|
||||
// 로그 타입
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
type: 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
// WebView2 Native Bridge Types
|
||||
declare global {
|
||||
interface Window {
|
||||
chrome?: {
|
||||
webview?: {
|
||||
hostObjects: {
|
||||
machine: MachineBridgeInterface;
|
||||
};
|
||||
addEventListener(type: string, listener: (event: MessageEvent) => void): void;
|
||||
removeEventListener(type: string, listener: (event: MessageEvent) => void): void;
|
||||
postMessage(message: unknown): void;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 근태 관련 타입
|
||||
export interface KuntaeModel {
|
||||
idx: number;
|
||||
gcode: string;
|
||||
uid: string;
|
||||
uname: string;
|
||||
cate: string;
|
||||
sdate: string | null;
|
||||
edate: string | null;
|
||||
term: number;
|
||||
termdr: number;
|
||||
drtime: number;
|
||||
crtime: number;
|
||||
contents: string;
|
||||
tag: string;
|
||||
extcate: string;
|
||||
wuid: string;
|
||||
wdate: string;
|
||||
}
|
||||
|
||||
// 업무일지 관련 타입 (기존 - 사용 안함)
|
||||
export interface JobreportModel {
|
||||
idx: number;
|
||||
gcode: string;
|
||||
uid: string;
|
||||
uname: string;
|
||||
pdate: string;
|
||||
title: string;
|
||||
contents: string;
|
||||
wuid: string;
|
||||
wdate: string;
|
||||
}
|
||||
|
||||
// 업무일지 타입 (vJobReportForUser 뷰)
|
||||
export interface JobReportItem {
|
||||
idx: number;
|
||||
pidx: number;
|
||||
pdate: string;
|
||||
id: string; // 사용자 ID (uid -> id)
|
||||
name: string; // 사용자 이름 (username -> name)
|
||||
type: string; // 업무형태
|
||||
svalue: string; // 업무형태 표시값
|
||||
hrs: number;
|
||||
ot: number;
|
||||
requestpart: string;
|
||||
package: string;
|
||||
userprocess: string; // 사용자 공정
|
||||
status: string;
|
||||
projectName: string;
|
||||
description: string;
|
||||
ww: string;
|
||||
otpms: string; // OT PMS
|
||||
process: string;
|
||||
}
|
||||
|
||||
// 업무일지 사용자 타입
|
||||
export interface JobReportUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 로그인 관련 타입
|
||||
export interface UserInfo {
|
||||
Id: string;
|
||||
Name: string;
|
||||
NameE: string;
|
||||
Dept: string;
|
||||
Email: string;
|
||||
Level: number;
|
||||
Gcode: string;
|
||||
}
|
||||
|
||||
export interface LoginStatusResponse {
|
||||
Success: boolean;
|
||||
IsLoggedIn: boolean;
|
||||
User: UserInfo | null;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
Success: boolean;
|
||||
Message: string;
|
||||
RedirectUrl?: string;
|
||||
UserName?: string;
|
||||
VersionWarning?: string;
|
||||
}
|
||||
|
||||
export interface UserGroup {
|
||||
gcode: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PreviousLoginInfo {
|
||||
Success: boolean;
|
||||
Data: {
|
||||
LastGcode: string;
|
||||
LastDept: string;
|
||||
LastId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 사용자 정보 상세 타입
|
||||
export interface UserInfoDetail {
|
||||
Id: string;
|
||||
NameK: string;
|
||||
NameE: string;
|
||||
Dept: string;
|
||||
Grade: string;
|
||||
Email: string;
|
||||
Tel: string;
|
||||
Hp: string;
|
||||
DateIn: string;
|
||||
DateO: string;
|
||||
Memo: string;
|
||||
Process: string;
|
||||
State: string;
|
||||
UseJobReport: boolean;
|
||||
UseUserState: boolean;
|
||||
ExceptHoly: boolean;
|
||||
Level: number;
|
||||
}
|
||||
|
||||
// 공용코드 관련 타입
|
||||
export interface CommonCodeGroup {
|
||||
code: string;
|
||||
svalue: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export interface CommonCode {
|
||||
idx: number;
|
||||
gcode?: string;
|
||||
grp: string;
|
||||
code: string;
|
||||
svalue: string;
|
||||
ivalue: number;
|
||||
fvalue: number;
|
||||
svalue2?: string;
|
||||
memo: string;
|
||||
wuid?: string;
|
||||
wdate?: string;
|
||||
}
|
||||
|
||||
// 품목정보 관련 타입
|
||||
export interface ItemInfo {
|
||||
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;
|
||||
}
|
||||
|
||||
// 사용자 목록 관련 타입
|
||||
export interface GroupUser {
|
||||
id: string;
|
||||
name: string;
|
||||
nameE: string;
|
||||
grade: string;
|
||||
email: string;
|
||||
tel: string;
|
||||
indate: string | null;
|
||||
outdate: string | null;
|
||||
hp: string;
|
||||
processs: string;
|
||||
state: string;
|
||||
memo: string;
|
||||
level: number;
|
||||
useUserState: boolean;
|
||||
useJobReport: boolean;
|
||||
exceptHoly: boolean;
|
||||
gcode: string;
|
||||
dept?: string;
|
||||
}
|
||||
|
||||
// MachineBridge 인터페이스
|
||||
export interface MachineBridgeInterface {
|
||||
// Todo API
|
||||
Todo_GetTodos(): Promise<string>;
|
||||
Todo_GetTodo(id: number): Promise<string>;
|
||||
CreateTodo(title: string, remark: string, expire: string | null, seqno: number, flag: boolean, request: string | null, status: string): Promise<string>;
|
||||
Todo_UpdateTodo(idx: number, title: string, remark: string, expire: string | null, seqno: number, flag: boolean, request: string | null, status: string): Promise<string>;
|
||||
Todo_DeleteTodo(id: number): Promise<string>;
|
||||
GetUrgentTodos(): Promise<string>;
|
||||
|
||||
// Dashboard API
|
||||
TodayCountH(): Promise<string>;
|
||||
GetHolydayRequestCount(): Promise<string>;
|
||||
GetCurrentUserCount(): Promise<string>;
|
||||
GetPurchaseWaitCount(): Promise<string>;
|
||||
GetHolyUser(): Promise<string>;
|
||||
GetHolyRequestUser(): Promise<string>;
|
||||
GetPresentUserList(): Promise<string>;
|
||||
GetPurchaseNRList(): Promise<string>;
|
||||
GetPurchaseCRList(): Promise<string>;
|
||||
|
||||
// Kuntae API
|
||||
Kuntae_GetList(sd: string, ed: string): Promise<string>;
|
||||
Kuntae_Delete(id: number): Promise<string>;
|
||||
|
||||
// Jobreport API (JobReport 뷰/테이블)
|
||||
Jobreport_GetList(sd: string, ed: string, uid: string, cate: string, searchKey: string): Promise<string>;
|
||||
Jobreport_GetUsers(): Promise<string>;
|
||||
Jobreport_GetDetail(id: number): Promise<string>;
|
||||
Jobreport_Add(pdate: string, projectName: string, 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_Delete(id: number): Promise<string>;
|
||||
Jobreport_GetPermission(targetUserId: string): Promise<string>;
|
||||
Jobreport_GetJobTypes(process: string): Promise<string>;
|
||||
|
||||
// App Info API
|
||||
GetAppVersion(): Promise<string>;
|
||||
|
||||
// Login API
|
||||
CheckLoginStatus(): Promise<string>;
|
||||
Login(gcode: string, id: string, password: string, rememberMe: boolean): Promise<string>;
|
||||
Logout(): Promise<string>;
|
||||
GetUserGroups(): Promise<string>;
|
||||
GetPreviousLoginInfo(): Promise<string>;
|
||||
|
||||
// User API
|
||||
GetCurrentUserInfo(): Promise<string>;
|
||||
GetUserInfoById(userId: string): Promise<string>;
|
||||
SaveUserInfo(jsonData: string): Promise<string>;
|
||||
ChangePassword(oldPassword: string, newPassword: string): Promise<string>;
|
||||
|
||||
// Common Code API
|
||||
Common_GetGroups(): Promise<string>;
|
||||
Common_GetList(grp: string): Promise<string>;
|
||||
Common_Save(idx: number, grp: string, code: string, svalue: string, ivalue: number, fvalue: number, svalue2: string, memo: string): Promise<string>;
|
||||
Common_Delete(idx: number): Promise<string>;
|
||||
|
||||
// Items API
|
||||
Items_GetCategories(): 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_Delete(idx: number): Promise<string>;
|
||||
|
||||
// UserList API
|
||||
UserList_GetCurrentLevel(): Promise<string>;
|
||||
UserList_GetList(process: string): Promise<string>;
|
||||
UserList_GetUser(userId: string): Promise<string>;
|
||||
UserList_SaveGroupUser(userId: string, dept: string, level: number, useUserState: boolean, useJobReport: boolean, exceptHoly: boolean): Promise<string>;
|
||||
UserList_SaveUserFull(jsonData: string): Promise<string>;
|
||||
UserList_DeleteGroupUser(userId: string): Promise<string>;
|
||||
|
||||
// Holiday API (월별근무표)
|
||||
Holiday_GetList(month: string): Promise<string>;
|
||||
Holiday_Save(month: string, holidaysJson: string): Promise<string>;
|
||||
Holiday_Initialize(month: string): Promise<string>;
|
||||
|
||||
// MailForm API (메일양식)
|
||||
MailForm_GetList(): Promise<string>;
|
||||
MailForm_GetDetail(idx: number): Promise<string>;
|
||||
MailForm_Add(cate: string, title: string, tolist: string, bcc: string, cc: string, subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean, selfBCC: boolean, exceptmail: string, exceptmailcc: string): Promise<string>;
|
||||
MailForm_Edit(idx: number, cate: string, title: string, tolist: string, bcc: string, cc: string, subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean, selfBCC: boolean, exceptmail: string, exceptmailcc: string): Promise<string>;
|
||||
MailForm_Delete(idx: number): Promise<string>;
|
||||
|
||||
// UserGroup API (그룹정보/권한설정)
|
||||
UserGroup_GetList(): Promise<string>;
|
||||
UserGroup_Add(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_GetPermissionInfo(): Promise<string>;
|
||||
}
|
||||
|
||||
// 사용자 권한 정보 타입
|
||||
export interface UserLevelInfo {
|
||||
Level: number;
|
||||
CurrentUserId: string;
|
||||
CanEdit: boolean;
|
||||
}
|
||||
|
||||
// 업무일지 권한 정보 타입
|
||||
export interface JobReportPermission {
|
||||
Success: boolean;
|
||||
CurrentUserId: string;
|
||||
Level: number;
|
||||
CanViewOT: boolean;
|
||||
}
|
||||
|
||||
// 업무형태 타입 (Common 테이블, grp='15')
|
||||
export interface JobTypeItem {
|
||||
idx: number;
|
||||
code: string;
|
||||
type: string; // memo - 업무형태
|
||||
jobgrp: string; // svalue - 업무분류
|
||||
process: string; // svalue2 - 공정
|
||||
}
|
||||
|
||||
// 앱 버전 정보 타입
|
||||
export interface AppVersionInfo {
|
||||
Success: boolean;
|
||||
ProductName: string;
|
||||
ProductVersion: string;
|
||||
DisplayVersion: string;
|
||||
}
|
||||
|
||||
// 사용자 전체 정보 저장용 타입
|
||||
export interface UserFullData {
|
||||
id: string;
|
||||
name: string;
|
||||
nameE: string;
|
||||
grade: string;
|
||||
email: string;
|
||||
tel: string;
|
||||
hp: string;
|
||||
indate: string;
|
||||
outdate: string;
|
||||
memo: string;
|
||||
processs: string;
|
||||
state: string;
|
||||
level: number;
|
||||
useUserState: boolean;
|
||||
useJobReport: boolean;
|
||||
exceptHoly: boolean;
|
||||
}
|
||||
|
||||
// 월별근무표 항목 타입
|
||||
export interface HolidayItem {
|
||||
idx: number;
|
||||
pdate: string;
|
||||
free: boolean;
|
||||
memo: string;
|
||||
wuid?: string;
|
||||
wdate?: string;
|
||||
}
|
||||
|
||||
// 메일양식 항목 타입
|
||||
export interface MailFormItem {
|
||||
idx: number;
|
||||
gcode: string;
|
||||
cate: string;
|
||||
title: string;
|
||||
tolist: string;
|
||||
bcc: string;
|
||||
cc: string;
|
||||
subject: string;
|
||||
tail: string;
|
||||
body: string;
|
||||
selfTo: boolean;
|
||||
selfCC: boolean;
|
||||
selfBCC: boolean;
|
||||
wuid: string;
|
||||
wdate: string;
|
||||
exceptmail: string;
|
||||
exceptmailcc: string;
|
||||
}
|
||||
|
||||
// 그룹정보 타입
|
||||
export interface UserGroupItem {
|
||||
dept: string;
|
||||
gcode: string;
|
||||
path_kj: string;
|
||||
permission: number;
|
||||
advpurchase: boolean;
|
||||
advkisul: boolean;
|
||||
managerinfo: string;
|
||||
devinfo: string;
|
||||
usemail: boolean;
|
||||
}
|
||||
|
||||
// 권한 정보 타입
|
||||
export interface PermissionInfo {
|
||||
index: number;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
1
Project/frontend/src/vite-env.d.ts
vendored
Normal file
1
Project/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
77
Project/frontend/tailwind.config.js
Normal file
77
Project/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
Project/frontend/tsconfig.json
Normal file
25
Project/frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
Project/frontend/tsconfig.node.json
Normal file
10
Project/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
Project/frontend/vite.config.ts
Normal file
25
Project/frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
base: './',
|
||||
});
|
||||
Submodule Sub/tcpservice updated: 1680e266da...d7fe2baa0e
Reference in New Issue
Block a user