초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)

레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너.
기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49.

이 커밋에 함께 포함된 자격증명 분리:
- appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환
- 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외,
  csproj Debug 조건부 복사라 Release 산출물에 실리지 않음)
- ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경,
  미치환 플레이스홀더는 '미설정'으로 간주

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 17:20:22 +09:00
co-authored by Claude Fable 5
commit 16c07f48dc
102 changed files with 14210 additions and 0 deletions
@@ -0,0 +1,690 @@
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 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 => SetProperty(ref isSheetListLoading, value);
}
/// <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());
ExitCommand = new Command((sender, e) => 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));
}
#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? 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.CanUndo
&& MessageBox.Show($"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있을 수 있습니다.\n닫을까요?",
"문서 닫기", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
{
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>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
private DesignerViewModel? FindOpenDbDocument(string shtCod)
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
#endregion
#region Methods - /
private async Task OnLoadedAsync()
{
OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
await LoadSheetListAsync();
}
private void OnNewFile()
{
try
{
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
StatusText = "새 서식 (720×856)";
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString());
}
}
private void OnOpenFile()
{
try
{
var path = dialogService.ShowOpenFile(XmlFilter, "서식 XML 열기");
if (path is null)
{
return;
}
var document = dataBusiness.OpenXmlFile(path);
AttachDocument(new DesignerViewModel(document) { FilePath = path });
StatusText = $"파일 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
ShowReadWarnings(document.Meta.ReadWarnings, "서식 열기");
}
catch (Exception ex)
{
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnSaveFile(bool saveAs)
{
try
{
if (CurrentDesigner is null)
{
return;
}
var path = CurrentDesigner.FilePath;
if (saveAs || path is null)
{
path = dialogService.ShowSaveFile(XmlFilter, "서식 XML 저장",
CurrentDesigner.Document.FormId + ".xml");
if (path is null)
{
return;
}
}
dataBusiness.SaveXmlFile(CurrentDesigner.Document, path);
CurrentDesigner.FilePath = path;
CurrentDesigner.NotifyDisplayNameChanged();
Title = $"SheetMe 서식생성기 — {CurrentDesigner.DisplayName}";
StatusText = $"저장됨: {path}";
}
catch (Exception ex)
{
MessageBox.Show($"저장 중 오류가 발생했습니다.\n\n{ex}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
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)
{
MessageBox.Show($"DB에서 서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화</summary>
private void OpenDbSheet(string shtCod)
{
var existing = FindOpenDbDocument(shtCod);
if (existing is not null)
{
CurrentDesigner = existing;
StatusText = $"이미 열린 서식: {shtCod}";
return;
}
var document = dataBusiness.OpenFromDb(shtCod);
if (document is null)
{
MessageBox.Show("활성 디자인을 찾지 못했습니다.", "DB 열기",
MessageBoxButton.OK, MessageBoxImage.Information);
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 열기");
}
private void OnSaveToDb()
{
try
{
if (CurrentDesigner is null)
{
return;
}
if (!dataBusiness.CanSaveToDb)
{
MessageBox.Show("DB 저장이 비활성화되어 있습니다.\nappsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정한 뒤 사용하세요.\n(운영 레거시 테이블에 기록되므로 명시적 활성화가 필요합니다)",
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var document = CurrentDesigner.Document;
// 미등록 서식이면 신규 등록 다이얼로그
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;
}
dataBusiness.RegisterSheet(register.SheetCode, register.SheetName, register.ClassCode);
document.FormId = register.SheetCode;
document.Title = register.SheetName;
}
var confirm = MessageBox.Show(
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?\n\n" +
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
"DB 저장 확인", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
{
return;
}
var sdgKey = dataBusiness.SaveToDb(document);
CurrentDesigner.IsFromDb = true;
CurrentDesigner.NotifyDisplayNameChanged();
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
MessageBox.Show($"저장되었습니다. (SdgKey {sdgKey})\n레거시 뷰어/디자이너에서 열어 확인하세요.", "DB 저장",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"DB 저장 중 오류가 발생했습니다. (트랜잭션 롤백됨)\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
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)
{
MessageBox.Show($"JSON 내보내기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
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);
}
AttachDocument(new DesignerViewModel(document));
StatusText = $"JSON 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
}
catch (Exception ex)
{
MessageBox.Show($"JSON 가져오기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#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));
SheetList.Clear();
foreach (var sheet in sheets)
{
SheetList.Add(sheet);
}
StatusText = $"서식 목록 {sheets.Count}건";
}
catch (Exception ex)
{
MessageBox.Show($"서식 목록 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "서식 목록",
MessageBoxButton.OK, MessageBoxImage.Error);
}
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)
{
MessageBox.Show("선택한 서식에는 저장된 디자인이 없습니다.", "서식 열기",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
OpenDbSheet(sheet.ShtCod);
}
catch (Exception ex)
{
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#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)
{
MessageBox.Show($"인쇄 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
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")
{
MessageBox.Show("수정이력은 DB에 저장된 서식에서 사용할 수 있습니다.", "서식 수정이력",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var versions = dataBusiness.ListVersions(document.FormId);
if (versions.Count == 0)
{
MessageBox.Show("저장된 버전이 없습니다.", "서식 수정이력",
MessageBoxButton.OK, MessageBoxImage.Information);
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)
{
MessageBox.Show("해당 버전을 불러오지 못했습니다.", "서식 수정이력",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
AttachDocument(new DesignerViewModel(versionDocument, dataBusiness.LoadSpreadGrids(document.FormId))
{
IsFromDb = true,
HistorySdgKey = sdgKey,
});
StatusText = $"이력 열람: {document.FormId} SdgKey {sdgKey} — 'DB에 저장' 시 이 내용이 새 활성 버전이 됩니다";
}
catch (Exception ex)
{
MessageBox.Show($"수정이력 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnRecordWords()
{
try
{
if (CurrentDesigner is null)
{
return;
}
if (!EnsureDb())
{
return;
}
var document = CurrentDesigner.Document;
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
{
MessageBox.Show("상용구는 서식 코드 단위로 저장됩니다.\n먼저 DB에 저장(서식 등록)한 뒤 사용하세요.",
"상용구 관리", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title)
{
Owner = Application.Current.MainWindow,
}.ShowDialog();
}
catch (Exception ex)
{
MessageBox.Show($"상용구 관리 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#endregion
#region Methods - Private
private bool EnsureDb()
{
if (dataBusiness.CanUseDb)
{
return true;
}
MessageBox.Show("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.\nappsettings.json 을 확인하세요.",
"DB 연결", MessageBoxButton.OK, MessageBoxImage.Information);
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;
MessageBox.Show($"읽기 경고 {warnings.Count}건 (미지원 컨트롤은 자리표시로 보존됩니다):\n\n{summary}{more}",
caption, MessageBoxButton.OK, MessageBoxImage.Information);
}
private static int CountControls(List<Core.Models.ControlElement> controls)
=> controls.Sum(c => 1 + CountControls(c.Children));
#endregion
}