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:
backuppc
2025-11-27 17:25:31 +09:00
parent b57af6dad7
commit c9b5d756e1
65 changed files with 14028 additions and 467 deletions

View File

@@ -1,6 +1,6 @@
namespace Project.Dialog namespace Project.Dialog
{ {
partial class fDashboardNew partial class fDashboard
{ {
/// <summary> /// <summary>
/// Required designer variable. /// Required designer variable.

View File

@@ -15,26 +15,18 @@ using System.Windows.Forms;
namespace Project.Dialog namespace Project.Dialog
{ {
public partial class fDashboardNew : fBase public partial class fDashboard : fBase
{ {
private Web.WebSocketServer _wsServer;
private WebView2 webView; private WebView2 webView;
public fDashboardNew() private Web.MachineBridge _machineBridge;
public fDashboard()
{ {
InitializeComponent(); InitializeComponent();
InitializeWebView2(); 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; bool loadok = false;
public void RefreshView() public void RefreshView()
{ {
@@ -80,7 +72,7 @@ namespace Project.Dialog
await this.webView.EnsureCoreWebView2Async(); 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( webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
"hmi.local", "hmi.local",
wwwroot, wwwroot,
@@ -88,16 +80,13 @@ namespace Project.Dialog
// 2. Inject Native Object // 2. Inject Native Object
webView.CoreWebView2.AddHostObjectToScript("machine", new Web.MachineBridge(this)); webView.CoreWebView2.AddHostObjectToScript("machine", _machineBridge);
Pub.WebServiceURL = "http://hmi.local"; 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; label1.Visible = false;
loadok = true; loadok = true;
} }
@@ -106,6 +95,18 @@ namespace Project.Dialog
MessageBox.Show($"WebView2 초기화 실패: {ex.Message}"); 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) protected override void OnLoad(EventArgs e)
{ {
base.OnLoad(e); base.OnLoad(e);

View File

@@ -192,24 +192,24 @@ namespace Project.Dialog
} }
} }
FCOMMON.info.Login.no = drUser.id; info.Login.no = drUser.id;
FCOMMON.info.Login.nameK = drUser.name; info.Login.nameK = drUser.name;
FCOMMON.info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text; info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text;
FCOMMON.info.Login.level = drGrpUser.level; info.Login.level = drGrpUser.level;
FCOMMON.info.Login.email = drUser.email; info.Login.email = drUser.email;
FCOMMON.info.Login.nameE = drUser.nameE; info.Login.nameE = drUser.nameE;
FCOMMON.info.Login.hp = drUser.hp; info.Login.hp = drUser.hp;
FCOMMON.info.Login.tel = drUser.tel; info.Login.tel = drUser.tel;
FCOMMON.info.Login.title = drUser.dept + "(" + drUser.grade + ")"; info.Login.title = drUser.dept + "(" + drUser.grade + ")";
FCOMMON.info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView; info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView;
//var gcode = FCOMMON.DBM.ExecuteScalar("select isnull(gcode,'NOGCODE') from UserGroup where dept ='" + cmbDept.Text + "'"); //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 + "'"); var gperm = FCOMMON.DBM.ExecuteScalar("select isnull(permission,0) from UserGroup where dept ='" + cmbDept.Text + "'");
FCOMMON.info.Login.gcode = gCode;// gcode; info.Login.gcode = gCode;// gcode;
FCOMMON.info.Login.process = drUser.id == "dev" ? "개발자" : drGrpUser.Process; info.Login.process = drUser.id == "dev" ? "개발자" : drGrpUser.Process;
FCOMMON.info.Login.permission = 0; info.Login.permission = 0;
FCOMMON.info.Login.gpermission = int.Parse(gperm); info.Login.gpermission = int.Parse(gperm);
//FCOMMON.info.datapath = Pub.setting.SharedDataPath; //info.datapath = Pub.setting.SharedDataPath;
FCOMMON.info.ShowBuyerror = Pub.setting.Showbuyerror; //210625 info.ShowBuyerror = Pub.setting.Showbuyerror; //210625
@@ -220,34 +220,34 @@ namespace Project.Dialog
{ {
return; return;
} }
FCOMMON.info.Login.no = "dev"; info.Login.no = "dev";
FCOMMON.info.Login.nameK = "개발자"; info.Login.nameK = "개발자";
FCOMMON.info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text; info.Login.dept = cmbDept.Text;// userdr.dept;// cmbDept.Text;
FCOMMON.info.Login.level = 10; info.Login.level = 10;
FCOMMON.info.Login.email = ""; info.Login.email = "";
FCOMMON.info.Login.nameE = "DEVELOPER"; info.Login.nameE = "DEVELOPER";
FCOMMON.info.Login.hp = ""; info.Login.hp = "";
FCOMMON.info.Login.tel = ""; info.Login.tel = "";
FCOMMON.info.Login.title = "업무일지 개발자"; info.Login.title = "업무일지 개발자";
FCOMMON.info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView; info.NotShowJobReportview = Pub.setting.NotShowJobreportPRewView;
//var gcode = FCOMMON.DBM.ExecuteScalar("select isnull(gcode,'NOGCODE') from UserGroup where dept ='" + cmbDept.Text + "'"); //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 + "'"); var gperm = FCOMMON.DBM.ExecuteScalar("select isnull(permission,0) from UserGroup where dept ='" + cmbDept.Text + "'");
FCOMMON.info.Login.gcode = gCode; info.Login.gcode = gCode;
FCOMMON.info.Login.process = "개발자"; info.Login.process = "개발자";
FCOMMON.info.Login.permission = 0; info.Login.permission = 0;
FCOMMON.info.Login.gpermission = int.Parse(gperm); info.Login.gpermission = int.Parse(gperm);
//var datapath = FCOMMON.DBM.getCodeSavlue("55", "01"); //var datapath = FCOMMON.DBM.getCodeSavlue("55", "01");
//FCOMMON.info.datapath = datapath;// Pub.setting.SharedDataPath; //info.datapath = datapath;// Pub.setting.SharedDataPath;
FCOMMON.info.ShowBuyerror = Pub.setting.Showbuyerror; //210625 info.ShowBuyerror = Pub.setting.Showbuyerror; //210625
} }
//if (FCOMMON.info.datapath.isEmpty() && gCode == "EET1P") //210524 //if (info.datapath.isEmpty() && gCode == "EET1P") //210524
// FCOMMON.info.datapath = @"\\k4fs3201n\k4bpartcenter$"; // info.datapath = @"\\k4fs3201n\k4bpartcenter$";
//using (var dbEnity = new EEEntitiesMain()) //using (var dbEnity = new EEEntitiesMain())
//{ //{
// var drGrpUser = dbEnity.EETGW_GroupUser.Where(t => t.uid == userdr.id & t.gcode == gCode).FirstOrDefault(); // 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" ? "개발자" : ""); // if (drGrpUser == null) info.Login.process = (userdr.id == "dev" ? "개발자" : "");
// else FCOMMON.info.Login.process = drGrpUser.Process; // else info.Login.process = drGrpUser.Process;
//} //}
//로그인정보 기록 //로그인정보 기록
@@ -260,7 +260,7 @@ namespace Project.Dialog
Pub.MakeAutoJobReportByAuto(); Pub.MakeAutoJobReportByAuto();
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
FCOMMON.info.Login.loginusetime = (DateTime.Now - dt).TotalMilliseconds; info.Login.loginusetime = (DateTime.Now - dt).TotalMilliseconds;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -293,7 +293,7 @@ namespace Project.Dialog
try try
{ {
var ta = new dsMSSQLTableAdapters.EETGW_LoginInfoTableAdapter(); 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) catch (Exception ex)
{ {
@@ -336,9 +336,9 @@ namespace Project.Dialog
//} //}
var gCode = this.cmbDept.SelectedValue.ToString();// as dsMSSQL.UserGroupRow; var gCode = this.cmbDept.SelectedValue.ToString();// as dsMSSQL.UserGroupRow;
FCOMMON.info.Login.gcode = gCode; info.Login.gcode = gCode;
FCOMMON.info.Login.no = "new"; info.Login.no = "new";
FCOMMON.info.Login.dept = this.cmbDept.Text; info.Login.dept = this.cmbDept.Text;
var dlg = FCOMMON.Util.MsgQ($"현재 선택된 그룹[{this.cmbDept.Text}]의 사용자를 추가할까요?\n" + var dlg = FCOMMON.Util.MsgQ($"현재 선택된 그룹[{this.cmbDept.Text}]의 사용자를 추가할까요?\n" +
"추가된 사용자는 담당자로부터 승인 완료되어야 접속이 가능 합니다\n" + "추가된 사용자는 담당자로부터 승인 완료되어야 접속이 가능 합니다\n" +

View File

@@ -220,11 +220,11 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Dialog\fDashboardNew.cs"> <Compile Include="Dialog\fDashboard.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
</Compile> </Compile>
<Compile Include="Dialog\fDashboardNew.Designer.cs"> <Compile Include="Dialog\fDashboard.Designer.cs">
<DependentUpon>fDashboardNew.cs</DependentUpon> <DependentUpon>fDashboard.cs</DependentUpon>
</Compile> </Compile>
<Compile Include="fSystemCheck.cs"> <Compile Include="fSystemCheck.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
@@ -358,6 +358,12 @@
<Compile Include="Web\MachineBridge\MachineBridge.Jobreport.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.Jobreport.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Kuntae.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.Kuntae.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Project.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\PageModel.cs" />
<Compile Include="Web\Model\ProjectModel.cs" /> <Compile Include="Web\Model\ProjectModel.cs" />
<Compile Include="Web\Model\TodoModel.cs" /> <Compile Include="Web\Model\TodoModel.cs" />
@@ -365,7 +371,6 @@
<Compile Include="Settings.cs" /> <Compile Include="Settings.cs" />
<Compile Include="SqlServerTypes\Loader.cs" /> <Compile Include="SqlServerTypes\Loader.cs" />
<Compile Include="StateMachine\ReportUserData.cs" /> <Compile Include="StateMachine\ReportUserData.cs" />
<Compile Include="Web\WebSocketServer.cs" />
<Compile Include="_Common\fADSUserList.cs"> <Compile Include="_Common\fADSUserList.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
</Compile> </Compile>
@@ -426,8 +431,8 @@
<EmbeddedResource Include="Dev\fDisableItem.resx"> <EmbeddedResource Include="Dev\fDisableItem.resx">
<DependentUpon>fDisableItem.cs</DependentUpon> <DependentUpon>fDisableItem.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Dialog\fDashboardNew.resx"> <EmbeddedResource Include="Dialog\fDashboard.resx">
<DependentUpon>fDashboardNew.cs</DependentUpon> <DependentUpon>fDashboard.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Dialog\fDebug.resx"> <EmbeddedResource Include="Dialog\fDebug.resx">
<DependentUpon>fDebug.cs</DependentUpon> <DependentUpon>fDebug.cs</DependentUpon>
@@ -732,6 +737,9 @@
<ItemGroup /> <ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <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')" /> <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"> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup> <PropertyGroup>
<ErrorText>이 프로젝트는 이 컴퓨터에 없는 NuGet 패키지를 참조합니다. 해당 패키지를 다운로드하려면 NuGet 패키지 복원을 사용하십시오. 자세한 내용은 http://go.microsoft.com/fwlink/?LinkID=322105를 참조하십시오. 누락된 파일은 {0}입니다.</ErrorText> <ErrorText>이 프로젝트는 이 컴퓨터에 없는 NuGet 패키지를 참조합니다. 해당 패키지를 다운로드하려면 NuGet 패키지 복원을 사용하십시오. 자세한 내용은 http://go.microsoft.com/fwlink/?LinkID=322105를 참조하십시오. 누락된 파일은 {0}입니다.</ErrorText>

View File

@@ -229,5 +229,198 @@ namespace Project.Web
} }
#endregion #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
} }
} }

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

View File

@@ -12,62 +12,47 @@ namespace Project.Web
#region Jobreport API #region Jobreport API
/// <summary> /// <summary>
/// 업무일지 목록 조회 /// 업무일지 목록 조회 (vJobReportForUser 뷰 사용)
/// </summary> /// </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 try
{ {
var sql = @"SELECT j.idx, j.jdate, j.uid, j.cate, j.title, j.doit, j.remark, j.jfrom, j.jto, var sql = @"SELECT idx, pidx, pdate, id, name,type, svalue, hrs,ot,requestpart,package,userprocess,status, projectName, description, ww,otpms,process
u.name as userName, j.wdate FROM vJobReportForUser WITH (nolock)
FROM EETGW_Jobreport j WITH (nolock) WHERE gcode = @gcode AND (pdate BETWEEN @sd AND @ed)";
LEFT JOIN Users u ON j.uid = u.id
WHERE j.gcode = @gcode";
var parameters = new List<SqlParameter>(); var parameters = new List<SqlParameter>();
parameters.Add(new SqlParameter("@gcode", info.Login.gcode)); parameters.Add(new SqlParameter("@gcode", info.Login.gcode));
parameters.Add(new SqlParameter("@sd", sd));
parameters.Add(new SqlParameter("@ed", ed));
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)) if (!string.IsNullOrEmpty(uid))
{ {
sql += " AND j.uid = @uid"; sql += " AND id = @uid";
parameters.Add(new SqlParameter("@uid", uid)); parameters.Add(new SqlParameter("@uid", uid));
} }
if (!string.IsNullOrEmpty(cate))
if (!string.IsNullOrEmpty(searchKey))
{ {
sql += " AND j.cate = @cate"; 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("@cate", cate)); parameters.Add(new SqlParameter("@searchKey", "%" + searchKey + "%"));
}
if (!string.IsNullOrEmpty(doit))
{
sql += " AND j.doit = @doit";
parameters.Add(new SqlParameter("@doit", doit));
} }
sql += " ORDER BY j.jdate DESC, j.idx DESC"; sql += " ORDER BY pdate DESC, idx DESC";
var cs = Properties.Settings.Default.gwcs; var cs = Properties.Settings.Default.gwcs;
var cn = new SqlConnection(cs); using (var cn = new SqlConnection(cs))
var cmd = new SqlCommand(sql, cn); using (var cmd = new SqlCommand(sql, cn))
cmd.Parameters.AddRange(parameters.ToArray()); {
cmd.Parameters.AddRange(parameters.ToArray());
var da = new SqlDataAdapter(cmd); using (var da = new SqlDataAdapter(cmd))
var dt = new DataTable(); {
da.Fill(dt); var dt = new DataTable();
da.Dispose(); da.Fill(dt);
cmd.Dispose(); return JsonConvert.SerializeObject(new { Success = true, Data = dt });
cn.Dispose(); }
}
return JsonConvert.SerializeObject(new { Success = true, Data = dt }, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -88,18 +73,17 @@ namespace Project.Web
ORDER BY u.name"; ORDER BY u.name";
var cs = Properties.Settings.Default.gwcs; var cs = Properties.Settings.Default.gwcs;
var cn = new SqlConnection(cs); using (var cn = new SqlConnection(cs))
var cmd = new SqlCommand(sql, cn); using (var cmd = new SqlCommand(sql, cn))
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode); {
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); var dt = new DataTable();
da.Dispose(); da.Fill(dt);
cmd.Dispose(); return JsonConvert.SerializeObject(dt);
cn.Dispose(); }
}
return JsonConvert.SerializeObject(dt, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -109,36 +93,40 @@ namespace Project.Web
} }
/// <summary> /// <summary>
/// 업무일지 상세 조회 /// 업무일지 상세 조회 (vJobReportForUser 뷰 사용)
/// </summary> /// </summary>
public string Jobreport_GetDetail(int id) public string Jobreport_GetDetail(int id)
{ {
try try
{ {
var sql = @"SELECT j.*, u.name as userName var sql = @"SELECT idx, pidx, pdate, id, name, type, svalue, hrs, ot, requestpart, package,
FROM EETGW_Jobreport j WITH (nolock) userprocess, status, projectName, description, ww, otpms, process
LEFT JOIN Users u ON j.uid = u.id FROM vJobReportForUser WITH (nolock)
WHERE j.idx = @idx AND j.gcode = @gcode"; WHERE idx = @idx AND gcode = @gcode";
var cs = Properties.Settings.Default.gwcs; var cs = Properties.Settings.Default.gwcs;
var cn = new SqlConnection(cs); using (var cn = new SqlConnection(cs))
var cmd = new SqlCommand(sql, cn); using (var cmd = new SqlCommand(sql, cn))
cmd.Parameters.AddWithValue("@idx", id);
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();
if (dt.Rows.Count > 0)
{ {
return JsonConvert.SerializeObject(new { Success = true, Data = dt.Rows[0] }, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); cmd.Parameters.AddWithValue("@idx", id);
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
using (var da = new SqlDataAdapter(cmd))
{
var dt = new DataTable();
da.Fill(dt);
if (dt.Rows.Count > 0)
{
var row = dt.Rows[0];
var data = new Dictionary<string, object>();
foreach (DataColumn col in dt.Columns)
{
data[col.ColumnName] = row[col] == DBNull.Value ? null : row[col];
}
return JsonConvert.SerializeObject(new { Success = true, Data = data });
}
return JsonConvert.SerializeObject(new { Success = false, Message = "데이터를 찾을 수 없습니다." });
}
} }
return JsonConvert.SerializeObject(new { Success = false, Message = "데이터를 찾을 수 없습니다." });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -147,37 +135,50 @@ namespace Project.Web
} }
/// <summary> /// <summary>
/// 업무일지 추가 /// 업무일지 추가 (JobReport 테이블)
/// </summary> /// </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 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();"; SELECT SCOPE_IDENTITY();";
var cs = Properties.Settings.Default.gwcs; var cs = Properties.Settings.Default.gwcs;
var cn = new SqlConnection(cs); using (var cn = new SqlConnection(cs))
var cmd = new SqlCommand(sql, cn); using (var cmd = new SqlCommand(sql, cn))
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode); {
cmd.Parameters.AddWithValue("@uid", info.Login.no); cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
cmd.Parameters.AddWithValue("@jdate", jdate ?? DateTime.Now.ToString("yyyy-MM-dd")); cmd.Parameters.AddWithValue("@uid", info.Login.no);
cmd.Parameters.AddWithValue("@cate", cate ?? ""); cmd.Parameters.AddWithValue("@pdate", pdate);
cmd.Parameters.AddWithValue("@title", title ?? ""); cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
cmd.Parameters.AddWithValue("@doit", doit ?? ""); cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
cmd.Parameters.AddWithValue("@remark", remark ?? ""); cmd.Parameters.AddWithValue("@package", package ?? "");
cmd.Parameters.AddWithValue("@jfrom", jfrom ?? ""); cmd.Parameters.AddWithValue("@type", type ?? "");
cmd.Parameters.AddWithValue("@jto", jto ?? ""); cmd.Parameters.AddWithValue("@process", process ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no); 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(); cn.Open();
var newId = Convert.ToInt32(cmd.ExecuteScalar()); var newId = Convert.ToInt32(cmd.ExecuteScalar());
cn.Close(); return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다.", Data = new { idx = newId } });
cmd.Dispose(); }
cn.Dispose();
return JsonConvert.SerializeObject(new { Success = true, Message = "저장되었습니다.", Data = new { idx = newId } });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -186,39 +187,73 @@ namespace Project.Web
} }
/// <summary> /// <summary>
/// 업무일지 수정 /// 업무일지 수정 (JobReport 테이블)
/// </summary> /// </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 try
{ {
var sql = @"UPDATE EETGW_Jobreport SET // 권한 체크
jdate = @jdate, cate = @cate, title = @title, doit = @doit, int curLevel = Math.Max(info.Login.level, DBM.getAuth(DBM.eAuthType.jobreport));
remark = @remark, jfrom = @jfrom, jto = @jto,
// 마감 체크
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() wuid = @wuid, wdate = GETDATE()
WHERE idx = @idx AND gcode = @gcode"; WHERE idx = @idx AND gcode = @gcode";
var cs = Properties.Settings.Default.gwcs; var cs = Properties.Settings.Default.gwcs;
var cn = new SqlConnection(cs); using (var cn = new SqlConnection(cs))
var cmd = new SqlCommand(sql, cn); using (var cmd = new SqlCommand(sql, cn))
cmd.Parameters.AddWithValue("@idx", idx); {
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode); cmd.Parameters.AddWithValue("@idx", idx);
cmd.Parameters.AddWithValue("@jdate", jdate ?? DateTime.Now.ToString("yyyy-MM-dd")); cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
cmd.Parameters.AddWithValue("@cate", cate ?? ""); cmd.Parameters.AddWithValue("@pdate", pdate);
cmd.Parameters.AddWithValue("@title", title ?? ""); cmd.Parameters.AddWithValue("@projectName", projectName ?? "");
cmd.Parameters.AddWithValue("@doit", doit ?? ""); cmd.Parameters.AddWithValue("@requestpart", requestpart ?? "");
cmd.Parameters.AddWithValue("@remark", remark ?? ""); cmd.Parameters.AddWithValue("@package", package ?? "");
cmd.Parameters.AddWithValue("@jfrom", jfrom ?? ""); cmd.Parameters.AddWithValue("@type", type ?? "");
cmd.Parameters.AddWithValue("@jto", jto ?? ""); cmd.Parameters.AddWithValue("@process", process ?? "");
cmd.Parameters.AddWithValue("@wuid", info.Login.no); 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(); cn.Open();
var result = cmd.ExecuteNonQuery(); var result = cmd.ExecuteNonQuery();
cn.Close(); return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "수정되었습니다." : "수정에 실패했습니다." });
cmd.Dispose(); }
cn.Dispose();
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "수정되었습니다." : "수정에 실패했습니다." });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -233,21 +268,128 @@ namespace Project.Web
{ {
try 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 cs = Properties.Settings.Default.gwcs;
var cn = new SqlConnection(cs); using (var cn = new SqlConnection(cs))
var cmd = new SqlCommand(sql, cn); using (var cmd = new SqlCommand(sql, cn))
cmd.Parameters.AddWithValue("@idx", idx); {
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode); cmd.Parameters.AddWithValue("@idx", idx);
cmd.Parameters.AddWithValue("@gcode", info.Login.gcode);
cn.Open(); cn.Open();
var result = cmd.ExecuteNonQuery(); var result = cmd.ExecuteNonQuery();
cn.Close(); return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "삭제되었습니다." : "삭제에 실패했습니다." });
cmd.Dispose(); }
cn.Dispose(); }
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
return JsonConvert.SerializeObject(new { Success = result > 0, Message = result > 0 ? "삭제되었습니다." : "삭제에 실패했습니다." }); /// <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) catch (Exception ex)
{ {

View File

@@ -191,8 +191,19 @@ namespace Project.Web
{ {
mainForm.OnLoginCompleted(); 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) 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>
/// 그룹 목록 조회 /// 그룹 목록 조회
/// </summary> /// </summary>
@@ -288,6 +323,68 @@ namespace Project.Web
return JsonConvert.SerializeObject(result); 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 #endregion
} }
} }

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

