diff --git a/TaxBaik.Web/Hubs/NotificationHub.cs b/TaxBaik.Web/Hubs/NotificationHub.cs
new file mode 100644
index 0000000..0b029a6
--- /dev/null
+++ b/TaxBaik.Web/Hubs/NotificationHub.cs
@@ -0,0 +1,87 @@
+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 b492bda..5f8d62d 100644
--- a/TaxBaik.Web/Program.cs
+++ b/TaxBaik.Web/Program.cs
@@ -21,6 +21,9 @@ 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();
@@ -66,6 +69,9 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
+// Notifications (SignalR)
+builder.Services.AddScoped();
+
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -179,6 +185,9 @@ if (!app.Environment.IsDevelopment())
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
new file mode 100644
index 0000000..90ebf75
--- /dev/null
+++ b/TaxBaik.Web/Services/NotificationService.cs
@@ -0,0 +1,72 @@
+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);
+ }
+}