fix: per-page WASM render mode, Contact checkbox binding, Telegram inquiry channel
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m11s

- Admin: replace the global @rendermode on <Routes>/<Router> with per-page
  render mode. Login.razor now prerenders (form visible before WASM loads);
  every other [Authorize] page stays prerender: false to avoid the
  AuthorizeRouteView blank-render regression from earlier attempts. Adds a
  "준비 중" -> "로그인" splash tied to WASM boot completion, and lets the
  authenticated-shell loading overlay stay up until AdminShell actually renders.
- Contact.cshtml: fix the "Agree" checkbox missing value="true" - a checked
  box sent the browser-default "on", which bool model binding can't parse,
  so ModelState.IsValid silently went false and OnPostAsync returned a blank
  form with no visible error on every submission. Validation summary widened
  from ModelOnly to All so this class of failure isn't silent again.
- TelegramInquiryNotificationService: read Telegram:InquiryChatId (falling
  back to ChatId) instead of only ChatId, matching the channel routing
  CLAUDE.md documents and deploy.yml already provisions as separate secrets.
- Reconcile CLAUDE.md's self-contradicting Phase 8 prerender notes (Phase 9),
  rewrite validate_admin_render.sh for the per-page design, and add a
  SmartAdmin 5.5 design reference section to DOUZONE_UX_GUIDE.md for future
  admin screens (existing screens unchanged, tracked as WBS P4-03).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 10:15:27 +09:00