View File

@@ -79,6 +79,7 @@ namespace Project.Web
} }
} }
/// <summary> /// <summary>
/// 할일 추가 /// 할일 추가
/// </summary> /// </summary>

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

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

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

View File

@@ -20,12 +20,102 @@ namespace Project.Web
public partial class MachineBridge public partial class MachineBridge
{ {
// Reference to the main form to update logic // 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; _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> /// <summary>

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

View File

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

View File

@@ -706,7 +706,7 @@
showLoading(); showLoading();
try { 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); const data = JSON.parse(jsonStr);
if (data.Success) { if (data.Success) {

View File

@@ -44,7 +44,6 @@
this.sbChat = new System.Windows.Forms.ToolStripStatusLabel(); this.sbChat = new System.Windows.Forms.ToolStripStatusLabel();
this.menuStrip1 = new System.Windows.Forms.MenuStrip(); this.menuStrip1 = new System.Windows.Forms.MenuStrip();
this.btSetting = new System.Windows.Forms.ToolStripMenuItem(); this.btSetting = new System.Windows.Forms.ToolStripMenuItem();
this.ToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.commonToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.commonToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.codesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.codesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.itemsToolStripMenuItem = 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.Font = new System.Drawing.Font("맑은 고딕", 10F);
this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.btSetting, this.btSetting,
this.ToolStripMenuItem,
this.commonToolStripMenuItem, this.commonToolStripMenuItem,
this.managementToolStripMenuItem, this.managementToolStripMenuItem,
this.mn_docu, this.mn_docu,
@@ -269,14 +267,6 @@
this.btSetting.Text = "설정"; this.btSetting.Text = "설정";
this.btSetting.Click += new System.EventHandler(this.settingToolStripMenuItem_Click); 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 // commonToolStripMenuItem
// //
this.commonToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { 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 mn_project;
private System.Windows.Forms.ToolStripMenuItem projectImportCompleteToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem projectImportCompleteToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem purchaseOrderImportToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem purchaseOrderImportToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem mn_dailyhistory; private System.Windows.Forms.ToolStripMenuItem mn_dailyhistory;
private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem1; private System.Windows.Forms.ToolStripMenuItem ToolStripMenuItem1;

View File

@@ -158,14 +158,15 @@ namespace Project
Console.WriteLine($"WebView2 초기화 상태: {Pub.InitWebView}"); Console.WriteLine($"WebView2 초기화 상태: {Pub.InitWebView}");
Func_Login();
// WebView2 로그인이 아닌 경우에만 여기서 후처리 실행 // WebView2 로그인이 아닌 경우에만 여기서 후처리 실행
// WebView2 로그인의 경우 OnLoginCompleted()에서 호출됨 // WebView2 로그인의 경우 OnLoginCompleted()에서 호출됨
if (Pub.InitWebView != 1) //if (Pub.InitWebView != 1)
{ //{
OnLoginCompleted(); Menu_Dashboard();
} //OnLoginCompleted();
//}
} }
/// <summary> /// <summary>
@@ -236,22 +237,22 @@ namespace Project
Util.RunExplorer(cmd); Util.RunExplorer(cmd);
} }
void Func_Login() //void Func_Login()
{ //{
this.sbWeb.Text = $"WebView:{Pub.InitWebView}"; // this.sbWeb.Text = $"WebView:{Pub.InitWebView}";
if (Pub.InitWebView == 1) // if (Pub.InitWebView == 1)
{ // {
// WebView2 기반 대시보드 로그인 // // WebView2 기반 대시보드 로그인
Menu_Dashboard(); // Menu_Dashboard();
} // }
else // else
{ // {
// 기존 WinForms 로그인 // // 기존 WinForms 로그인
using (var f = new Dialog.fLogin()) // using (var f = new Dialog.fLogin())
if (f.ShowDialog() != System.Windows.Forms.DialogResult.OK) // if (f.ShowDialog() != System.Windows.Forms.DialogResult.OK)
Application.ExitThread(); // Application.ExitThread();
} // }
} //}
void Func_RunStartForm() void Func_RunStartForm()
{ {
var menu_purchaseVisible = FCOMMON.Util.getBit(FCOMMON.info.Login.gpermission, (int)FCOMMON.eGroupPermission.menu_purchase); var menu_purchaseVisible = FCOMMON.Util.getBit(FCOMMON.info.Login.gpermission, (int)FCOMMON.eGroupPermission.menu_purchase);
@@ -658,11 +659,11 @@ namespace Project
f.Show(); f.Show();
} }
private void ToolStripMenuItem_Click(object sender, EventArgs e) //private void 로그인ToolStripMenuItem_Click(object sender, EventArgs e)
{ //{
CloseAllForm(); // CloseAllForm();
Func_Login(); // Func_Login();
} //}
void CloseAllForm() void CloseAllForm()
{ {
@@ -675,7 +676,25 @@ namespace Project
tabControl1.TabPages.Remove(tab); tabControl1.TabPages.Remove(tab);
this.tabControl1.Refresh(); 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) 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() void Menu_Dashboard()
{ {
string formkey = "DASHBOARD"; string formkey = "DASHBOARD";
if (!ShowForm(formkey)) if (!ShowForm(formkey))
{ {
if (fdashboard == null || fdashboard.IsDisposed) if (fdashboard == null || fdashboard.IsDisposed)
fdashboard = new Dialog.fDashboardNew(); fdashboard = new Dialog.fDashboard();
AddForm(formkey, fdashboard); AddForm(formkey, fdashboard);
} }
} }
@@ -1528,14 +1548,15 @@ namespace Project
f.ShowDialog(); f.ShowDialog();
} }
Dialog.fDashboard fdashboard = null;
private void tabControl1_SelectedIndexChanged(object sender, EventArgs e) private void tabControl1_SelectedIndexChanged(object sender, EventArgs e)
{ {
if(this.tabControl1.SelectedIndex == 0) if (this.tabControl1.SelectedIndex == 0)
{ {
if (fdashboard != null) if (fdashboard != null)
{ {
fdashboard.RefreshView(); fdashboard.RefreshView();
Console.WriteLine( "view update"); Console.WriteLine("view update");
} }
} }

View File

@@ -12,7 +12,6 @@ using System.Net;
using System.IO.Compression; using System.IO.Compression;
using System.IO; using System.IO;
using FCOMMON; using FCOMMON;
using Microsoft.Owin.Hosting;
using System.Diagnostics; using System.Diagnostics;
namespace Project.Dialog namespace Project.Dialog

28
Project/frontend/.gitignore vendored Normal file
View 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
View 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();
```

View 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

View 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

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

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

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

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

View File

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
export { Layout } from './Layout';
export { Header } from './Header';
export { StatusBar } from './StatusBar';

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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: [],
}

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

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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