diff --git a/CLAUDE.md b/CLAUDE.md index 918b019..2ca21ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,8 @@ Blazor → Service (서버) → DB ✅ 현재: API-First (클라이언트-서버 분리) -Blazor (UI만) ← API (모든 로직) ← DB - SignalR (변경 알림만) +Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB + Blazor 데이터 변경 자동 push/broadcast 금지 ``` ### SOLID 기반 순차 마이그레이션 전략 @@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080; **완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴 -#### Phase 6: SignalR 통합 -- [ ] NotificationHub (변경 알림만) -- [ ] Blazor에서 구독 -- [ ] 알림 후 API로 데이터 검증 +#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거 +- [x] NotificationHub 제거 +- [x] 데이터 변경용 INotificationService 제거 +- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거 #### Phase 7: 순차적 마이그레이션 ✅ - [x] Blog 페이지 → API 클라이언트 @@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080; - Status Color Chips (Error/Warning/Success) - Client 링크 (상세 페이지 연동) -### **Phase 6: SignalR 통합** ✅ -- NotificationHub (브로드캐스트만, 상태 관리 없음) -- INotificationService (이벤트 기반) -- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status) -- Program.cs SignalR 등록 +### **Phase 6: Lite Blazor 운영 원칙** ✅ +- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다. +- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다. +- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다. +- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다. --- @@ -160,11 +160,11 @@ Repositories (데이터 계층) PostgreSQL Database ``` -**Blazor Server SignalR**: -- 자동 연결 (내장 Hub connection) -- NotificationHub 클라이언트 그룹 (admins) -- 이벤트 기반 메시지 (상태 관리 없음) -- 클라이언트는 알림 후 API로 데이터 검증 +**Lite Blazor 데이터 갱신**: +- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다. +- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다. +- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다. +- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다. --- @@ -182,10 +182,10 @@ PostgreSQL Database - [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료** - [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion) -**실시간 알림 (Phase 6)**: -- [x] NotificationHub 구현 -- [x] Event-driven 알림 시스템 -- [x] Scoped DI 등록 +**Lite Blazor / 데이터 갱신 (Phase 6)**: +- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거 +- [x] NotificationHub 제거 +- [x] 데이터 변경용 INotificationService 제거 **Blazor 페이지 & UI 고도화 (Phase 7-4)**: - [x] 5개 CRM/세무관리 Blazor 페이지 diff --git a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor index 3442860..bb5cb62 100644 --- a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor +++ b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor @@ -100,7 +100,7 @@ @foreach (var client in clients) { - @client.CompanyName + @GetClientDisplayName(client) } @@ -155,9 +155,9 @@ try { activities = await ActivityClient.GetAllAsync(); - var (clientItems, _) = await ClientClient.GetPagedAsync(); + var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); clients = clientItems.ToList(); - clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? ""); + clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); } catch (Exception ex) { @@ -273,6 +273,12 @@ activityForm = new(); } + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; private class ConsultingActivityForm { public int ClientId { get; set; } diff --git a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor index a31ec56..9d480be 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor @@ -110,7 +110,7 @@ @foreach (var client in clients) { - @(string.IsNullOrEmpty(client.CompanyName) ? client.Name : client.CompanyName) + @GetClientDisplayName(client) } @@ -165,9 +165,9 @@ try { contracts = await ContractClient.GetAllAsync(); - var (clientItems, _) = await ClientClient.GetPagedAsync(); + var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); clients = clientItems.ToList(); - clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? ""); + clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); mrr = await ContractClient.GetMonthlyRecurringRevenueAsync(); } catch (Exception ex) @@ -253,6 +253,12 @@ contractForm = new(); } + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; private class ContractForm { public int? ClientId { get; set; } diff --git a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor index cb54fbf..dfa1c3f 100644 --- a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor +++ b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor @@ -96,7 +96,7 @@ @foreach (var client in clients) { - @client.CompanyName + @GetClientDisplayName(client) } @@ -150,9 +150,9 @@ try { revenues = await RevenueClient.GetAllAsync(); - var (clientItems, _) = await ClientClient.GetPagedAsync(); + var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); clients = clientItems.ToList(); - clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? ""); + clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); } catch (Exception ex) { @@ -252,6 +252,12 @@ revenueForm = new(); } + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; private class RevenueForm { public int ClientId { get; set; } diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor index 51e45ca..4ef829e 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor @@ -127,7 +127,7 @@ RequiredError="고객을 선택하세요."> @foreach (var client in clients) { - @(string.IsNullOrEmpty(client.CompanyName) ? client.Name : client.CompanyName) + @GetClientDisplayName(client) } @@ -182,9 +182,9 @@ try { schedules = await TaxFilingClient.GetAllAsync(); - var (clientItems, _) = await ClientClient.GetPagedAsync(); + var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); clients = clientItems.ToList(); - clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? ""); + clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); } catch (Exception ex) { @@ -286,9 +286,15 @@ scheduleForm = new(); } + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; private class TaxFilingScheduleForm { - public int ClientId { get; set; } + public int? ClientId { get; set; } public string FilingType { get; set; } = ""; public DateTime? DueDate { get; set; } public int FilingYear { get; set; } = DateTime.Now.Year; diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor index 1993d02..3c58f0e 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor @@ -101,7 +101,7 @@ { try { - var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value); + var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value); return items; } catch @@ -110,6 +110,12 @@ } } + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; private async Task AddFiling() { try diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor index 4098001..ef88d0d 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor @@ -87,7 +87,7 @@ else @foreach (var client in clients) { - @(string.IsNullOrEmpty(client.CompanyName) ? client.Name : client.CompanyName) + @GetClientDisplayName(client) } @@ -150,9 +150,9 @@ else try { profiles = await TaxProfileClient.GetAllAsync(); - var (clientItems, _) = await ClientClient.GetPagedAsync(); + var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); clients = clientItems.ToList(); - clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? ""); + clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); } catch (Exception ex) { @@ -195,7 +195,7 @@ else await form.Validate(); if (!form.IsValid) { - Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning); + Snackbar.Add("고객을 선택하세요.", Severity.Warning); return; } } @@ -210,8 +210,13 @@ else } else { + if (!profileForm.ClientId.HasValue) + { + Snackbar.Add("고객을 선택하세요.", Severity.Warning); + return; + } var newId = await TaxProfileClient.CreateAsync( - profileForm.ClientId, + profileForm.ClientId.Value, profileForm.BusinessType); if (newId > 0) { @@ -274,9 +279,15 @@ else _ => Color.Default }; + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; private class TaxProfileForm { - public int ClientId { get; set; } + public int? ClientId { get; set; } public string BusinessType { get; set; } = ""; public string TaxRiskLevel { get; set; } = "normal"; public DateTime? NextFilingDueDate { get; set; } diff --git a/TaxBaik.Web/Hubs/NotificationHub.cs b/TaxBaik.Web/Hubs/NotificationHub.cs deleted file mode 100644 index 0b029a6..0000000 --- a/TaxBaik.Web/Hubs/NotificationHub.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.SignalR; - -namespace TaxBaik.Web.Hubs; - -/// -/// Real-time notification hub for admin dashboard -/// SOLID: Single Responsibility - Only broadcasts change notifications -/// No state management - stateless broadcast pattern -/// -[Authorize] -public class NotificationHub : Hub -{ - private const string AdminGroup = "admins"; - - public override async Task OnConnectedAsync() - { - await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup); - await base.OnConnectedAsync(); - } - - /// - /// Broadcast inquiry status changed to all connected admins - /// Clients should re-fetch from API to verify - /// - public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus) - { - await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new - { - InquiryId = inquiryId, - Status = newStatus, - ChangedAt = DateTime.UtcNow - }); - } - - /// - /// Broadcast inquiry submitted (new inquiry created) - /// - public async Task NotifyInquiryCreated(int inquiryId, string name) - { - await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new - { - InquiryId = inquiryId, - Name = name, - CreatedAt = DateTime.UtcNow - }); - } - - /// - /// Broadcast client created - /// - public async Task NotifyClientCreated(int clientId, string name) - { - await Clients.Group(AdminGroup).SendAsync("ClientCreated", new - { - ClientId = clientId, - Name = name, - CreatedAt = DateTime.UtcNow - }); - } - - /// - /// Broadcast announcement published - /// - public async Task NotifyAnnouncementPublished(int announcementId, string title) - { - await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new - { - AnnouncementId = announcementId, - Title = title, - PublishedAt = DateTime.UtcNow - }); - } - - /// - /// Broadcast tax filing completed - /// - public async Task NotifyFilingCompleted(int filingId, string filingType) - { - await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new - { - FilingId = filingId, - FilingType = filingType, - CompletedAt = DateTime.UtcNow - }); - } -} diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 253eb5a..89308fe 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -52,9 +52,6 @@ builder.Services.AddControllers(); builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); -// SignalR (Notifications only, no state management) -builder.Services.AddSignalR(); - // Razor Pages + Blazor Server 통합 builder.Services.AddRazorPages(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -197,9 +194,6 @@ builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthorization(); builder.Services.AddAuthorizationCore(); -// Notifications (SignalR) -builder.Services.AddScoped(); - // Telegram Notification builder.Services.AddHttpClient(); @@ -350,8 +344,6 @@ app.MapControllers(); app.MapHealthChecks("/healthz"); app.MapRazorPages(); -// SignalR Hub -app.MapHub("/taxbaik/notifications"); // AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다. // 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다. app.MapRazorComponents() diff --git a/TaxBaik.Web/Services/NotificationService.cs b/TaxBaik.Web/Services/NotificationService.cs deleted file mode 100644 index 90ebf75..0000000 --- a/TaxBaik.Web/Services/NotificationService.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace TaxBaik.Web.Services; - -/// -/// Notification service for real-time admin updates -/// SOLID: Single Responsibility - Event notification only -/// Uses Blazor Server's built-in SignalR for real-time communication -/// -public interface INotificationService -{ - event Func? OnInquiryStatusChanged; - event Func? OnInquiryCreated; - event Func? OnClientCreated; - event Func? OnAnnouncementPublished; - event Func? OnFilingCompleted; - - Task TriggerInquiryStatusChanged(int inquiryId, string status); - Task TriggerInquiryCreated(int inquiryId, string name); - Task TriggerClientCreated(int clientId, string name); - Task TriggerAnnouncementPublished(int announcementId, string title); - Task TriggerFilingCompleted(int filingId, string filingType); -} - -public class NotificationService : INotificationService -{ - private readonly ILogger _logger; - - public NotificationService(ILogger logger) - { - _logger = logger; - } - - public event Func? OnInquiryStatusChanged; - public event Func? OnInquiryCreated; - public event Func? OnClientCreated; - public event Func? OnAnnouncementPublished; - public event Func? OnFilingCompleted; - - public async Task TriggerInquiryStatusChanged(int inquiryId, string status) - { - _logger.LogInformation($"Inquiry {inquiryId} status changed to {status}"); - if (OnInquiryStatusChanged != null) - await OnInquiryStatusChanged(inquiryId, status); - } - - public async Task TriggerInquiryCreated(int inquiryId, string name) - { - _logger.LogInformation($"New inquiry {inquiryId} from {name}"); - if (OnInquiryCreated != null) - await OnInquiryCreated(inquiryId, name); - } - - public async Task TriggerClientCreated(int clientId, string name) - { - _logger.LogInformation($"New client {clientId}: {name}"); - if (OnClientCreated != null) - await OnClientCreated(clientId, name); - } - - public async Task TriggerAnnouncementPublished(int announcementId, string title) - { - _logger.LogInformation($"Announcement {announcementId} published: {title}"); - if (OnAnnouncementPublished != null) - await OnAnnouncementPublished(announcementId, title); - } - - public async Task TriggerFilingCompleted(int filingId, string filingType) - { - _logger.LogInformation($"Filing {filingId} ({filingType}) completed"); - if (OnFilingCompleted != null) - await OnFilingCompleted(filingId, filingType); - } -}