diff --git a/Assets/_DDD/_Addressables/Prefabs/Uis/GameUi/PopupUis/RestaurantManagementUi.prefab b/Assets/_DDD/_Addressables/Prefabs/Uis/GameUi/PopupUis/RestaurantManagementUi.prefab index 1a346f07d..1cb4e065e 100644 --- a/Assets/_DDD/_Addressables/Prefabs/Uis/GameUi/PopupUis/RestaurantManagementUi.prefab +++ b/Assets/_DDD/_Addressables/Prefabs/Uis/GameUi/PopupUis/RestaurantManagementUi.prefab @@ -6311,7 +6311,7 @@ PrefabInstance: m_AddedComponents: - targetCorrespondingSourceObject: {fileID: 6952779389930089995, guid: 4f2bf029cb06b084ba41defc8fc76731, type: 3} insertIndex: -1 - addedObject: {fileID: 2438716745211137680} + addedObject: {fileID: 7197937761328488698} m_SourcePrefab: {fileID: 100100000, guid: 4f2bf029cb06b084ba41defc8fc76731, type: 3} --- !u!224 &4720669467062659157 stripped RectTransform: @@ -6323,7 +6323,7 @@ GameObject: m_CorrespondingSourceObject: {fileID: 6952779389930089995, guid: 4f2bf029cb06b084ba41defc8fc76731, type: 3} m_PrefabInstance: {fileID: 4463400116329503023} m_PrefabAsset: {fileID: 0} ---- !u!114 &2438716745211137680 +--- !u!114 &7197937761328488698 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -6332,10 +6332,10 @@ MonoBehaviour: m_GameObject: {fileID: 6740783381500491556} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 46c8396c996c804449b383960b44e812, type: 3} + m_Script: {fileID: 11500000, guid: 8a2e0954aa144633aad86e53dc80a46a, type: 3} m_Name: m_EditorClassIdentifier: - _enableBlockImage: 1 + _enableBlockImage: 0 _uiActionsInputBinding: {fileID: 11400000, guid: 8073fcaf56fc7c34e996d0d47044f146, type: 2} _checklistView: {fileID: 7075966153492927588} _inventoryView: {fileID: 3570087040626823091} @@ -6346,7 +6346,6 @@ MonoBehaviour: _menuCategoryTabs: {fileID: 6805049896193344908} _cookwareCategoryTabs: {fileID: 6628923975427483430} _completeBatchFilledImage: {fileID: 2965326806322860544} - _holdCompleteTime: 0.5 --- !u!1001 &4530765275021007961 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/_DDD/_Scripts/GameUi/New/NewRestaurantManagementUi.cs b/Assets/_DDD/_Scripts/GameUi/New/NewRestaurantManagementUi.cs new file mode 100644 index 000000000..6f7404022 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/NewRestaurantManagementUi.cs @@ -0,0 +1,275 @@ +using UnityEngine; +using UnityEngine.UI; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem; +using DDD.MVVM; + +namespace DDD +{ + /// + /// MVVM 패턴을 적용한 새로운 레스토랑 관리 UI + /// 기존 RestaurantManagementUi의 기능을 ViewModel과 분리하여 구현 + /// + public class NewRestaurantManagementUi : IntegratedBasePopupUi + { + [Header("Sub Views")] + [SerializeField] private ChecklistView _checklistView; + [SerializeField] private InventoryView _inventoryView; + [SerializeField] private ItemDetailView _itemDetailView; + [SerializeField] private TodayMenuView _todayMenuView; + [SerializeField] private TodayRestaurantStateView _todayRestaurantStateView; + + [Header("Tab Groups")] + [SerializeField] private TabGroupUi _sectionTabs; + [SerializeField] private TabGroupUi _menuCategoryTabs; + [SerializeField] private TabGroupUi _cookwareCategoryTabs; + + [Header("Hold Progress UI")] + [SerializeField, BindTo(nameof(RestaurantManagementViewModel.NormalizedHoldProgress))] + private Image _completeBatchFilledImage; + + protected override void Awake() + { + base.Awake(); + + SetupViewModelEvents(); + } + + protected override void Update() + { + base.Update(); + + if (_viewModel != null && _viewModel.IsHolding) + { + _viewModel.UpdateHoldProgress(); + } + } + + public override void Open(OpenPopupUiEvent evt) + { + base.Open(evt); + + InitializeViews(); + SetupTabs(); + } + + protected override GameObject GetInitialSelected() + { + // ViewModel의 현재 상태에 따라 초기 선택 UI 결정 + var inventoryInitialSelected = _inventoryView.GetInitialSelected(); + if (inventoryInitialSelected) return inventoryInitialSelected; + + var menuCategoryButton = _menuCategoryTabs.GetFirstInteractableButton(); + if (menuCategoryButton != null && menuCategoryButton.activeInHierarchy) + return menuCategoryButton; + + var cookwareCategoryButton = _cookwareCategoryTabs.GetFirstInteractableButton(); + if (cookwareCategoryButton != null && cookwareCategoryButton.activeInHierarchy) + return cookwareCategoryButton; + + return null; + } + + protected override void SetupBindings() + { + // Attribute 기반 자동 바인딩이 처리됨 + // 추가적인 수동 바인딩이 필요한 경우 여기에 구현 + } + + protected override void HandleCustomPropertyChanged(string propertyName) + { + switch (propertyName) + { + case nameof(RestaurantManagementViewModel.CurrentSection): + UpdateSectionTabs(); + break; + case nameof(RestaurantManagementViewModel.CurrentCategory): + UpdateCategoryTabs(); + break; + } + } + + private void SetupViewModelEvents() + { + if (!_viewModel) return; + + _viewModel.OnBatchCompleted = HandleBatchCompleted; + _viewModel.OnChecklistFailed = HandleChecklistFailed; + _viewModel.OnMenuSectionSelected = HandleMenuSectionSelected; + _viewModel.OnCookwareSectionSelected = HandleCookwareSectionSelected; + _viewModel.OnCategoryChanged = HandleCategoryChanged; + _viewModel.OnTabMoved = HandleTabMoved; + _viewModel.OnInteractRequested = HandleInteractRequested; + _viewModel.OnCloseRequested = HandleCloseRequested; + _viewModel.OnMenuCategorySelected = HandleMenuCategorySelected; + } + + private void InitializeViews() + { + _checklistView.Initalize(); + _inventoryView.Initialize(); + _itemDetailView.Initialize(); + _todayMenuView.Initialize(); + _todayRestaurantStateView.Initialize(); + } + + private void SetupTabs() + { + SetupCategoryTabs(); + InitializeTabGroups(); + SelectInitialTabs(); + } + + private void SetupCategoryTabs() + { + _menuCategoryTabs.UseDefaultAllowedValues(); + _cookwareCategoryTabs.UseDefaultAllowedValues(); + } + + private void InitializeTabGroups() + { + _sectionTabs.Initialize(OnSectionTabSelected); + _menuCategoryTabs.Initialize(OnCategoryTabSelected); + _cookwareCategoryTabs.Initialize(OnCategoryTabSelected); + } + + private void SelectInitialTabs() + { + _sectionTabs.SelectFirstTab(); + _menuCategoryTabs.SelectFirstTab(); + } + + private void UpdateSectionTabs() + { + if (_viewModel == null) return; + _sectionTabs.SelectTab((int)_viewModel.CurrentSection); + } + + private void UpdateCategoryTabs() + { + if (_viewModel == null) return; + + switch (_viewModel.CurrentSection) + { + case SectionButtonType.Menu: + _menuCategoryTabs.SelectTab((int)_viewModel.CurrentCategory); + break; + case SectionButtonType.Cookware: + _cookwareCategoryTabs.SelectTab((int)_viewModel.CurrentCategory); + break; + } + } + + // ViewModel 이벤트 핸들러들 + private void HandleBatchCompleted() + { + Close(); + } + + private void HandleChecklistFailed() + { + var evt = GameEvents.OpenPopupUiEvent; + evt.UiType = typeof(ConfirmUi); + evt.IsCancelButtonVisible = true; + evt.NewMessageKey = "checklist_failed_message"; + evt.OnConfirm = ClosePanel; + EventBus.Broadcast(evt); + } + + private void HandleMenuSectionSelected() + { + _menuCategoryTabs.SelectFirstTab(); + } + + private void HandleCookwareSectionSelected() + { + _cookwareCategoryTabs.SelectFirstTab(); + } + + private void HandleCategoryChanged(InventoryCategoryType category) + { + _inventoryView.UpdateCategoryView(category); + _itemDetailView.UpdateCategory(category); + } + + private void HandleTabMoved(int direction) + { + _sectionTabs.Move(direction); + } + + private void HandleInteractRequested() + { + var selected = EventSystem.current.currentSelectedGameObject; + var interactable = selected?.GetComponent(); + interactable?.OnInteract(); + } + + private void HandleCloseRequested() + { + Close(); + } + + private void HandleMenuCategorySelected(InventoryCategoryType category) + { + _menuCategoryTabs.SelectTab((int)category); + } + + // UI 이벤트 핸들러들 (TabGroupUi 콜백) + private void OnSectionTabSelected(int sectionValue) + { + _viewModel?.SetSection((SectionButtonType)sectionValue); + } + + private void OnCategoryTabSelected(int categoryValue) + { + _viewModel?.SetCategory((InventoryCategoryType)categoryValue); + } + + // 입력 처리 - ViewModel로 위임 + protected override bool OnInputPerformed(RestaurantUiActions actionEnum, InputAction.CallbackContext context) + { + var isHandled = base.OnInputPerformed(actionEnum, context); + + if (isHandled && _viewModel != null) + { + switch (actionEnum) + { + case RestaurantUiActions.Cancel: + _viewModel.CloseUi(); + break; + case RestaurantUiActions.PreviousTab: + _viewModel.MoveTab(-1); + break; + case RestaurantUiActions.NextTab: + _viewModel.MoveTab(1); + break; + case RestaurantUiActions.Interact1: + _viewModel.InteractWithSelected(); + break; + case RestaurantUiActions.Interact2: + _viewModel.StartHold(); + break; + } + } + + return isHandled; + } + + protected override bool OnInputCanceled(RestaurantUiActions actionEnum, InputAction.CallbackContext context) + { + var isHandled = base.OnInputCanceled(actionEnum, context); + + if (isHandled && _viewModel != null) + { + switch (actionEnum) + { + case RestaurantUiActions.Interact2: + _viewModel.CancelHold(); + break; + } + } + + return isHandled; + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/NewRestaurantManagementUi.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/NewRestaurantManagementUi.cs.meta new file mode 100644 index 000000000..fb8636ee8 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/NewRestaurantManagementUi.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8a2e0954aa144633aad86e53dc80a46a +timeCreated: 1755673386 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/RestaurantManagementViewModel.cs b/Assets/_DDD/_Scripts/GameUi/New/RestaurantManagementViewModel.cs new file mode 100644 index 000000000..e88b79e83 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/RestaurantManagementViewModel.cs @@ -0,0 +1,241 @@ +using System.Linq; +using DDD.MVVM; +using UnityEngine; + +namespace DDD +{ + /// + /// 레스토랑 관리 UI의 ViewModel + /// 기존 RestaurantManagementUi의 상태 관리와 비즈니스 로직을 담당 + /// + public class RestaurantManagementViewModel : SimpleViewModel, IEventHandler + { + // 홀드 진행 상태 관리 + private bool _isHolding; + private float _elapsedTime; + private float _holdCompleteTime = 1f; + + // 탭 상태 관리 + private SectionButtonType _currentSection = SectionButtonType.Menu; + private InventoryCategoryType _currentCategory = InventoryCategoryType.Food; + + /// + /// 현재 홀드 상태 + /// + public bool IsHolding + { + get => _isHolding; + private set => SetField(ref _isHolding, value); + } + + /// + /// 홀드 진행 시간 (0.0 ~ 1.0) + /// + public float HoldProgress + { + get => _elapsedTime; + private set => SetField(ref _elapsedTime, value); + } + + /// + /// 홀드 완료에 필요한 시간 + /// + public float HoldCompleteTime + { + get => _holdCompleteTime; + set => SetField(ref _holdCompleteTime, value); + } + + /// + /// 현재 선택된 섹션 + /// + public SectionButtonType CurrentSection + { + get => _currentSection; + set => SetField(ref _currentSection, value); + } + + /// + /// 현재 선택된 카테고리 + /// + public InventoryCategoryType CurrentCategory + { + get => _currentCategory; + set => SetField(ref _currentCategory, value); + } + + /// + /// 배치 완료 가능 여부 (체크리스트 완료 상태) + /// + public bool CanCompleteBatch => + RestaurantState.Instance.ManagementState.GetChecklistStates().All(state => state); + + /// + /// 홀드 진행률을 0~1 범위로 변환한 값 + /// + public float NormalizedHoldProgress => HoldCompleteTime <= 0f ? 1f : Mathf.Clamp01(HoldProgress / HoldCompleteTime); + + public override void Initialize() + { + base.Initialize(); + RegisterEvents(); + ResetHoldState(); + } + + public override void Cleanup() + { + base.Cleanup(); + UnregisterEvents(); + } + + private void RegisterEvents() + { + EventBus.Register(this); + } + + private void UnregisterEvents() + { + EventBus.Unregister(this); + } + + /// + /// 홀드 진행 업데이트 (View에서 Update마다 호출) + /// + public void UpdateHoldProgress() + { + if (!IsHolding) return; + + if (HoldCompleteTime <= 0f) + { + ProcessCompleteBatch(); + return; + } + + var deltaTime = Time.deltaTime; + HoldProgress += deltaTime; + + if (HoldProgress >= HoldCompleteTime) + { + ProcessCompleteBatch(); + } + + // UI 업데이트를 위한 정규화된 진행률 알림 + OnPropertyChanged(nameof(NormalizedHoldProgress)); + } + + /// + /// 홀드 시작 + /// + public void StartHold() + { + IsHolding = true; + HoldProgress = 0f; + OnPropertyChanged(nameof(NormalizedHoldProgress)); + } + + /// + /// 홀드 취소 + /// + public void CancelHold() + { + ResetHoldState(); + } + + private void ResetHoldState() + { + IsHolding = false; + HoldProgress = 0f; + OnPropertyChanged(nameof(NormalizedHoldProgress)); + } + + /// + /// 배치 완료 처리 + /// + private void ProcessCompleteBatch() + { + ResetHoldState(); + + if (CanCompleteBatch) + { + // 배치 완료 - UI 닫기 이벤트 발생 + OnBatchCompleted?.Invoke(); + } + else + { + // 체크리스트 미완료 - 실패 팝업 표시 이벤트 발생 + OnChecklistFailed?.Invoke(); + } + } + + /// + /// 섹션 탭 선택 처리 + /// + public void SetSection(SectionButtonType section) + { + CurrentSection = section; + + // 섹션 변경 시 해당 섹션의 첫 번째 카테고리로 설정 + switch (section) + { + case SectionButtonType.Menu: + OnMenuSectionSelected?.Invoke(); + break; + case SectionButtonType.Cookware: + OnCookwareSectionSelected?.Invoke(); + break; + } + } + + /// + /// 카테고리 탭 선택 처리 + /// + public void SetCategory(InventoryCategoryType category) + { + CurrentCategory = category; + OnCategoryChanged?.Invoke(category); + } + + /// + /// 탭 이동 (이전/다음) + /// + public void MoveTab(int direction) + { + OnTabMoved?.Invoke(direction); + } + + /// + /// 현재 선택된 UI 요소와 상호작용 + /// + public void InteractWithSelected() + { + OnInteractRequested?.Invoke(); + } + + /// + /// UI 닫기 + /// + public void CloseUi() + { + OnCloseRequested?.Invoke(); + } + + // View에서 구독할 이벤트들 + public System.Action OnBatchCompleted; + public System.Action OnChecklistFailed; + public System.Action OnMenuSectionSelected; + public System.Action OnCookwareSectionSelected; + public System.Action OnCategoryChanged; + public System.Action OnTabMoved; + public System.Action OnInteractRequested; + public System.Action OnCloseRequested; + + // 이벤트 핸들러 + public void Invoke(TodayMenuRemovedEvent evt) + { + SetCategory(evt.InventoryCategoryType); + OnMenuCategorySelected?.Invoke(evt.InventoryCategoryType); + } + + public System.Action OnMenuCategorySelected; + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/RestaurantManagementViewModel.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/RestaurantManagementViewModel.cs.meta new file mode 100644 index 000000000..10b0cc221 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/RestaurantManagementViewModel.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 80dee5e1862248aab26236036049e5fc +timeCreated: 1755673405 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs index 4f7310265..4ab0a5655 100644 --- a/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs +++ b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs @@ -3,7 +3,7 @@ using System.Runtime.CompilerServices; using UnityEngine; -namespace DDD.MVVM +namespace DDD { public abstract class SimpleViewModel : MonoBehaviour, INotifyPropertyChanged { diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs index f5139fa52..6805b6df9 100644 --- a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs @@ -1,31 +1,43 @@ using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; using UnityEngine; using UnityEngine.EventSystems; -using DDD.MVVM; +using UnityEngine.UI; using Sirenix.OdinInspector; using UnityEngine.InputSystem; +using DDD.MVVM; namespace DDD { - public abstract class IntegratedBasePopupUi : IntegratedBaseUi + public abstract class IntegratedBasePopupUi : BasePopupUi where TInputEnum : Enum where TViewModel : SimpleViewModel { [SerializeField, Required] protected BaseUiActionsInputBinding _uiActionsInputBinding; - + protected readonly List<(InputAction action, Action handler)> _registeredHandlers = new(); + + // MVVM 기능들 + protected BindingContext _bindingContext; + protected TViewModel _viewModel; - public InputActionMaps InputActionMaps => _uiActionsInputBinding.InputActionMaps; - public bool IsTopPopup => UiManager.Instance.PopupUiState.IsTopPopup(this as BasePopupUi); + public override InputActionMaps InputActionMaps => _uiActionsInputBinding.InputActionMaps; + public bool IsTopPopup => UiManager.Instance.PopupUiState.IsTopPopup(this); protected override void Awake() { base.Awake(); - - // BasePopupUi의 기본값 적용 + _enableBlockImage = true; + _viewModel = GetComponent(); + + _bindingContext = new BindingContext(); + SetupAutoBindings(); + SetupBindings(); } protected override void OnEnable() @@ -36,8 +48,7 @@ protected override void OnEnable() protected override void Update() { base.Update(); - - // BasePopupUi의 Update 로직 구현 + if (IsOpenPanel() == false) return; var currentSelectedGameObject = EventSystem.current.currentSelectedGameObject; @@ -50,8 +61,26 @@ protected override void Update() } } } + + public override void Open(OpenPopupUiEvent evt) + { + base.Open(evt); - protected abstract GameObject GetInitialSelected(); + var initialSelected = GetInitialSelected(); + if (initialSelected != null) + { + EventSystem.current.SetSelectedGameObject(initialSelected); + } + + transform.SetAsLastSibling(); + + if (IsTopPopup) + { + InputManager.Instance.SwitchCurrentActionMap(_uiActionsInputBinding.InputActionMaps); + } + } + + protected override abstract GameObject GetInitialSelected(); protected override void TryRegister() { @@ -110,37 +139,114 @@ protected override void TryUnregister() // 입력 처리 메서드들 protected virtual bool OnInputStarted(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup; + protected virtual bool OnInputPerformed(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup; + protected virtual bool OnInputCanceled(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup; - protected virtual bool OnInputPerformed(TInputEnum actionEnum, InputAction.CallbackContext context) => - IsTopPopup; - - protected virtual bool OnInputCanceled(TInputEnum actionEnum, InputAction.CallbackContext context) => - IsTopPopup; - - - public virtual void Open(OpenPopupUiEvent evt) + /// + /// Attribute 기반 자동 바인딩 설정 + /// + private void SetupAutoBindings() { - OpenPanel(); + var fields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public) + .Where(f => f.GetCustomAttribute() != null); - var initialSelected = GetInitialSelected(); - if (initialSelected != null) + foreach (var field in fields) { - EventSystem.current.SetSelectedGameObject(initialSelected); + var bindAttribute = field.GetCustomAttribute(); + SetupBinding(field, bindAttribute); } - transform.SetAsLastSibling(); + // 컬렉션 바인딩 설정 + var collectionFields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public) + .Where(f => f.GetCustomAttribute() != null); - if (IsTopPopup) + foreach (var field in collectionFields) { - InputManager.Instance.SwitchCurrentActionMap(_uiActionsInputBinding.InputActionMaps); + var bindAttribute = field.GetCustomAttribute(); + SetupCollectionBinding(field, bindAttribute); } } - public virtual void Close() + /// + /// 개별 필드의 바인딩 설정 + /// + private void SetupBinding(FieldInfo field, BindToAttribute bindAttribute) { - var evt = GameEvents.ClosePopupUiEvent; - evt.UiType = GetType(); - EventBus.Broadcast(evt); + var target = field.GetValue(this); + + IValueConverter converter = null; + if (bindAttribute.ConverterType != null) + { + converter = Activator.CreateInstance(bindAttribute.ConverterType) as IValueConverter; + } + + // UI 컴포넌트 타입별 바인딩 타겟 생성 + IBindingTarget bindingTarget = target switch + { + Text text => new TextBindingTarget(text, bindAttribute.PropertyPath), + Image image => new ImageBindingTarget(image, bindAttribute.PropertyPath), + GameObject gameObject => new ActiveBindingTarget(gameObject, bindAttribute.PropertyPath), + Slider slider => new SliderBindingTarget(slider, bindAttribute.PropertyPath), + _ => null + }; + + if (bindingTarget != null) + { + _bindingContext.Bind(bindAttribute.PropertyPath, bindingTarget, converter); + } + } + + /// + /// 컬렉션 바인딩 설정 + /// + private void SetupCollectionBinding(FieldInfo field, BindCollectionAttribute bindAttribute) + { + var target = field.GetValue(this); + + if (target is Transform parent) + { + // 컬렉션 바인딩 로직 (필요시 확장) + Debug.Log($"Collection binding for {bindAttribute.PropertyPath} is set up on {parent.name}"); + } + } + + /// + /// 추가 바인딩 설정 - 하위 클래스에서 구현 + /// + protected virtual void SetupBindings() { } + + /// + /// ViewModel 속성 변경 이벤트 핸들러 + /// + protected virtual void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + HandleCustomPropertyChanged(e.PropertyName); + } + + /// + /// 커스텀 속성 변경 처리 (하위 클래스에서 오버라이드) + /// + protected virtual void HandleCustomPropertyChanged(string propertyName) + { + // 하위 클래스에서 구현 + } + + /// + /// UI 패널 열기 오버라이드 (ViewModel 초기화 추가) + /// + public override void OpenPanel() + { + base.OpenPanel(); + _viewModel?.Initialize(); + } + + /// + /// UI 패널 닫기 오버라이드 (ViewModel 정리 추가) + /// + public override void ClosePanel() + { + _viewModel?.Cleanup(); + base.ClosePanel(); } } } \ No newline at end of file