태그 피커 재설계 — 찾기(초성·건수)와 둘러보기(분류)를 함께

칩으로 자주 쓰는 것을 앞에 깔았지만, 피커 자체는 여전히 384줄짜리 평면 목록이었다.
목록에 구조가 없으면 무엇이 있는지 알 길이 없어 결국 아는 이름만 반복해 쓰게 된다.

■ 분류 — '무엇이 있는지'

태그 이름에는 이미 구조가 있다(PAT_ 환자 · OCM_ 진료·오더 · ETC_ 기관·문서).
그 구조를 왼쪽 분류 목록으로 드러냈다. 건수와 함께 보이므로 규모가 잡힌다:
환자 40 · 진료·오더 260 · 기관·문서 75 · 그 밖 9.

가상 분류 셋을 위에 뒀다:
  · 자주 쓰는 것 — 이 컨트롤 타입의 실사용 상위(타입마다 다르다)
  · 운영에서 쓰임 157 — 384종 중 실제로 한 번이라도 쓰인 것만
  · 전체 384

아는 접두어만 분류로 올리고 나머지는 '그 밖'으로 묶는다. 레거시에는 규약에서 벗어난 이름이
섞여 있어서(오타 ECT_로그인사용자싸인 — bzDataInterface.vb:17850 의 실제 오타다,
접두어가 아예 없는 GetNurseShtCod) 접두어를 그대로 분류로 쓰면 1~3개짜리 분류가
목록을 채워 '무엇이 있는지'를 오히려 가린다.

■ 사용 건수 — '이게 표준인가'

이름만으로는 PAT_주민번호 와 PAT_주민번호_Dash 중 무엇이 표준인지 알 수 없다.
운영 실측(--db-tags)을 줄마다 배지로 띄운다(PAT_이름 669, PAT_차트번호 463 …).
한 번도 안 쓰인 태그는 '미사용'으로 표시한다 — 그것도 정보다.
정렬은 자주 쓰는 순 → 운영 건수 → 이름. 가나다순은 운영 절반을 차지하는 태그와
한 번도 안 쓰인 태그를 같은 무게로 보여 준다.

■ 초성 검색 — 손이 덜 가게

한글 태그 이름이 길어 전체를 칠 이유가 없다. "ㅊㅌㅂㅎ" 네 글자로 PAT_차트번호 에 닿는다.
입력이 초성만으로 이뤄졌을 때만 초성 대조를 시도한다 — 일반 검색어를 초성으로 오인하면 안 된다.
공백으로 나눈 여러 토큰은 모두 만족해야 하고(AND), 일반어와 초성을 섞어도 된다("OCM ㅈㄷㅅ").

■ 그 밖

· 검색칸에서 ↑↓·PageUp/Down 으로 바로 목록을 움직인다 — 검색하고 Tab 으로 옮겨 가는 왕복을 없앴다.
· 현재 값이 있으면 그 값이 속한 분류로 열고 그 줄을 선택해 둔다.
· 검색으로 지금 분류가 비면 결과가 있는 분류로 옮겨 간다(빈 화면을 보여 주지 않는다).
· 창을 720×620 으로 키우고 크기 조절을 열었다. 선택된 값을 하단에 항상 띄운다.
· AutomationProperties.Name 을 붙였다(사내 UI 가이드 접근성 항목).

사내 UI 가이드의 판단 기준을 따랐다 — '빈도 높은 동작에 모달을 쓰지 않는다'.
자주 쓰는 태그는 인스펙터 행의 칩으로 이미 빠져 있어 이 창을 열 필요가 없고,
이 창은 '둘러보기' 전용으로 남긴다(그 용도에는 집중 화면이 맞다).

