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
}