using System.Windows;
using System.Windows.Controls;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Views;
///
/// 미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.
///
/// 창은 하나만 뜬다. 전에는 누를 때마다 새 창이 생겼다. 모덜리스라 여러 장이 겹쳐 쌓이고,
/// 각각이 서로 다른 시점의 서식을 들고 있었다. 어느 것이 지금 것인지 화면으로 구분할 수 없다.
/// 환자 데이터가 들어가면 그건 개인정보 사고가 된다 — 환자를 바꿔도 옛 창이 옛 환자를 계속 띄운다.
///
/// 내용은 따라간다. 전에는 생성자에서 한 번만 그려서, 컨트롤을 추가·삭제하거나
/// 숨김·인쇄출력을 토글해도 창은 옛 그림을 계속 보여 줬다 —
/// 저장 전에 결과를 확인하려고 여는 창이 구조적으로 거짓말을 하고 있었다.
/// 페이지·컨트롤 추가/삭제는 즉시, 그 밖의 변경은 창을 누르는 순간(Activated) 다시 그린다.
/// 즉시 갱신이 안 되는 변경이 남아 있으므로 새로 고침 버튼도 함께 둔다.
///
public partial class PreviewWindow : Window
{
#region Member Fields
/// 지금 떠 있는 창 — 두 번째를 만들지 않는다
private static PreviewWindow? open;
private DesignerViewModel designer;
private double zoom = 1.0;
///
/// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
///
private readonly SessionTagResolver session = new();
///
/// 지금 쓰는 해석기. 환자를 고르면 가 앞에 붙고,
/// 해제하면 다시 뿐이다 — 환자를 해제했는데 옛 값이 남으면
/// 화면이 실제와 다른 것을 보여 준다.
///
private SheetMe.Core.Catalog.ITagValueResolver tags;
///
/// MDataTable 실행기 — DataTableField 배선의 값을 낸다.
/// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다).
///
private MDataTableRunner? fields;
/// 지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다
private SheetMe.Data.Stores.PatientContext? patient;
#endregion
#region Constructors
///
/// 진단(--dialog-shots)만 직접 부른다 — 앱 코드는 를 쓸 것.
/// 여기로 만들면 단일 인스턴스 등록을 건너뛰므로 두 장이 생긴다.
///
internal PreviewWindow(DesignerViewModel designer)
{
this.designer = designer;
tags = session;
InitializeComponent();
Activated += (_, _) => BuildPages();
// 환자는 앱 전체가 하나를 공유한다 — 제목줄 아이콘에서 바꿔도 이 창이 따라와야 한다
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) =>
{
Unsubscribe();
PatientSession.Changed -= OnPatientSessionChanged;
if (ReferenceEquals(open, this))
{
open = null;
}
};
Subscribe();
// 이미 다른 화면에서 고른 환자가 있으면 그것을 그대로 물려받는다 —
// 창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다.
// 환자가 없어도 러너는 만든다(데이터소스 개수를 알아야 버튼 상태가 맞다).
UsePatient(PatientSession.Current, PatientSession.Label);
}
#endregion
#region Methods
///
/// 미리보기를 보여 준다 — 이미 떠 있으면 그 창을 지금 문서로 바꿔 앞으로 가져온다.
/// 새 창을 하나 더 만들면 어느 것이 지금 것인지 알 수 없다.
///
public static void ShowFor(DesignerViewModel designer, Window? owner)
{
if (open is null)
{
open = new PreviewWindow(designer) { Owner = owner };
open.Show();
return;
}
open.Retarget(designer);
if (open.WindowState == WindowState.Minimized)
{
open.WindowState = WindowState.Normal;
}
open.Activate();
}
/// 다른 문서로 갈아탄다 — 탭을 바꾸고 미리보기를 다시 누른 경우
private void Retarget(DesignerViewModel next)
{
if (ReferenceEquals(designer, next))
{
BuildPages();
return;
}
Unsubscribe();
designer = next;
Subscribe();
// 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 옛 서식의 쿼리로
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
UsePatient(patient, PatientText.Text);
}
/// 페이지·컨트롤 추가/삭제는 즉시 따라간다
private void Subscribe()
{
designer.Pages.CollectionChanged += OnStructureChanged;
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged += OnStructureChanged;
}
}
private void Unsubscribe()
{
designer.Pages.CollectionChanged -= OnStructureChanged;
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged -= OnStructureChanged;
}
}
private void OnStructureChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
// 페이지가 늘거나 줄면 그 페이지의 컨트롤 구독도 다시 걸어야 한다
if (ReferenceEquals(sender, designer.Pages))
{
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged -= OnStructureChanged;
page.Controls.CollectionChanged += OnStructureChanged;
}
}
BuildPages();
}
private void BuildPages()
{
// XAML 파싱 중에도 불린다 — 토글의 IsChecked="True" 가 파스 시점에 Checked 를 발생시키는데
// 그때 PagesHost 는 아직 만들어지지 않았다(토글이 트리에서 앞에 있다).
// 이 가드가 없으면 InitializeComponent 안에서 NullReferenceException 이 난다.
if (PagesHost is null)
{
return;
}
PagesHost.Children.Clear();
foreach (var page in designer.Pages)
{
var frame = new Border
{
Background = page.PaperBrush,
BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20),
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields),
Width = page.WidthDip,
Height = page.HeightDip,
};
PagesHost.Children.Add(frame);
}
Title = designer.Document.Title.Length > 0
? $"미리보기 — {designer.Document.Title}"
: "미리보기";
}
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
///
/// 실제 환자를 골라 태그에 값을 넣는다.
///
/// 권한 문·감사 기록·문맥 읽기는 모두 안에 있다 —
/// 여기서 다시 확인하면 두 곳이 어긋날 수 있다.
///
private void OnPickPatient(object sender, RoutedEventArgs e)
{
var picked = PatientPickerDialogView.Pick(this, out var label);
if (picked is null)
{
return;
}
// 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 모든 화면을 갱신한다.
// 여기서 직접 UsePatient 도 부르면 두 번 그리고, 더 나쁘게는
// 세션과 창이 어긋나는 경로가 하나 더 생긴다.
PatientSession.Set(picked, label);
}
/// 환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다
private void OnClearPatient(object sender, RoutedEventArgs e)
=> PatientSession.Set(null, string.Empty);
private void OnPatientSessionChanged(object? sender, EventArgs e)
=> UsePatient(PatientSession.Current, PatientSession.Label);
///
/// 환자를 붙이거나 뗀다 — 해석기 교체·표시·다시 그리기를 한 군데에 모은다.
///
/// 모달과 분리하는 이유: 고르는 일(권한·감사·문맥 읽기)과 붙이는 일(해석기 교체 → 종이)은
/// 다른 일이고, 붙이는 쪽만 진단이 확인할 수 있어야 한다. 창을 띄우는 코드에 묻어 두면
/// "환자를 골랐는데 종이가 그대로다"를 자동으로 잡을 방법이 없다 — 그게 이 단계에서 가장 무서운 고장이다.
///
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
patient = picked;
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
// 가장 민감한 값을 여러 번 꺼내게 된다.
tags = picked is null
? session
: new PatientTagResolver(picked, session,
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked),
PatientIdentity.HospitalNameOf(picked),
PatientIdentity.DiagnosisSourceFor(picked),
PatientIdentity.SurgerySourceFor(picked));
// 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
PatientText.Text = picked is null ? "환자 없음" : label;
PatientClearButton.IsEnabled = picked is not null;
DataSourceButton.IsEnabled = fields.Names.Count > 0;
BuildPages();
}
///
/// 데이터소스 조회 결과를 펼쳐 본다 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
///
/// 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]"처럼 한 줄만 나온다.
/// 그것으로는 쿼리가 틀렸는지, 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 못 가른다.
/// 치환을 마친 SQL 원문을 보여 주는 것이 이 창의 핵심이다.
///
private void OnShowDataSources(object sender, RoutedEventArgs e)
{
if (fields is null)
{
return;
}
DataSourceResultDialogView.ShowFor(fields, this);
}
/// 보기 전환 — 인쇄 기준 / 화면 그대로
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
private void OnPrint(object sender, RoutedEventArgs e)
{
try
{
// 이 창이 종이에 그리는 것과 같은 해석기로 인쇄한다.
// 안 넘기면 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로가 된다 —
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title
: designer.Document.FormId, tags, fields);
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "인쇄 중 오류가 발생했습니다.", ex.Message, this);
}
}
private void OnZoomIn(object sender, RoutedEventArgs e) => ApplyZoom(zoom * 1.15);
private void OnZoomOut(object sender, RoutedEventArgs e) => ApplyZoom(zoom / 1.15);
private void ApplyZoom(double value)
{
zoom = Math.Clamp(value, 0.25, 3.0);
ZoomTransform.ScaleX = zoom;
ZoomTransform.ScaleY = zoom;
ZoomText.Text = $"{zoom * 100:0}%";
}
#endregion
}