단위 테스트 20건 추가(분류 매핑·규약 밖 이름 접기·초성 추출·초성 판정·부분 일치·
다중 토큰 AND·변형 묶기·건수 스냅샷).
회귀: 테스트 162/162, 편집 스모크 210건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 14:02:25 +09:00
co-authored by Claude Opus 5
parent de01012b7e
commit 146d808aac
7 changed files with 778 additions and 39 deletions
@@ -0,0 +1,240 @@
namespace SheetMe.Core.Catalog;
/// <summary>
/// 태그별 운영 사용 건수 — 피커가 '이게 표준인지 희귀한지'를 보여 주기 위한 실측값.
///
/// 출처: 진단 <c>--db-tags</c>. 운영 디자인 1,271건의 DataInterfaceTag·DataActionTag 8,032건을 집계했다.
/// 카탈로그에는 있으나 운영에 한 번도 안 쓰인 태그는 여기 없다(건수 0으로 본다) —
/// 그것도 정보다. 설계자가 처음 보는 태그를 고를 때 '아무도 안 쓰는 것'임을 알 수 있어야 한다.
///
/// 수치는 스냅샷이라 시간이 지나면 어긋난다. 순서와 규모의 근거로만 쓰고, 정확한 통계로 쓰지 않는다.
/// </summary>
public static class LegacyTagUsageCounts
{
#region Member Fields
private static readonly Dictionary<string, int> 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
/// <summary>운영 사용 건수 — 기록이 없으면 0</summary>
public static int Of(string tag) => counts.TryGetValue(tag, out var n) ? n : 0;
/// <summary>운영에서 한 번이라도 쓰인 적이 있는지</summary>
public static bool IsUsed(string tag) => counts.ContainsKey(tag);
#endregion
}
+126
View File
@@ -0,0 +1,126 @@
using System.Text;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 태그 검색·분류 — 피커가 "찾기"와 "둘러보기"를 둘 다 되게 하는 규칙.
///
/// 태그 이름은 <c>PAT_주민번호_Dash</c> 처럼 접두어와 밑줄로 이미 구조가 있다.
/// 그 구조를 화면에 드러내지 않고 384줄을 한 덩어리로 늘어놓으면,
/// 무엇이 있는지 알 수 없어 결국 아는 이름만 반복해 쓰게 된다.
/// </summary>
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
/// <summary>그 어느 접두어 규약에도 안 맞는 태그가 모이는 곳</summary>
public const string OtherCategory = "그 밖";
/// <summary>
/// 태그가 속한 분류 — 접두어로 가른다.
/// 레거시 bzDataInterface 의 메서드 이름 규약이 그대로 분류가 된다.
///
/// <b>아는 접두어만 분류로 올린다.</b> 나머지는 전부 '그 밖'으로 묶는다 —
/// 레거시에는 규약에서 벗어난 이름이 섞여 있어서(오타 <c>ECT_로그인사용자싸인</c>,
/// 접두어가 아예 없는 <c>GetNurseShtCod</c>), 접두어를 그대로 분류로 쓰면
/// 1~3개짜리 분류가 목록을 채워 '무엇이 있는지' 를 오히려 가린다.
/// 액션 태그는 접두어 규약 자체가 없어 통째로 여기로 온다.
/// </summary>
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,
};
}
/// <summary>
/// 분류 안의 소분류 — 두 번째 토큰. <c>PAT_주민번호_Dash</c> → "주민번호".
/// 같은 뿌리에서 갈라진 변형(_Dash, _Blind, _Refer)이 흩어지지 않고 붙어 보이게 한다.
/// </summary>
public static string GroupOf(string tag)
{
var parts = tag.Split('_');
return parts.Length >= 2 ? parts[1] : tag;
}
/// <summary>
/// 한글 문자열의 초성만 뽑는다 — 한글이 아니면 그대로 둔다.
/// "PAT_차트번호" → "PAT_ㅊㅌㅂㅎ"
/// </summary>
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();
}
/// <summary>입력이 초성만으로 이루어졌는지 — 그럴 때만 초성 대조를 시도한다</summary>
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;
}
/// <summary>
/// 한 토큰이 태그에 걸리는지 — 부분 일치, 그리고 초성 입력이면 초성 대조.
///
/// 초성을 받는 이유는 한글 태그 이름이 길어서다. "ㅊㅌㅂㅎ" 네 글자로
/// PAT_차트번호 에 닿을 수 있으면 손이 훨씬 덜 간다.
/// </summary>
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);
}
/// <summary>공백으로 나눈 여러 토큰을 전부 만족해야 통과(AND)</summary>
public static bool MatchesAll(string tag, IReadOnlyList<string> tokens)
{
foreach (var token in tokens)
{
if (!Matches(tag, token))
{
return false;
}
}
return true;
}
#endregion
}