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 @@
-
-
+
+
+
+