태그 피커 재설계 — 찾기(초성·건수)와 둘러보기(분류)를 함께
칩으로 자주 쓰는 것을 앞에 깔았지만, 피커 자체는 여전히 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:
co-authored by
Claude Opus 5
parent
de01012b7e
commit
146d808aac
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -719,6 +719,9 @@ public static class DbSmoke
|
|||||||
var unknown = ranked.Where(p => !catalog.Contains(p.Key)).ToList();
|
var unknown = ranked.Where(p => !catalog.Contains(p.Key)).ToList();
|
||||||
lines.Add($" 카탈로그에 없는 값: {unknown.Count}종 / {unknown.Sum(p => p.Value)}건");
|
lines.Add($" 카탈로그에 없는 값: {unknown.Count}종 / {unknown.Sum(p => p.Value)}건");
|
||||||
lines.AddRange(unknown.Take(10).Select(p => $" {p.Key} ×{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)
|
if (perSheet.Count > 0)
|
||||||
|
|||||||
@@ -115,7 +115,11 @@ public static class DialogShots
|
|||||||
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
|
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
|
||||||
("03-query-editor", () => new Views.QueryEditorWindow("데이터소스",
|
("03-query-editor", () => new Views.QueryEditorWindow("데이터소스",
|
||||||
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
|
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.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일")),
|
("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")),
|
||||||
// 데이터소스 배선 — 운영 다수파인 Rows 형 값을 넣어 되읽기·형태 유지가 보이게 찍는다
|
// 데이터소스 배선 — 운영 다수파인 Rows 형 값을 넣어 되읽기·형태 유지가 보이게 찍는다
|
||||||
("05b-datatable-field", () => new Views.DataTableFieldDialogView(
|
("05b-datatable-field", () => new Views.DataTableFieldDialogView(
|
||||||
|
|||||||
@@ -1,24 +1,88 @@
|
|||||||
<Window x:Class="SheetMe.Designer.Views.TagPickerDialogView"
|
<Window x:Class="SheetMe.Designer.Views.TagPickerDialogView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="태그 선택" Width="480" Height="560"
|
Title="태그 선택" Width="720" Height="620" MinWidth="560" MinHeight="440"
|
||||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
Style="{StaticResource ThemedWindow}">
|
Style="{StaticResource ThemedWindow}">
|
||||||
<DockPanel Margin="12">
|
<Window.Resources>
|
||||||
|
<!-- 분류 목록 한 줄: 이름 + 건수 -->
|
||||||
|
<DataTemplate x:Key="CategoryItem">
|
||||||
|
<DockPanel MinWidth="120">
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding Count}" FontSize="10.5"
|
||||||
|
Foreground="{DynamicResource B.Muted}" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||||
|
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<!-- 태그 한 줄: 이름 + 소분류 + 사용 건수 배지 -->
|
||||||
|
<DataTemplate x:Key="TagItem">
|
||||||
|
<DockPanel MinWidth="240" Margin="0,1">
|
||||||
|
<Border DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
|
CornerRadius="4" Padding="5,1" Background="{DynamicResource B.Input}"
|
||||||
|
BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1"
|
||||||
|
Visibility="{Binding HasUsage, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
ToolTip="운영 서식에서 쓰인 횟수">
|
||||||
|
<TextBlock Text="{Binding UsageText}" FontSize="10" Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="미사용" FontSize="10" Margin="8,0,0,0"
|
||||||
|
VerticalAlignment="Center" Foreground="{DynamicResource B.Muted}"
|
||||||
|
Visibility="{Binding IsUnused, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
ToolTip="운영 서식에서 한 번도 쓰이지 않은 태그입니다"/>
|
||||||
|
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<DockPanel Margin="14">
|
||||||
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
||||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
TextWrapping="Wrap" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
<TextBox x:Name="SearchBox" DockPanel.Dock="Top" Padding="4,3"
|
<!-- 검색: 초성·다중 토큰 -->
|
||||||
TextChanged="OnSearchChanged" VerticalContentAlignment="Center"/>
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,10">
|
||||||
|
<TextBlock DockPanel.Dock="Right" x:Name="CountText" VerticalAlignment="Center" Margin="10,0,0,0"
|
||||||
|
Foreground="{DynamicResource B.Muted}" FontSize="11"/>
|
||||||
|
<TextBox x:Name="SearchBox" Padding="6,4" VerticalContentAlignment="Center"
|
||||||
|
TextChanged="OnSearchChanged" PreviewKeyDown="OnSearchKeyDown"
|
||||||
|
AutomationProperties.Name="태그 검색"
|
||||||
|
ToolTip="이름 일부 또는 초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호). 공백으로 나누면 모두 포함하는 것만 남습니다."/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
<TextBlock x:Name="SelectedText" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
|
FontFamily="Consolas" FontSize="11.5" Foreground="{DynamicResource B.Muted}"
|
||||||
|
TextTrimming="CharacterEllipsis" MaxWidth="330"/>
|
||||||
<Button Content="값 지우기" Padding="12,4" Click="OnClear"/>
|
<Button Content="값 지우기" Padding="12,4" Click="OnClear"/>
|
||||||
<Button Content="선택" Padding="18,4" Margin="8,0,0,0" Click="OnPick" IsDefault="True"/>
|
<Button x:Name="PickButton" Content="선택" Padding="18,4" Margin="8,0,0,0"
|
||||||
|
Style="{StaticResource Primary}" Click="OnPick" IsDefault="True"/>
|
||||||
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
|
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<ListBox x:Name="TagList" Margin="0,8,0,0" MouseDoubleClick="OnPick"
|
<Grid>
|
||||||
VirtualizingPanel.IsVirtualizing="True" FontSize="13"/>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="190"/>
|
||||||
|
<ColumnDefinition Width="12"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- 왼쪽: 분류. '무엇이 있는지' 는 여기서 먼저 보인다 -->
|
||||||
|
<DockPanel Grid.Column="0">
|
||||||
|
<TextBlock DockPanel.Dock="Top" Text="분류" FontSize="11"
|
||||||
|
Foreground="{DynamicResource B.Muted}" Margin="2,0,0,4"/>
|
||||||
|
<ListBox x:Name="CategoryList" ItemTemplate="{StaticResource CategoryItem}"
|
||||||
|
SelectionChanged="OnCategoryChanged" FontSize="12.5"
|
||||||
|
AutomationProperties.Name="태그 분류"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- 오른쪽: 태그 -->
|
||||||
|
<DockPanel Grid.Column="2">
|
||||||
|
<TextBlock DockPanel.Dock="Top" x:Name="ListCaption" FontSize="11"
|
||||||
|
Foreground="{DynamicResource B.Muted}" Margin="2,0,0,4"/>
|
||||||
|
<ListBox x:Name="TagList" ItemTemplate="{StaticResource TagItem}"
|
||||||
|
SelectionChanged="OnTagChanged" MouseDoubleClick="OnPick"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True" FontSize="12.5"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
AutomationProperties.Name="태그 목록"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,24 +1,73 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
namespace SheetMe.Designer.Views;
|
namespace SheetMe.Designer.Views;
|
||||||
|
|
||||||
|
/// <summary>분류 목록 한 줄</summary>
|
||||||
|
public sealed class TagCategoryItem
|
||||||
|
{
|
||||||
|
/// <summary>표시 이름</summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>이 분류에 속한 태그 수(검색 결과 기준)</summary>
|
||||||
|
public int Count { get; init; }
|
||||||
|
|
||||||
|
/// <summary>'전체'·'자주 쓰는 것' 같은 가상 분류인지</summary>
|
||||||
|
public string? Filter { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>태그 목록 한 줄 — 이름과 사용 실적</summary>
|
||||||
|
public sealed class TagListItem
|
||||||
|
{
|
||||||
|
/// <summary>태그 이름(저장되는 값)</summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>운영 사용 건수</summary>
|
||||||
|
public int Usage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>배지 문구</summary>
|
||||||
|
public string UsageText => Usage >= 1000 ? $"{Usage / 1000.0:F1}k" : Usage.ToString();
|
||||||
|
|
||||||
|
/// <summary>건수 배지를 띄울지</summary>
|
||||||
|
public bool HasUsage => Usage > 0;
|
||||||
|
|
||||||
|
/// <summary>운영에서 한 번도 안 쓰인 태그인지 — 고르기 전에 알 수 있어야 한다</summary>
|
||||||
|
public bool IsUnused => Usage == 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 태그 선택 대화상자 — 검색(부분 일치, 공백 구분 다중 토큰 AND) + 목록 선택.
|
/// 태그 선택 — 찾기(검색·초성)와 둘러보기(분류)를 함께.
|
||||||
/// '값 지우기'는 빈 값으로 확정(속성 제거).
|
///
|
||||||
|
/// <b>왜 분류가 필요한가.</b> 데이터 태그는 384종이다. 한 덩어리로 늘어놓으면
|
||||||
|
/// 무엇이 있는지 알 길이 없어 결국 아는 이름만 반복해 쓰게 된다.
|
||||||
|
/// 태그 이름에는 이미 구조가 있다(<c>PAT_</c> 환자 · <c>OCM_</c> 진료 · <c>ETC_</c> 기관) —
|
||||||
|
/// 그 구조를 화면에 드러낸다.
|
||||||
|
///
|
||||||
|
/// <b>왜 건수를 보여 주는가.</b> 이름만으로는 <c>PAT_주민번호</c> 와 <c>PAT_주민번호_Dash</c> 중
|
||||||
|
/// 무엇이 표준인지 알 수 없다. 운영 실측(--db-tags)을 배지로 띄우면 판단이 선다.
|
||||||
|
/// 한 번도 안 쓰인 태그는 '미사용'으로 표시한다 — 그것도 정보다.
|
||||||
|
///
|
||||||
|
/// 이 창은 <b>둘러보기 전용</b>이다. 자주 쓰는 태그는 인스펙터 행에 칩으로 깔려 있어
|
||||||
|
/// 이 창을 열 필요가 없다(사내 UI 가이드: 빈도 높은 동작에 모달을 쓰지 않는다).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class TagPickerDialogView : Window
|
public partial class TagPickerDialogView : Window
|
||||||
{
|
{
|
||||||
#region Member Fields
|
#region Member Fields
|
||||||
private readonly IReadOnlyList<string> allTags;
|
private readonly IReadOnlyList<string> allTags;
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Member Fields - Ordering
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 목록 맨 위로 올린다.
|
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 별도 분류로 맨 위에 둔다.
|
||||||
/// 가나다순으로만 늘어놓으면 운영 절반을 차지하는 태그와 한 번도 안 쓰인 태그가 같은 무게로 보인다.
|
/// 가나다순으로만 늘어놓으면 운영 절반을 차지하는 태그와 한 번도 안 쓰인 태그가 같은 무게로 보인다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly IReadOnlyList<string> preferred;
|
private readonly IReadOnlyList<string> preferred;
|
||||||
|
|
||||||
|
private const string FilterAll = "*";
|
||||||
|
private const string FilterPreferred = "!";
|
||||||
|
private const string FilterUsed = "+";
|
||||||
|
private string activeFilter = FilterAll;
|
||||||
|
private bool suppressCategoryReload;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
@@ -34,46 +83,131 @@ public partial class TagPickerDialogView : Window
|
|||||||
preferred = preferredTags ?? Array.Empty<string>();
|
preferred = preferredTags ?? Array.Empty<string>();
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
TitleText.Text = title;
|
TitleText.Text = title;
|
||||||
ApplyFilter(string.Empty);
|
|
||||||
if (!string.IsNullOrEmpty(currentValue))
|
// 현재 값이 있으면 그 값이 든 분류로 열어 준다 — 어디에 속한 값인지가 곧 맥락이다
|
||||||
|
if (!string.IsNullOrEmpty(currentValue) && allTags.Contains(currentValue))
|
||||||
{
|
{
|
||||||
TagList.SelectedItem = tags.FirstOrDefault(t => t == currentValue);
|
activeFilter = TagSearch.CategoryOf(currentValue);
|
||||||
TagList.ScrollIntoView(TagList.SelectedItem);
|
|
||||||
}
|
}
|
||||||
|
else if (preferred.Count > 0)
|
||||||
|
{
|
||||||
|
activeFilter = FilterPreferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rebuild(selectValue: currentValue);
|
||||||
Loaded += (_, _) => SearchBox.Focus();
|
Loaded += (_, _) => SearchBox.Focus();
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
private void OnSearchChanged(object sender, TextChangedEventArgs e)
|
private static string[] Tokens(string keyword)
|
||||||
=> ApplyFilter(SearchBox.Text);
|
=> keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
private void ApplyFilter(string keyword)
|
/// <summary>검색어를 통과한 태그 전체(분류 적용 전)</summary>
|
||||||
|
private List<string> Searched()
|
||||||
{
|
{
|
||||||
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
var tokens = Tokens(SearchBox.Text);
|
||||||
IEnumerable<string> matched = tokens.Length == 0
|
return tokens.Length == 0
|
||||||
? allTags
|
? allTags.ToList()
|
||||||
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)));
|
: allTags.Where(t => TagSearch.MatchesAll(t, tokens)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// 자주 쓰는 것 먼저, 그 안에서는 빈도 순서 그대로. 나머지는 원래(가나다) 순서를 지킨다.
|
private bool PassesFilter(string tag) => activeFilter switch
|
||||||
// OrderBy 는 안정 정렬이라 같은 순위끼리는 입력 순서가 보존된다.
|
{
|
||||||
var filtered = matched
|
FilterAll => true,
|
||||||
|
FilterPreferred => preferred.Contains(tag, StringComparer.Ordinal),
|
||||||
|
FilterUsed => LegacyTagUsageCounts.IsUsed(tag),
|
||||||
|
var category => string.Equals(TagSearch.CategoryOf(tag), category, StringComparison.Ordinal),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>분류 목록과 태그 목록을 함께 다시 만든다</summary>
|
||||||
|
private void Rebuild(string? selectValue = null)
|
||||||
|
{
|
||||||
|
var searched = Searched();
|
||||||
|
|
||||||
|
// ── 분류 ──
|
||||||
|
var categories = new List<TagCategoryItem>();
|
||||||
|
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<string> searched, string? selectValue = null)
|
||||||
|
{
|
||||||
|
var items = searched
|
||||||
|
.Where(PassesFilter)
|
||||||
|
// 자주 쓰는 순서 → 운영 건수 → 이름. 가나다순만으로는 표준과 희귀가 구분되지 않는다.
|
||||||
.OrderBy(t =>
|
.OrderBy(t =>
|
||||||
{
|
{
|
||||||
var index = IndexOfPreferred(t);
|
var index = IndexOfPreferred(t);
|
||||||
return index < 0 ? int.MaxValue : index;
|
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();
|
.ToList();
|
||||||
|
|
||||||
TagList.ItemsSource = filtered;
|
TagList.ItemsSource = items;
|
||||||
var hit = filtered.Count(t => IndexOfPreferred(t) >= 0);
|
ListCaption.Text = items.Count == 0
|
||||||
CountText.Text = hit > 0
|
? "결과 없음"
|
||||||
? $"{filtered.Count}/{allTags.Count}건 · 자주 쓰는 것 {hit}건이 위에"
|
: $"{items.Count}개 — 자주 쓰는 것과 많이 쓰인 것이 위에";
|
||||||
: $"{filtered.Count}/{allTags.Count}건";
|
CountText.Text = $"{Searched().Count}/{allTags.Count}건";
|
||||||
if (filtered.Count > 0)
|
|
||||||
|
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;
|
TagList.SelectedIndex = 0;
|
||||||
}
|
}
|
||||||
|
UpdateSelectedText();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int IndexOfPreferred(string tag)
|
private int IndexOfPreferred(string tag)
|
||||||
@@ -88,13 +222,65 @@ public partial class TagPickerDialogView : Window
|
|||||||
return -1;
|
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();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 검색칸에서 바로 목록을 움직인다 — 검색하고 Tab 으로 옮겨 가는 왕복을 없앤다.
|
||||||
|
/// Enter 는 선택 확정(IsDefault 버튼이 받는다).
|
||||||
|
/// </summary>
|
||||||
|
private void OnSearchKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (TagList.Items.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
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;
|
DialogResult = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 태그 검색·분류 규칙 테스트.
|
||||||
|
///
|
||||||
|
/// 태그는 384종이라 검색이 곧 사용성이다. 한글 이름이 길어 초성으로 닿을 수 있어야 하고,
|
||||||
|
/// 분류는 레거시의 규약 밖 이름(오타·접두어 없음)에 흔들리지 않아야 한다.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 규약 밖 이름은 전부 '그 밖'으로 묶인다.
|
||||||
|
/// ECT_ 는 레거시 bzDataInterface.vb:17850 의 실제 오타이고(ETC 가 아니다),
|
||||||
|
/// GetNurseShtCod 는 접두어가 아예 없다. 접두어를 그대로 분류로 쓰면
|
||||||
|
/// 1~3개짜리 분류가 목록을 채워 '무엇이 있는지'를 오히려 가린다.
|
||||||
|
/// </summary>
|
||||||
|
[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("진단서의사명"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>초성만 입력했을 때만 초성 대조를 시도한다 — 일반 검색어를 초성으로 오인하면 안 된다</summary>
|
||||||
|
[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));
|
||||||
|
|
||||||
|
/// <summary>초성 네 글자로 긴 한글 태그에 닿는다 — 손이 덜 가는 것이 요점이다</summary>
|
||||||
|
[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"), "대소문자를 가리지 않아야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>공백으로 나눈 토큰은 모두 만족해야 한다(AND)</summary>
|
||||||
|
[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", "ㅈㄷㅅ" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>변형이 흩어지지 않게 같은 뿌리로 묶는다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void GroupOf_UsesSecondToken()
|
||||||
|
{
|
||||||
|
Assert.AreEqual("주민번호", TagSearch.GroupOf("PAT_주민번호"));
|
||||||
|
Assert.AreEqual("주민번호", TagSearch.GroupOf("PAT_주민번호_Dash"));
|
||||||
|
Assert.AreEqual("주민번호", TagSearch.GroupOf("PAT_주민번호_Blind"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 사용 건수는 실측 스냅샷이다 — 상위 태그가 비어 있으면 피커의 정렬 근거가 사라진다.
|
||||||
|
/// </summary>
|
||||||
|
[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("존재하지_않는_태그"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>집계된 태그는 전부 실제 카탈로그에 있어야 한다 — 없는 태그를 상위로 올리면 안 된다</summary>
|
||||||
|
[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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user