서식 목록을 분류(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
@@ -6,7 +6,15 @@ namespace SheetMe.Data.Stores;
|
|||||||
|
|
||||||
/// <summary>서식 목록 1건(열기 대화상자)</summary>
|
/// <summary>서식 목록 1건(열기 대화상자)</summary>
|
||||||
/// <param name="DesignAllowed">서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용</param>
|
/// <param name="DesignAllowed">서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용</param>
|
||||||
public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign, bool DesignAllowed);
|
/// <param name="ClassCode">분류 코드(E_ShtMst.ShtClsCod) — 그룹 정렬 키</param>
|
||||||
|
/// <param name="ClassName">분류 한글명(M_DtlMst.DtlCodNam) — 그룹 헤더 표시</param>
|
||||||
|
public sealed record SheetSummary(
|
||||||
|
string ShtCod,
|
||||||
|
string Name,
|
||||||
|
bool HasDesign,
|
||||||
|
bool DesignAllowed,
|
||||||
|
string ClassCode,
|
||||||
|
string ClassName);
|
||||||
|
|
||||||
/// <summary>서식생성기 사용 권한 판정 결과</summary>
|
/// <summary>서식생성기 사용 권한 판정 결과</summary>
|
||||||
public enum SheetDesignPermission
|
public enum SheetDesignPermission
|
||||||
@@ -49,7 +57,15 @@ public sealed class OracleLegacyFormStore
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods - 조회
|
#region Methods - 조회
|
||||||
/// <summary>서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외</summary>
|
/// <summary>
|
||||||
|
/// 서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외.
|
||||||
|
///
|
||||||
|
/// 분류(ShtClsCod)를 함께 읽어 목록을 묶는다. 코드→한글명은 <c>M_DtlMst</c> 의
|
||||||
|
/// <c>DtlTblCod='EMR_SHEET_GROUP'</c> 행이 소유하며, EMR 뷰어 트리가 쓰는 것과 같은 조인이라
|
||||||
|
/// (`[200]SheetMe/src/Data/SheetMe.Data/EmrTreeDataBusiness.cs:55`) 사용자가 이미 아는 분류가 그대로 나온다.
|
||||||
|
/// TRIM 비교 필수 — 두 컬럼 다 고정폭 잔여 공백이 섞여 있다.
|
||||||
|
/// 실측(운영 1,628건): 코드 24종 전부 마스터에 존재, 미분류는 1건뿐.
|
||||||
|
/// </summary>
|
||||||
public List<SheetSummary> ListSheets(string? keyword, int max = 300)
|
public List<SheetSummary> ListSheets(string? keyword, int max = 300)
|
||||||
{
|
{
|
||||||
using var connection = Open();
|
using var connection = Open();
|
||||||
@@ -61,7 +77,9 @@ public sealed class OracleLegacyFormStore
|
|||||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
for (var i = 0; i < tokens.Length; i++)
|
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()}%"));
|
command.Parameters.Add(new OracleParameter($"k{i}", $"%{tokens[i].ToUpperInvariant()}%"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,19 +87,41 @@ public sealed class OracleLegacyFormStore
|
|||||||
"SELECT * FROM (" +
|
"SELECT * FROM (" +
|
||||||
" SELECT s.ShtCod, s.ShtKorNam," +
|
" SELECT s.ShtCod, s.ShtKorNam," +
|
||||||
" (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount," +
|
" (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount," +
|
||||||
" NVL(TRIM(s.ShtUsrDesYon),' ') AS UsrDesYon" +
|
" NVL(TRIM(s.ShtUsrDesYon),' ') AS UsrDesYon," +
|
||||||
$" FROM E_ShtMst s {where} ORDER BY s.ShtCod" +
|
" 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}";
|
$") WHERE ROWNUM <= {max}";
|
||||||
|
|
||||||
var result = new List<SheetSummary>();
|
var result = new List<SheetSummary>();
|
||||||
using var reader = command.ExecuteReader();
|
using var reader = command.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
|
var classCode = reader.GetString(4).Trim();
|
||||||
|
var className = reader.GetString(5).Trim();
|
||||||
result.Add(new SheetSummary(
|
result.Add(new SheetSummary(
|
||||||
reader.GetString(0),
|
reader.GetString(0),
|
||||||
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
|
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
|
||||||
reader.GetDecimal(2) > 0,
|
reader.GetDecimal(2) > 0,
|
||||||
IsDesignAllowed(reader.GetString(3))));
|
IsDesignAllowed(reader.GetString(3)),
|
||||||
|
classCode,
|
||||||
|
// 마스터에 없는 코드는 코드 자체를 헤더로 — 사이트가 코드를 추가했는데 마스터를 안 채운 경우
|
||||||
|
// '(미분류)'로 뭉뚱그리면 서로 다른 분류가 한 덩어리가 된다.
|
||||||
|
className.Length > 0 ? className : classCode));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ public partial class App : Application
|
|||||||
return Diagnostics.DbSmoke.RunGateReport(args[1]);
|
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]}", "서식생성기",
|
MessageBox.Show($"알 수 없는 진단 옵션입니다: {args[0]}", "서식생성기",
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
return 2;
|
return 2;
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public static class LucideIcons
|
|||||||
["chevron-right"] = """<path d="m9 18 6-6-6-6" />""",
|
["chevron-right"] = """<path d="m9 18 6-6-6-6" />""",
|
||||||
["chevron-left"] = """<path d="m15 18-6-6 6-6" />""",
|
["chevron-left"] = """<path d="m15 18-6-6 6-6" />""",
|
||||||
["chevron-down"] = """<path d="m6 9 6 6 6-6" />""",
|
["chevron-down"] = """<path d="m6 9 6 6 6-6" />""",
|
||||||
|
["chevrons-up-down"] = """<path d="m7 15 5 5 5-5" /> <path d="m7 9 5-5 5 5" />""",
|
||||||
|
["chevrons-down-up"] = """<path d="m7 20 5-5 5 5" /> <path d="m7 4 5 5 5-5" />""",
|
||||||
["layout-template"] = """<rect width="18" height="7" x="3" y="3" rx="1" /> <rect width="9" height="7" x="3" y="14" rx="1" /> <rect width="5" height="7" x="16" y="14" rx="1" />""",
|
["layout-template"] = """<rect width="18" height="7" x="3" y="3" rx="1" /> <rect width="9" height="7" x="3" y="14" rx="1" /> <rect width="5" height="7" x="16" y="14" rx="1" />""",
|
||||||
["download"] = """<path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" />""",
|
["download"] = """<path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" />""",
|
||||||
["pencil"] = """<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
|
["pencil"] = """<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
|
||||||
|
|||||||
@@ -56,9 +56,17 @@ internal sealed class FormDesignDataBusiness
|
|||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>DB 서식 목록 검색</summary>
|
/// <summary>
|
||||||
|
/// 서식 목록 조회 상한.
|
||||||
|
/// 운영 실측 모집단이 1,628건이라 잘림 없이 전량이 온다. 종전 300 상한은 분류 그룹핑과 함께면
|
||||||
|
/// 정렬이 분류순이라 <b>뒤쪽 분류가 통째로</b> 사라져 "그 분류가 없는 것"처럼 보인다.
|
||||||
|
/// 서식이 늘어 이 값을 넘기면 같은 형태로 잘리므로, 도달 시 호출부가 사용자에게 알려야 한다.
|
||||||
|
/// </summary>
|
||||||
|
public const int SheetListLimit = 2000;
|
||||||
|
|
||||||
|
/// <summary>DB 서식 목록 검색 — <see cref="SheetListLimit"/> 건까지</summary>
|
||||||
public List<SheetSummary> ListSheets(string? keyword)
|
public List<SheetSummary> ListSheets(string? keyword)
|
||||||
=> DbStore().ListSheets(keyword);
|
=> DbStore().ListSheets(keyword, SheetListLimit);
|
||||||
|
|
||||||
/// <summary>DB 활성 디자인 열기 — 디자인 없으면 null</summary>
|
/// <summary>DB 활성 디자인 열기 — 디자인 없으면 null</summary>
|
||||||
public FormDocument? OpenFromDb(string shtCod)
|
public FormDocument? OpenFromDb(string shtCod)
|
||||||
|
|||||||
@@ -148,6 +148,206 @@ public static class DbSmoke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 서식 목록 그룹핑 후보 분포(--db-cls) — 분류 컬럼별 값 분포를 집계한다.
|
||||||
|
///
|
||||||
|
/// 그룹 기준을 잘못 고르면 "묶였는데 오히려 못 찾는" 결과가 된다. 판정 기준 셋:
|
||||||
|
/// ① 그룹 수가 과다하지 않을 것(수십 개면 스크롤이 더 길어진다)
|
||||||
|
/// ② 한 그룹에 전부 몰리지 않을 것(그룹핑 효과 0)
|
||||||
|
/// ③ 빈 값 비율이 낮을 것(대부분이 '(미분류)'로 가면 의미가 없다)
|
||||||
|
/// 목록에 실제로 뜨는 모집단(ShtTyp≠'S')만 세고, 디자인 보유 건수를 함께 낸다.
|
||||||
|
/// </summary>
|
||||||
|
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<string>
|
||||||
|
{
|
||||||
|
"서식 목록 그룹핑 후보 분포 — 모집단: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 테이블/컬럼 이름 검색(--db-tables <패턴>) — 이름을 모르는 마스터 테이블을 찾을 때.
|
||||||
|
/// 레거시 스키마에 코드 마스터가 여러 관례로 흩어져 있어(E_*/M_*), 이름 추측으로 헛도는 것을 막는다.
|
||||||
|
/// 접근 가능한 모든 스키마를 훑되 소유자를 함께 표시한다(읽기 전용).
|
||||||
|
/// </summary>
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
|
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
|
||||||
public static int RunColumns(string tableName, string reportPath)
|
public static int RunColumns(string tableName, string reportPath)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 사용자별 화면 취향 보존 — %LocalAppData%\SheetMe\prefs.json.
|
||||||
|
///
|
||||||
|
/// 업무 데이터가 아니라 "이 사람이 이 화면을 어떻게 보길 원하는가"만 담는다. 매 실행마다 사용자가
|
||||||
|
/// 같은 조작을 반복하게 만들지 않는 것이 목적이라, 읽기·쓰기 실패는 전부 삼키고 기본값으로 간다
|
||||||
|
/// (설정 파일 때문에 앱이 막히면 안 된다). <see cref="ThemeManager"/> 와 같은 폴더 규약.
|
||||||
|
/// </summary>
|
||||||
|
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<string, bool> values = new(StringComparer.Ordinal);
|
||||||
|
private static bool loaded;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>불리언 취향 읽기 — 미저장이면 기본값</summary>
|
||||||
|
public static bool GetBool(string key, bool fallback)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return values.TryGetValue(key, out var value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>불리언 취향 저장</summary>
|
||||||
|
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<Dictionary<string, bool>>(File.ReadAllText(SettingsPath))
|
||||||
|
?? new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 손상된 설정은 무시하고 기본값으로 — 다음 저장 때 덮인다
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
|
||||||
|
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(values));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 쓰기 실패(권한·잠금)로 업무를 막지 않는다 — 이번 실행에만 반영된다
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
private string statusText = "준비";
|
private string statusText = "준비";
|
||||||
private string sheetSearchKeyword = string.Empty;
|
private string sheetSearchKeyword = string.Empty;
|
||||||
private bool isSheetListLoading;
|
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 int selectedLeftTabIndex;
|
||||||
private bool isHandTool;
|
private bool isHandTool;
|
||||||
private const string XmlFilter = "서식 XML (*.xml)|*.xml|모든 파일 (*.*)|*.*";
|
private const string XmlFilter = "서식 XML (*.xml)|*.xml|모든 파일 (*.*)|*.*";
|
||||||
@@ -104,10 +109,59 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
return "불러오는 중…";
|
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>
|
/// <summary>열린 문서 존재 여부 — 빈 상태 오버레이 표시 판정</summary>
|
||||||
public bool HasOpenDocuments => OpenDesigners.Count > 0;
|
public bool HasOpenDocuments => OpenDesigners.Count > 0;
|
||||||
|
|
||||||
@@ -664,12 +718,28 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
IsSheetListLoading = true;
|
IsSheetListLoading = true;
|
||||||
var keyword = SheetSearchKeyword;
|
var keyword = SheetSearchKeyword;
|
||||||
var sheets = await Task.Run(() => dataBusiness.ListSheets(keyword));
|
var sheets = await Task.Run(() => dataBusiness.ListSheets(keyword));
|
||||||
|
|
||||||
|
// 그룹 라벨은 목록에 담기 전에 등록해야 한다 — 나중에 바꾸면 이미 만들어진 헤더가 코드로 남는다
|
||||||
|
SheetGroupRegistry.Register(sheets);
|
||||||
SheetList.Clear();
|
SheetList.Clear();
|
||||||
foreach (var sheet in sheets)
|
foreach (var sheet in sheets)
|
||||||
{
|
{
|
||||||
SheetList.Add(sheet);
|
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)
|
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();
|
||||||
|
}
|
||||||
@@ -47,6 +47,19 @@
|
|||||||
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
||||||
<ctl:InverseBoolConverter x:Key="InverseBool"/>
|
<ctl:InverseBoolConverter x:Key="InverseBool"/>
|
||||||
|
|
||||||
|
<vm:SheetClassToGroupConverter x:Key="SheetClassToGroup"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
서식 목록 — 분류(E_ShtMst.ShtClsCod) 그룹 뷰.
|
||||||
|
SortDescriptions 를 두지 않는 것이 의도다: 그룹 순서는 SQL 의 ORDER BY(분류코드 A~X, 미분류 뒤)가
|
||||||
|
정하고 CollectionView 는 등장 순서대로 그룹을 만든다. 여기서 다시 정렬하면 그 의도가 덮인다.
|
||||||
|
-->
|
||||||
|
<CollectionViewSource x:Key="SheetsGrouped" Source="{Binding SheetList}">
|
||||||
|
<CollectionViewSource.GroupDescriptions>
|
||||||
|
<PropertyGroupDescription PropertyName="ClassCode" Converter="{StaticResource SheetClassToGroup}"/>
|
||||||
|
</CollectionViewSource.GroupDescriptions>
|
||||||
|
</CollectionViewSource>
|
||||||
|
|
||||||
<!-- 팔레트(도구 상자/플라이아웃 공용) — 카테고리 그룹 뷰 -->
|
<!-- 팔레트(도구 상자/플라이아웃 공용) — 카테고리 그룹 뷰 -->
|
||||||
<CollectionViewSource x:Key="PaletteGrouped" Source="{Binding PaletteItems}">
|
<CollectionViewSource x:Key="PaletteGrouped" Source="{Binding PaletteItems}">
|
||||||
<CollectionViewSource.GroupDescriptions>
|
<CollectionViewSource.GroupDescriptions>
|
||||||
@@ -309,6 +322,11 @@
|
|||||||
Margin="6,0,0,0" ToolTip="검색 (Enter)"
|
Margin="6,0,0,0" ToolTip="검색 (Enter)"
|
||||||
Command="{Binding SearchSheetsCommand}"
|
Command="{Binding SearchSheetsCommand}"
|
||||||
Content="{Binding Source=search, Converter={StaticResource IconName}, ConverterParameter=15}"/>
|
Content="{Binding Source=search, Converter={StaticResource IconName}, ConverterParameter=15}"/>
|
||||||
|
<!-- 분류 24종을 하나씩 여닫는 건 번거롭다 — 전체 여닫기 -->
|
||||||
|
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}" Width="26" Height="28"
|
||||||
|
Margin="4,0,0,0" ToolTip="분류 전체 펼치기 / 접기"
|
||||||
|
IsChecked="{Binding AreSheetGroupsExpanded, Mode=TwoWay}"
|
||||||
|
Content="{Binding SheetGroupToggleIcon, Converter={StaticResource IconName}, ConverterParameter=15}"/>
|
||||||
<!-- 자리표시자는 TextBox 위에 겹친 TextBlock — Text 가 비었을 때만 보인다 -->
|
<!-- 자리표시자는 TextBox 위에 겹친 TextBlock — Text 가 비었을 때만 보인다 -->
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextBox x:Name="SheetSearchBox"
|
<TextBox x:Name="SheetSearchBox"
|
||||||
@@ -337,15 +355,79 @@
|
|||||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding SheetListStatusText}" FontSize="11"
|
<TextBlock DockPanel.Dock="Bottom" Text="{Binding SheetListStatusText}" FontSize="11"
|
||||||
Foreground="{DynamicResource B.Muted}" Margin="9,4,9,8" TextWrapping="Wrap"/>
|
Foreground="{DynamicResource B.Muted}" Margin="9,4,9,8" TextWrapping="Wrap"/>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ListBox x:Name="SheetListBox" ItemsSource="{Binding SheetList}"
|
<!--
|
||||||
|
IsVirtualizingWhenGrouping 은 반드시 켜야 한다 — 그룹핑을 걸면 WPF 는 기본적으로
|
||||||
|
가상화를 끄고 1,600여 항목을 한꺼번에 실체화한다(탭 전환이 눈에 띄게 멈춘다).
|
||||||
|
-->
|
||||||
|
<ListBox x:Name="SheetListBox" ItemsSource="{Binding Source={StaticResource SheetsGrouped}}"
|
||||||
Margin="4,0,4,4"
|
Margin="4,0,4,4"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.IsVirtualizingWhenGrouping="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
MouseDoubleClick="OnSheetListDoubleClick"
|
MouseDoubleClick="OnSheetListDoubleClick"
|
||||||
ToolTip="더블클릭으로 열기 — 여러 서식을 탭으로 동시에 편집할 수 있습니다">
|
ToolTip="분류 머리글을 눌러 펼치고, 서식을 더블클릭해 엽니다">
|
||||||
|
<ListBox.GroupStyle>
|
||||||
|
<GroupStyle>
|
||||||
|
<GroupStyle.ContainerStyle>
|
||||||
|
<Style TargetType="GroupItem">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="GroupItem">
|
||||||
|
<StackPanel>
|
||||||
|
<!-- 그룹 키가 SheetGroupViewModel 인스턴스라 Name.IsExpanded 를 TwoWay 로 잡을 수 있다 -->
|
||||||
|
<ToggleButton IsChecked="{Binding Name.IsExpanded, Mode=TwoWay}" Cursor="Hand"
|
||||||
|
Focusable="False">
|
||||||
|
<ToggleButton.Template>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border x:Name="bd" Background="Transparent" CornerRadius="5"
|
||||||
|
Padding="4,5" Margin="1,4,1,1">
|
||||||
|
<DockPanel>
|
||||||
|
<ContentControl DockPanel.Dock="Left" Margin="0,0,5,0" VerticalAlignment="Center"
|
||||||
|
IsTabStop="False" Focusable="False"
|
||||||
|
Content="{Binding Name.ChevronIcon, Converter={StaticResource IconName}, ConverterParameter=12}"/>
|
||||||
|
<!-- 검색 중이면 이 숫자는 '검색 통과분'이지 그 분류의 전체 건수가 아니다 -->
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding ItemCount}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource B.Muted}" VerticalAlignment="Center"
|
||||||
|
Margin="6,0,3,0"
|
||||||
|
ToolTip="현재 목록에 표시된 건수(검색 중이면 검색 통과분)"/>
|
||||||
|
<TextBlock Text="{Binding Name.Label}" FontWeight="Bold" FontSize="11.5"
|
||||||
|
Foreground="{DynamicResource B.Muted}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ToggleButton.Template>
|
||||||
|
</ToggleButton>
|
||||||
|
<ItemsPresenter>
|
||||||
|
<ItemsPresenter.Style>
|
||||||
|
<Style TargetType="ItemsPresenter">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Name.IsExpanded}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ItemsPresenter.Style>
|
||||||
|
</ItemsPresenter>
|
||||||
|
</StackPanel>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</GroupStyle.ContainerStyle>
|
||||||
|
</GroupStyle>
|
||||||
|
</ListBox.GroupStyle>
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Orientation="Horizontal" Margin="1">
|
<!-- 좌측 여백은 분류 머리글 아래 항목임을 드러내는 들여쓰기 -->
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="14,1,1,1">
|
||||||
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||||
Width="52" VerticalAlignment="Center"/>
|
Width="52" VerticalAlignment="Center"/>
|
||||||
<!-- 서식생성기 사용이 꺼진 서식(ShtUsrDesYon='N') — 열기 시 거부된다 -->
|
<!-- 서식생성기 사용이 꺼진 서식(ShtUsrDesYon='N') — 열기 시 거부된다 -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Window x:Class="SheetMe.Designer.Views.SheetOpenDialogView"
|
<Window x:Class="SheetMe.Designer.Views.SheetOpenDialogView"
|
||||||
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="DB에서 서식 열기" Width="560" Height="520"
|
Title="DB에서 서식 열기" Width="680" Height="560"
|
||||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||||
<DockPanel Margin="12">
|
<DockPanel Margin="12">
|
||||||
<DockPanel DockPanel.Dock="Top">
|
<DockPanel DockPanel.Dock="Top">
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
<ListView x:Name="SheetList" Margin="0,10,0,0" MouseDoubleClick="OnOpen">
|
<ListView x:Name="SheetList" Margin="0,10,0,0" MouseDoubleClick="OnOpen">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="서식코드" Width="110" DisplayMemberBinding="{Binding ShtCod}"/>
|
<GridViewColumn Header="서식코드" Width="100" DisplayMemberBinding="{Binding ShtCod}"/>
|
||||||
<GridViewColumn Header="서식명" Width="310" DisplayMemberBinding="{Binding Name}"/>
|
<!-- 좌측 패널은 분류로 묶어 보여주지만 이 대화상자는 그리드다 — 같은 정보를 열로 준다 -->
|
||||||
|
<GridViewColumn Header="분류" Width="120" DisplayMemberBinding="{Binding ClassName}"/>
|
||||||
|
<GridViewColumn Header="서식명" Width="300" DisplayMemberBinding="{Binding Name}"/>
|
||||||
<GridViewColumn Header="디자인" Width="70">
|
<GridViewColumn Header="디자인" Width="70">
|
||||||
<GridViewColumn.CellTemplate>
|
<GridViewColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
|
|||||||
Reference in New Issue
Block a user