parent d015bb6c92
commit e5981769b9
42 changed files with 198 additions and 77 deletions
+19 -2
View File
@@ -134,7 +134,24 @@ export DOTNET_PRINT_TELEMETRY_MESSAGE=false
**완료**: 2026-07-03 / WebAssembly 기반 아키텍처 확정 + 프로덕션 검증
**현재 상태**: ** Phase 1-8 COMPLETE & VERIFIED (2026-07-03)**
**⚠️ Phase 8 알려진 한계 (Phase 9에서 수정됨)**:
Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@rendermode`를 지정해 `prerender: false`로 고정했다. 그 결과 로그인 화면을 포함한 모든 어드민 페이지가 WASM 다운로드 완료 전까지 빈 화면/스피너만 보여주는 문제가 있었다(`scripts/validate_admin_render.sh`에 이 트레이드오프가 "기능 우선, 흰 화면 0.5~2초 감수"로 기록되어 있었음). 이는 `docs/ENGINEERING_HARNESS.md`의 "로그인 화면은 예외적으로 서버 프리렌더 허용" 규칙을 충족하지 못한 상태였다. Phase 9에서 페이지별 개별 렌더모드 지정으로 교체했다.
#### Phase 9: 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
- [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음)
- [x] `Login.razor``@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨
- [x] 나머지 `[Authorize]` 어드민 페이지는 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`로 명시 유지 → 인증 컨텍스트 없이 prerender될 때 `AuthorizeRouteView`가 빈 화면을 그리는 문제(Phase 8 초기에 겪었던 문제) 재발 방지
- [x] WASM 부팅 완료 전 로그인 버튼은 "준비 중" 비활성 상태로 표시, 부팅 완료 시 정상 상태로 전환(업데이트 스플래시)
**핵심 원칙**: Blazor Web App은 "전역 렌더모드" 또는 "페이지별 렌더모드" 중 하나만 선택할 수 있다. Router/Routes에 렌더모드를 지정하면 그 하위 모든 페이지의 개별 `@rendermode` 지시자는 무시된다. 로그인만 예외적으로 prerender가 필요하므로 전역 방식을 버리고 페이지별 방식으로 전환했다.
**완료**: 2026-07-03 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지
**보류된 결정 (2026-07-03, 향후 별도 Phase)**:
- 공개 홈페이지 Razor Pages → MVC(Controller+View) 전면 재작성: 기능적 이득 없이 운영 중인 SEO 트래픽 페이지 전체를 기계적으로 재작성하는 고비용 작업이라 이번엔 보류. 필요 시 Phase 10으로 별도 진행.
- 포털(고객용, `Pages/Portal/*`, 현재 Razor Pages + 쿠키/OAuth) → 어드민과 동일한 MudBlazor+WASM 전환: 완전히 새로운 프로젝트 구조가 필요해 이번 범위에서 제외. 필요 시 Phase 11로 별도 진행.
**현재 상태**: **✅ Phase 1-9 COMPLETE & VERIFIED (2026-07-03)**
- ✅ 모든 API 엔드포인트 구현됨
- ✅ 모든 Browser Client 구현됨
- ✅ 16개 Blazor 페이지 API-First 마이그레이션 완료
@@ -2035,7 +2052,7 @@ else
| 항목 | 이전 | 현재 | 개선 |
|------|------|------|------|
| **Blazor 프리렌더링** | `prerender: false` | `prerender: true` | 흰 화면 제거 |
| **Blazor 프리렌더링** | 전역 `prerender: false` (로그인 포함 전체 흰 화면) | 페이지별 지정 (로그인만 `prerender: true`, 나머지 `false`) | 로그인 흰 화면 제거, 인증 페이지는 그대로 안정 |
| **배포 헬스 체크** | 40 × 3초 = 120초 | 20 × 3초 = 60초 | -50% |
| **E2E 배포 대기** | 30 × 5초 = 150초 | 20 × 3초 = 60초 | -60% |
| **Playwright 병렬** | `fullyParallel: false` | CI에서 `true` | 테스트 병렬화 |
+10 -1
View File
@@ -35,8 +35,17 @@
<p>로드 중...</p>
</div>
</div>
<script>
// 로그인 화면은 prerender로 즉시 표시되므로 스피너가 필요 없다.
// 그 외 인증 화면은 WASM 부팅이 끝날 때까지(AdminShell.OnAfterRenderAsync에서 hideLoading 호출)
// 스피너를 "업데이트 스플래시"로 보여준다.
if (!document.documentElement.classList.contains('admin-login-route')) {
var loadingOverlay = document.getElementById('blazor-loading');
if (loadingOverlay) loadingOverlay.classList.add('show');
}
</script>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<Routes @rendermode="new InteractiveWebAssemblyRenderMode(prerender: false)" />
<Routes />
<script src="/taxbaik/_content/MudBlazor/MudBlazor.min.js"></script>
<script src="/taxbaik/js/admin-session.js"></script>
<script src="/taxbaik/_framework/blazor.web.js"></script>
@@ -1,5 +1,5 @@
@page "/admin"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@inject NavigationManager NavigationManager
@@ -1,6 +1,6 @@
@page "/admin/announcements/create"
@page "/admin/announcements/{Id:int}/edit"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services
@@ -1,5 +1,5 @@
@page "/admin/announcements"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@@ -1,5 +1,5 @@
@page "/admin/blog/create"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.WasmClient.Components.Admin.Pages.Blog
@@ -1,5 +1,5 @@
@page "/admin/blog/{id:int}/edit"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.WasmClient.Components.Admin.Pages.Blog
@@ -1,5 +1,5 @@
@page "/admin/blog"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@inject IBlogBrowserClient BlogClient
@inject ISnackbar Snackbar
@@ -1,5 +1,5 @@
@page "/admin/clients/{ClientId:int}"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Web.Services.AdminClients
@@ -1,6 +1,6 @@
@page "/admin/clients/create"
@page "/admin/clients/{Id:int}/edit"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services
@@ -1,5 +1,5 @@
@page "/admin/clients"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@@ -1,5 +1,5 @@
@page "/admin/common-codes"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Domain.Entities
@attribute [Authorize]
@@ -1,5 +1,5 @@
@page "/admin/companies/create"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.WasmClient.Components.Admin.Forms
@inject IApiClient ApiClient
@@ -1,5 +1,5 @@
@page "/admin/companies/{id:int}/edit"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.WasmClient.Components.Admin.Forms
@inject IApiClient ApiClient
@@ -1,5 +1,5 @@
@page "/admin/companies"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@inject IApiClient ApiClient
@inject ISnackbar Snackbar
@@ -1,5 +1,5 @@
@page "/admin/consulting-activities"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.WasmClient.Components.Admin.Shared
@inject IConsultingActivityBrowserClient ActivityClient
@@ -1,5 +1,5 @@
@page "/admin/contracts"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.WasmClient.Components.Admin.Shared
@inject IContractBrowserClient ContractClient
@@ -1,5 +1,5 @@
@page "/admin/dashboard"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.WasmClient.Components.Admin.Shared
@@ -1,6 +1,6 @@
@page "/admin/faqs/create"
@page "/admin/faqs/{Id:int}/edit"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@@ -1,5 +1,5 @@
@page "/admin/faqs"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@@ -1,5 +1,5 @@
@page "/admin/inquiries/create"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.WasmClient.Components.Admin.Forms
@@ -1,5 +1,5 @@
@page "/admin/inquiries/{InquiryId:int}"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@inject IInquiryBrowserClient InquiryClient
@@ -1,5 +1,5 @@
@page "/admin/inquiries/{id:int}/edit"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.WasmClient.Components.Admin.Forms
@@ -1,5 +1,5 @@
@page "/admin/inquiries"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@inject IInquiryBrowserClient InquiryClient
@@ -1,6 +1,6 @@
@page "/admin/login"
@layout TaxBaik.WasmClient.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous]
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
<PageTitle>로그인</PageTitle>
<AdminLoginForm />
@@ -1,5 +1,5 @@
@page "/admin/logout"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Services
@inject CustomAuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@@ -1,5 +1,5 @@
@page "/admin/revenue-trackings"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.WasmClient.Components.Admin.Shared
@inject IRevenueTrackingBrowserClient RevenueClient
@@ -1,5 +1,5 @@
@page "/admin/season-simulator"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.Seasonal
@using TaxBaik.Application.Services
@@ -1,5 +1,5 @@
@page "/admin/settings"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using System.ComponentModel.DataAnnotations
@using System.Collections.Generic
@@ -1,5 +1,5 @@
@page "/admin/tax-filing-schedules"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Domain.Entities
@using TaxBaik.WasmClient.Components.Admin.Shared
@@ -1,5 +1,5 @@
@page "/admin/tax-filings"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@@ -1,5 +1,5 @@
@page "/admin/tax-profiles"
@rendermode InteractiveWebAssembly
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.WasmClient.Components.Admin.Shared
@inject ITaxProfileBrowserClient TaxProfileClient
@@ -1,7 +1,7 @@
@namespace TaxBaik.WasmClient.Components.Admin
@using Microsoft.AspNetCore.Components.Routing
<Router AppAssembly="@typeof(TaxBaik.WasmClient._Imports).Assembly" @rendermode="new InteractiveWebAssemblyRenderMode(prerender: false)">
<Router AppAssembly="@typeof(TaxBaik.WasmClient._Imports).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.WasmClient.Components.Admin.Layout.MainLayout)">
<NotAuthorized>
@@ -28,9 +28,11 @@
<div class="mud-alert mud-alert-filled-error mb-4 login-error-message" style="display:none;">로그인 중 오류가 발생했습니다.</div>
<button type="submit"
id="admin-login-submit"
disabled
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;">
<span>로그인</span>
<span>준비 중...</span>
</button>
</form>
</MudPaper>
+2 -2
View File
@@ -38,7 +38,7 @@
<form method="post" id="contactForm">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div asp-validation-summary="All" class="text-danger mb-3"></div>
<div class="mb-3">
<label for="name" class="form-label">이름 <span class="text-danger">*</span></label>
@@ -73,7 +73,7 @@
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="agree" name="Agree" required />
<input type="checkbox" class="form-check-input" id="agree" name="Agree" value="true" required />
<label class="form-check-label" for="agree">
개인정보 수집·이용에 동의합니다
</label>
@@ -23,10 +23,12 @@ public class TelegramInquiryNotificationService : IInquiryNotificationService
public async Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
{
var botToken = _configuration["Telegram:BotToken"];
var chatId = _configuration["Telegram:ChatId"];
var chatId = _configuration["Telegram:InquiryChatId"];
if (string.IsNullOrWhiteSpace(chatId))
chatId = _configuration["Telegram:ChatId"];
if (string.IsNullOrWhiteSpace(botToken) || string.IsNullOrWhiteSpace(chatId))
{
_logger.LogWarning("텔레그램 새 문의 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:ChatId를 확인하세요.");
_logger.LogWarning("텔레그램 새 문의 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:InquiryChatId/ChatId를 확인하세요.");
return;
}
@@ -80,10 +82,12 @@ public class TelegramInquiryNotificationService : IInquiryNotificationService
public async Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
{
var botToken = _configuration["Telegram:BotToken"];
var chatId = _configuration["Telegram:ChatId"];
var chatId = _configuration["Telegram:InquiryChatId"];
if (string.IsNullOrWhiteSpace(chatId))
chatId = _configuration["Telegram:ChatId"];
if (string.IsNullOrWhiteSpace(botToken) || string.IsNullOrWhiteSpace(chatId))
{
_logger.LogWarning("텔레그램 상태 변경 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:ChatId를 확인하세요.");
_logger.LogWarning("텔레그램 상태 변경 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:InquiryChatId/ChatId를 확인하세요.");
return;
}
+14 -3
View File
@@ -239,11 +239,12 @@ window.taxbaikAdminSession = {
window.addEventListener('hashchange', window.taxbaikAdminSession.syncRouteClass);
if (document.documentElement.classList.contains('admin-login-route')) {
// Login prerenders immediately; no boot splash needed.
window.taxbaikAdminSession.hideLoading();
}
// Keep the initial overlay hidden unless explicitly enabled elsewhere.
window.taxbaikAdminSession.hideLoading();
// Non-login routes: leave the overlay showing until AdminShell's
// OnAfterRenderAsync(firstRender) calls hideLoading once WASM has
// actually rendered the authenticated shell.
const modal = document.getElementById('components-reconnect-modal');
if (!modal) return;
@@ -266,6 +267,16 @@ window.taxbaikAdminSession = {
form.dataset.bound = '1';
window.taxbaikAdminSession.traceUiState('admin-login', 'bindLoginForm attached');
// 업데이트 스플래시: 제출 핸들러가 실제로 붙기 전까지는 버튼을 "준비 중" 상태로 두고,
// 여기서 활성화해 사용자가 로그인 가능 시점을 알 수 있게 한다.
const readyButton = form.querySelector('#admin-login-submit');
if (readyButton) {
readyButton.disabled = false;
const label = readyButton.querySelector('span');
if (label) label.textContent = '로그인';
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
+1
View File
@@ -78,6 +78,7 @@ Razor Page/Form
| P5-01 | CI gate 명문화 | workflow 체크 목록 | 6개 gate 모두 required |
| P5-02 | 배포본 API smoke 확장 | workflow curl 추가 | Blog/Inquiry create-read-update test 2xx |
| P5-03 | 운영 회귀 대시보드 | test report/version endpoint | 배포 커밋과 E2E 결과 추적 가능 |
| P4-03 | 기존 20개+ 어드민 화면을 SmartAdmin 5.5 참조(`legacy/smartadmin/`, `DOUZONE_UX_GUIDE.md`)로 재단장 (2026-07-03 시점 미착수, 향후 별도 진행) | 각 화면의 색상/카드/타이포그래피 갱신 | SmartAdmin 매핑 표 기준 적용 화면 수 / 전체 화면 수 100% |
## Immediate Refactor Order
+23
View File
@@ -65,6 +65,29 @@ ContentSurface
- 색상은 의미를 유지한다.
- 동일 상태는 동일 색을 사용한다.
## SmartAdmin 5.5 Design Reference (2026-07-03, 신규 화면부터 적용)
이 섹션은 어드민의 **시각적 스킨**(색상, 카드 크롬, 로그인 화면 스타일, 셸 레이아웃) 기준이다. 위 UX Principles(고밀도, 표준 동선, 더존 정신)는 그대로 유지하고, SmartAdmin 5.5는 그 위에 입히는 룩앤필만 담당한다.
- 소스: `legacy/smartadmin/`(로컬에 이미 포함된 v5.5 HTML/CSS 데모 패키지, Bootstrap 5 기반). 정확한 색상/여백 값이 필요하면 이 디렉터리를 직접 참조한다(추측 금지).
- 적용 범위: **향후 신규 어드민 화면부터**. 기존 20개+ 화면(Dashboard, Blog, Inquiry, Client 등)은 이번엔 재단장하지 않는다. 기존 화면을 다른 이유로 수정할 때 자연스럽게 이 기준으로 수렴시킨다.
### 매핑 표
| SmartAdmin 5.5 참조 | 파일 | TaxBaik MudBlazor 대응 |
| --- | --- | --- |
| 상단 `<header>` 툴바 | `dashboard-control-center.html` | `AdminShell``MudAppBar` |
| `<aside class="app-sidebar">` (로고 + 필터 입력 + 메뉴) | `dashboard-control-center.html` | `AdminShell``MudDrawer` (검색/필터 입력 포함) |
| 로그인 카드 (`rounded-4`, 반투명 다크 글래스, `bg-dark bg-opacity-50`) | `auth-login.html` | `AdminLoginForm.razor``MudPaper` 카드 — 반투명/블러 배경 톤 참고 |
| 색상 팔레트 | `colorpalette.html`, `css/smartapp.min.css` | `App.razor``MudTheme.Palette` (Primary/Secondary/Tertiary 등) |
| 카드형 위젯 | `dashboard-*.html` | `AdminMetricCard`, `MudPaper` 기반 카드 |
### 적용 규칙
- 새 어드민 화면을 만들 때: 레이아웃/동선/밀도는 `DOUZONE_UX_GUIDE.md` 상단 원칙을 따르고, 색상·카드 모서리·그림자·로그인류 화면의 톤은 `legacy/smartadmin/`을 참조해 `MudTheme`/CSS 변수로 반영한다.
- SmartAdmin 원본은 jQuery/Bootstrap 5 기반이므로 JS/DOM 구조를 그대로 이식하지 않는다. **시각적 토큰(색, 반경, 여백, 타이포그래피)만** 가져오고, 동작은 MudBlazor 컴포넌트로 구현한다.
- 기존 화면을 SmartAdmin 스타일로 일괄 재단장하는 작업은 별도 WBS로 `docs/ADMIN_PATTERN_CRITIQUE_WBS.md`에 등록한 뒤 진행한다(이번 범위 아님).
## Text And Labels
- 라벨은 짧게 쓴다.
+51 -33
View File
@@ -1,57 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
# Phase 8: WebAssembly 마이그레이션 - 기능 우선 아키텍처 (2026-07-03)
# Phase 9: 어드민 페이지별 렌더모드 정상화 (2026-07-03)
#
# DESIGN DECISION: prerender: false (not true)
# DESIGN: Router/Routes에는 렌더모드를 지정하지 않고, 페이지별로 개별 지정한다.
#
# RATIONALE:
# - prerender: true = SSR (Server-Side Rendering) - 서버가 정적 HTML 미리 생성
# - InteractiveWebAssembly = CSR (Client-Side Rendering) - 클라이언트에서 동적 렌더링
# - These are contradictory: cannot prerender interactive WebAssembly content
# - Blazor Web App은 "전역 렌더모드"와 "페이지별 렌더모드" 중 하나만 선택할 수 있다.
# - Router/Routes에 @rendermode를 지정하면 하위 모든 페이지의 개별 @rendermode는 무시된다.
# - 로그인만 prerender: true가 필요하고(흰 화면 방지), 나머지 [Authorize] 페이지는
# prerender: false가 필요하다(인증 컨텍스트 없이 prerender 시 AuthorizeRouteView가
# 빈 화면을 그리는 문제를 피하기 위함). 이 둘을 동시에 만족하려면 전역 지정을 버리고
# 페이지별 지정으로 가야 한다.
#
# OBSERVED PROBLEM with prerender: true:
# 1. Unauthenticated users see prerendered HTML (no auth context)
# 2. AuthorizeRouteView renders nothing (authorized content cannot prerender)
# 3. After login, WebAssembly tries to render same component differently
# 4. Conflict: static prerendered HTML vs. dynamic WASM runtime
#
# SOLUTION: prerender: false
# - App boots to blank screen briefly (WebAssembly loading ~0.5-2s)
# - After load: all protected pages render correctly with auth
# - No conflicting render modes
# - Functional > Performance optimization
#
# See CLAUDE.md Phase 8 for architecture details.
# See CLAUDE.md Phase 9 for architecture details.
app_file="TaxBaik.Web.Client/Components/Admin/App.razor"
routes_file="TaxBaik.Web.Client/Components/Admin/Routes.razor"
login_file="TaxBaik.Web.Client/Components/Admin/Pages/Login.razor"
for file in "$app_file" "$login_file"; do
for file in "$app_file" "$routes_file" "$login_file"; do
if [ ! -f "$file" ]; then
echo "Missing admin render file: $file" >&2
exit 1
fi
done
# Require WebAssemblyRenderMode (regardless of prerender value)
if ! grep -nE "InteractiveWebAssemblyRenderMode" "$app_file" >/dev/null; then
echo "Admin shell must use InteractiveWebAssemblyRenderMode." >&2
exit 1
fi
# Reject InteractiveServerRenderMode (Blazor Server architecture forbidden)
if grep -nE "InteractiveServerRenderMode" "$app_file" >/dev/null; then
# Reject InteractiveServerRenderMode anywhere (Blazor Server architecture forbidden)
if grep -rnE "InteractiveServerRenderMode" "$app_file" "$routes_file" >/dev/null; then
echo "Admin shell must NOT use InteractiveServerRenderMode (Blazor Server)." >&2
exit 1
fi
# Login page also requires WebAssembly mode
# Accept both: @rendermode InteractiveWebAssembly OR @rendermode @(new InteractiveWebAssemblyRenderMode(...))
if ! grep -nE "InteractiveWebAssembly" "$login_file" >/dev/null; then
echo "Login page must use InteractiveWebAssemblyRenderMode or @rendermode InteractiveWebAssembly." >&2
# Router/Routes must NOT declare a global rendermode boundary.
# A global rendermode on <Routes> or <Router> overrides every per-page @rendermode
# directive beneath it, which is exactly the bug this design fixes.
if grep -nE "@rendermode" "$app_file" >/dev/null; then
echo "App.razor's <Routes> must not declare a global @rendermode (breaks per-page prerender control)." >&2
exit 1
fi
echo "✅ Admin render harness passed (WebAssembly mode verified)."
echo " ️ Note: prerender: false by design (protects @Authorize content from SSR conflicts)"
if grep -nE "@rendermode" "$routes_file" >/dev/null; then
echo "Routes.razor's <Router> must not declare a global @rendermode (breaks per-page prerender control)." >&2
exit 1
fi
# Login page must explicitly prerender (shows the login form in the first HTML response).
if ! grep -nE "InteractiveWebAssemblyRenderMode\(\s*prerender:\s*true\s*\)" "$login_file" >/dev/null; then
echo "Login page must use InteractiveWebAssemblyRenderMode(prerender: true) so the form renders before WASM loads." >&2
exit 1
fi
# Only routable pages (files with a @page directive) need their own render mode.
# Child components (e.g. BlogForm.razor, FilingTable.razor) inherit the render
# mode of whichever page hosts them and must NOT declare their own.
missing_rendermode=0
while IFS= read -r -d '' page; do
[ "$page" = "$login_file" ] && continue
if ! grep -qE "^@page " "$page"; then
continue
fi
if ! grep -nE "@rendermode" "$page" >/dev/null; then
echo "⚠️ $page has no @rendermode directive (will not be interactive)." >&2
missing_rendermode=1
fi
done < <(find TaxBaik.Web.Client/Components/Admin/Pages -name "*.razor" -print0)
if [ "$missing_rendermode" -ne 0 ]; then
echo "One or more admin pages are missing an explicit @rendermode directive." >&2
exit 1
fi
echo "✅ Admin render harness passed (per-page render mode verified)."
echo " ️ Login: prerender: true (visible before WASM loads). Other pages: WebAssembly-interactive."
+21
View File
@@ -54,4 +54,25 @@ test.describe('contact submit', () => {
await navigateInBlazor(page, `${baseUrl}/admin/inquiries`);
await expect(page.locator('.mud-main-content').getByText('문의 관리').first()).toBeVisible({ timeout: 20_000 });
});
test('submitting the public Contact.cshtml form shows the success message', async ({ page }) => {
// Regression test: Contact.cshtml's "Agree" checkbox had no value="true", so a
// checked box submitted the browser-default "on", which bool model binding
// cannot parse. ModelState.IsValid became false and OnPostAsync silently
// returned Page() with a blank form and no visible error - the "무한 작성 유도"
// bug. This drives the real form through a browser to catch that class of bug.
const stamp = Date.now();
await page.goto(`${baseUrl}/contact`, { waitUntil: 'networkidle' });
await page.fill('#name', `Contact-E2E-${stamp}`);
await page.fill('#phone', '010-1234-5678');
await page.fill('#email', `contact-e2e-${stamp}@example.com`);
await page.fill('#message', 'Playwright로 Contact.cshtml 폼을 직접 제출한 테스트입니다.');
await page.check('#agree');
await page.click('button[type="submit"]');
await expect(page.locator('#contact-success')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#contact-success')).toContainText('접수되었습니다');
});
});
+15
View File
@@ -4,6 +4,21 @@ const baseUrl = 'https://www.taxbaik.com/taxbaik';
const username = 'test_admin';
const password = 'TestAdmin@123456';
test('Admin Login Page - prerendered HTML contains the form before JS runs', async ({ request }) => {
// Login.razor is @rendermode ...(prerender: true), so the raw HTTP response
// (no JS execution) must already contain the login form markup. This is the
// regression check for the WASM-boot white-screen bug: if Router/Routes ever
// regains a global @rendermode, this prerender is silently dropped and this
// test catches it without needing a browser.
const response = await request.get(`${baseUrl}/admin/login`);
expect(response.ok()).toBeTruthy();
const html = await response.text();
expect(html).toContain('name="username"');
expect(html).toContain('name="password"');
expect(html).toContain('admin-login-form');
});
test('Admin Login Page - Full Flow Test', async ({ page }) => {
// 콘솔 에러 캡처
page.on('console', msg => {