using System.Windows; using System.Windows.Input; using SheetMe.Core.Catalog; using SheetMe.Data.Stores; using SheetMe.Designer.DataBusiness; using SheetMe.Designer.Services; namespace SheetMe.Designer.Views; /// /// 미리보기에 쓸 실제 환자·내원을 고른다. /// /// 왜 모달인가. 30초 안에 끝나는 자족적인 일이고(찾기 → 고르기), 권한 문이 걸린 동작이다. /// 사이드 패널로 두면 미리보기 종이와 나란히 놓여 항상 열려 있게 되는데, /// 이 창은 실제 환자 정보를 꺼내는 창이라 열려 있는 것 자체가 비용이다. /// /// 왜 두 목록을 한 화면에 두는가. 고르는 대상은 환자가 아니라 내원이다. /// 환자 문맥 5행이 전부 내원번호와 적용일시로 읽히므로() /// 환자만 골라서는 아무것도 읽을 수 없다. 환자를 고른 뒤 화면이 바뀌면 "이 사람 맞나"를 /// 다시 확인하러 왕복해야 한다. /// /// 문맥을 여기서 읽는다. 창을 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고, /// 그때는 이미 고른 것이 사라진 뒤다. 여기서 읽어 실패하면 창을 닫지 않는다. /// public partial class PatientPickerDialogView : Window { #region Member Fields private readonly PatientVisitStore? store; private readonly PatientContextStore? contexts; #endregion #region Constructors public PatientPickerDialogView() { InitializeComponent(); var connection = ConfigService.Current.ConnectionString; if (connection.Length > 0) { store = new PatientVisitStore(connection); contexts = new PatientContextStore(connection); } Loaded += (_, _) => TermBox.Focus(); } #endregion #region Methods /// 고른 내원의 환자 문맥 — 취소했으면 null public PatientContext? Picked { get; private set; } /// 고른 것을 사람에게 보여 줄 한 줄. 미리보기 창 머리에 그대로 쓴다 public string PickedLabel { get; private set; } = string.Empty; /// /// 권한을 확인하고 창을 띄운다 — 못 쓰는 사용자에게는 사유를 말한다. /// 문을 여기 한 군데만 두면 호출부가 늘어도 빠뜨릴 곳이 없다. /// public static PatientContext? Pick(Window? owner, out string label) { label = string.Empty; if (!FormDesignDataBusiness.CanPreviewPatient()) { DialogService.Notify(DialogKind.Warning, "환자 미리보기", "실제 환자로 미리보기를 할 수 없습니다.", FormDesignDataBusiness.PreviewPatientDenyReason(), owner); return null; } var dialog = new PatientPickerDialogView { Owner = owner }; if (dialog.ShowDialog() != true || dialog.Picked is not { } picked) { return null; } // 감사 기록은 고른 직후 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다. // 환자 이름은 남기지 않는다(내원번호로 추적 가능하고, 로그가 개인정보를 들고 있으면 로그가 위험물이 된다). AppLog.Audit(PatientPreviewPolicy.AuditLine( UserSession.Current.UidCod, picked.ComNum.ToString("F0"))); label = dialog.PickedLabel; return picked; } private void OnTermKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Enter) { e.Handled = true; OnSearch(sender, e); } } /// 검색 유형의 사람이 읽는 이름 — 0건 안내가 "무엇으로 찾았는지" 말할 때 쓴다 private static string KindLabel(PatientSearchKind kind) => kind switch { PatientSearchKind.Name => "성명 접두일치", PatientSearchKind.ResidentNumber => "주민번호 접두일치", PatientSearchKind.MobilePhone => "휴대전화 부분일치", _ => "차트번호 완전일치", }; private void OnSearch(object sender, RoutedEventArgs e) { if (store is null) { DialogService.Notify(DialogKind.Warning, "환자 검색", "DB 에 접속되어 있지 않습니다.", string.Empty, this); return; } var kind = KindCombo.SelectedIndex switch { 1 => PatientSearchKind.Name, 2 => PatientSearchKind.ResidentNumber, 3 => PatientSearchKind.MobilePhone, _ => PatientSearchKind.ChartNumber, }; VisitList.ItemsSource = null; VisitCountText.Text = "내원"; try { // 동기로 둔다. 행 상한이 200이라 오래 걸리지 않고, 비동기로 만들면 // "검색 중에 또 검색"을 다뤄야 해서 이 창 크기에 맞지 않는 복잡도가 생긴다. Mouse.OverrideCursor = Cursors.Wait; SearchButton.IsEnabled = false; var found = store.Search(kind, TermBox.Text); var padded = string.Empty; // 차트번호는 완전일치다. 병원마다 자릿수가 정해져 있어 실제 값은 0으로 채워져 있는데 // (P_PatInf 가 고정 폭이다) 사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다. // 저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데 이 호출부가 그걸 안 지켰다. // // 자릿수를 설정으로 정하지 않는다. 병원마다 다르고 틀리면 멀쩡한 검색을 망친다. // 대신 0건일 때만 한 번 더 찾는다 — 8·10자리는 레거시가 쓰던 두 폭이다. // 찾았으면 무엇으로 찾았는지 칸에 되써서 사용자가 그 번호를 기억하게 한다. if (found.Count == 0 && kind == PatientSearchKind.ChartNumber && TermBox.Text.Trim() is { Length: > 0 } typed && typed.All(char.IsDigit)) { foreach (var width in new[] { 8, 10 }) { if (typed.Length >= width) { continue; } var retry = store.Search(kind, typed.PadLeft(width, '0')); if (retry.Count > 0) { found = retry; padded = typed.PadLeft(width, '0'); TermBox.Text = padded; break; } } } PatientList.ItemsSource = found; PatientCountText.Text = found.Count switch { // 0건일 때 무엇으로 찾았는지 말한다 — 유형이 틀려서 0건인 경우가 가장 흔한데 // 전에는 그 힌트가 화면에 하나도 없어 접속이나 권한을 의심하게 됐다 0 => $"환자 — 없습니다({KindLabel(kind)}로 찾았습니다)", _ when padded.Length > 0 => $"환자 {found.Count}명 — 자릿수를 채워 '{padded}' 로 찾았습니다", PatientVisitStore.DefaultLimit => $"환자 {found.Count}명 — 상한에 걸렸습니다. 검색어를 좁히세요", _ => $"환자 {found.Count}명", }; if (found.Count > 0) { PatientList.SelectedIndex = 0; PatientList.Focus(); } } catch (Exception ex) { DialogService.Notify(DialogKind.Error, "환자 검색", "검색 중 오류가 발생했습니다.", ex.Message, this); } finally { SearchButton.IsEnabled = true; Mouse.OverrideCursor = null; } } private void OnPatientChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) { UpdateChosen(); if (store is null || PatientList.SelectedItem is not PatientSummary patient) { return; } try { Mouse.OverrideCursor = Cursors.Wait; var visits = store.ListVisits(patient.ChtNum); VisitList.ItemsSource = visits; VisitCountText.Text = visits.Count == 0 ? "내원 — 없습니다" : $"내원 {visits.Count}건"; if (visits.Count > 0) { VisitList.SelectedIndex = 0; } } catch (Exception ex) { DialogService.Notify(DialogKind.Error, "내원 조회", "내원을 읽지 못했습니다.", ex.Message, this); } finally { Mouse.OverrideCursor = null; } } private void OnVisitChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) => UpdateChosen(); /// 고른 것을 글로 확인시킨다 — 목록 두 개짜리 화면에서 무엇이 선택됐는지는 쉽게 놓친다 private void UpdateChosen() { var patient = PatientList.SelectedItem as PatientSummary; var visit = VisitList.SelectedItem as VisitSummary; SelectButton.IsEnabled = patient is not null && visit is not null; ChosenText.Text = (patient, visit) switch { (null, _) => "환자를 찾아 내원을 고르세요.", ({ } p, null) => $"{p.Name}({p.ChtNum}) — 내원을 고르세요.", ({ } p, { } v) => $"{p.Name}({p.ChtNum}) · {v.Kind} {v.AcceptedAtText} · 내원 {v.ComNum:F0}", }; } private void OnSelect(object sender, RoutedEventArgs e) { if (PatientList.SelectedItem is not PatientSummary patient || VisitList.SelectedItem is not VisitSummary visit) { return; } if (contexts is null) { DialogService.Notify(DialogKind.Warning, "환자 미리보기", "DB 에 접속되어 있지 않습니다.", string.Empty, this); return; } var edge = EdgeCombo.SelectedIndex == 1 ? VisitEdge.First : VisitEdge.Last; try { Mouse.OverrideCursor = Cursors.Wait; var context = contexts.Load(visit.ComNum, edge); if (context is null) { // 목록에는 있는데 다시 읽을 때 없다 — 그 사이에 지워졌거나 권한이 다르다. // 창을 닫지 않는다. 닫으면 미리보기가 조용히 빈 종이를 낸다. DialogService.Notify(DialogKind.Warning, "환자 미리보기", "이 내원의 정보를 읽지 못했습니다.", $"내원 {visit.ComNum:F0} 을 다시 읽을 수 없습니다. 목록을 새로 검색해 보세요.", this); return; } Picked = context; PickedLabel = $"{patient.Name}({patient.ChtNum}) · {visit.Kind}" + $" {visit.AcceptedAtText} · 기준 {Readable(context.AdpDtm)}"; DialogResult = true; } catch (Exception ex) { DialogService.Notify(DialogKind.Error, "환자 미리보기", "환자 정보를 읽는 중 오류가 발생했습니다.", ex.Message, this); } finally { Mouse.OverrideCursor = null; } } private static string Readable(string stamp) => stamp.Length == 12 ? $"{stamp[..4]}-{stamp[4..6]}-{stamp[6..8]} {stamp[8..10]}:{stamp[10..12]}" : stamp; #endregion }