Files
SheetMe/src/SheetMe.Designer/ViewModels/MainViewModel.cs
T
MsystechandClaude Opus 5 ddf2bdaee8 같은 서식 중복 열기 차단 — 출처를 가리지 않는다 (+ 3단계 판단용 실측 진단)
■ 중복 열기로 편집분이 통째로 사라지던 문제

중복 차단이 d.IsFromDb 를 요구했다. 그래서 XML 파일로 이미 열어 둔 서식을 목록에서
더블클릭해 DB 로 또 열 수 있었고, 두 탭에서 교대로 저장하면 낙관적 충돌 검출이
양쪽 어디에도 없어 마지막 저장이 앞선 편집을 통째로 덮었다.

레거시 MultiSheetFormAllow 는 탭 제목의 [서식코드]만 보고 출처와 무관하게 막는다
(frmSheetDesigner.vb:242-252). 같은 규칙으로 맞췄다:
  · 판정을 FindOpenSheet(서식코드) 하나로 모으고 IsFromDb 조건을 뺐다.
  · 열기 경로 3곳(DB / XML 파일 / JSON 가져오기)이 전부 이 지점을 지난다.
    종전에는 DB 경로에만 검사가 있었다.
  · 이력 탭은 계속 제외한다 — 현재본을 열려는 요청이 과거 스냅샷을 활성화하면 안 된다.
  · 빈 서식코드는 서로 다른 문서로 본다. 묶으면 '새 서식'을 두 번 만들 수 없다.
  · 막을 때 어느 탭으로 갔는지 상태줄에 적는다. 아무 일도 안 일어난 것처럼 보이면 사용자는 다시 누른다.

비교용으로 나란히 열어야 하는 경우를 위해 탈출구를 남겼다 —
DataConfig.AllowMultiSheetForm(기본 false, 레거시 AllowMultiSheetForm 기본 "N" 과 동일).

■ 함께 고친 것 — 이력본을 저장해도 탭이 '이력'으로 남던 문제

DB 저장 후 HistorySdgKey 를 null 로 되돌리지 않아 탭 제목이 '(이력 N)' 으로 남고,
종료 확인이 '저장할 수 없는 탭' 분기로 들어가 저장 선택지 없이
'변경을 버리고 종료할까요?' 만 물었다 — 저장 뒤의 편집분이 조용히 버려진다.
저장한 순간 그 탭은 스냅샷이 아니라 활성 디자인이므로 표식을 지운다.

이 수정이 만드는 경계가 하나 있다: 이력 탭을 되살려 저장하면 같은 서식의 다른 탭이
이제 옛 내용을 들고 있게 된다. 그 탭에서 저장하면 방금 저장분을 덮으므로,
어느 탭이 낡았는지 알리는 경고를 띄운다(자동으로 닫지는 않는다 — 미저장 편집분이 있을 수 있다).

■ 3단계 판단용 실측(--db-phase3)

사이트 확인을 기다리는 동안 DB 로 알 수 있는 것부터 쟀다:
  · ShtCneYon='Y' 40건, 그중 현역 19건 — TK_MODIFY(제자리 정정) 부재의 실제 영향 범위.
  · ShtDepCls 는 418건(24%)에 실제 분류가 들어 있다(산업재해·물리치료실·병동출력물…).
    비어 있지 않으므로 진료과 2단계 그룹 미이식은 목록 구성을 실제로 바꾼다.
  · ShtDspSeq 는 1,714건(98.7%)이 1 이상이다. 표시 순번은 사실상 전 서식에 채워져 있어,
    정렬 미이식은 거의 모든 서식의 목록 순서를 레거시와 다르게 만든다.
  · ShtTyp='S'(스캔형) 108건, 현역 105건. 사이트 조건 이식이 걸리는 모집단이 실재한다.
  · EMRShtSortTyp 는 확인하지 못했다 — 코드 마스터(DtlCod/DtlCodVal) 테이블이
    이 접속 스키마에 없다. 운영 접속에서 재측정이 필요하다.

편집 스모크 5건 추가(출처 무관 판정 / 대소문자 / 이력 제외 / 무관 서식 / 빈 코드).
회귀: 테스트 142/142, 편집 스모크 192건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:27:00 +09:00

