diff --git a/src/SheetMe.Core/Catalog/LegacyTagUsageCounts.cs b/src/SheetMe.Core/Catalog/LegacyTagUsageCounts.cs
new file mode 100644
index 0000000..643ae33
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/LegacyTagUsageCounts.cs
@@ -0,0 +1,240 @@
+namespace SheetMe.Core.Catalog;
+
+///
+/// 태그별 운영 사용 건수 — 피커가 '이게 표준인지 희귀한지'를 보여 주기 위한 실측값.
+///
+/// 출처: 진단 --db-tags. 운영 디자인 1,271건의 DataInterfaceTag·DataActionTag 8,032건을 집계했다.
+/// 카탈로그에는 있으나 운영에 한 번도 안 쓰인 태그는 여기 없다(건수 0으로 본다) —
+/// 그것도 정보다. 설계자가 처음 보는 태그를 고를 때 '아무도 안 쓰는 것'임을 알 수 있어야 한다.
+///
+/// 수치는 스냅샷이라 시간이 지나면 어긋난다. 순서와 규모의 근거로만 쓰고, 정확한 통계로 쓰지 않는다.
+///
+public static class LegacyTagUsageCounts
+{
+ #region Member Fields
+ private static readonly Dictionary counts = new(StringComparer.Ordinal)
+ {
+ ["RadCheckControl"] = 852,
+ ["DtrLicList"] = 155,
+ ["ChoiceData"] = 135,
+ ["ComOkdList"] = 77,
+ ["ImgeList"] = 59,
+ ["OkdInfList"] = 37,
+ ["VisibleYon"] = 35,
+ ["CodeFInd"] = 32,
+ ["OprInfList"] = 25,
+ ["DataCopy_Chk"] = 24,
+ ["YondoList"] = 24,
+ ["UmlsMst"] = 21,
+ ["OkdInfData"] = 20,
+ ["MinusToPlus"] = 17,
+ ["ReturnUidNam"] = 15,
+ ["AllCheck"] = 15,
+ ["EmrSelectData"] = 13,
+ ["TPRList"] = 10,
+ ["PanelVisible"] = 9,
+ ["ComPidList"] = 9,
+ ["TPR"] = 7,
+ ["ReturnLabData"] = 6,
+ ["EmgTotalEmr"] = 6,
+ ["DataCopy_MtM"] = 6,
+ ["LabOdrList"] = 5,
+ ["ReturnEmrData"] = 5,
+ ["ButtonData"] = 5,
+ ["UnAllCheck"] = 5,
+ ["PanelUnVisible"] = 4,
+ ["DataTableRefresh"] = 4,
+ ["CodList"] = 3,
+ ["ComAcpLev_O_Ver2"] = 3,
+ ["ZipFinder"] = 3,
+ ["BldOutList"] = 2,
+ ["PrgRcdList"] = 2,
+ ["SMSWebLink"] = 2,
+ ["NdsMst"] = 2,
+ ["ComAcpLev_O_Ver3"] = 2,
+ ["ComAcpLev_I_Ver3"] = 2,
+ ["ComAcpLev_I_Ver2"] = 2,
+ ["ComAcpLev_I"] = 2,
+ ["ComAcpLev_O"] = 2,
+ ["ComAcpLev"] = 2,
+ ["SetVisibleStatus"] = 2,
+ ["ReturnAllEmrData"] = 2,
+ ["HiraSearch"] = 1,
+ ["EmgOkdInfData"] = 1,
+ ["PdeOdrList"] = 1,
+ ["ReturnLevOrderList"] = 1,
+ ["EmgLevTim"] = 1,
+ ["EndoOdrList"] = 1,
+ ["PathologyInfo"] = 1,
+ ["EmgKTAS"] = 1,
+ ["MakeDataList"] = 1,
+ ["EmgDetList"] = 1,
+ ["DateVisibleYon"] = 1,
+ ["PAT_이름"] = 669,
+ ["PAT_차트번호"] = 463,
+ ["ETC_요양기관명칭_병원명"] = 303,
+ ["ETC_현재일자"] = 282,
+ ["PAT_성별"] = 277,
+ ["OCM_진단서의사명"] = 228,
+ ["PAT_생년월일"] = 216,
+ ["PAT_나이"] = 204,
+ ["OCM_진료과"] = 193,
+ ["OCM_진단서의사싸인"] = 174,
+ ["PAT_주민번호_Dash"] = 169,
+ ["ETC_로그인_사용자명"] = 163,
+ ["PAT_휴대전화번호"] = 156,
+ ["OCM_진단서의사면허번호"] = 154,
+ ["ETC_요양기관기호"] = 149,
+ ["OCM_담당의사"] = 144,
+ ["PAT_도로명주소"] = 126,
+ ["ETC_현재일시"] = 118,
+ ["OCM_병실"] = 117,
+ ["ETC_요양기관명칭_병원주소"] = 109,
+ ["ETC_요양기관명칭_병원전화번호"] = 95,
+ ["OCM_진단서전문과목"] = 84,
+ ["ETC_요양기관명칭_병원직인"] = 82,
+ ["OCM_진단서의사전문의번호"] = 78,
+ ["PAT_자택전화번호"] = 76,
+ ["OCM_주상병명"] = 74,
+ ["PAT_연락처"] = 65,
+ ["PAT_지번주소"] = 61,
+ ["OCM_외래내원일자"] = 55,
+ ["PAT_나이_세"] = 46,
+ ["OCM_입원내원일시"] = 44,
+ ["OCM_입원과"] = 43,
+ ["PAT_주민번호"] = 41,
+ ["OCM_병동"] = 37,
+ ["OCM_주상병코드"] = 36,
+ ["PAT_주민번호_SexTyp_Blind"] = 36,
+ ["OCM_담당의사면허번호"] = 35,
+ ["OCM_진단서의사싸인_Refer"] = 34,
+ ["OCM_상병코드_상병명"] = 31,
+ ["OCM_입원일시"] = 30,
+ ["ETC_요양기관명칭_병원팩스번호"] = 29,
+ ["PAT_성별_나이"] = 28,
+ ["OCM_상병명_세로_Refer"] = 26,
+ ["OCM_몸무게"] = 24,
+ ["OCM_보험유형"] = 24,
+ ["OCM_진료과대외명칭"] = 24,
+ ["PAT_주민번호_Blind"] = 23,
+ ["ETC_요양기관명칭_사업자등록번호"] = 23,
+ ["OCM_상병코드_세로_Refer"] = 23,
+ ["ETC_요양기관명칭_병원로고"] = 22,
+ ["OCM_담당의사싸인"] = 21,
+ ["PAT_한글성별"] = 20,
+ ["OCM_키"] = 19,
+ ["OCM_주상병일자"] = 17,
+ ["OCM_입원내원일자"] = 17,
+ ["OCM_혈압"] = 16,
+ ["ETC_요양기관명칭_병원명_영문"] = 16,
+ ["OCM_상병명_가로"] = 16,
+ ["ETC_요양기관명칭_병원대표자"] = 16,
+ ["OCM_주상병코드_Refer"] = 16,
+ ["OCM_체온"] = 15,
+ ["OCM_주상병명_Refer"] = 15,
+ ["OCM_호흡"] = 14,
+ ["OCM_맥박"] = 14,
+ ["OCM_퇴원일시"] = 14,
+ ["ETC_현재일시_MaskBox"] = 14,
+ ["ETC_건강보험증번호"] = 14,
+ ["OCM_진료기간"] = 14,
+ ["OCM_키_LAST"] = 13,
+ ["OCM_상병명_세로"] = 13,
+ ["OCM_외래내원일시"] = 12,
+ ["ETC_요양기관명칭_병원주소_영문"] = 12,
+ ["OCM_진료과_Refer"] = 12,
+ ["OCM_몸무게_LAST"] = 11,
+ ["PAT_우편번호"] = 10,
+ ["OCM_로그인_사용자싸인"] = 9,
+ ["ETC_요양기관명칭_병원장귀하"] = 9,
+ ["OCM_혈액형"] = 8,
+ ["OCM_진단서의사면허번호_Refer"] = 8,
+ ["OCM_병동_병실"] = 7,
+ ["OCM_전과일자"] = 7,
+ ["OCM_진료과_전화번호"] = 7,
+ ["OCM_초진일_발병일"] = 7,
+ ["OCM_상병코드_세로"] = 6,
+ ["OCM_진단서의사명_Refer"] = 6,
+ ["OCM_입통원구분_통원"] = 5,
+ ["OCM_입통원구분_입원"] = 5,
+ ["ETC_요양기관명칭_병원장"] = 5,
+ ["ETC_요양기관명칭_영문_병원전화_팩스번호"] = 5,
+ ["OCM_퇴원약"] = 4,
+ ["OCM_적용진료의사"] = 4,
+ ["OCM_상병코드_상병명_주상병"] = 4,
+ ["OCM_맥박_LAST"] = 4,
+ ["PAT_생년월일_영문"] = 4,
+ ["OCM_수술진단명"] = 4,
+ ["PAT_보호자연락처"] = 4,
+ ["OCM_체온_LAST"] = 3,
+ ["PAT_건보_급여_세대주명"] = 3,
+ ["OCM_혈압_LAST"] = 3,
+ ["OCM_SPO2"] = 3,
+ ["ETC_수술간호사목록"] = 3,
+ ["OCM_진단서의사_영문"] = 3,
+ ["OCM_상병코드_가로"] = 3,
+ ["PAT_여권번호"] = 3,
+ ["OCM_진단서의사전문의번호_Refer"] = 3,
+ ["OCM_퇴원의사"] = 2,
+ ["OCM_입원의사"] = 2,
+ ["OCM_전과"] = 2,
+ ["OCM_상병코드_상병명_부상병"] = 2,
+ ["OCM_호흡_LAST"] = 2,
+ ["ETC_현재년도"] = 2,
+ ["OCM_진단서전문그룹과목"] = 2,
+ ["OCM_수술일자"] = 2,
+ ["OCM_SPO2_LAST"] = 2,
+ ["OCM_적용진료과"] = 2,
+ ["OCM_입통원구분_입원_Refer"] = 2,
+ ["OCM_입통원구분_통원_Refer"] = 2,
+ ["ETC_요양기관명칭_병원전화_팩스번호"] = 2,
+ ["OCM_상병명_가로_Refer"] = 2,
+ ["OCM_진료기간_Refer"] = 2,
+ ["OCM_상병코드_상병명_Refer"] = 2,
+ ["OCM_외래실진료일"] = 2,
+ ["ETC_건강보험증번호_Refer"] = 2,
+ ["OCM_진단서전문과목_Refer"] = 2,
+ ["ETC_로그인_근무부서"] = 2,
+ ["OCM_진단구분_상병코드_상병명"] = 1,
+ ["OCM_퇴원과"] = 1,
+ ["OCM_심사_부상병코드"] = 1,
+ ["OCM_심사_부상병명"] = 1,
+ ["OCM_심사_주상병명"] = 1,
+ ["OCM_심사_주상병코드"] = 1,
+ ["OCM_임신주기"] = 1,
+ ["OCM_전과내역"] = 1,
+ ["OCM_계산유형"] = 1,
+ ["PAT_휴대전화번호_뒷4자리"] = 1,
+ ["OCM_부상병명"] = 1,
+ ["OCM_응급증상여부_NO"] = 1,
+ ["OCM_응급증상여부_YES"] = 1,
+ ["OCM_BST"] = 1,
+ ["OCM_담당의사전문의번호"] = 1,
+ ["OCM_재원일수"] = 1,
+ ["OCM_수술명칭"] = 1,
+ ["ETC_로그인사용자_병동_수간호사"] = 1,
+ ["OCM_진단서전문그룹과목_Refer"] = 1,
+ ["OCM_퇴원일시_Refer"] = 1,
+ ["OCM_입원일시_Refer"] = 1,
+ ["OCM_User진료의사명"] = 1,
+ ["OCM_상병코드_상병명_주상병_Refer"] = 1,
+ ["OCM_담당의사_영문_Refer"] = 1,
+ ["OCM_진단서의사_영문_Refer"] = 1,
+ ["OCM_User진료과"] = 1,
+ ["PAT_주민번호_SexTyp_BlindStar"] = 1,
+ ["PAT_국적"] = 1,
+ ["PAT_건보세대주명"] = 1,
+ ["OCM_병실_Refer"] = 1,
+ ["OCM_비급여처방"] = 1,
+ ["ETC_로그인_근무부서_사용자명"] = 1,
+ };
+ #endregion
+
+ #region Methods
+ /// 운영 사용 건수 — 기록이 없으면 0
+ public static int Of(string tag) => counts.TryGetValue(tag, out var n) ? n : 0;
+
+ /// 운영에서 한 번이라도 쓰인 적이 있는지
+ public static bool IsUsed(string tag) => counts.ContainsKey(tag);
+ #endregion
+}
diff --git a/src/SheetMe.Core/Catalog/TagSearch.cs b/src/SheetMe.Core/Catalog/TagSearch.cs
new file mode 100644
index 0000000..5caeaf7
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/TagSearch.cs
@@ -0,0 +1,126 @@
+using System.Text;
+
+namespace SheetMe.Core.Catalog;
+
+///
+/// 태그 검색·분류 — 피커가 "찾기"와 "둘러보기"를 둘 다 되게 하는 규칙.
+///
+/// 태그 이름은 PAT_주민번호_Dash 처럼 접두어와 밑줄로 이미 구조가 있다.
+/// 그 구조를 화면에 드러내지 않고 384줄을 한 덩어리로 늘어놓으면,
+/// 무엇이 있는지 알 수 없어 결국 아는 이름만 반복해 쓰게 된다.
+///
+public static class TagSearch
+{
+ #region Member Fields
+ // 초성 표 — 한글 음절 U+AC00~U+D7A3 은 (초성×21×28) 규칙으로 합성된다
+ private const string Choseong = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
+ private const char HangulBase = '가';
+ private const char HangulLast = '힣';
+ #endregion
+
+ #region Methods
+ /// 그 어느 접두어 규약에도 안 맞는 태그가 모이는 곳
+ public const string OtherCategory = "그 밖";
+
+ ///
+ /// 태그가 속한 분류 — 접두어로 가른다.
+ /// 레거시 bzDataInterface 의 메서드 이름 규약이 그대로 분류가 된다.
+ ///
+ /// 아는 접두어만 분류로 올린다. 나머지는 전부 '그 밖'으로 묶는다 —
+ /// 레거시에는 규약에서 벗어난 이름이 섞여 있어서(오타 ECT_로그인사용자싸인,
+ /// 접두어가 아예 없는 GetNurseShtCod), 접두어를 그대로 분류로 쓰면
+ /// 1~3개짜리 분류가 목록을 채워 '무엇이 있는지' 를 오히려 가린다.
+ /// 액션 태그는 접두어 규약 자체가 없어 통째로 여기로 온다.
+ ///
+ public static string CategoryOf(string tag)
+ {
+ var underscore = tag.IndexOf('_');
+ var prefix = underscore > 0 ? tag[..underscore] : tag;
+ return prefix switch
+ {
+ "PAT" => "환자",
+ "OCM" => "진료·오더",
+ "ETC" => "기관·문서",
+ "USR" => "사용자",
+ _ => OtherCategory,
+ };
+ }
+
+ ///
+ /// 분류 안의 소분류 — 두 번째 토큰. PAT_주민번호_Dash → "주민번호".
+ /// 같은 뿌리에서 갈라진 변형(_Dash, _Blind, _Refer)이 흩어지지 않고 붙어 보이게 한다.
+ ///
+ public static string GroupOf(string tag)
+ {
+ var parts = tag.Split('_');
+ return parts.Length >= 2 ? parts[1] : tag;
+ }
+
+ ///
+ /// 한글 문자열의 초성만 뽑는다 — 한글이 아니면 그대로 둔다.
+ /// "PAT_차트번호" → "PAT_ㅊㅌㅂㅎ"
+ ///
+ public static string ToChoseong(string text)
+ {
+ var builder = new StringBuilder(text.Length);
+ foreach (var ch in text)
+ {
+ if (ch is >= HangulBase and <= HangulLast)
+ {
+ builder.Append(Choseong[(ch - HangulBase) / (21 * 28)]);
+ }
+ else
+ {
+ builder.Append(ch);
+ }
+ }
+ return builder.ToString();
+ }
+
+ /// 입력이 초성만으로 이루어졌는지 — 그럴 때만 초성 대조를 시도한다
+ public static bool IsChoseongQuery(string query)
+ {
+ if (query.Length == 0)
+ {
+ return false;
+ }
+ foreach (var ch in query)
+ {
+ if (Choseong.IndexOf(ch) < 0)
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ ///
+ /// 한 토큰이 태그에 걸리는지 — 부분 일치, 그리고 초성 입력이면 초성 대조.
+ ///
+ /// 초성을 받는 이유는 한글 태그 이름이 길어서다. "ㅊㅌㅂㅎ" 네 글자로
+ /// PAT_차트번호 에 닿을 수 있으면 손이 훨씬 덜 간다.
+ ///
+ public static bool Matches(string tag, string token)
+ {
+ if (tag.Contains(token, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ return IsChoseongQuery(token)
+ && ToChoseong(tag).Contains(token, StringComparison.Ordinal);
+ }
+
+ /// 공백으로 나눈 여러 토큰을 전부 만족해야 통과(AND)
+ public static bool MatchesAll(string tag, IReadOnlyList tokens)
+ {
+ foreach (var token in tokens)
+ {
+ if (!Matches(tag, token))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index 821c804..c8efdcd 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -719,6 +719,9 @@ public static class DbSmoke
var unknown = ranked.Where(p => !catalog.Contains(p.Key)).ToList();
lines.Add($" 카탈로그에 없는 값: {unknown.Count}종 / {unknown.Sum(p => p.Value)}건");
lines.AddRange(unknown.Take(10).Select(p => $" {p.Key} ×{p.Value}"));
+ // 카탈로그 생성용 전량 덤프 — 피커가 건수를 보여 주려면 상위 30종으로는 부족하다
+ lines.Add($" ══ 전량({key}) ══");
+ lines.AddRange(ranked.Where(p => catalog.Contains(p.Key)).Select(p => $" §{p.Key}\t{p.Value}"));
}
if (perSheet.Count > 0)
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index d38e5ab..22c0b89 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -115,7 +115,11 @@ public static class DialogShots
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
("03-query-editor", () => new Views.QueryEditorWindow("데이터소스",
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <>")),
- ("04-tag-picker", () => new Views.TagPickerDialogView("동작 태그", SampleTags(), SampleTags()[1])),
+ // 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다
+ ("04-tag-picker", () => new Views.TagPickerDialogView(
+ "데이터 태그 선택 — 자동 채움 원천(bzDataInterface)",
+ Core.Catalog.LegacyTagCatalog.DataInterfaceTags, null,
+ Core.Catalog.LegacyTagUsageCatalog.SuggestFor("DataInterfaceTag", "TextBox"))),
("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")),
// 데이터소스 배선 — 운영 다수파인 Rows 형 값을 넣어 되읽기·형태 유지가 보이게 찍는다
("05b-datatable-field", () => new Views.DataTableFieldDialogView(
diff --git a/src/SheetMe.Designer/Views/TagPickerDialogView.xaml b/src/SheetMe.Designer/Views/TagPickerDialogView.xaml
index 28fc220..8e5aea6 100644
--- a/src/SheetMe.Designer/Views/TagPickerDialogView.xaml
+++ b/src/SheetMe.Designer/Views/TagPickerDialogView.xaml
@@ -1,24 +1,88 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextWrapping="Wrap" Margin="0,0,0,10"/>
-
+
+
+
+
+
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs b/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs
index ddd74e1..f4c44de 100644
--- a/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs
+++ b/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs
@@ -1,24 +1,73 @@
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Input;
+using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views;
+/// 분류 목록 한 줄
+public sealed class TagCategoryItem
+{
+ /// 표시 이름
+ public string Name { get; init; } = string.Empty;
+
+ /// 이 분류에 속한 태그 수(검색 결과 기준)
+ public int Count { get; init; }
+
+ /// '전체'·'자주 쓰는 것' 같은 가상 분류인지
+ public string? Filter { get; init; }
+}
+
+/// 태그 목록 한 줄 — 이름과 사용 실적
+public sealed class TagListItem
+{
+ /// 태그 이름(저장되는 값)
+ public string Name { get; init; } = string.Empty;
+
+ /// 운영 사용 건수
+ public int Usage { get; init; }
+
+ /// 배지 문구
+ public string UsageText => Usage >= 1000 ? $"{Usage / 1000.0:F1}k" : Usage.ToString();
+
+ /// 건수 배지를 띄울지
+ public bool HasUsage => Usage > 0;
+
+ /// 운영에서 한 번도 안 쓰인 태그인지 — 고르기 전에 알 수 있어야 한다
+ public bool IsUnused => Usage == 0;
+}
+
///
-/// 태그 선택 대화상자 — 검색(부분 일치, 공백 구분 다중 토큰 AND) + 목록 선택.
-/// '값 지우기'는 빈 값으로 확정(속성 제거).
+/// 태그 선택 — 찾기(검색·초성)와 둘러보기(분류)를 함께.
+///
+/// 왜 분류가 필요한가. 데이터 태그는 384종이다. 한 덩어리로 늘어놓으면
+/// 무엇이 있는지 알 길이 없어 결국 아는 이름만 반복해 쓰게 된다.
+/// 태그 이름에는 이미 구조가 있다(PAT_ 환자 · OCM_ 진료 · ETC_ 기관) —
+/// 그 구조를 화면에 드러낸다.
+///
+/// 왜 건수를 보여 주는가. 이름만으로는 PAT_주민번호 와 PAT_주민번호_Dash 중
+/// 무엇이 표준인지 알 수 없다. 운영 실측(--db-tags)을 배지로 띄우면 판단이 선다.
+/// 한 번도 안 쓰인 태그는 '미사용'으로 표시한다 — 그것도 정보다.
+///
+/// 이 창은 둘러보기 전용이다. 자주 쓰는 태그는 인스펙터 행에 칩으로 깔려 있어
+/// 이 창을 열 필요가 없다(사내 UI 가이드: 빈도 높은 동작에 모달을 쓰지 않는다).
///
public partial class TagPickerDialogView : Window
{
#region Member Fields
private readonly IReadOnlyList allTags;
- #endregion
- #region Member Fields - Ordering
///
- /// 이 컨트롤 타입에서 자주 쓰이는 태그 — 목록 맨 위로 올린다.
+ /// 이 컨트롤 타입에서 자주 쓰이는 태그 — 별도 분류로 맨 위에 둔다.
/// 가나다순으로만 늘어놓으면 운영 절반을 차지하는 태그와 한 번도 안 쓰인 태그가 같은 무게로 보인다.
///
private readonly IReadOnlyList preferred;
+
+ private const string FilterAll = "*";
+ private const string FilterPreferred = "!";
+ private const string FilterUsed = "+";
+ private string activeFilter = FilterAll;
+ private bool suppressCategoryReload;
#endregion
#region Properties
@@ -34,46 +83,131 @@ public partial class TagPickerDialogView : Window
preferred = preferredTags ?? Array.Empty();
InitializeComponent();
TitleText.Text = title;
- ApplyFilter(string.Empty);
- if (!string.IsNullOrEmpty(currentValue))
+
+ // 현재 값이 있으면 그 값이 든 분류로 열어 준다 — 어디에 속한 값인지가 곧 맥락이다
+ if (!string.IsNullOrEmpty(currentValue) && allTags.Contains(currentValue))
{
- TagList.SelectedItem = tags.FirstOrDefault(t => t == currentValue);
- TagList.ScrollIntoView(TagList.SelectedItem);
+ activeFilter = TagSearch.CategoryOf(currentValue);
}
+ else if (preferred.Count > 0)
+ {
+ activeFilter = FilterPreferred;
+ }
+
+ Rebuild(selectValue: currentValue);
Loaded += (_, _) => SearchBox.Focus();
}
#endregion
#region Methods
- private void OnSearchChanged(object sender, TextChangedEventArgs e)
- => ApplyFilter(SearchBox.Text);
+ private static string[] Tokens(string keyword)
+ => keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- private void ApplyFilter(string keyword)
+ /// 검색어를 통과한 태그 전체(분류 적용 전)
+ private List Searched()
{
- var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- IEnumerable matched = tokens.Length == 0
- ? allTags
- : allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)));
+ var tokens = Tokens(SearchBox.Text);
+ return tokens.Length == 0
+ ? allTags.ToList()
+ : allTags.Where(t => TagSearch.MatchesAll(t, tokens)).ToList();
+ }
- // 자주 쓰는 것 먼저, 그 안에서는 빈도 순서 그대로. 나머지는 원래(가나다) 순서를 지킨다.
- // OrderBy 는 안정 정렬이라 같은 순위끼리는 입력 순서가 보존된다.
- var filtered = matched
+ private bool PassesFilter(string tag) => activeFilter switch
+ {
+ FilterAll => true,
+ FilterPreferred => preferred.Contains(tag, StringComparer.Ordinal),
+ FilterUsed => LegacyTagUsageCounts.IsUsed(tag),
+ var category => string.Equals(TagSearch.CategoryOf(tag), category, StringComparison.Ordinal),
+ };
+
+ /// 분류 목록과 태그 목록을 함께 다시 만든다
+ private void Rebuild(string? selectValue = null)
+ {
+ var searched = Searched();
+
+ // ── 분류 ──
+ var categories = new List();
+ if (preferred.Count > 0)
+ {
+ categories.Add(new TagCategoryItem
+ {
+ Name = "자주 쓰는 것",
+ Filter = FilterPreferred,
+ Count = searched.Count(t => preferred.Contains(t, StringComparer.Ordinal)),
+ });
+ }
+ categories.Add(new TagCategoryItem
+ {
+ Name = "운영에서 쓰임",
+ Filter = FilterUsed,
+ Count = searched.Count(LegacyTagUsageCounts.IsUsed),
+ });
+ categories.Add(new TagCategoryItem { Name = "전체", Filter = FilterAll, Count = searched.Count });
+
+ var groups = searched
+ .GroupBy(TagSearch.CategoryOf)
+ .OrderByDescending(g => g.Sum(t => LegacyTagUsageCounts.Of(t)))
+ .ThenBy(g => g.Key, StringComparer.Ordinal)
+ .ToList();
+ // 분류가 하나뿐이면 '전체' 와 같은 뜻이라 줄만 늘린다(액션 태그는 접두어 규약이 없어 늘 이 경우다)
+ if (groups.Count > 1)
+ {
+ foreach (var group in groups)
+ {
+ categories.Add(new TagCategoryItem { Name = group.Key, Filter = group.Key, Count = group.Count() });
+ }
+ }
+
+ suppressCategoryReload = true;
+ CategoryList.ItemsSource = categories;
+ var active = categories.FirstOrDefault(c => c.Filter == activeFilter);
+ // 검색으로 현재 분류가 사라졌으면 결과가 있는 곳으로 옮겨 간다 — 빈 화면을 보여 주지 않는다
+ if (active is null || active.Count == 0)
+ {
+ active = categories.FirstOrDefault(c => c.Count > 0) ?? categories[^1];
+ activeFilter = active.Filter ?? FilterAll;
+ }
+ CategoryList.SelectedItem = active;
+ suppressCategoryReload = false;
+
+ RebuildTagList(searched, selectValue);
+ }
+
+ private void RebuildTagList(List searched, string? selectValue = null)
+ {
+ var items = searched
+ .Where(PassesFilter)
+ // 자주 쓰는 순서 → 운영 건수 → 이름. 가나다순만으로는 표준과 희귀가 구분되지 않는다.
.OrderBy(t =>
{
var index = IndexOfPreferred(t);
return index < 0 ? int.MaxValue : index;
})
+ .ThenByDescending(LegacyTagUsageCounts.Of)
+ .ThenBy(t => t, StringComparer.Ordinal)
+ .Select(t => new TagListItem { Name = t, Usage = LegacyTagUsageCounts.Of(t) })
.ToList();
- TagList.ItemsSource = filtered;
- var hit = filtered.Count(t => IndexOfPreferred(t) >= 0);
- CountText.Text = hit > 0
- ? $"{filtered.Count}/{allTags.Count}건 · 자주 쓰는 것 {hit}건이 위에"
- : $"{filtered.Count}/{allTags.Count}건";
- if (filtered.Count > 0)
+ TagList.ItemsSource = items;
+ ListCaption.Text = items.Count == 0
+ ? "결과 없음"
+ : $"{items.Count}개 — 자주 쓰는 것과 많이 쓰인 것이 위에";
+ CountText.Text = $"{Searched().Count}/{allTags.Count}건";
+
+ if (selectValue is { Length: > 0 })
+ {
+ var found = items.FirstOrDefault(i => i.Name == selectValue);
+ if (found is not null)
+ {
+ TagList.SelectedItem = found;
+ TagList.ScrollIntoView(found);
+ }
+ }
+ if (TagList.SelectedItem is null && items.Count > 0)
{
TagList.SelectedIndex = 0;
}
+ UpdateSelectedText();
}
private int IndexOfPreferred(string tag)
@@ -88,13 +222,65 @@ public partial class TagPickerDialogView : Window
return -1;
}
- private void OnPick(object sender, RoutedEventArgs e)
+ private void UpdateSelectedText()
{
- if (TagList.SelectedItem is not string tag)
+ var tag = (TagList.SelectedItem as TagListItem)?.Name;
+ SelectedText.Text = tag ?? string.Empty;
+ PickButton.IsEnabled = tag is { Length: > 0 };
+ }
+
+ private void OnSearchChanged(object sender, TextChangedEventArgs e) => Rebuild();
+
+ ///
+ /// 검색칸에서 바로 목록을 움직인다 — 검색하고 Tab 으로 옮겨 가는 왕복을 없앤다.
+ /// Enter 는 선택 확정(IsDefault 버튼이 받는다).
+ ///
+ private void OnSearchKeyDown(object sender, KeyEventArgs e)
+ {
+ if (TagList.Items.Count == 0)
{
return;
}
- SelectedTag = tag;
+ switch (e.Key)
+ {
+ case Key.Down:
+ TagList.SelectedIndex = Math.Min(TagList.SelectedIndex + 1, TagList.Items.Count - 1);
+ break;
+ case Key.Up:
+ TagList.SelectedIndex = Math.Max(TagList.SelectedIndex - 1, 0);
+ break;
+ case Key.PageDown:
+ TagList.SelectedIndex = Math.Min(TagList.SelectedIndex + 10, TagList.Items.Count - 1);
+ break;
+ case Key.PageUp:
+ TagList.SelectedIndex = Math.Max(TagList.SelectedIndex - 10, 0);
+ break;
+ default:
+ return;
+ }
+ TagList.ScrollIntoView(TagList.SelectedItem);
+ e.Handled = true;
+ }
+
+ private void OnCategoryChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (suppressCategoryReload || CategoryList.SelectedItem is not TagCategoryItem item)
+ {
+ return;
+ }
+ activeFilter = item.Filter ?? FilterAll;
+ RebuildTagList(Searched());
+ }
+
+ private void OnTagChanged(object sender, SelectionChangedEventArgs e) => UpdateSelectedText();
+
+ private void OnPick(object sender, RoutedEventArgs e)
+ {
+ if (TagList.SelectedItem is not TagListItem item)
+ {
+ return;
+ }
+ SelectedTag = item.Name;
DialogResult = true;
}
diff --git a/tests/SheetMe.Core.Tests/TagSearchTests.cs b/tests/SheetMe.Core.Tests/TagSearchTests.cs
new file mode 100644
index 0000000..23363e4
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/TagSearchTests.cs
@@ -0,0 +1,116 @@
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// 태그 검색·분류 규칙 테스트.
+///
+/// 태그는 384종이라 검색이 곧 사용성이다. 한글 이름이 길어 초성으로 닿을 수 있어야 하고,
+/// 분류는 레거시의 규약 밖 이름(오타·접두어 없음)에 흔들리지 않아야 한다.
+///
+[TestClass]
+public sealed class TagSearchTests
+{
+ [DataTestMethod]
+ [DataRow("PAT_이름", "환자")]
+ [DataRow("PAT_주민번호_Dash", "환자")]
+ [DataRow("OCM_진단서의사명", "진료·오더")]
+ [DataRow("ETC_요양기관명칭_병원명", "기관·문서")]
+ public void CategoryOf_MapsKnownPrefixes(string tag, string expected)
+ => Assert.AreEqual(expected, TagSearch.CategoryOf(tag));
+
+ ///
+ /// 규약 밖 이름은 전부 '그 밖'으로 묶인다.
+ /// ECT_ 는 레거시 bzDataInterface.vb:17850 의 실제 오타이고(ETC 가 아니다),
+ /// GetNurseShtCod 는 접두어가 아예 없다. 접두어를 그대로 분류로 쓰면
+ /// 1~3개짜리 분류가 목록을 채워 '무엇이 있는지'를 오히려 가린다.
+ ///
+ [DataTestMethod]
+ [DataRow("ECT_로그인사용자싸인")]
+ [DataRow("GetNurseShtCod")]
+ [DataRow("GetOprInfDT_OKDMain")]
+ [DataRow("RadCheckControl")]
+ public void CategoryOf_FoldsIrregularNames(string tag)
+ => Assert.AreEqual(TagSearch.OtherCategory, TagSearch.CategoryOf(tag));
+
+ [TestMethod]
+ public void ToChoseong_ExtractsKoreanInitials()
+ {
+ Assert.AreEqual("PAT_ㅊㅌㅂㅎ", TagSearch.ToChoseong("PAT_차트번호"));
+ Assert.AreEqual("ㅈㄷㅅㅇㅅㅁ", TagSearch.ToChoseong("진단서의사명"));
+ }
+
+ /// 초성만 입력했을 때만 초성 대조를 시도한다 — 일반 검색어를 초성으로 오인하면 안 된다
+ [DataTestMethod]
+ [DataRow("ㅊㅌㅂㅎ", true)]
+ [DataRow("ㅇㅁ", true)]
+ [DataRow("차트", false)]
+ [DataRow("PAT", false)]
+ [DataRow("", false)]
+ public void IsChoseongQuery_OnlyForPureInitials(string query, bool expected)
+ => Assert.AreEqual(expected, TagSearch.IsChoseongQuery(query));
+
+ /// 초성 네 글자로 긴 한글 태그에 닿는다 — 손이 덜 가는 것이 요점이다
+ [TestMethod]
+ public void Matches_FindsByChoseong()
+ {
+ Assert.IsTrue(TagSearch.Matches("PAT_차트번호", "ㅊㅌㅂㅎ"));
+ Assert.IsTrue(TagSearch.Matches("OCM_진단서의사명", "ㅈㄷㅅ"));
+ Assert.IsFalse(TagSearch.Matches("PAT_이름", "ㅊㅌㅂㅎ"));
+ }
+
+ [TestMethod]
+ public void Matches_StillDoesPlainSubstring()
+ {
+ Assert.IsTrue(TagSearch.Matches("PAT_차트번호", "차트"));
+ Assert.IsTrue(TagSearch.Matches("PAT_차트번호", "pat"), "대소문자를 가리지 않아야 한다");
+ }
+
+ /// 공백으로 나눈 토큰은 모두 만족해야 한다(AND)
+ [TestMethod]
+ public void MatchesAll_RequiresEveryToken()
+ {
+ var tokens = new[] { "OCM", "의사" };
+ Assert.IsTrue(TagSearch.MatchesAll("OCM_진단서의사명", tokens));
+ Assert.IsFalse(TagSearch.MatchesAll("PAT_이름", tokens));
+ // 일반어와 초성을 섞어도 된다
+ Assert.IsTrue(TagSearch.MatchesAll("OCM_진단서의사명", new[] { "OCM", "ㅈㄷㅅ" }));
+ }
+
+ /// 변형이 흩어지지 않게 같은 뿌리로 묶는다
+ [TestMethod]
+ public void GroupOf_UsesSecondToken()
+ {
+ Assert.AreEqual("주민번호", TagSearch.GroupOf("PAT_주민번호"));
+ Assert.AreEqual("주민번호", TagSearch.GroupOf("PAT_주민번호_Dash"));
+ Assert.AreEqual("주민번호", TagSearch.GroupOf("PAT_주민번호_Blind"));
+ }
+
+ ///
+ /// 사용 건수는 실측 스냅샷이다 — 상위 태그가 비어 있으면 피커의 정렬 근거가 사라진다.
+ ///
+ [TestMethod]
+ public void UsageCounts_CarryTheMeasuredTopTags()
+ {
+ Assert.AreEqual(669, LegacyTagUsageCounts.Of("PAT_이름"));
+ Assert.AreEqual(852, LegacyTagUsageCounts.Of("RadCheckControl"));
+ Assert.IsTrue(LegacyTagUsageCounts.IsUsed("OCM_진단서의사싸인"));
+ // 기록에 없는 태그는 0 — '미사용'으로 표시되어야 한다
+ Assert.AreEqual(0, LegacyTagUsageCounts.Of("존재하지_않는_태그"));
+ Assert.IsFalse(LegacyTagUsageCounts.IsUsed("존재하지_않는_태그"));
+ }
+
+ /// 집계된 태그는 전부 실제 카탈로그에 있어야 한다 — 없는 태그를 상위로 올리면 안 된다
+ [TestMethod]
+ public void UsageCounts_OnlyContainCatalogTags()
+ {
+ var known = LegacyTagCatalog.DataInterfaceTags
+ .Concat(LegacyTagCatalog.DataActionTags)
+ .ToHashSet(StringComparer.Ordinal);
+
+ foreach (var tag in new[] { "PAT_이름", "RadCheckControl", "OCM_진단서의사싸인", "ETC_현재일자" })
+ {
+ Assert.IsTrue(known.Contains(tag), $"카탈로그에 없는 태그가 집계에 있습니다: {tag}");
+ }
+ }
+}