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; /// /// 메인 셸 ViewModel — 멀티 문서 탭(여러 기록지 동시 편집), 상시 서식 목록 패널, /// 파일/DB 열기·저장, 도구(상용구/폰트). 컨트롤 클립보드는 문서 간 공유(서식 간 복사/붙여넣기). /// 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 /// 창 제목 public string Title { get => title; set => SetProperty(ref title, value); } /// 상태바 텍스트 public string StatusText { get => statusText; set => SetProperty(ref statusText, value); } /// 열린 문서(탭) 목록 public ObservableCollection OpenDesigners { get; } = new(); /// 활성 문서(선택 탭) public DesignerViewModel? CurrentDesigner { get => currentDesigner; set { if (SetProperty(ref currentDesigner, value)) { Title = value is null ? "SheetMe 서식생성기" : $"SheetMe 서식생성기 — {value.DisplayName}"; } } } /// 팔레트 컨트롤 목록(레지스트리) public IReadOnlyList PaletteItems => ControlRegistry.All; /// 서식 목록(상시 패널) — DB E_ShtMst public ObservableCollection SheetList { get; } = new(); /// 서식 목록 검색어 public string SheetSearchKeyword { get => sheetSearchKeyword; set => SetProperty(ref sheetSearchKeyword, value); } /// 서식 목록 로딩 중 public bool IsSheetListLoading { get => isSheetListLoading; set { if (SetProperty(ref isSheetListLoading, value)) { OnPropertyChanged(nameof(SheetListStatusText)); } } } /// /// 서식 목록 하단 상태 한 줄 — 로딩·건수·미접속을 목록 바로 아래에서 알린다. /// 종전에는 목록 위에 "불러오는 중..." 오버레이를 덮어 목록이 가려졌고, 건수는 창 하단 /// 전역 상태줄에만 떠서 목록과 시선 거리가 멀었다. /// 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; } } /// 현재 목록에 나타난 분류 수 — 목록 하단 표시용 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; /// DB 사용 가능 여부(서식 목록 패널 안내용) public bool CanUseDb => dataBusiness.CanUseDb; /// 좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행) public int SelectedLeftTabIndex { get => selectedLeftTabIndex; set => SetProperty(ref selectedLeftTabIndex, value); } /// 손(팬) 도구 활성 — 캔버스 드래그로 화면 이동(false=선택 도구, [200] 플로팅 바 관행) 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 /// 창 로드 public ICustomCommand? LoadedCommand { get; set; } /// 새 서식 public ICustomCommand? NewFileCommand { get; set; } /// 서식 XML 열기 public ICustomCommand? OpenFileCommand { get; set; } /// 저장 public ICustomCommand? SaveFileCommand { get; set; } /// 다른 이름으로 저장 public ICustomCommand? SaveAsFileCommand { get; set; } /// DB에서 서식 열기(검색 대화상자) public ICustomCommand? OpenFromDbCommand { get; set; } /// DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트 public ICustomCommand? SaveToDbCommand { get; set; } /// JSON 내보내기 public ICustomCommand? ExportJsonCommand { get; set; } /// JSON 가져오기 public ICustomCommand? ImportJsonCommand { get; set; } /// 미리보기 public ICustomCommand? PreviewCommand { get; set; } /// 인쇄 public ICustomCommand? PrintCommand { get; set; } /// 상용구 관리 public ICustomCommand? RecordWordCommand { get; set; } /// 폰트 일괄 변경 public ICustomCommand? FontManagerCommand { get; set; } /// 서식 수정이력(버전 열람/복원) public ICustomCommand? SheetHistoryCommand { get; set; } /// 줌 확대 public ICustomCommand? ZoomInCommand { get; set; } /// 줌 축소 public ICustomCommand? ZoomOutCommand { get; set; } /// 줌 100% public ICustomCommand? ZoomResetCommand { get; set; } /// 화면 맞춤 — 활성 페이지 한 장이 뷰포트에 들어오는 배율 public ICustomCommand? ZoomFitCommand { get; set; } /// 종료 public ICustomCommand? ExitCommand { get; set; } /// 서식 목록 검색 public ICustomCommand? SearchSheetsCommand { get; set; } /// 서식 목록에서 열기(더블클릭) public ICustomCommand? OpenSheetCommand { get; set; } /// 문서 탭 닫기 public ICustomCommand? CloseDocumentCommand { get; set; } #endregion #region Methods - 문서 탭 /// 문서를 탭으로 추가하고 활성화 — 좌측 패널은 레이어 탭으로 전환 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; } } /// /// 종료 전 미저장 문서 처리 — 진행 가능하면 true, 사용자가 한 곳에서라도 취소하면 false. /// 문서별로 확인하며, 확인 직전 해당 문서를 활성 탭으로 올려 어느 문서를 묻는지 보이게 한다 /// (저장 경로도 CurrentDesigner 를 대상으로 동작하므로 전환이 필수다). /// 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; } /// 종료 확인을 이미 통과했는지 — MainView.Closing 이 재질문하지 않도록 public bool IsShuttingDown { get; private set; } /// /// 이미 열려 있는 같은 서식 — 없으면 null. /// /// 출처(파일/DB)를 보지 않는다. 레거시 MultiSheetFormAllow 도 탭 제목의 [서식코드]만 보고 /// 출처와 무관하게 막는다(frmSheetDesigner.vb:242-252). 출처를 따지면 XML 파일로 열어 둔 서식을 /// 목록에서 다시 DB 로 열 수 있고, 두 탭에서 교대로 저장하면 마지막 저장이 앞선 편집을 통째로 덮는다 /// — 양쪽 어디에도 낙관적 충돌 검출이 없다. /// /// 이력본(HistorySdgKey 가 있는 읽기 전용 스냅샷)은 제외한다. 같은 ShtCod 를 갖고 있어서 /// 걸러내지 않으면 현재본을 열려는 요청이 옛 이력 탭을 활성화하고, 사용자는 현재본을 /// 편집한다고 믿은 채 스냅샷을 고치게 된다. /// /// 서식 코드가 비어 있으면(새 서식) 서로 다른 문서로 본다 — 빈 코드끼리 묶으면 /// '새 서식'을 두 번 만들 수 없다. /// internal static DesignerViewModel? FindOpenSheet(IEnumerable open, string formId) => formId.Length == 0 ? null : open.FirstOrDefault(d => d.HistorySdgKey is null && string.Equals(d.Document.FormId, formId, StringComparison.OrdinalIgnoreCase)); /// /// 이미 열려 있으면 그 탭을 활성화하고 true — 새로 열지 말라는 뜻. /// 설정으로 중복 열기를 허용하면 항상 false(레거시 AllowMultiSheetForm 과 같은 탈출구). /// 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; } /// /// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식. /// 거부 메시지는 레거시 문구를 그대로 재현한다(사용자 재교육 비용 0, 조치 안내가 이미 담겨 있다). /// 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 - 열기/저장 /// 기동 인자로 받은 서식 코드 — 창 로드 후 자동으로 연다(App.OnStartup 이 세팅) 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); } } /// 파일 저장 — 성공 시 true. 대화상자 취소·오류는 false(종료 가드가 이 값으로 진행 여부를 판단한다) 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; } } /// /// 포커스가 머물러 있는 편집 중 값을 소스에 반영한다. /// 인스펙터 바인딩이 UpdateSourceTrigger=LostFocus 라, 값을 고치다 바로 Ctrl+S 를 누르면 /// 커밋되지 않은 채 저장되어 조용히 유실된다. /// 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); } } /// /// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화. /// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다. /// 레거시는 게이트가 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했다. /// 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 열기"); } /// DB 저장 — 성공 시 true. 게이트 차단·취소·오류는 false 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 - 서식 목록 패널 /// 서식 목록 로드(비동기) — DB 미접속이면 건너뜀 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; } } /// 서식 목록에서 열기 — 뷰 더블클릭 핸들러가 위임 호출 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 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 controls) => controls.Sum(c => 1 + CountControls(c.Children)); #endregion }