refactor: complete WebAssembly migration - proper architecture
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m17s

Phase 8: Complete WebAssembly 렌더 모드 전환 (정공법)

Migration Summary:
- ALL Admin components → TaxBaik.Web.Client
- Routes.razor, Pages/*, Layout/*, Shared/*, Forms/*
- App.razor → TaxBaik.WasmClient (호스트 컴포넌트)
- Shared utilities → TaxBaik.Application.Utils

Architecture:
 App.razor: TaxBaik.WasmClient (WebAssembly, 호스트)
 Routes + Pages: TaxBaik.WasmClient (WebAssembly)
 Layout + Shared + Forms: TaxBaik.WasmClient (WebAssembly)
 Services: TaxBaik.Web (API-First)

Key Changes:
- Namespaces: TaxBaik.Web.Components.Admin → TaxBaik.WasmClient.Components.Admin
- Shared utilities: TaxBaik.Application.Utils (single source of truth)
- Program.cs: MapRazorComponents<TaxBaik.WasmClient.Components.Admin.App>()
- _Imports.razor: Components/Admin 폴더에 재구성

Build Status:  0 errors, 0 warnings

Benefits:
- Stateless server (no Circuit memory)
- Client-side rendering (WebAssembly)
- Unlimited concurrent users (horizontal scaling)
- ERP-ready architecture

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:03:51 +09:00
parent 76446ee0f0
commit 8202c3278b
63 changed files with 41 additions and 30 deletions
@@ -0,0 +1,43 @@
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">@Eyebrow</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@Title</MudText>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<MudText Typo="Typo.body2" Class="admin-page-subtitle">@Subtitle</MudText>
}
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="OnCancel">
@CancelText
</MudButton>
</section>
<AdminEditorPanel Loading="@Loading" SkeletonContent="@SkeletonContent">
@ChildContent
</AdminEditorPanel>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = "";
[Parameter, EditorRequired]
public string Eyebrow { get; set; } = "";
[Parameter]
public string? Subtitle { get; set; }
[Parameter, EditorRequired]
public EventCallback OnCancel { get; set; }
[Parameter]
public string CancelText { get; set; } = "취소";
[Parameter]
public bool Loading { get; set; }
[Parameter]
public RenderFragment? SkeletonContent { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,28 @@
<MudPaper Class="admin-surface" Elevation="0">
@if (Loading)
{
@if (SkeletonContent is not null)
{
@SkeletonContent
}
else
{
<AdminSkeletonRows />
}
}
else if (ChildContent is not null)
{
@ChildContent
}
</MudPaper>
@code {
[Parameter]
public bool Loading { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public RenderFragment? SkeletonContent { get; set; }
}
@@ -0,0 +1,21 @@
<MudPaper Class="@CssClass" Elevation="@Elevation">
@if (!string.IsNullOrWhiteSpace(Title))
{
<MudText Typo="Typo.h6" Class="mb-3">@Title</MudText>
}
@ChildContent
</MudPaper>
@code {
[Parameter]
public string? Title { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string CssClass { get; set; } = "pa-4";
[Parameter]
public int Elevation { get; set; } = 1;
}
@@ -0,0 +1,16 @@
<AdminDataPanel Loading="@Loading" SkeletonContent="@SkeletonContent">
<div class="admin-editor-panel-shell">
@ChildContent
</div>
</AdminDataPanel>
@code {
[Parameter]
public bool Loading { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public RenderFragment? SkeletonContent { get; set; }
}
@@ -0,0 +1,12 @@
<div class="pa-6 text-center">
<MudIcon Icon="@Icon" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">@Message</MudText>
</div>
@code {
[Parameter, EditorRequired]
public string Icon { get; set; } = Icons.Material.Filled.Info;
[Parameter, EditorRequired]
public string Message { get; set; } = "";
}
@@ -0,0 +1,44 @@
<div class="d-flex gap-2">
<MudButton Variant="@SubmitVariant"
Color="@SubmitColor"
StartIcon="@SubmitIcon"
@onclick="OnSubmit"
Disabled="@IsSubmitting">
@(IsSubmitting ? LoadingText : SubmitText)
</MudButton>
@if (OnCancel.HasDelegate)
{
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">
@CancelText
</MudButton>
}
</div>
@code {
[Parameter, EditorRequired]
public string SubmitText { get; set; } = "저장";
[Parameter]
public string LoadingText { get; set; } = "저장 중...";
[Parameter]
public string CancelText { get; set; } = "취소";
[Parameter]
public Variant SubmitVariant { get; set; } = Variant.Filled;
[Parameter]
public Color SubmitColor { get; set; } = Color.Primary;
[Parameter]
public string? SubmitIcon { get; set; }
[Parameter]
public EventCallback OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
[Parameter]
public bool IsSubmitting { get; set; }
}
@@ -0,0 +1,25 @@
<div class="@CssClass">
@if (!string.IsNullOrWhiteSpace(Title))
{
<MudText Typo="Typo.subtitle1" Class="font-weight-bold mb-1">@Title</MudText>
}
@if (!string.IsNullOrWhiteSpace(Description))
{
<MudText Typo="Typo.body2" Class="mb-2">@Description</MudText>
}
@ChildContent
</div>
@code {
[Parameter]
public string? Title { get; set; }
[Parameter]
public string? Description { get; set; }
[Parameter]
public string CssClass { get; set; } = "";
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,70 @@
@inject ILocalStorageService LocalStorageService
@inject IJSRuntime Js
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
<form id="admin-login-form">
<input class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="사용자명"
autocomplete="username"
name="username"
value="@rememberedUsername" />
<input type="password"
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="비밀번호"
autocomplete="current-password"
name="password" />
<div class="mb-4">
<input class="mud-checkbox" type="checkbox" name="rememberMe" />
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
</div>
<div class="mud-alert mud-alert-filled-error mb-4 login-error-message" style="display:none;">로그인 중 오류가 발생했습니다.</div>
<button type="submit"
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>
</button>
</form>
</MudPaper>
</MudContainer>
@code {
private string rememberedUsername = "";
private const string RememberedUsernameKey = "admin-remembered-username";
protected override async Task OnInitializedAsync()
{
try
{
rememberedUsername = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey) ?? "";
}
catch
{
rememberedUsername = "";
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm");
}
catch
{
// Login UI must remain visible even if JS binding fails.
}
}
}
}
@@ -0,0 +1,36 @@
<div class="admin-metric-card @Accent cursor-pointer" @onclick="OnClick">
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">@Label</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: @ValueColor;">@Value</span>
<span class="admin-metric-card-icon" style="color: @IconColor;">@Icon</span>
</div>
<span class="admin-metric-card-caption">@Caption</span>
</div>
</div>
@code {
[Parameter, EditorRequired]
public string Label { get; set; } = "";
[Parameter, EditorRequired]
public object? Value { get; set; }
[Parameter, EditorRequired]
public string Caption { get; set; } = "";
[Parameter, EditorRequired]
public string Accent { get; set; } = "";
[Parameter, EditorRequired]
public string Icon { get; set; } = "";
[Parameter]
public string ValueColor { get; set; } = "inherit";
[Parameter]
public string IconColor { get; set; } = "inherit";
[Parameter]
public EventCallback OnClick { get; set; }
}
@@ -0,0 +1,31 @@
<section class="admin-page-hero">
<div>
@if (!string.IsNullOrWhiteSpace(Eyebrow))
{
<MudText Typo="Typo.caption" Class="admin-eyebrow">@Eyebrow</MudText>
}
<MudText Typo="Typo.h4" Class="admin-page-title">@Title</MudText>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<MudText Typo="Typo.body2" Class="admin-page-subtitle">@Subtitle</MudText>
}
</div>
@if (ChildContent is not null)
{
<div>@ChildContent</div>
}
</section>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = "";
[Parameter]
public string? Eyebrow { get; set; }
[Parameter]
public string? Subtitle { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,142 @@
@inject NavigationManager Navigation
@inject IJSRuntime JS
@inject VersionInfo VersionInfo
@implements IDisposable
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout Class="admin-shell">
<MudAppBar Elevation="0" Class="admin-topbar">
<MudIconButton Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
Class="admin-menu-button"
OnClick="@ToggleDrawer" />
<div class="admin-topbar-title">
<MudText Typo="Typo.body2" Class="font-weight-bold admin-brand-text">TaxBaik</MudText>
<MudText Typo="Typo.body2" Class="admin-brand-subtitle">세무회계 관리 대시보드</MudText>
</div>
<MudSpacer />
<div class="admin-topbar-actions">
<MudButton Class="admin-topbar-action"
Variant="Variant.Text"
Color="Color.Inherit"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.OpenInNew"
Href="/taxbaik"
Target="_blank">
공개 사이트
</MudButton>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<MudButton Class="admin-topbar-action"
Variant="Variant.Text"
Color="Color.Error"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Logout"
Href="/taxbaik/admin/logout">
로그아웃
</MudButton>
</div>
</MudAppBar>
<MudDrawer @bind-open="@drawerOpen"
Elevation="0"
Variant="DrawerVariant.Responsive"
Breakpoint="Breakpoint.Md"
Class="admin-drawer">
<div class="admin-drawer-brand">
<div class="admin-brand-mark">T</div>
<div>
<MudText Typo="Typo.subtitle1">TaxBaik</MudText>
<MudText Typo="Typo.caption">세무 운영 콘솔</MudText>
</div>
</div>
<MudNavMenu Class="admin-nav">
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/dashboard"))">대시보드</MudNavLink>
<MudNavGroup Title="CRM & 세무관리" Icon="@Icons.Material.Filled.BusinessCenter" @bind-Expanded="@expandedCRMGroup">
<MudNavLink Href="/taxbaik/admin/tax-profiles" Icon="@Icons.Material.Filled.Assignment" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/tax-profiles"))">세무 프로필</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filing-schedules" Icon="@Icons.Material.Filled.CalendarMonth" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/tax-filing-schedules"))">신고 일정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/contracts" Icon="@Icons.Material.Filled.Description" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/contracts"))">계약 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/consulting-activities" Icon="@Icons.Material.Filled.ChatBubble" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/consulting-activities"))">상담 활동</MudNavLink>
<MudNavLink Href="/taxbaik/admin/revenue-trackings" Icon="@Icons.Material.Filled.Receipt" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/revenue-trackings"))">수익 추적</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup">
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/clients"))">고객 카드</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.Assessment" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/tax-filings"))">세무신고</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/announcements"))">공지사항</MudNavLink>
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/faqs"))">FAQ 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/blog"))">블로그 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/season-simulator"))">시즌 시뮬레이터</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/inquiries"))">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/settings"))">설정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/common-codes" Icon="@Icons.Material.Filled.Category" ClickPreventDefault="true" OnClick="@(() => NavigateTo("/taxbaik/admin/common-codes"))">공통관리</MudNavLink>
</MudNavMenu>
<div class="admin-drawer-version">
<div class="admin-drawer-version-label">Version</div>
<div class="admin-drawer-version-value">v@(VersionInfo.Version)</div>
<div class="admin-drawer-version-built">@VersionInfo.Built</div>
</div>
</MudDrawer>
<MudMainContent Class="admin-main">
<MudContainer MaxWidth="MaxWidth.False" Class="admin-content">
@ChildContent
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
private bool drawerOpen = true;
private bool expandedCRMGroup = true;
private bool expandedCustomerGroup = false;
private bool expandedWebsiteGroup = false;
protected override void OnInitialized()
{
Navigation.LocationChanged += OnLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("taxbaikAdminSession.setContext", "admin/shell", "navigation", "layout", "shell", "shell", "", "main");
await JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading");
}
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
var route = new Uri(args.Location).AbsolutePath;
_ = JS.InvokeVoidAsync("taxbaikAdminSession.setContext", route, "navigation", "route-change", "layout", "shell", "", route);
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading"));
}
private void ToggleDrawer()
{
drawerOpen = !drawerOpen;
_ = JS.InvokeVoidAsync("taxbaikAdminSession.setContext", "admin/shell", "navigation", "drawer", drawerOpen ? "opened" : "closed", "shell", "", "drawer");
_ = JS.InvokeVoidAsync("taxbaikAdminSession.traceUiState", "admin-shell", drawerOpen ? "drawer opened" : "drawer closed");
}
private void NavigateTo(string url)
{
Navigation.NavigateTo(url);
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
}
@@ -0,0 +1,26 @@
<div class="admin-skeleton-stack">
@for (var i = 0; i < Rows; i++)
{
<div class="admin-skeleton-row">
@for (var j = 0; j < Columns; j++)
{
<div class="admin-skeleton-block @GetWidthClass(j)" />
}
</div>
}
</div>
@code {
[Parameter]
public int Rows { get; set; } = 4;
[Parameter]
public int Columns { get; set; } = 3;
private static string GetWidthClass(int index) => index switch
{
0 => "w-40",
1 => "w-25",
_ => "w-20"
};
}
@@ -0,0 +1,107 @@
@using System.Text.RegularExpressions
@inject IJSRuntime Js
@inject NavigationManager Navigation
@code {
[Parameter] public string Screen { get; set; } = "";
[Parameter] public string Feature { get; set; } = "";
[Parameter] public string Action { get; set; } = "";
[Parameter] public string Step { get; set; } = "";
[Parameter] public string Entity { get; set; } = "";
[Parameter] public string EntityId { get; set; } = "";
[Parameter] public string DataKey { get; set; } = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var route = GetRoute();
var context = ResolveContext(route);
await Js.InvokeVoidAsync("taxbaikAdminSession.setContext",
string.IsNullOrWhiteSpace(Screen) ? context.Screen : Screen,
string.IsNullOrWhiteSpace(Feature) ? context.Feature : Feature,
string.IsNullOrWhiteSpace(Action) ? context.Action : Action,
string.IsNullOrWhiteSpace(Step) ? context.Step : Step,
string.IsNullOrWhiteSpace(Entity) ? context.Entity : Entity,
string.IsNullOrWhiteSpace(EntityId) ? context.EntityId : EntityId,
string.IsNullOrWhiteSpace(DataKey) ? context.DataKey : DataKey);
}
catch
{
// telemetry must never block rendering
}
}
}
private string GetRoute()
{
var path = Navigation.ToBaseRelativePath(Navigation.Uri);
return string.IsNullOrWhiteSpace(path) ? "/" : "/" + path.TrimStart('/');
}
private static (string Screen, string Feature, string Action, string Step, string Entity, string EntityId, string DataKey) ResolveContext(string route)
=> route.ToLowerInvariant() switch
{
"/" => ("admin/index", "shell", "load", "index", "admin", "", "index"),
"/admin/login" => ("admin/login", "auth", "render", "login page", "auth", "", "login"),
"/admin/dashboard" => ("admin/dashboard", "dashboard", "load", "summary", "dashboard", "", "summary"),
"/admin/common-codes" => ("admin/common-codes", "common-code", "load", "group list", "common_code", "", "group"),
"/admin/blog" => ("admin/blog", "content", "load", "list", "blog", "", "list"),
"/admin/blog/create" => ("admin/blog/create", "content", "create", "form", "blog", "", "create"),
"/admin/blog/0/edit" => ("admin/blog/edit", "content", "edit", "form", "blog", "0", "edit"),
"/admin/inquiries" => ("admin/inquiries", "customer-request", "load", "list", "inquiry", "", "list"),
"/admin/inquiries/create" => ("admin/inquiries/create", "customer-request", "create", "form", "inquiry", "", "create"),
"/admin/settings" => ("admin/settings", "system", "load", "settings", "site_setting", "", "settings"),
"/admin/announcements" => ("admin/announcements", "content", "load", "list", "announcement", "", "list"),
"/admin/announcements/create" => ("admin/announcements/create", "content", "create", "form", "announcement", "", "create"),
"/admin/companies" => ("admin/companies", "company", "load", "list", "company", "", "list"),
"/admin/faqs" => ("admin/faqs", "faq", "load", "list", "faq", "", "list"),
"/admin/tax-profiles" => ("admin/tax-profiles", "tax-profile", "load", "list", "tax_profile", "", "list"),
"/admin/tax-filing-schedules" => ("admin/tax-filing-schedules", "schedule", "load", "list", "tax_filing_schedule", "", "list"),
"/admin/contracts" => ("admin/contracts", "crm", "load", "list", "contract", "", "list"),
"/admin/consulting-activities" => ("admin/consulting-activities", "crm", "load", "list", "consulting_activity", "", "list"),
"/admin/revenue-trackings" => ("admin/revenue-trackings", "crm", "load", "list", "revenue_tracking", "", "list"),
"/admin/clients" => ("admin/clients", "customer", "load", "list", "client", "", "list"),
"/admin/tax-filings" => ("admin/tax-filings", "tax-filing", "load", "list", "tax_filing", "", "list"),
"/admin/season-simulator" => ("admin/season-simulator", "schedule", "load", "simulator", "season", "", "simulator"),
_ => ResolveDynamicContext(route)
};
private static (string Screen, string Feature, string Action, string Step, string Entity, string EntityId, string DataKey) ResolveDynamicContext(string route)
{
var normalized = route.ToLowerInvariant().TrimEnd('/');
foreach (var pattern in new[]
{
("/admin/blog/", "admin/blog/edit", "content", "edit", "form", "blog", "edit"),
("/admin/announcements/", "admin/announcements/edit", "content", "edit", "form", "announcement", "edit"),
("/admin/inquiries/", "admin/inquiries/edit", "customer-request", "edit", "form", "inquiry", "edit"),
("/admin/clients/", "admin/clients/detail", "customer", "view", "detail", "client", "detail"),
("/admin/companies/", "admin/companies/edit", "company", "edit", "form", "company", "edit"),
("/admin/faqs/", "admin/faqs/edit", "faq", "edit", "form", "faq", "edit"),
("/admin/tax-profiles/", "admin/tax-profiles/edit", "tax-profile", "edit", "form", "tax_profile", "edit"),
("/admin/tax-filing-schedules/", "admin/tax-filing-schedules/edit", "schedule", "edit", "form", "tax_filing_schedule", "edit"),
})
{
if (!normalized.StartsWith(pattern.Item1, StringComparison.OrdinalIgnoreCase))
continue;
var remainder = normalized[pattern.Item1.Length..].Trim('/');
var id = ExtractLeadingId(remainder);
if (string.IsNullOrWhiteSpace(id))
id = remainder.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
return (pattern.Item2, pattern.Item3, pattern.Item4, pattern.Item5, pattern.Item6, id, pattern.Item7);
}
return (route.Trim('/'), "admin", "load", "view", "admin", "", route.Trim('/'));
}
private static string ExtractLeadingId(string value)
{
var match = Regex.Match(value, @"^\d+");
return match.Success ? match.Value : "";
}
}
@@ -0,0 +1,37 @@
<AdminDataPanel Loading="@false">
<AdminFormSection Title="그룹 선택" Description="코드 그룹을 먼저 선택합니다." CssClass="mb-4">
<MudSelect T="string"
Value="@SelectedGroup"
ValueChanged="OnSelectedGroupChanged"
Label="코드 그룹"
Variant="Variant.Outlined"
FullWidth="true"
Clearable="true">
<MudSelectItem Value="@string.Empty">선택</MudSelectItem>
@foreach (var group in Groups)
{
<MudSelectItem Value="@group">@group</MudSelectItem>
}
</MudSelect>
</AdminFormSection>
<AdminFormSection Title="새 코드" Description="선택한 그룹에 항목을 추가합니다.">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnCreateRequested">새 코드 추가</MudButton>
</AdminFormSection>
</AdminDataPanel>
@code {
[Parameter, EditorRequired]
public IReadOnlyList<string> Groups { get; set; } = [];
[Parameter]
public string SelectedGroup { get; set; } = "";
[Parameter, EditorRequired]
public EventCallback<string> SelectedGroupChanged { get; set; }
[Parameter, EditorRequired]
public EventCallback OnCreateRequested { get; set; }
private Task OnSelectedGroupChanged(string value) => SelectedGroupChanged.InvokeAsync(value);
}
@@ -0,0 +1,72 @@
<AdminDataPanel Loading="@Loading">
<AdminFormSection Title="코드 목록" Description="그룹별 공통코드와 상태를 관리합니다." CssClass="mb-4">
<MudTable Items="@Codes" Dense="true" Hover="true">
<HeaderContent>
<MudTh>그룹</MudTh>
<MudTh>값</MudTh>
<MudTh>이름</MudTh>
<MudTh>순서</MudTh>
<MudTh>상태</MudTh>
<MudTh>작업</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.CodeGroup</MudTd>
<MudTd>@context.CodeValue</MudTd>
<MudTd>@context.CodeName</MudTd>
<MudTd>@context.SortOrder</MudTd>
<MudTd>@(context.IsActive ? "활성" : "비활성")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Text" OnClick="@(async () => await InvokeEditAsync(context))">수정</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Error" OnClick="@(async () => await InvokeDeleteAsync(context))">삭제</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
</AdminFormSection>
<MudDivider Class="my-4" />
<AdminFormSection Title="코드 편집" Description="공백 없는 값과 일관된 이름만 허용합니다.">
<MudForm>
<MudTextField @bind-Value="EditModel.CodeGroup" Label="그룹" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!IsCreateMode)" MaxLength="80" Class="mb-3" />
<MudTextField @bind-Value="EditModel.CodeValue" Label="값" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!IsCreateMode)" MaxLength="120" Class="mb-3" />
<MudTextField @bind-Value="EditModel.CodeName" Label="이름" Variant="Variant.Outlined" FullWidth="true" Required="true" MaxLength="200" Class="mb-3" />
<MudNumericField T="int" @bind-Value="EditModel.SortOrder" Label="순서" Variant="Variant.Outlined" FullWidth="true" Class="mb-3" />
<MudSwitch @bind-Checked="EditModel.IsActive" Color="Color.Primary">활성</MudSwitch>
<div class="d-flex gap-2 mt-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnSaveRequested">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="OnResetRequested">초기화</MudButton>
</div>
</MudForm>
</AdminFormSection>
</AdminDataPanel>
@code {
[Parameter]
public bool Loading { get; set; }
[Parameter, EditorRequired]
public IReadOnlyList<CommonCode> Codes { get; set; } = [];
[Parameter, EditorRequired]
public CommonCode EditModel { get; set; } = new();
[Parameter]
public bool IsCreateMode { get; set; }
[Parameter, EditorRequired]
public EventCallback<CommonCode> EditRequested { get; set; }
[Parameter, EditorRequired]
public EventCallback<CommonCode> DeleteRequested { get; set; }
[Parameter, EditorRequired]
public EventCallback SaveRequested { get; set; }
[Parameter, EditorRequired]
public EventCallback ResetRequested { get; set; }
private Task InvokeEditAsync(CommonCode code) => EditRequested.InvokeAsync(code);
private Task InvokeDeleteAsync(CommonCode code) => DeleteRequested.InvokeAsync(code);
private Task OnSaveRequested() => SaveRequested.InvokeAsync();
private Task OnResetRequested() => ResetRequested.InvokeAsync();
}
@@ -0,0 +1,56 @@
@using TaxBaik.Domain.Entities
@using TaxBaik.Web.Services.AdminClients
@inject ICommonCodeBrowserClient CommonCodeClient
<MudSelect T="string"
Value="Value"
ValueChanged="ValueChanged"
Label="@Label"
Variant="@Variant"
FullWidth="@FullWidth"
Class="@Class"
Required="@Required"
Clearable="@Clearable"
Disabled="@Disabled">
@if (!string.IsNullOrWhiteSpace(Placeholder))
{
<MudSelectItem Value="@string.Empty">@Placeholder</MudSelectItem>
}
@foreach (var item in items)
{
<MudSelectItem Value="@item.CodeValue">@item.CodeName</MudSelectItem>
}
</MudSelect>
@code {
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
[Parameter] public string Group { get; set; } = string.Empty;
[Parameter] public string Label { get; set; } = string.Empty;
[Parameter] public Variant Variant { get; set; } = Variant.Outlined;
[Parameter] public bool FullWidth { get; set; } = true;
[Parameter] public string? Class { get; set; }
[Parameter] public bool Required { get; set; }
[Parameter] public bool Clearable { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public string? Placeholder { get; set; }
private List<CommonCode> items = [];
protected override async Task OnParametersSetAsync()
{
var normalizedGroup = Group?.Trim() ?? string.Empty;
if (!string.Equals(normalizedGroup, _loadedGroup, StringComparison.OrdinalIgnoreCase))
{
_loadedGroup = normalizedGroup;
items = string.IsNullOrWhiteSpace(normalizedGroup)
? []
: (await CommonCodeClient.GetByGroupAsync(normalizedGroup))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.CodeName)
.ToList();
}
}
private string? _loadedGroup;
}
@@ -0,0 +1,20 @@
@using MudBlazor
<MudDialog>
<DialogContent>
<MudText>@Message</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">취소</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Confirm">삭제</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] MudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string Title { get; set; } = "";
[Parameter] public string Message { get; set; } = "";
private void Cancel() => MudDialog.Cancel();
private void Confirm() => MudDialog.Close();
}