#nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Threading; using Housing.Services; namespace Housing.Login { public sealed class StartupLoginWindowResult { public string Maker { get; set; } = string.Empty; public string Model { get; set; } = string.Empty; public string Variant1 { get; set; } = string.Empty; public string Variant2 { get; set; } = string.Empty; public string Operator { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public string LineNo { get; set; } = string.Empty; public string LotNo { get; set; } = string.Empty; public string JigNo { get; set; } = string.Empty; } public partial class StartupLoginWindow : Window { private const string OperatorHistoryFieldName = "Operator"; private const string SuppressedOperatorHistoryValue = "su"; private readonly LoginHistoryStore _loginHistoryStore; private readonly LoginSelectionOptions _loginOptions; private Popup? _openHistoryPopup; public StartupLoginWindowResult Result { get; private set; } = new(); public StartupLoginWindow() { _loginHistoryStore = LoginHistoryStore.Load(GetLoginHistoryPath()); _loginOptions = LoginSelectionOptions.Load(GetLoginOptionsPath()); RemoveSuppressedOperatorHistory(); InitializeComponent(); ApplyLoginOptions(); AttachLoginHistoryMenus(); AttachSelectionDropDowns(); PasswordBox.GotKeyboardFocus += (_, _) => CloseHistoryPopup(); LoginButton.GotKeyboardFocus += (_, _) => CloseHistoryPopup(); PasswordBox.KeyDown += (s, e) => { if (e.Key == System.Windows.Input.Key.Enter) LogInButton_Click(LoginButton, e); }; OperatorTextBox.KeyDown += (s, e) => { if (e.Key == System.Windows.Input.Key.Enter) LogInButton_Click(LoginButton, e); }; MakerComboBox.Focus(); } private enum LoginCheckStatus { Success, InvalidCredentials, DbTimeoutOrError } private async void LogInButton_Click(object sender, RoutedEventArgs e) { CloseHistoryPopup(); if (!TryReadRequiredFields(out var loginResult, out var password)) { return; } string originalButtonText = "ACCESS / LOG IN"; if (sender is Button button) { originalButtonText = button.Content?.ToString() ?? "ACCESS / LOG IN"; button.IsEnabled = false; button.Content = "⏳ DB 접속 확인 중..."; button.UpdateLayout(); // WPF Dispatcher 렌더링 강제 갱신으로 클릭 즉시 화면 피드백 표기 Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.Render); } System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait; try { var checkResult = await CheckLoginAsync(loginResult.Operator, password); if (checkResult == LoginCheckStatus.InvalidCredentials) { MessageBox.Show(this, "등록되지 않은 ID/PW입니다.", "Login", MessageBoxButton.OK, MessageBoxImage.Error); PasswordBox.Clear(); PasswordBox.Focus(); return; } else if (checkResult == LoginCheckStatus.DbTimeoutOrError) { MessageBox.Show(this, "DB 연결 상태 또는 ID/PW를 확인해 주십시오.", "로그인 오류", MessageBoxButton.OK, MessageBoxImage.Warning); PasswordBox.Clear(); PasswordBox.Focus(); return; } loginResult.Password = password; // 비밀번호를 결과 객체에도 담아둠 Result = loginResult; SaveLoginHistory(loginResult); DialogResult = true; Close(); } catch (Exception ex) { MessageBox.Show(this, $"로그인 확인 실패: {ex.Message}", "Login", MessageBoxButton.OK, MessageBoxImage.Error); OperatorTextBox.Focus(); OperatorTextBox.SelectAll(); } finally { System.Windows.Input.Mouse.OverrideCursor = null; if (sender is Button loginButton) { loginButton.IsEnabled = true; loginButton.Content = originalButtonText; } } } private async Task CheckLoginAsync(string loginId, string password) { // 1. LoginOptions.json의 로컬 계정 정보에서 먼저 찾기 if (_loginOptions.LoginAccounts != null && _loginOptions.LoginAccounts.Count > 0) { var matched = _loginOptions.LoginAccounts.Any(account => string.Equals(account.Id, loginId, StringComparison.OrdinalIgnoreCase) && string.Equals(account.Password, password, StringComparison.Ordinal)); if (matched) { return LoginCheckStatus.Success; } } // 2. 오프라인 모드일 경우 통과 var loginSettings = LoginSettings.Load(GetDatabaseIniPath()); if (loginSettings.OfflinePreview) { return LoginCheckStatus.Success; } // 3. DB 접속 및 계정 조회 (3초 하드 제한) try { var repository = new LoginAccountRepository( DatabaseSettings.Load(GetDatabaseIniPath()), loginSettings); var dbCheckTask = repository.ExistsAsync(loginId, password); var timeoutTask = Task.Delay(3000); // 3초 하드 제한 var completedTask = await Task.WhenAny(dbCheckTask, timeoutTask); if (completedTask == dbCheckTask) { bool isExist = await dbCheckTask; return isExist ? LoginCheckStatus.Success : LoginCheckStatus.InvalidCredentials; } else { System.Diagnostics.Debug.WriteLine("[로그인 타임아웃] DB 응답 3초 초과 -> 타임아웃/DB연결안내 팝업 표출"); return LoginCheckStatus.DbTimeoutOrError; } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[로그인 오류] DB 연결 예외 발생: {ex.Message}"); return LoginCheckStatus.DbTimeoutOrError; } } private bool TryReadRequiredFields(out StartupLoginWindowResult result, out string password) { result = new StartupLoginWindowResult(); password = string.Empty; if (!TryReadRequiredComboBox(MakerComboBox, "Maker", out var maker) || !TryReadRequiredComboBox(ModelComboBox, "Model", out var model) || !TryReadTextBox(OperatorTextBox, "ID", out var loginId) || !TryReadPasswordBox(PasswordBox, "PW", out password) || !TryReadRequiredComboBox(LineNoComboBox, "Line", out var lineNo) || !TryReadTextBox(LotNoTextBox, "Lot No", out var lotNo) || !TryReadRequiredComboBox(JigNoComboBox, "Jig No", out var jigNo)) { return false; } if (!string.IsNullOrWhiteSpace(lotNo) && lotNo.Length < 2) { lotNo = lotNo.PadLeft(2, '0'); } var variant1 = ReadOptionalComboBox(Variant1ComboBox); var variant2 = ReadOptionalComboBox(Variant2ComboBox); result = new StartupLoginWindowResult { Maker = maker, Model = model, Variant1 = variant1, Variant2 = variant2, Operator = loginId, LineNo = lineNo, LotNo = lotNo, JigNo = jigNo }; return true; } private void ApplyLoginOptions() { SetComboBoxOptions(MakerComboBox, _loginOptions.Makers, allowBlank: false, historyFieldName: "Maker"); SetComboBoxOptions(ModelComboBox, _loginOptions.Models, allowBlank: false, historyFieldName: "Model"); SetComboBoxOptions(Variant1ComboBox, _loginOptions.Variant1Values, allowBlank: true, historyFieldName: "Variant1"); SetComboBoxOptions(Variant2ComboBox, _loginOptions.Variant2Values, allowBlank: true, historyFieldName: "Variant2"); SetComboBoxOptions(LineNoComboBox, _loginOptions.LineValues, allowBlank: false, historyFieldName: "LineNo"); SetComboBoxOptions(JigNoComboBox, _loginOptions.JigNoValues, allowBlank: false, historyFieldName: "JigNo"); } private void SetComboBoxOptions( ComboBox comboBox, IReadOnlyList values, bool allowBlank, string historyFieldName) { var items = new List(); if (allowBlank) { items.Add(new LoginOptionItem("(None)", string.Empty)); } foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value))) { if (items.Any(item => string.Equals(item.Value, value, StringComparison.OrdinalIgnoreCase))) { continue; } items.Add(new LoginOptionItem(value, value)); } // 옵션 항목 개수를 최대 3개로 제한 items = items.Take(3).ToList(); comboBox.ItemsSource = items; comboBox.DisplayMemberPath = nameof(LoginOptionItem.DisplayText); var rememberedValue = _loginHistoryStore.GetValues(historyFieldName).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(rememberedValue) && TrySelectComboBoxValue(comboBox, rememberedValue)) { return; } if (allowBlank) { comboBox.SelectedIndex = 0; } else if (items.Count == 1) { comboBox.SelectedIndex = 0; } } private static bool TrySelectComboBoxValue(ComboBox comboBox, string value) { for (var index = 0; index < comboBox.Items.Count; index++) { if (comboBox.Items[index] is LoginOptionItem item && string.Equals(item.Value, value, StringComparison.OrdinalIgnoreCase)) { comboBox.SelectedIndex = index; return true; } } return false; } private void AttachSelectionDropDowns() { foreach (var comboBox in new[] { MakerComboBox, ModelComboBox, Variant1ComboBox, Variant2ComboBox, LineNoComboBox, JigNoComboBox }) { comboBox.GotKeyboardFocus += (_, _) => CloseHistoryPopup(); comboBox.DropDownOpened += (_, _) => CloseHistoryPopup(); } } private void AttachLoginHistoryMenus() { AttachHistoryMenu(OperatorTextBox, OperatorHistoryFieldName, PasswordBox); AttachHistoryMenu(LotNoTextBox, "LotNo", JigNoComboBox); } private void AttachHistoryMenu(TextBox textBox, string fieldName, Control nextFocusControl) { textBox.GotKeyboardFocus += (_, _) => ShowHistoryMenu(textBox, fieldName, nextFocusControl); textBox.PreviewMouseLeftButtonDown += (_, _) => { if (textBox.IsKeyboardFocusWithin) { ShowHistoryMenu(textBox, fieldName, nextFocusControl); } }; } private void ShowHistoryMenu(TextBox textBox, string fieldName, Control nextFocusControl) { // 최근 기록 히스토리 항목 개수 최대 3개로 제한 var values = _loginHistoryStore.GetValues(fieldName).Take(3).ToList(); if (values.Count == 0) { CloseHistoryPopup(); return; } CloseHistoryPopup(); var menuWidth = Math.Max(160, textBox.ActualWidth); var stackPanel = new StackPanel(); var popup = new Popup { PlacementTarget = textBox, Placement = PlacementMode.Bottom, HorizontalOffset = 0, StaysOpen = true, AllowsTransparency = true, Focusable = false }; foreach (var value in values) { var rowGrid = new Grid { Width = menuWidth - 2, Background = textBox.Background }; rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); var selectBtn = new Button { Content = value, Padding = new Thickness(10, 6, 10, 6), BorderThickness = new Thickness(0), Background = System.Windows.Media.Brushes.Transparent, Foreground = textBox.Foreground, HorizontalContentAlignment = HorizontalAlignment.Left }; selectBtn.Click += (_, _) => { textBox.Text = value; textBox.CaretIndex = textBox.Text.Length; CloseHistoryPopup(); FocusControl(nextFocusControl); }; Grid.SetColumn(selectBtn, 0); rowGrid.Children.Add(selectBtn); var deleteBtn = new Button { Content = "✕", Width = 28, Height = 28, Padding = new Thickness(0), BorderThickness = new Thickness(0), Background = System.Windows.Media.Brushes.Transparent, Foreground = System.Windows.Media.Brushes.Red, FontWeight = FontWeights.Bold, ToolTip = "이력 삭제" }; deleteBtn.Click += (s, e) => { _loginHistoryStore.Forget(fieldName, value); _loginHistoryStore.Save(); ShowHistoryMenu(textBox, fieldName, nextFocusControl); }; Grid.SetColumn(deleteBtn, 1); rowGrid.Children.Add(deleteBtn); stackPanel.Children.Add(rowGrid); } popup.Child = new Border { Width = menuWidth, MaxHeight = 180, Background = textBox.Background, BorderBrush = textBox.BorderBrush, BorderThickness = new Thickness(1), Child = new ScrollViewer { MaxHeight = 180, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = stackPanel } }; _openHistoryPopup = popup; popup.IsOpen = true; } private void CloseHistoryPopup() { if (_openHistoryPopup is not null) { _openHistoryPopup.IsOpen = false; _openHistoryPopup = null; } } private void FocusControl(Control control) { Dispatcher.BeginInvoke(new Action(() => { control.Focus(); if (control is TextBox textBox) { textBox.CaretIndex = textBox.Text.Length; } else if (control is PasswordBox passwordBox) { passwordBox.Focus(); } }), DispatcherPriority.Input); } private void SaveLoginHistory(StartupLoginWindowResult result) { _loginHistoryStore.Remember("Maker", result.Maker); _loginHistoryStore.Remember("Model", result.Model); _loginHistoryStore.Remember("Variant1", result.Variant1); _loginHistoryStore.Remember("Variant2", result.Variant2); RememberOperatorHistory(result.Operator); _loginHistoryStore.Remember("LineNo", result.LineNo); _loginHistoryStore.Remember("LotNo", result.LotNo); _loginHistoryStore.Remember("JigNo", result.JigNo); _loginHistoryStore.Save(); } private void RememberOperatorHistory(string operatorId) { if (IsSuppressedOperatorHistoryValue(operatorId)) { _loginHistoryStore.Forget(OperatorHistoryFieldName, operatorId); return; } _loginHistoryStore.Remember(OperatorHistoryFieldName, operatorId); } private void RemoveSuppressedOperatorHistory() { try { if (_loginHistoryStore.Forget(OperatorHistoryFieldName, SuppressedOperatorHistoryValue)) { _loginHistoryStore.Save(); } } catch { // Login history cleanup is optional and should not block the login window. } } private static bool IsSuppressedOperatorHistoryValue(string value) { return string.Equals(value.Trim(), SuppressedOperatorHistoryValue, StringComparison.OrdinalIgnoreCase); } private bool TryReadRequiredComboBox(ComboBox comboBox, string fieldName, out string value) { value = ReadComboBoxValue(comboBox); if (!string.IsNullOrWhiteSpace(value)) { return true; } MessageBox.Show( this, $"{fieldName} value is required. Edit LoginOptions.json and select a value.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning); comboBox.Focus(); comboBox.IsDropDownOpen = true; return false; } private static string ReadOptionalComboBox(ComboBox comboBox) { return ReadComboBoxValue(comboBox); } private static string ReadComboBoxValue(ComboBox comboBox) { return comboBox.SelectedItem is LoginOptionItem item ? item.Value.Trim() : comboBox.Text.Trim(); } private bool TryReadTextBox(TextBox textBox, string fieldName, out string value) { value = textBox.Text.Trim(); if (!string.IsNullOrWhiteSpace(value)) { return true; } MessageBox.Show(this, $"{fieldName}을(를) 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning); textBox.Focus(); textBox.SelectAll(); return false; } private bool TryReadPasswordBox(PasswordBox passwordBox, string fieldName, out string value) { value = passwordBox.Password; if (!string.IsNullOrWhiteSpace(value)) { return true; } MessageBox.Show(this, $"{fieldName}을(를) 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning); passwordBox.Focus(); passwordBox.SelectAll(); return false; } private static string GetDatabaseIniPath() { return Path.Combine(AppContext.BaseDirectory, "Database.ini"); } private static string GetLoginOptionsPath() { // 1. 실행 파일과 동일한 디렉토리에 있는 LoginOptions.json을 최우선적으로 직접 사용 var bundledPath = Path.Combine(AppContext.BaseDirectory, "LoginOptions.json"); if (File.Exists(bundledPath)) { return bundledPath; } // 2. 실행 디렉토리에 존재하지 않는 경우, 로컬 AppData 폴백 처리 (하위 호환성) var directory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Housing"); var filePath = Path.Combine(directory, "LoginOptions.json"); if (!File.Exists(filePath)) { Directory.CreateDirectory(directory); LoginSelectionOptions.CreateDefaultFile(filePath); } return filePath; } private static string GetLoginHistoryPath() { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Housing", "LoginHistory.json"); } private sealed class LoginOptionItem { public string DisplayText { get; } public string Value { get; } public LoginOptionItem(string displayText, string value) { DisplayText = displayText; Value = value; } } } }