서식 목록을 분류(ShtClsCod)로 묶기 — 평면 1,629건 → 접이식 24분류
사용자 지적: "서식 목록 구분없이 하나로 다 나오는거 불편한데 묶여서 나와야해". [기준 선정] 후보 컬럼 7종을 실DB로 전수 집계해(신규 --db-cls) ShtClsCod 를 택했다. 이것만이 판정 3기준을 통과한다 — 그룹 24종, 최대 그룹 269건(16%), 빈값 1건(0.06%). 나머지는 전부 한쪽 쏠림이다: ShtTyp D 79% / ShtPatTyp C 92% / ShtLstTyp NONREC 88% / ShtDepCls 빈값 74%. 의미상으로도 ShtClsCod 만 '분류'이고 나머지는 동작 규칙이다(ShtWrtTyp=1장 제약, ShtUsrTyp· ShtPatTyp=작성 게이트, ShtOprTyp=.NET 클래스명, ShtDspSeq=정렬키). 무엇보다 이건 사용자가 이미 아는 축이다. 레거시에서 서식생성기를 여는 화면(기록지정보 fmShtMst) 상단이 `[ 분류별 전체 ]` + 분류별 탭이고, EMR 기록지 트리 1단 헤더도 같은 값이다. 재교육 비용 0. [라벨] 코드→한글명은 M_DtlMst 의 DtlTblCod='EMR_SHEET_GROUP' 이 소유한다. 소스 어디에도 매핑표가 없고 병원별 마스터라(HspCod 분기가 레거시에 다수) 하드코딩하면 다른 병원에서 즉시 오답이 된다. 런타임 조인만 쓴다. 실측: 마스터 24행 ↔ 서식 24코드 완전 일치, 고아 코드 0건. [조인 방향] E_ShtMst 드라이빙 + LEFT JOIN 이다. 레거시 EMR 트리(bizSheetList.vb)는 M_DtlMst 를 드라이빙으로 써서 분류 미지정 서식이 목록에서 통째로 사라지는데, 열기 목록에서 그건 "그 서식을 영영 못 여는" 장애다. 기록지정보 화면 방식(dtShtMst.vb:75-81)을 따랐다. [조인 증식 방어] M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 코드가 기간별 다중행일 수 있다. 그대로 조인하면 서식이 목록에 두 번 뜬다. 실측상 현재 증식 0건이지만 그건 데이터 운이지 쿼리가 막은 게 아니므로, ROW_NUMBER 로 적용일 최신 1행만 택하게 했다. [그룹 순서] LPAD(DtlDspSeq,4,'0') 우선, 그다음 코드순, 미분류는 맨 뒤. 병원이 정한 표시순서가 1순위여야 레거시 분류 탭과 같은 자리에 뜬다. 이 병원은 DspSeq 가 전부 비어 있어 결과적으로 코드순(A~X)이지만, 채워 쓰는 병원에서 어긋나지 않는다. LPAD 는 레거시의 2자리 대신 4자리 — '100' 과 '99' 가 뒤집히는 것을 막는다. [상한 300 → 2,000] 종전 300 상한은 정렬이 분류순으로 바뀌면서 성격이 달라진다. 코드순 300 은 고르게 잘렸지만 분류순 300 은 뒤쪽 분류가 통째로 사라져 "그 분류가 없는 것"처럼 보인다. 모집단 1,629건이라 이제 전량이 온다. 도달 시 하단에 잘렸다고 명시한다(조용한 절단 금지). [분류명도 검색 대상] 헤더에서 본 '간호기록'을 그대로 쳤는데 0건이면 배신감이 크다. LIKE 토큰에 DtlCodNam 을 추가했다. [접기 상태] 기본 접힘 + 검색 시 자동 펼침. 다만 레거시는 서식이 꽉 찬 평면 목록으로 시작하므로 접힌 첫 화면을 낯설어하는 사용자가 있다 — 전체 여닫기 토글을 검색칩 옆에 두고, 그 선택을 %LocalAppData%\SheetMe\prefs.json 에 보존한다(신규 UserPrefs). 매 실행마다 다시 펴지 않아도 된다. [WPF 함정] 그룹핑을 걸면 WPF 는 기본적으로 가상화를 끈다 — 1,600여 항목이 한꺼번에 실체화돼 탭 전환이 눈에 띄게 멈춘다. IsVirtualizingWhenGrouping + VirtualizationMode=Recycling 필수. 그룹 키를 문자열이 아니라 SheetGroupViewModel 인스턴스로 둔 것도 의도다 — CollectionViewSource 가 값 동등성으로 묶으므로 코드당 같은 인스턴스를 주면(SheetGroupRegistry) 헤더에서 Name.IsExpanded 를 TwoWay 로 바인딩해 접기 상태를 VM 이 소유할 수 있다. [열기 대화상자] 같은 ListSheets 를 쓰는 SheetOpenDialogView 는 그리드라 트리 대신 '분류' 열을 추가했다(같은 정보, 그 화면에 맞는 형태). [진단 모드 신설] --db-cls (분류 후보 분포 + 라벨 마스터 + 조인 증식 검사 + 고아 코드 + 모집단), --db-tables <패턴> (이름 모르는 마스터 테이블 탐색). 둘 다 읽기 전용. 검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0, **종이 렌더 P062 픽셀 대조 차이 0**. 실 UI: 24분류 한 화면 · 의사기록 펼침 266건 · '경과기록' 45건/6분류 자동 펼침 · '간호기록' 204건/4분류(분류명 매칭 동작 확인). 별건 보고: 목록 모집단에 폐기 서식(ShtUseYon='N')이 452건(28%) 섞여 그룹 건수를 부풀린다. 레거시 서식생성기도 이 필터를 걸지 않으므로 이번엔 동작을 바꾸지 않았다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d20cc2971
commit
ca16630f3a
@@ -23,6 +23,11 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
private string statusText = "준비";
|
||||
private string sheetSearchKeyword = string.Empty;
|
||||
private bool isSheetListLoading;
|
||||
private int sheetGroupCount;
|
||||
private bool sheetListTruncated;
|
||||
private string appliedSheetKeyword = string.Empty;
|
||||
private const string SheetGroupsExpandedPref = "sheetGroupsExpanded";
|
||||
private bool areSheetGroupsExpanded = UserPrefs.GetBool(SheetGroupsExpandedPref, false);
|
||||
private int selectedLeftTabIndex;
|
||||
private bool isHandTool;
|
||||
private const string XmlFilter = "서식 XML (*.xml)|*.xml|모든 파일 (*.*)|*.*";
|
||||
@@ -104,10 +109,59 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
return "불러오는 중…";
|
||||
}
|
||||
return SheetList.Count == 0 ? "결과 없음" : $"{SheetList.Count}건";
|
||||
if (SheetList.Count == 0)
|
||||
{
|
||||
return "결과 없음";
|
||||
}
|
||||
var text = sheetGroupCount > 0 ? $"{SheetList.Count}건 · 분류 {sheetGroupCount}종" : $"{SheetList.Count}건";
|
||||
if (sheetListTruncated)
|
||||
{
|
||||
text += $" · 상한 {FormDesignDataBusiness.SheetListLimit}건에 도달해 뒤쪽 분류가 잘렸습니다 — 검색어로 좁히세요";
|
||||
}
|
||||
// 입력 중인 검색어가 아니라 '적용된' 검색어를 본다 — 타이핑 중 문구가 깜빡이면 안 된다
|
||||
if (appliedSheetKeyword.Length > 0)
|
||||
{
|
||||
text += " (검색 결과 기준)";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>현재 목록에 나타난 분류 수 — 목록 하단 표시용</summary>
|
||||
public int SheetGroupCount
|
||||
{
|
||||
get => sheetGroupCount;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref sheetGroupCount, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(SheetListStatusText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서식 목록 분류 그룹 전체 펼치기/접기 — 목록 헤더 버튼.
|
||||
/// 선택을 보존한다: 레거시는 서식이 꽉 찬 평면 목록으로 시작하므로 접힌 첫 화면을 낯설어하는
|
||||
/// 사용자가 있다. 한 번 펴 두면 다음 실행에도 그대로 열려 있어야 매번 다시 펴지 않는다.
|
||||
/// </summary>
|
||||
public bool AreSheetGroupsExpanded
|
||||
{
|
||||
get => areSheetGroupsExpanded;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref areSheetGroupsExpanded, value))
|
||||
{
|
||||
SheetGroupRegistry.SetAllExpanded(value);
|
||||
UserPrefs.SetBool(SheetGroupsExpandedPref, value);
|
||||
OnPropertyChanged(nameof(SheetGroupToggleIcon));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>전체 펼치기/접기 버튼 아이콘</summary>
|
||||
public string SheetGroupToggleIcon => areSheetGroupsExpanded ? "chevrons-down-up" : "chevrons-up-down";
|
||||
|
||||
/// <summary>열린 문서 존재 여부 — 빈 상태 오버레이 표시 판정</summary>
|
||||
public bool HasOpenDocuments => OpenDesigners.Count > 0;
|
||||
|
||||
@@ -664,12 +718,28 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
IsSheetListLoading = true;
|
||||
var keyword = SheetSearchKeyword;
|
||||
var sheets = await Task.Run(() => dataBusiness.ListSheets(keyword));
|
||||
|
||||
// 그룹 라벨은 목록에 담기 전에 등록해야 한다 — 나중에 바꾸면 이미 만들어진 헤더가 코드로 남는다
|
||||
SheetGroupRegistry.Register(sheets);
|
||||
SheetList.Clear();
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
SheetList.Add(sheet);
|
||||
}
|
||||
StatusText = $"서식 목록 {sheets.Count}건";
|
||||
// 검색 중이면 결과가 접힌 헤더 뒤에 숨지 않도록 전부 펼친다. 검색어를 지우면 사용자가
|
||||
// 보존해 둔 취향으로 되돌아간다. 새로 생긴 그룹은 기본 접힘이라 값이 같아도 매번 밀어 넣어야
|
||||
// 토글 버튼 표시와 어긋나지 않는다.
|
||||
var expand = keyword.Trim().Length > 0 || UserPrefs.GetBool(SheetGroupsExpandedPref, false);
|
||||
SheetGroupRegistry.SetAllExpanded(expand);
|
||||
areSheetGroupsExpanded = expand;
|
||||
OnPropertyChanged(nameof(AreSheetGroupsExpanded));
|
||||
OnPropertyChanged(nameof(SheetGroupToggleIcon));
|
||||
|
||||
// 상한에 닿았다는 건 뒤쪽 분류가 통째로 잘렸다는 뜻이다 — 조용히 넘기면 "그 분류가 없다"로 읽힌다
|
||||
sheetListTruncated = sheets.Count >= FormDesignDataBusiness.SheetListLimit;
|
||||
appliedSheetKeyword = keyword.Trim();
|
||||
SheetGroupCount = sheets.Select(s => s.ClassCode).Distinct(StringComparer.Ordinal).Count();
|
||||
StatusText = $"서식 목록 {sheets.Count}건 · 분류 {SheetGroupCount}종";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 목록 그룹 1개 — 분류 코드(E_ShtMst.ShtClsCod) 하나에 대응.
|
||||
///
|
||||
/// CollectionViewSource 는 그룹 키를 <b>값 동등성</b>으로 묶으므로, 같은 분류 코드에는 반드시 같은
|
||||
/// 인스턴스를 돌려줘야 한다(<see cref="SheetGroupRegistry"/> 가 그 역할). 그래야 그룹 헤더에서
|
||||
/// <c>Name.IsExpanded</c> 를 TwoWay 로 바인딩해 접기/펴기 상태를 VM 이 소유할 수 있다.
|
||||
/// </summary>
|
||||
public sealed class SheetGroupViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private bool isExpanded;
|
||||
private string label;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>분류 코드(A~X). 미분류는 빈 문자열</summary>
|
||||
public string Code { get; }
|
||||
|
||||
/// <summary>그룹 헤더 표시명 — M_DtlMst 한글명, 없으면 코드, 코드도 없으면 (미분류)</summary>
|
||||
public string Label
|
||||
{
|
||||
get => label;
|
||||
set => SetProperty(ref label, value);
|
||||
}
|
||||
|
||||
/// <summary>펼침 상태</summary>
|
||||
public bool IsExpanded
|
||||
{
|
||||
get => isExpanded;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref isExpanded, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(ChevronIcon));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>펼침 표시 아이콘</summary>
|
||||
public string ChevronIcon => isExpanded ? "chevron-down" : "chevron-right";
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public SheetGroupViewModel(string code, string label)
|
||||
{
|
||||
Code = code;
|
||||
this.label = label;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public override string ToString() => Label;
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 분류 코드 → 그룹 VM 인스턴스 캐시(프로세스 전역, 단일 UI 스레드에서만 접근).
|
||||
///
|
||||
/// 전역인 이유: 그룹 키 인스턴스는 목록을 다시 조회해도 같아야 한다. 조회마다 새로 만들면
|
||||
/// 사용자가 펼쳐 둔 분류가 검색할 때마다 도로 접힌다. 목록 화면이 둘(좌측 패널·열기 대화상자)이라
|
||||
/// 상태가 공유되는 것도 의도한 동작이다.
|
||||
/// </summary>
|
||||
public static class SheetGroupRegistry
|
||||
{
|
||||
#region Member Fields
|
||||
private const string UnclassifiedLabel = "(미분류)";
|
||||
private static readonly Dictionary<string, SheetGroupViewModel> groups = new(StringComparer.Ordinal);
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>코드에 대응하는 그룹 VM — 없으면 생성</summary>
|
||||
public static SheetGroupViewModel For(string? code)
|
||||
{
|
||||
var key = (code ?? string.Empty).Trim();
|
||||
if (!groups.TryGetValue(key, out var group))
|
||||
{
|
||||
group = new SheetGroupViewModel(key, key.Length == 0 ? UnclassifiedLabel : key);
|
||||
groups[key] = group;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 조회 결과의 분류명을 그룹에 반영한다 — 목록에 담기 <b>전</b>에 호출할 것.
|
||||
/// 라벨이 뒤늦게 바뀌면 이미 만들어진 그룹 헤더가 코드 그대로 남는다.
|
||||
/// </summary>
|
||||
public static void Register(IEnumerable<SheetSummary> sheets)
|
||||
{
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var group = For(sheet.ClassCode);
|
||||
if (sheet.ClassName.Length > 0)
|
||||
{
|
||||
group.Label = sheet.ClassName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 전 그룹 펼침/접힘 일괄 설정.
|
||||
/// 검색 중에는 펴고(결과가 헤더 뒤에 숨으면 검색이 무의미하다), 전체 목록에서는 접는다
|
||||
/// (분류 24종 × 평균 68건이라 다 펴면 그냥 긴 목록으로 되돌아간다).
|
||||
/// </summary>
|
||||
public static void SetAllExpanded(bool expanded)
|
||||
{
|
||||
foreach (var group in groups.Values)
|
||||
{
|
||||
group.IsExpanded = expanded;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 분류 코드 → 그룹 VM 컨버터(PropertyGroupDescription 용).
|
||||
/// SheetSummary 는 Data 계층 레코드라 VM 참조를 담지 않는다 — 그룹핑 시점에 여기서 붙인다.
|
||||
/// </summary>
|
||||
public sealed class SheetClassToGroupConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> SheetGroupRegistry.For(value as string);
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
Reference in New Issue
Block a user