1007 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.ObjectModel;
using System.IO;
using System.Windows;
using M.Framework.WPF;
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
using SheetMe.Designer.DataBusiness;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.ViewModels;
/// <summary>
/// 메인 셸 ViewModel — 멀티 문서 탭(여러 기록지 동시 편집), 상시 서식 목록 패널,
/// 파일/DB 열기·저장, 도구(상용구/폰트). 컨트롤 클립보드는 문서 간 공유(서식 간 복사/붙여넣기).
/// </summary>
internal sealed class MainViewModel : ViewModelBase
{
#region Member Fields
private readonly FormDesignDataBusiness dataBusiness = new();
private readonly DialogService dialogService = new();
private DesignerViewModel? currentDesigner;
private string title = "SheetMe 서식생성기";
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|모든 파일 (*.*)|*.*";
#endregion
#region Properties
/// <summary>창 제목</summary>
public string Title
{
get => title;
set => SetProperty(ref title, value);
}
/// <summary>상태바 텍스트</summary>
public string StatusText
{
get => statusText;
set => SetProperty(ref statusText, value);
}
/// <summary>열린 문서(탭) 목록</summary>
public ObservableCollection<DesignerViewModel> OpenDesigners { get; } = new();
/// <summary>활성 문서(선택 탭)</summary>
public DesignerViewModel? CurrentDesigner
{
get => currentDesigner;
set
{
if (SetProperty(ref currentDesigner, value))
{
Title = value is null
? "SheetMe 서식생성기"
: $"SheetMe 서식생성기 — {value.DisplayName}";
}
}
}
/// <summary>팔레트 컨트롤 목록(레지스트리)</summary>
public IReadOnlyList<ControlDescriptor> PaletteItems => ControlRegistry.All;
/// <summary>서식 목록(상시 패널) — DB E_ShtMst</summary>
public ObservableCollection<SheetSummary> SheetList { get; } = new();
/// <summary>서식 목록 검색어</summary>
public string SheetSearchKeyword
{
get => sheetSearchKeyword;
set => SetProperty(ref sheetSearchKeyword, value);
}
/// <summary>서식 목록 로딩 중</summary>
public bool IsSheetListLoading
{
get => isSheetListLoading;
set
{
if (SetProperty(ref isSheetListLoading, value))
{
OnPropertyChanged(nameof(SheetListStatusText));
}
}
}
/// <summary>
/// 서식 목록 하단 상태 한 줄 — 로딩·건수·미접속을 목록 바로 아래에서 알린다.
/// 종전에는 목록 위에 "불러오는 중..." 오버레이를 덮어 목록이 가려졌고, 건수는 창 하단
/// 전역 상태줄에만 떠서 목록과 시선 거리가 멀었다.
/// </summary>
public string SheetListStatusText
{
get
{
if (!CanUseDb)
{
return "DB 미접속 — 파일 모드";
}
if (IsSheetListLoading)
{
return "불러오는 중…";
}
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;
/// <summary>DB 사용 가능 여부(서식 목록 패널 안내용)</summary>
public bool CanUseDb => dataBusiness.CanUseDb;
/// <summary>좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행)</summary>
public int SelectedLeftTabIndex
{
get => selectedLeftTabIndex;
set => SetProperty(ref selectedLeftTabIndex, value);
}
/// <summary>손(팬) 도구 활성 — 캔버스 드래그로 화면 이동(false=선택 도구, [200] 플로팅 바 관행)</summary>
public bool IsHandTool
{
get => isHandTool;
set => SetProperty(ref isHandTool, value);
}
#endregion
#region Constructors
public MainViewModel()
{
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
NewFileCommand = new Command((sender, e) => OnNewFile());
OpenFileCommand = new Command((sender, e) => OnOpenFile());
SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false));
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb());
SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
ExportJsonCommand = new Command((sender, e) => OnExportJson());
ImportJsonCommand = new Command((sender, e) => OnImportJson());
PreviewCommand = new Command((sender, e) => OnPreview());
PrintCommand = new Command((sender, e) => OnPrint());
RecordWordCommand = new Command((sender, e) => OnRecordWords());
FontManagerCommand = new Command((sender, e) => OnFontManager());
SheetHistoryCommand = new Command((sender, e) => OnSheetHistory());
ZoomInCommand = new Command((sender, e) => CurrentDesigner?.ZoomIn());
ZoomOutCommand = new Command((sender, e) => CurrentDesigner?.ZoomOut());
ZoomResetCommand = new Command((sender, e) => CurrentDesigner?.ZoomReset());
ZoomFitCommand = new Command((sender, e) => CurrentDesigner?.ZoomFit());
// Application.Shutdown() 은 Window.Closing 의 e.Cancel 을 무시하므로 VM 에서 먼저 확인한다
ExitCommand = new Command((sender, e) =>
{
if (!ConfirmCloseAll())
{
return;
}
IsShuttingDown = true;
Application.Current.Shutdown();
});
SearchSheetsCommand = new Command(async (sender, e) => await LoadSheetListAsync());
OpenSheetCommand = new Command((object param) => OnOpenSheetFromList(param as SheetSummary));
CloseDocumentCommand = new Command((object param) => OnCloseDocument(param as DesignerViewModel));
SheetList.CollectionChanged += (s, e) => OnPropertyChanged(nameof(SheetListStatusText));
OpenDesigners.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasOpenDocuments));
}
#endregion
#region Commands
/// <summary>창 로드</summary>
public ICustomCommand? LoadedCommand { get; set; }
/// <summary>새 서식</summary>
public ICustomCommand? NewFileCommand { get; set; }
/// <summary>서식 XML 열기</summary>
public ICustomCommand? OpenFileCommand { get; set; }
/// <summary>저장</summary>
public ICustomCommand? SaveFileCommand { get; set; }
/// <summary>다른 이름으로 저장</summary>
public ICustomCommand? SaveAsFileCommand { get; set; }
/// <summary>DB에서 서식 열기(검색 대화상자)</summary>
public ICustomCommand? OpenFromDbCommand { get; set; }
/// <summary>DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트</summary>
public ICustomCommand? SaveToDbCommand { get; set; }
/// <summary>JSON 내보내기</summary>
public ICustomCommand? ExportJsonCommand { get; set; }
/// <summary>JSON 가져오기</summary>
public ICustomCommand? ImportJsonCommand { get; set; }
/// <summary>미리보기</summary>
public ICustomCommand? PreviewCommand { get; set; }
/// <summary>인쇄</summary>
public ICustomCommand? PrintCommand { get; set; }
/// <summary>상용구 관리</summary>
public ICustomCommand? RecordWordCommand { get; set; }
/// <summary>폰트 일괄 변경</summary>
public ICustomCommand? FontManagerCommand { get; set; }
/// <summary>서식 수정이력(버전 열람/복원)</summary>
public ICustomCommand? SheetHistoryCommand { get; set; }
/// <summary>줌 확대</summary>
public ICustomCommand? ZoomInCommand { get; set; }
/// <summary>줌 축소</summary>
public ICustomCommand? ZoomOutCommand { get; set; }
/// <summary>줌 100%</summary>
public ICustomCommand? ZoomResetCommand { get; set; }
/// <summary>화면 맞춤 — 활성 페이지 한 장이 뷰포트에 들어오는 배율</summary>
public ICustomCommand? ZoomFitCommand { get; set; }
/// <summary>종료</summary>
public ICustomCommand? ExitCommand { get; set; }
/// <summary>서식 목록 검색</summary>
public ICustomCommand? SearchSheetsCommand { get; set; }
/// <summary>서식 목록에서 열기(더블클릭)</summary>
public ICustomCommand? OpenSheetCommand { get; set; }
/// <summary>문서 탭 닫기</summary>
public ICustomCommand? CloseDocumentCommand { get; set; }
#endregion
#region Methods - 문서
/// <summary>문서를 탭으로 추가하고 활성화 — 좌측 패널은 레이어 탭으로 전환</summary>
private void AttachDocument(DesignerViewModel designer)
{
OpenDesigners.Add(designer);
CurrentDesigner = designer;
SelectedLeftTabIndex = 1;
}
private void OnCloseDocument(DesignerViewModel? designer)
{
designer ??= CurrentDesigner;
if (designer is null)
{
return;
}
if (designer.Undo.IsDirty
&& !DialogService.Confirm("문서 닫기",
$"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있습니다.",
"닫을까요?", yes: "닫기", no: "취소", destructive: true))
{
return;
}
var index = OpenDesigners.IndexOf(designer);
OpenDesigners.Remove(designer);
if (CurrentDesigner == designer)
{
CurrentDesigner = OpenDesigners.Count > 0
? OpenDesigners[Math.Clamp(index, 0, OpenDesigners.Count - 1)]
: null;
}
}
/// <summary>
/// 종료 전 미저장 문서 처리 — 진행 가능하면 true, 사용자가 한 곳에서라도 취소하면 false.
/// 문서별로 확인하며, 확인 직전 해당 문서를 활성 탭으로 올려 어느 문서를 묻는지 보이게 한다
/// (저장 경로도 CurrentDesigner 를 대상으로 동작하므로 전환이 필수다).
/// </summary>
public bool ConfirmCloseAll()
{
foreach (var designer in OpenDesigners.ToList())
{
if (!designer.Undo.IsDirty)
{
continue;
}
CurrentDesigner = designer;
// 과거 버전 열람 탭은 저장하면 활성 디자인을 과거 내용으로 덮게 되므로 저장 선택지를 주지 않는다
if (designer.HistorySdgKey is not null)
{
if (!DialogService.Confirm("종료",
$"'{designer.DisplayName}' 은(는) 과거 버전 열람 탭이라 저장할 수 없습니다.",
"변경을 버리고 종료할까요?", yes: "버리고 종료", no: "취소", destructive: true))
{
return false;
}
continue;
}
var answer = DialogService.ConfirmWithCancel("종료",
$"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있습니다.",
"저장할까요?", yes: "저장", no: "저장 안 함");
if (answer is null)
{
return false;
}
if (answer == false)
{
continue;
}
if (!(designer.IsFromDb ? OnSaveToDb() : OnSaveFile(saveAs: false)))
{
return false; // 저장 대화상자 취소 또는 오류 — 종료를 중단한다
}
}
return true;
}
/// <summary>종료 확인을 이미 통과했는지 — MainView.Closing 이 재질문하지 않도록</summary>
public bool IsShuttingDown { get; private set; }
/// <summary>
/// 이미 열려 있는 같은 서식 — 없으면 null.
///
/// <b>출처(파일/DB)를 보지 않는다.</b> 레거시 MultiSheetFormAllow 도 탭 제목의 [서식코드]만 보고
/// 출처와 무관하게 막는다(frmSheetDesigner.vb:242-252). 출처를 따지면 XML 파일로 열어 둔 서식을
/// 목록에서 다시 DB 로 열 수 있고, 두 탭에서 교대로 저장하면 <b>마지막 저장이 앞선 편집을 통째로 덮는다</b>
/// — 양쪽 어디에도 낙관적 충돌 검출이 없다.
///
/// 이력본(HistorySdgKey 가 있는 읽기 전용 스냅샷)은 제외한다. 같은 ShtCod 를 갖고 있어서
/// 걸러내지 않으면 현재본을 열려는 요청이 옛 이력 탭을 활성화하고, 사용자는 현재본을
/// 편집한다고 믿은 채 스냅샷을 고치게 된다.
///
/// 서식 코드가 비어 있으면(새 서식) 서로 다른 문서로 본다 — 빈 코드끼리 묶으면
/// '새 서식'을 두 번 만들 수 없다.
/// </summary>
internal static DesignerViewModel? FindOpenSheet(IEnumerable<DesignerViewModel> open, string formId)
=> formId.Length == 0
? null
: open.FirstOrDefault(d =>
d.HistorySdgKey is null
&& string.Equals(d.Document.FormId, formId, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// 이미 열려 있으면 그 탭을 활성화하고 true — 새로 열지 말라는 뜻.
/// 설정으로 중복 열기를 허용하면 항상 false(레거시 AllowMultiSheetForm 과 같은 탈출구).
/// </summary>
private bool ActivateIfAlreadyOpen(string formId, string source)
{
if (dataBusiness.Config.AllowMultiSheetForm)
{
return false;
}
var existing = FindOpenSheet(OpenDesigners, formId);
if (existing is null)
{
return false;
}
CurrentDesigner = existing;
// 어느 탭으로 갔는지 알려 준다 — 아무 일도 안 일어난 것처럼 보이면 사용자는 다시 누른다
var where = existing.IsFromDb ? "DB 탭" : "파일 탭";
StatusText = $"이미 열린 서식: {formId} — {where}으로 이동했습니다({source} 열기는 취소).";
return true;
}
/// <summary>
/// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식.
/// 거부 메시지는 레거시 문구를 그대로 재현한다(사용자 재교육 비용 0, 조치 안내가 이미 담겨 있다).
/// </summary>
private bool EnsureDesignAllowed(string shtCod)
{
try
{
switch (dataBusiness.GetDesignPermission(shtCod))
{
case SheetDesignPermission.Allowed:
return true;
case SheetDesignPermission.NotRegistered:
DialogService.Notify(DialogKind.Warning, "확인",
$"기록지 정보에 등록되지 않은 서식 코드입니다. ({shtCod})");
return false;
default:
Services.AppLog.Audit($"[권한거부] {shtCod} — ShtUsrDesYon 차단 (by {dataBusiness.User.Display})");
DialogService.Notify(DialogKind.Warning, "확인",
"서식생성기를 사용하지 않는 서식지입니다.", "기록지 정보를 확인해주세요.");
return false;
}
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Warning, "확인", "서식 권한을 확인하지 못했습니다.", ex.Message);
return false;
}
}
#endregion
#region Methods - 열기/저장
/// <summary>기동 인자로 받은 서식 코드 — 창 로드 후 자동으로 연다(App.OnStartup 이 세팅)</summary>
public string? PendingSheetCode { get; set; }
private async Task OnLoadedAsync()
{
await LoadSheetListAsync();
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
if (PendingSheetCode is { Length: > 0 } shtCod)
{
PendingSheetCode = null;
OpenDbSheet(shtCod);
if (OpenDesigners.Count > 0)
{
return;
}
}
OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
}
private void OnNewFile()
{
try
{
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
StatusText = "새 서식 (720×856)";
}
catch (Exception ex)
{
DialogService.ShowError("새 서식 만들기", ex);
}
}
private void OnOpenFile()
{
try
{
var path = dialogService.ShowOpenFile(XmlFilter, "서식 XML 열기");
if (path is null)
{
return;
}
var document = dataBusiness.OpenXmlFile(path);
// 같은 서식이 DB 탭으로 이미 열려 있을 수 있다 — 출처가 달라도 저장 대상은 같은 서식이다
if (ActivateIfAlreadyOpen(document.FormId, "파일"))
{
return;
}
AttachDocument(new DesignerViewModel(document) { FilePath = path });
StatusText = $"파일 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
ShowReadWarnings(document.Meta.ReadWarnings, "서식 열기");
}
catch (Exception ex)
{
DialogService.ShowError("서식 열기", ex);
}
}
/// <summary>파일 저장 — 성공 시 true. 대화상자 취소·오류는 false(종료 가드가 이 값으로 진행 여부를 판단한다)</summary>
private bool OnSaveFile(bool saveAs)
{
try
{
if (CurrentDesigner is null)
{
return false;
}
CommitPendingEdits();
var path = CurrentDesigner.FilePath;
if (saveAs || path is null)
{
path = dialogService.ShowSaveFile(XmlFilter, "서식 XML 저장",
CurrentDesigner.Document.FormId + ".xml");
if (path is null)
{
return false;
}
}
dataBusiness.SaveXmlFile(CurrentDesigner.Document, path);
CurrentDesigner.FilePath = path;
CurrentDesigner.Undo.MarkSaved();
CurrentDesigner.NotifyDisplayNameChanged();
Title = $"SheetMe 서식생성기 — {CurrentDesigner.DisplayName}";
StatusText = $"저장됨: {path}";
return true;
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "저장 중 오류가 발생했습니다.", ex.Message);
return false;
}
}
/// <summary>
/// 포커스가 머물러 있는 편집 중 값을 소스에 반영한다.
/// 인스펙터 바인딩이 UpdateSourceTrigger=LostFocus 라, 값을 고치다 바로 Ctrl+S 를 누르면
/// 커밋되지 않은 채 저장되어 조용히 유실된다.
/// </summary>
private static void CommitPendingEdits()
{
if (System.Windows.Input.Keyboard.FocusedElement is System.Windows.Controls.TextBox box)
{
box.GetBindingExpression(System.Windows.Controls.TextBox.TextProperty)?.UpdateSource();
}
}
private void OnOpenFromDb()
{
try
{
if (!EnsureDb())
{
return;
}
var dialog = new Views.SheetOpenDialogView(keyword => dataBusiness.ListSheets(keyword))
{
Owner = Application.Current.MainWindow,
};
if (dialog.ShowDialog() != true || dialog.SelectedSheet is null)
{
return;
}
OpenDbSheet(dialog.SelectedSheet.ShtCod);
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "DB에서 서식을 여는 중 오류가 발생했습니다.", ex.Message);
}
}
/// <summary>
/// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화.
/// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다.
/// 레거시는 게이트가 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했다.
/// </summary>
private void OpenDbSheet(string shtCod)
{
if (ActivateIfAlreadyOpen(shtCod, "DB"))
{
return;
}
if (!EnsureDesignAllowed(shtCod))
{
return;
}
var document = dataBusiness.OpenFromDb(shtCod);
if (document is null)
{
DialogService.Notify(DialogKind.Warning, "DB 열기", "활성 디자인을 찾지 못했습니다.");
return;
}
AttachDocument(new DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod)) { IsFromDb = true });
StatusText = $"DB 로드: {document.FormId} (SdgKey {document.Meta.SourceSdgKey}) · " +
$"페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
ShowReadWarnings(document.Meta.ReadWarnings, "DB 열기");
}
/// <summary>DB 저장 — 성공 시 true. 게이트 차단·취소·오류는 false</summary>
private bool OnSaveToDb()
{
try
{
if (CurrentDesigner is null)
{
return false;
}
if (!dataBusiness.CanSaveToDb)
{
DialogService.Notify(DialogKind.Warning, "DB 저장",
"DB 저장이 비활성화되어 있습니다.",
"appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정한 뒤 사용하세요.\n" +
"(운영 레거시 테이블에 기록되므로 명시적 활성화가 필요합니다)");
return false;
}
if (!dataBusiness.CanWriteDb)
{
DialogService.Notify(DialogKind.Warning, "DB 저장",
"HIS 사용자가 확인되지 않아 DB에 저장할 수 없습니다.",
"기록지정보 화면에서 서식생성기를 실행하거나, 사용자 코드를 인자로 전달해 주세요.\n" +
"(저장 이력에 남길 수정자를 특정할 수 없습니다)");
return false;
}
CommitPendingEdits();
var document = CurrentDesigner.Document;
// 열기 이후 마스터에서 플래그가 꺼졌을 수 있고, 파일→DB 저장은 열기 게이트를 아예 거치지 않는다.
// 레거시는 저장 경로에 게이트가 없어 이 구멍이 열려 있었다.
if (dataBusiness.SheetExists(document.FormId) && !EnsureDesignAllowed(document.FormId))
{
return false;
}
// 미등록 서식이면 신규 등록 다이얼로그
if (!dataBusiness.SheetExists(document.FormId))
{
var register = new Views.RegisterSheetDialogView(
document.FormId == "NewSheet" ? string.Empty : document.FormId,
document.Title == "새 서식" ? string.Empty : document.Title)
{
Owner = Application.Current.MainWindow,
};
if (register.ShowDialog() != true)
{
return false;
}
dataBusiness.RegisterSheet(register.SheetCode, register.SheetName, register.ClassCode);
document.FormId = register.SheetCode;
document.Title = register.SheetName;
}
var confirm = DialogService.Confirm("DB 저장 확인",
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?",
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
yes: "저장", no: "취소");
if (!confirm)
{
return false;
}
var sdgKey = dataBusiness.SaveToDb(document);
Services.AppLog.Audit($"[DB저장] {document.FormId} → SdgKey {sdgKey} (by {dataBusiness.User.Display})");
CurrentDesigner.IsFromDb = true;
// 이력 표식을 지운다 — 저장한 순간 이 탭은 과거 스냅샷이 아니라 활성 디자인이다.
// 남겨 두면 탭이 '(이력 N)' 으로 계속 보이고, 종료 확인이 '저장할 수 없는 탭' 분기로 들어가
// 저장 선택지 없이 '변경을 버리고 종료할까요?' 만 물어 이후 편집분이 조용히 버려진다.
var wasHistory = CurrentDesigner.HistorySdgKey is not null;
CurrentDesigner.HistorySdgKey = null;
CurrentDesigner.Undo.MarkSaved();
CurrentDesigner.NotifyDisplayNameChanged();
// 이력 탭을 되살린 경우, 같은 서식의 다른 탭은 이제 옛 내용을 들고 있다.
// 그 탭에서 저장하면 방금 저장한 내용을 덮으므로 어느 탭이 낡았는지 알려 준다.
if (wasHistory && OpenDesigners.Any(d => !ReferenceEquals(d, CurrentDesigner)
&& d.HistorySdgKey is null
&& string.Equals(d.Document.FormId, document.FormId, StringComparison.OrdinalIgnoreCase)))
{
DialogService.Notify(DialogKind.Warning, "같은 서식의 다른 탭",
$"서식 [{document.FormId}] 이(가) 다른 탭에도 열려 있습니다.",
"그 탭은 방금 저장하기 전의 내용입니다. 거기서 저장하면 이번 저장이 덮어써집니다 — 닫고 다시 여세요.");
}
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
DialogService.Notify(DialogKind.Info, "DB 저장",
$"저장되었습니다. (SdgKey {sdgKey})", "레거시 뷰어/디자이너에서 열어 확인하세요.");
return true;
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류",
"DB 저장 중 오류가 발생했습니다. (트랜잭션 롤백됨)", ex.Message);
return false;
}
}
private void OnExportJson()
{
try
{
if (CurrentDesigner is null)
{
return;
}
var path = dialogService.ShowSaveFile("서식 JSON (*.json)|*.json", "JSON 내보내기",
CurrentDesigner.Document.FormId + ".json");
if (path is null)
{
return;
}
var json = new Core.Serialization.FormJsonSerializer().Write(CurrentDesigner.Document);
File.WriteAllText(path, json, System.Text.Encoding.UTF8);
StatusText = $"JSON 내보내기 완료: {path}";
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "JSON 내보내기 중 오류가 발생했습니다.", ex.Message);
}
}
private void OnImportJson()
{
try
{
var path = dialogService.ShowOpenFile("서식 JSON (*.json)|*.json", "JSON 가져오기");
if (path is null)
{
return;
}
var document = new Core.Serialization.FormJsonSerializer().Read(File.ReadAllText(path));
if (document.FormId.Length == 0)
{
document.FormId = Path.GetFileNameWithoutExtension(path);
}
if (ActivateIfAlreadyOpen(document.FormId, "JSON"))
{
return;
}
AttachDocument(new DesignerViewModel(document));
StatusText = $"JSON 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "JSON 가져오기 중 오류가 발생했습니다.", ex.Message);
}
}
#endregion
#region Methods - 서식 목록 패널
/// <summary>서식 목록 로드(비동기) — DB 미접속이면 건너뜀</summary>
private async Task LoadSheetListAsync()
{
if (!dataBusiness.CanUseDb || IsSheetListLoading)
{
return;
}
try
{
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);
}
// 검색 중이면 결과가 접힌 헤더 뒤에 숨지 않도록 전부 펼친다. 검색어를 지우면 사용자가
// 보존해 둔 취향으로 되돌아간다. 새로 생긴 그룹은 기본 접힘이라 값이 같아도 매번 밀어 넣어야
// 토글 버튼 표시와 어긋나지 않는다.
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)
{
DialogService.Notify(DialogKind.Error, "서식 목록", "서식 목록 조회 중 오류가 발생했습니다.", ex.Message);
}
finally
{
IsSheetListLoading = false;
}
}
/// <summary>서식 목록에서 열기 — 뷰 더블클릭 핸들러가 위임 호출</summary>
public void OpenSheetFromList(SheetSummary? sheet) => OnOpenSheetFromList(sheet);
private void OnOpenSheetFromList(SheetSummary? sheet)
{
try
{
if (sheet is null)
{
return;
}
if (!sheet.HasDesign)
{
DialogService.Notify(DialogKind.Warning, "서식 열기", "선택한 서식에는 저장된 디자인이 없습니다.");
return;
}
OpenDbSheet(sheet.ShtCod);
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "서식을 여는 중 오류가 발생했습니다.", ex.Message);
}
}
#endregion
#region Methods - 도구
private void OnPreview()
{
if (CurrentDesigner is null)
{
return;
}
new Views.PreviewWindow(CurrentDesigner)
{
Owner = Application.Current.MainWindow,
}.Show();
}
private void OnPrint()
{
try
{
if (CurrentDesigner is null)
{
return;
}
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
? CurrentDesigner.Document.Title
: CurrentDesigner.Document.FormId);
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "인쇄 중 오류가 발생했습니다.", ex.Message);
}
}
private void OnFontManager()
{
if (CurrentDesigner is null)
{
return;
}
new Views.FontManagerDialogView(CurrentDesigner)
{
Owner = Application.Current.MainWindow,
}.ShowDialog();
}
private void OnSheetHistory()
{
try
{
if (CurrentDesigner is null || !EnsureDb())
{
return;
}
var document = CurrentDesigner.Document;
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
{
DialogService.Notify(DialogKind.Warning, "서식 수정이력", "수정이력은 DB에 저장된 서식에서 사용할 수 있습니다.");
return;
}
// 파일에서 연 문서의 FormId 가 우연히 거부 서식 코드와 같으면 이 경로로 이력 열람·복원이
// 가능해진다(레거시의 우회 경로 중 하나였다).
if (!EnsureDesignAllowed(document.FormId))
{
return;
}
var versions = dataBusiness.ListVersions(document.FormId);
if (versions.Count == 0)
{
DialogService.Notify(DialogKind.Info, "서식 수정이력", "저장된 버전이 없습니다.");
return;
}
var dialog = new Views.SheetHistoryDialogView(document.FormId, document.Title, versions)
{
Owner = Application.Current.MainWindow,
};
if (dialog.ShowDialog() != true || dialog.SelectedSdgKey is not { } sdgKey)
{
return;
}
// 이미 열람 중인 동일 버전 탭이면 활성화
var existing = OpenDesigners.FirstOrDefault(d => d.HistorySdgKey == sdgKey
&& d.Document.FormId == document.FormId);
if (existing is not null)
{
CurrentDesigner = existing;
return;
}
var versionDocument = dataBusiness.OpenFromDbVersion(document.FormId, sdgKey);
if (versionDocument is null)
{
DialogService.Notify(DialogKind.Warning, "서식 수정이력", "해당 버전을 불러오지 못했습니다.");
return;
}
AttachDocument(new DesignerViewModel(versionDocument, dataBusiness.LoadSpreadGrids(document.FormId))
{
IsFromDb = true,
HistorySdgKey = sdgKey,
});
StatusText = $"이력 열람: {document.FormId} SdgKey {sdgKey} — 'DB에 저장' 시 이 내용이 새 활성 버전이 됩니다";
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "수정이력 조회 중 오류가 발생했습니다.", ex.Message);
}
}
private void OnRecordWords()
{
try
{
if (CurrentDesigner is null)
{
return;
}
if (!EnsureDb())
{
return;
}
var document = CurrentDesigner.Document;
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
{
DialogService.Notify(DialogKind.Warning, "상용구 관리",
"상용구는 서식 코드 단위로 저장됩니다.", "먼저 DB에 저장(서식 등록)한 뒤 사용하세요.");
return;
}
new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title,
dataBusiness.User.UidCod, dataBusiness.CanWriteDb)
{
Owner = Application.Current.MainWindow,
}.ShowDialog();
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "상용구 관리 중 오류가 발생했습니다.", ex.Message);
}
}
#endregion
#region Methods - Private
private bool EnsureDb()
{
if (dataBusiness.CanUseDb)
{
return true;
}
DialogService.Notify(DialogKind.Warning, "DB 연결",
"DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.", "appsettings.json 을 확인하세요.");
return false;
}
private static void ShowReadWarnings(List<string> warnings, string caption)
{
if (warnings.Count == 0)
{
return;
}
var summary = string.Join("\n", warnings.Take(20));
var more = warnings.Count > 20 ? $"\n... 외 {warnings.Count - 20}건" : string.Empty;
DialogService.Notify(DialogKind.Warning, caption,
$"읽기 경고 {warnings.Count}건 (미지원 컨트롤은 자리표시로 보존됩니다)", summary + more);
}
private static int CountControls(List<Core.Models.ControlElement> controls)
=> controls.Sum(c => 1 + CountControls(c.Children));
#endregion
}