diff --git a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs index 3024152..760be7b 100644 --- a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs +++ b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs @@ -6,7 +6,15 @@ namespace SheetMe.Data.Stores; /// 서식 목록 1건(열기 대화상자) /// 서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용 -public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign, bool DesignAllowed); +/// 분류 코드(E_ShtMst.ShtClsCod) — 그룹 정렬 키 +/// 분류 한글명(M_DtlMst.DtlCodNam) — 그룹 헤더 표시 +public sealed record SheetSummary( + string ShtCod, + string Name, + bool HasDesign, + bool DesignAllowed, + string ClassCode, + string ClassName); /// 서식생성기 사용 권한 판정 결과 public enum SheetDesignPermission @@ -49,7 +57,15 @@ public sealed class OracleLegacyFormStore #endregion #region Methods - 조회 - /// 서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외 + /// + /// 서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외. + /// + /// 분류(ShtClsCod)를 함께 읽어 목록을 묶는다. 코드→한글명은 M_DtlMst 의 + /// DtlTblCod='EMR_SHEET_GROUP' 행이 소유하며, EMR 뷰어 트리가 쓰는 것과 같은 조인이라 + /// (`[200]SheetMe/src/Data/SheetMe.Data/EmrTreeDataBusiness.cs:55`) 사용자가 이미 아는 분류가 그대로 나온다. + /// TRIM 비교 필수 — 두 컬럼 다 고정폭 잔여 공백이 섞여 있다. + /// 실측(운영 1,628건): 코드 24종 전부 마스터에 존재, 미분류는 1건뿐. + /// public List ListSheets(string? keyword, int max = 300) { using var connection = Open(); @@ -61,7 +77,9 @@ public sealed class OracleLegacyFormStore .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); for (var i = 0; i < tokens.Length; i++) { - where += $" AND (UPPER(REPLACE(s.ShtKorNam,' ','')) LIKE :k{i} OR UPPER(s.ShtCod) LIKE :k{i})"; + // 분류명도 검색 대상 — 헤더에 보이는 '간호기록'을 그대로 쳤는데 0건이면 배신감이 크다 + where += $" AND (UPPER(REPLACE(s.ShtKorNam,' ','')) LIKE :k{i} OR UPPER(s.ShtCod) LIKE :k{i}" + + $" OR UPPER(REPLACE(dtl.DtlCodNam,' ','')) LIKE :k{i})"; command.Parameters.Add(new OracleParameter($"k{i}", $"%{tokens[i].ToUpperInvariant()}%")); } @@ -69,19 +87,41 @@ public sealed class OracleLegacyFormStore "SELECT * FROM (" + " SELECT s.ShtCod, s.ShtKorNam," + " (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount," + - " NVL(TRIM(s.ShtUsrDesYon),' ') AS UsrDesYon" + - $" FROM E_ShtMst s {where} ORDER BY s.ShtCod" + + " NVL(TRIM(s.ShtUsrDesYon),' ') AS UsrDesYon," + + " NVL(TRIM(s.ShtClsCod),' ') AS ClsCod," + + " NVL(dtl.DtlCodNam,' ') AS ClsNam" + + " FROM E_ShtMst s" + + // 코드당 1행으로 접어서 조인한다 — M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 분류 코드가 + // 기간별 다중행일 수 있고, 그대로 조인하면 서식이 목록에 두 번 뜬다(운영 실측은 현재 증식 0건이지만 + // 스키마상 가능하므로 데이터 운에 기대지 않는다). 적용일 최신 행을 택한다. + " LEFT JOIN (SELECT TRIM(DtlCod) AS Cod, DtlCodNam, DtlDspSeq," + + " ROW_NUMBER() OVER (PARTITION BY TRIM(DtlCod) ORDER BY DtlStrDte DESC) AS Rn" + + " FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP') dtl" + + " ON dtl.Cod = TRIM(s.ShtClsCod) AND dtl.Rn = 1" + + // 그룹 순서는 이 정렬이 그대로 정한다(CollectionView 는 등장 순서로 그룹을 만든다). + // 병원이 정한 표시순서(DtlDspSeq)를 1순위로 — 레거시 기록지정보 분류 탭이 이 순서다 + // (`[002]Master/[024]기록지정보/M.MST.ShtMst/dtShtMst.vb:361-368`). 이 병원은 DspSeq 가 전부 + // 비어 있어 결과적으로 코드순이 되지만, 채워 쓰는 병원에서 순서가 어긋나지 않는다. + // LPAD 는 레거시(2자리)보다 넉넉한 4자리 — '100' 과 '99' 가 뒤집히는 것을 막는다. + $" {where} ORDER BY CASE WHEN TRIM(s.ShtClsCod) IS NULL THEN 1 ELSE 0 END," + + " LPAD(NVL(dtl.DtlDspSeq,'9999'), 4, '0'), s.ShtClsCod, s.ShtCod" + $") WHERE ROWNUM <= {max}"; var result = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { + var classCode = reader.GetString(4).Trim(); + var className = reader.GetString(5).Trim(); result.Add(new SheetSummary( reader.GetString(0), reader.IsDBNull(1) ? string.Empty : reader.GetString(1), reader.GetDecimal(2) > 0, - IsDesignAllowed(reader.GetString(3)))); + IsDesignAllowed(reader.GetString(3)), + classCode, + // 마스터에 없는 코드는 코드 자체를 헤더로 — 사이트가 코드를 추가했는데 마스터를 안 채운 경우 + // '(미분류)'로 뭉뚱그리면 서로 다른 분류가 한 덩어리가 된다. + className.Length > 0 ? className : classCode)); } return result; } diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 1147fb7..f0f8c44 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -161,6 +161,16 @@ public partial class App : Application return Diagnostics.DbSmoke.RunGateReport(args[1]); } + if (args.Length >= 2 && args[0] == "--db-cls") + { + return Diagnostics.DbSmoke.RunClassReport(args[1]); + } + + if (args.Length >= 3 && args[0] == "--db-tables") + { + return Diagnostics.DbSmoke.RunTableSearch(args[1], args[2]); + } + MessageBox.Show($"알 수 없는 진단 옵션입니다: {args[0]}", "서식생성기", MessageBoxButton.OK, MessageBoxImage.Warning); return 2; diff --git a/src/SheetMe.Designer/Controls/LucideIcons.cs b/src/SheetMe.Designer/Controls/LucideIcons.cs index fb2db1c..77e548c 100644 --- a/src/SheetMe.Designer/Controls/LucideIcons.cs +++ b/src/SheetMe.Designer/Controls/LucideIcons.cs @@ -47,6 +47,8 @@ public static class LucideIcons ["chevron-right"] = """""", ["chevron-left"] = """""", ["chevron-down"] = """""", + ["chevrons-up-down"] = """ """, + ["chevrons-down-up"] = """ """, ["layout-template"] = """ """, ["download"] = """ """, ["pencil"] = """ """, diff --git a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs index 96ad490..b0f6bbd 100644 --- a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs +++ b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs @@ -56,9 +56,17 @@ internal sealed class FormDesignDataBusiness return document; } - /// DB 서식 목록 검색 + /// + /// 서식 목록 조회 상한. + /// 운영 실측 모집단이 1,628건이라 잘림 없이 전량이 온다. 종전 300 상한은 분류 그룹핑과 함께면 + /// 정렬이 분류순이라 뒤쪽 분류가 통째로 사라져 "그 분류가 없는 것"처럼 보인다. + /// 서식이 늘어 이 값을 넘기면 같은 형태로 잘리므로, 도달 시 호출부가 사용자에게 알려야 한다. + /// + public const int SheetListLimit = 2000; + + /// DB 서식 목록 검색 — 건까지 public List ListSheets(string? keyword) - => DbStore().ListSheets(keyword); + => DbStore().ListSheets(keyword, SheetListLimit); /// DB 활성 디자인 열기 — 디자인 없으면 null public FormDocument? OpenFromDb(string shtCod) diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 315b3f6..a719393 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -148,6 +148,206 @@ public static class DbSmoke } } + /// + /// 서식 목록 그룹핑 후보 분포(--db-cls) — 분류 컬럼별 값 분포를 집계한다. + /// + /// 그룹 기준을 잘못 고르면 "묶였는데 오히려 못 찾는" 결과가 된다. 판정 기준 셋: + /// ① 그룹 수가 과다하지 않을 것(수십 개면 스크롤이 더 길어진다) + /// ② 한 그룹에 전부 몰리지 않을 것(그룹핑 효과 0) + /// ③ 빈 값 비율이 낮을 것(대부분이 '(미분류)'로 가면 의미가 없다) + /// 목록에 실제로 뜨는 모집단(ShtTyp≠'S')만 세고, 디자인 보유 건수를 함께 낸다. + /// + public static int RunClassReport(string reportPath) + { + var candidates = new[] { "ShtClsCod", "ShtDepCls", "ShtTyp", "ShtUsrTyp", "ShtPatTyp", "ShtWrtTyp", "ShtLstTyp" }; + try + { + var config = ConfigService.Current; + using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); + connection.Open(); + + var lines = new List + { + "서식 목록 그룹핑 후보 분포 — 모집단: E_ShtMst 에서 스캔서식(ShtTyp='S') 제외", + string.Empty, + }; + + // 분류 코드 → 한글 라벨 마스터 실측(EmrTreeDataBusiness.cs:55 의 조인 규약 검증) + using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT TRIM(DtlCod), DtlCodNam, NVL(DtlDspSeq,'-'), NVL(DtlStrDte,'-'), NVL(DtlExpDte,'-') " + + "FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' ORDER BY DtlDspSeq, DtlCod"; + lines.Add("[M_DtlMst DtlTblCod='EMR_SHEET_GROUP'] — ShtClsCod 라벨 마스터 (DspSeq 순)"); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + lines.Add($" {reader.GetString(0),-6} → {(reader.IsDBNull(1) ? "(NULL)" : reader.GetString(1)),-20} " + + $"seq={reader.GetString(2),-6} {reader.GetString(3)}~{reader.GetString(4)}"); + } + lines.Add(string.Empty); + } + + // 조인 행 증식 검사 — M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 코드가 기간별 다중행일 수 있다. + // 증식하면 서식이 목록에 두 번 뜬다(사용자는 '데이터가 깨졌다'로 읽는다). + using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT TRIM(DtlCod), COUNT(*) FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' " + + "GROUP BY TRIM(DtlCod) HAVING COUNT(*) > 1 ORDER BY 2 DESC"; + lines.Add("[조인 증식 검사] 같은 DtlCod 가 2행 이상인 코드"); + using var reader = command.ExecuteReader(); + var any = false; + while (reader.Read()) + { + any = true; + lines.Add($" ⚠ {reader.GetString(0)} — {reader.GetDecimal(1)}행"); + } + lines.Add(any ? " → 조인에 최신행 1건 선택이 반드시 필요" : " 없음(단, 스키마상 가능하므로 방어는 유지)"); + lines.Add(string.Empty); + } + + // 마스터에 없는 고아 분류 코드 — 이 건수만큼 헤더가 원시 코드로 뜬다 + using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT NVL(TRIM(s.ShtClsCod),'(빈값)'), COUNT(*) FROM E_ShtMst s " + + "WHERE NVL(s.ShtTyp,' ') <> 'S' AND NOT EXISTS (" + + " SELECT 1 FROM M_DtlMst d WHERE d.DtlTblCod = 'EMR_SHEET_GROUP' " + + " AND TRIM(d.DtlCod) = TRIM(s.ShtClsCod)) " + + "GROUP BY NVL(TRIM(s.ShtClsCod),'(빈값)') ORDER BY 2 DESC"; + lines.Add("[고아 코드] E_ShtMst 에는 있으나 마스터에 없는 분류"); + using var reader = command.ExecuteReader(); + var any = false; + while (reader.Read()) + { + any = true; + lines.Add($" {reader.GetString(0)} — {reader.GetDecimal(1)}건"); + } + if (!any) + { + lines.Add(" 없음"); + } + lines.Add(string.Empty); + } + + // 폐기 서식(ShtUseYon='N')이 섞이면 그룹 헤더 건수가 부풀어 보인다 + using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)'), COUNT(*) " + + "FROM E_ShtMst GROUP BY NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)') ORDER BY 3 DESC"; + lines.Add("[모집단 검증] ShtTyp × ShtUseYon"); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + lines.Add($" ShtTyp={reader.GetString(0),-8} ShtUseYon={reader.GetString(1),-8} {reader.GetDecimal(2),5}건"); + } + lines.Add(string.Empty); + } + + foreach (var column in candidates) + { + using var command = connection.CreateCommand(); + command.CommandText = + $"SELECT NVL(TRIM(s.{column}),'(빈값)') AS K, COUNT(*) AS Total, " + + " SUM(CASE WHEN EXISTS (SELECT 1 FROM E_SdgMst d " + + " WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') " + + " THEN 1 ELSE 0 END) AS WithDesign " + + "FROM E_ShtMst s WHERE NVL(s.ShtTyp,' ') <> 'S' " + + $"GROUP BY NVL(TRIM(s.{column}),'(빈값)') ORDER BY 2 DESC"; + + var rows = new List<(string Key, decimal Total, decimal WithDesign)>(); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + rows.Add((reader.GetString(0), reader.GetDecimal(1), reader.GetDecimal(2))); + } + } + + var all = rows.Sum(r => r.Total); + var blank = rows.FirstOrDefault(r => r.Key == "(빈값)").Total; + var top = rows.Count > 0 ? rows[0] : default; + lines.Add($"=== {column} — 서로 다른 값 {rows.Count}개 / 빈값 {blank}건({(all == 0 ? 0 : blank * 100 / all)}%) / " + + $"최대 그룹 '{top.Key}' {top.Total}건 ==="); + foreach (var row in rows.Take(40)) + { + lines.Add($" {row.Key,-32} {row.Total,5}건 (디자인 {row.WithDesign,5})"); + } + if (rows.Count > 40) + { + lines.Add($" … 이하 {rows.Count - 40}개 생략"); + } + lines.Add(string.Empty); + } + + File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); + return 0; + } + catch (Exception ex) + { + File.WriteAllText(reportPath, "실패: " + ex.Message); + return 1; + } + } + + /// + /// 테이블/컬럼 이름 검색(--db-tables <패턴>) — 이름을 모르는 마스터 테이블을 찾을 때. + /// 레거시 스키마에 코드 마스터가 여러 관례로 흩어져 있어(E_*/M_*), 이름 추측으로 헛도는 것을 막는다. + /// 접근 가능한 모든 스키마를 훑되 소유자를 함께 표시한다(읽기 전용). + /// + public static int RunTableSearch(string pattern, string reportPath) + { + try + { + var config = ConfigService.Current; + using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); + connection.Open(); + var lines = new List(); + + using (var command = connection.CreateCommand()) + { + command.BindByName = true; + command.CommandText = + "SELECT OWNER, TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME LIKE UPPER(:p) " + + "ORDER BY OWNER, TABLE_NAME"; + command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern)); + lines.Add($"[테이블명 LIKE {pattern}]"); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}"); + } + } + + lines.Add(string.Empty); + using (var command = connection.CreateCommand()) + { + command.BindByName = true; + command.CommandText = + "SELECT OWNER, TABLE_NAME, COLUMN_NAME FROM ALL_TAB_COLUMNS WHERE COLUMN_NAME LIKE UPPER(:p) " + + "ORDER BY OWNER, TABLE_NAME, COLUMN_NAME"; + command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern)); + lines.Add($"[컬럼명 LIKE {pattern}]"); + using var reader = command.ExecuteReader(); + var count = 0; + while (reader.Read() && count++ < 200) + { + lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}.{reader.GetString(2)}"); + } + } + + File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); + return 0; + } + catch (Exception ex) + { + File.WriteAllText(reportPath, "실패: " + ex.Message); + return 1; + } + } + /// 테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용) public static int RunColumns(string tableName, string reportPath) { diff --git a/src/SheetMe.Designer/Services/UserPrefs.cs b/src/SheetMe.Designer/Services/UserPrefs.cs new file mode 100644 index 0000000..16f11dd --- /dev/null +++ b/src/SheetMe.Designer/Services/UserPrefs.cs @@ -0,0 +1,77 @@ +using System.IO; +using System.Text.Json; + +namespace SheetMe.Designer.Services; + +/// +/// 사용자별 화면 취향 보존 — %LocalAppData%\SheetMe\prefs.json. +/// +/// 업무 데이터가 아니라 "이 사람이 이 화면을 어떻게 보길 원하는가"만 담는다. 매 실행마다 사용자가 +/// 같은 조작을 반복하게 만들지 않는 것이 목적이라, 읽기·쓰기 실패는 전부 삼키고 기본값으로 간다 +/// (설정 파일 때문에 앱이 막히면 안 된다). 와 같은 폴더 규약. +/// +public static class UserPrefs +{ + #region Member Fields + private static readonly string SettingsPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SheetMe", "prefs.json"); + + private static Dictionary values = new(StringComparer.Ordinal); + private static bool loaded; + #endregion + + #region Methods + /// 불리언 취향 읽기 — 미저장이면 기본값 + public static bool GetBool(string key, bool fallback) + { + EnsureLoaded(); + return values.TryGetValue(key, out var value) ? value : fallback; + } + + /// 불리언 취향 저장 + public static void SetBool(string key, bool value) + { + EnsureLoaded(); + if (values.TryGetValue(key, out var current) && current == value) + { + return; + } + values[key] = value; + Save(); + } + + private static void EnsureLoaded() + { + if (loaded) + { + return; + } + loaded = true; + try + { + if (File.Exists(SettingsPath)) + { + values = JsonSerializer.Deserialize>(File.ReadAllText(SettingsPath)) + ?? new Dictionary(StringComparer.Ordinal); + } + } + catch + { + // 손상된 설정은 무시하고 기본값으로 — 다음 저장 때 덮인다 + } + } + + private static void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!); + File.WriteAllText(SettingsPath, JsonSerializer.Serialize(values)); + } + catch + { + // 쓰기 실패(권한·잠금)로 업무를 막지 않는다 — 이번 실행에만 반영된다 + } + } + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs index ff387be..e538111 100644 --- a/src/SheetMe.Designer/ViewModels/MainViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs @@ -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; } } + /// 현재 목록에 나타난 분류 수 — 목록 하단 표시용 + public int SheetGroupCount + { + get => sheetGroupCount; + set + { + if (SetProperty(ref sheetGroupCount, value)) + { + OnPropertyChanged(nameof(SheetListStatusText)); + } + } + } + + /// + /// 서식 목록 분류 그룹 전체 펼치기/접기 — 목록 헤더 버튼. + /// 선택을 보존한다: 레거시는 서식이 꽉 찬 평면 목록으로 시작하므로 접힌 첫 화면을 낯설어하는 + /// 사용자가 있다. 한 번 펴 두면 다음 실행에도 그대로 열려 있어야 매번 다시 펴지 않는다. + /// + public bool AreSheetGroupsExpanded + { + get => areSheetGroupsExpanded; + set + { + if (SetProperty(ref areSheetGroupsExpanded, value)) + { + SheetGroupRegistry.SetAllExpanded(value); + UserPrefs.SetBool(SheetGroupsExpandedPref, value); + OnPropertyChanged(nameof(SheetGroupToggleIcon)); + } + } + } + + /// 전체 펼치기/접기 버튼 아이콘 + public string SheetGroupToggleIcon => areSheetGroupsExpanded ? "chevrons-down-up" : "chevrons-up-down"; + /// 열린 문서 존재 여부 — 빈 상태 오버레이 표시 판정 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) { diff --git a/src/SheetMe.Designer/ViewModels/SheetGroupViewModel.cs b/src/SheetMe.Designer/ViewModels/SheetGroupViewModel.cs new file mode 100644 index 0000000..4b51dcb --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/SheetGroupViewModel.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Windows.Data; +using SheetMe.Data.Stores; + +namespace SheetMe.Designer.ViewModels; + +/// +/// 서식 목록 그룹 1개 — 분류 코드(E_ShtMst.ShtClsCod) 하나에 대응. +/// +/// CollectionViewSource 는 그룹 키를 값 동등성으로 묶으므로, 같은 분류 코드에는 반드시 같은 +/// 인스턴스를 돌려줘야 한다( 가 그 역할). 그래야 그룹 헤더에서 +/// Name.IsExpanded 를 TwoWay 로 바인딩해 접기/펴기 상태를 VM 이 소유할 수 있다. +/// +public sealed class SheetGroupViewModel : ViewModelBase +{ + #region Member Fields + private bool isExpanded; + private string label; + #endregion + + #region Properties + /// 분류 코드(A~X). 미분류는 빈 문자열 + public string Code { get; } + + /// 그룹 헤더 표시명 — M_DtlMst 한글명, 없으면 코드, 코드도 없으면 (미분류) + public string Label + { + get => label; + set => SetProperty(ref label, value); + } + + /// 펼침 상태 + public bool IsExpanded + { + get => isExpanded; + set + { + if (SetProperty(ref isExpanded, value)) + { + OnPropertyChanged(nameof(ChevronIcon)); + } + } + } + + /// 펼침 표시 아이콘 + 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 +} + +/// +/// 분류 코드 → 그룹 VM 인스턴스 캐시(프로세스 전역, 단일 UI 스레드에서만 접근). +/// +/// 전역인 이유: 그룹 키 인스턴스는 목록을 다시 조회해도 같아야 한다. 조회마다 새로 만들면 +/// 사용자가 펼쳐 둔 분류가 검색할 때마다 도로 접힌다. 목록 화면이 둘(좌측 패널·열기 대화상자)이라 +/// 상태가 공유되는 것도 의도한 동작이다. +/// +public static class SheetGroupRegistry +{ + #region Member Fields + private const string UnclassifiedLabel = "(미분류)"; + private static readonly Dictionary groups = new(StringComparer.Ordinal); + #endregion + + #region Methods + /// 코드에 대응하는 그룹 VM — 없으면 생성 + 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; + } + + /// + /// 조회 결과의 분류명을 그룹에 반영한다 — 목록에 담기 에 호출할 것. + /// 라벨이 뒤늦게 바뀌면 이미 만들어진 그룹 헤더가 코드 그대로 남는다. + /// + public static void Register(IEnumerable sheets) + { + foreach (var sheet in sheets) + { + var group = For(sheet.ClassCode); + if (sheet.ClassName.Length > 0) + { + group.Label = sheet.ClassName; + } + } + } + + /// + /// 전 그룹 펼침/접힘 일괄 설정. + /// 검색 중에는 펴고(결과가 헤더 뒤에 숨으면 검색이 무의미하다), 전체 목록에서는 접는다 + /// (분류 24종 × 평균 68건이라 다 펴면 그냥 긴 목록으로 되돌아간다). + /// + public static void SetAllExpanded(bool expanded) + { + foreach (var group in groups.Values) + { + group.IsExpanded = expanded; + } + } + #endregion +} + +/// +/// 분류 코드 → 그룹 VM 컨버터(PropertyGroupDescription 용). +/// SheetSummary 는 Data 계층 레코드라 VM 참조를 담지 않는다 — 그룹핑 시점에 여기서 붙인다. +/// +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(); +} diff --git a/src/SheetMe.Designer/Views/MainView.xaml b/src/SheetMe.Designer/Views/MainView.xaml index cada4b6..45246ec 100644 --- a/src/SheetMe.Designer/Views/MainView.xaml +++ b/src/SheetMe.Designer/Views/MainView.xaml @@ -47,6 +47,19 @@ + + + + + + + + + @@ -309,6 +322,11 @@ Margin="6,0,0,0" ToolTip="검색 (Enter)" Command="{Binding SearchSheetsCommand}" Content="{Binding Source=search, Converter={StaticResource IconName}, ConverterParameter=15}"/> + + - + + ToolTip="분류 머리글을 눌러 펼치고, 서식을 더블클릭해 엽니다"> + + + + + + + + + + + + + + - + + diff --git a/src/SheetMe.Designer/Views/SheetOpenDialogView.xaml b/src/SheetMe.Designer/Views/SheetOpenDialogView.xaml index e02e4a6..09c657b 100644 --- a/src/SheetMe.Designer/Views/SheetOpenDialogView.xaml +++ b/src/SheetMe.Designer/Views/SheetOpenDialogView.xaml @@ -1,7 +1,7 @@ @@ -18,8 +18,10 @@ - - + + + +