Harden admin telemetry and deployment safeguards
TaxBaik CI/CD / build-and-deploy (push) Successful in 4m30s

This commit is contained in:
2026-07-02 16:10:15 +09:00
parent b1601b0305
commit d780fecf8c
53 changed files with 1590 additions and 656 deletions
+4
View File
@@ -1,4 +1,5 @@
@using Microsoft.AspNetCore.Components.Web
@inject VersionInfo VersionInfo
<!DOCTYPE html>
<html lang="ko">
<head>
@@ -12,6 +13,8 @@
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<script>
window.taxbaikAdminBuildVersion = '@VersionInfo.Version';
window.taxbaikAdminComponent = 'AdminApp';
document.documentElement.classList.toggle(
'admin-login-route',
window.location.pathname.toLowerCase().endsWith('/admin/login'));
@@ -38,6 +41,7 @@
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/admin-session.js"></script>
<script src="_framework/blazor.web.js"></script>
<script>window.taxbaikAdminSession?.initErrorLogging();</script>
<script>window.taxbaikAdminSession?.bindLoginForm();</script>
<script>window.taxbaikAdminSession?.watchReconnect();</script>
</body>
@@ -3,31 +3,36 @@
@using TaxBaik.Web.Components.Admin.Shared
<MudForm @ref="form">
<MudTextField @bind-Value="model.Name" Label="이름"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<AdminFormSection Title="연락처" Description="고객 식별과 기본 회신 정보입니다." CssClass="mb-4">
<MudTextField @bind-Value="model.Name" Label="이름"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
</AdminFormSection>
<CommonCodeSelect @bind-Value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형" Class="mb-4" />
<AdminFormSection Title="문의 내용" Description="운영 분류와 처리 메모를 함께 관리합니다." CssClass="mb-4">
<CommonCodeSelect @bind-Value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형" Class="mb-4" />
<MudTextField @bind-Value="model.Message" Label="문의 내용"
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Message" Label="문의 내용"
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
<CommonCodeSelect @bind-Value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" />
<CommonCodeSelect @bind-Value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" />
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
</AdminFormSection>
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
@ButtonText
</MudButton>
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
</div>
<AdminFormActions SubmitText="@ButtonText"
LoadingText="저장 중..."
CancelText="취소"
SubmitIcon="@Icons.Material.Filled.Save"
OnSubmit="@HandleSubmit"
OnCancel="@OnCancel"
IsSubmitting="false" />
</MudForm>
@code {
@@ -1,3 +1,5 @@
@inherits LayoutComponentBase
<AdminTelemetryContext />
@Body
@@ -1,141 +1,7 @@
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject IJSRuntime JS
@inject VersionInfo VersionInfo
@implements IDisposable
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
<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" style="display: flex; align-items: center; gap: 8px;">
<MudText Typo="Typo.body2" Class="font-weight-bold" Style="color: var(--primary-color);">[TaxBaik]</MudText>
<MudText Typo="Typo.body2" Style="font-weight: bold; color: #1E293B;">세무회계 관리 대시보드</MudText>
</div>
<MudSpacer />
<!-- 상단 액션 바 -->
<div class="admin-topbar-actions">
<MudTooltip Text="공개 웹사이트 방문">
<MudButton Class="admin-topbar-action"
Variant="Variant.Text"
Color="Color.Inherit"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.OpenInNew"
Href="/taxbaik"
Target="_blank">
공개 사이트
</MudButton>
</MudTooltip>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<MudTooltip Text="로그아웃 (Ctrl+Q)">
<MudButton Class="admin-topbar-action"
Variant="Variant.Text"
Color="Color.Error"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Logout"
Href="/taxbaik/admin/logout">
로그아웃
</MudButton>
</MudTooltip>
</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">대시보드</MudNavLink>
<MudNavGroup Title="CRM & 세무관리" Icon="@Icons.Material.Filled.BusinessCenter" @bind-Expanded="@expandedCRMGroup">
<MudNavLink Href="/taxbaik/admin/tax-profiles" Icon="@Icons.Material.Filled.Assignment">세무 프로필</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filing-schedules" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/contracts" Icon="@Icons.Material.Filled.Description">계약 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/consulting-activities" Icon="@Icons.Material.Filled.ChatBubble">상담 활동</MudNavLink>
<MudNavLink Href="/taxbaik/admin/revenue-trackings" Icon="@Icons.Material.Filled.Receipt">수익 추적</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup">
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.Assessment">세무신고</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/common-codes" Icon="@Icons.Material.Filled.Category">공통관리</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">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
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.hideLoading");
}
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading"));
}
private void ToggleDrawer()
{
drawerOpen = !drawerOpen;
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
}
<AdminShell>
<AdminTelemetryContext />
@Body
</AdminShell>
@@ -27,10 +27,9 @@
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface" Elevation="0">
<AdminDataPanel Loading="@(announcements is null)" SkeletonContent="@AnnouncementSkeleton">
@if (announcements is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!FilteredAnnouncements.Any())
{
@@ -98,7 +97,7 @@
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
</MudText>
}
</MudPaper>
</AdminDataPanel>
@code {
[CascadingParameter]
@@ -107,6 +106,14 @@
private List<Announcement>? announcements;
private string searchQuery = "";
private RenderFragment AnnouncementSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 4);
builder.CloseComponent();
};
private IEnumerable<Announcement> FilteredAnnouncements => announcements?
.Where(a => string.IsNullOrEmpty(searchQuery) ||
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
@@ -9,18 +9,15 @@
<PageTitle>새 포스트 작성</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">새 포스트 작성</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</MudText>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" OnCancel="GoBack" />
</MudPaper>
<AdminCrudPageShell Title="새 포스트 작성"
Eyebrow="Content"
Subtitle="새로운 블로그 포스트를 작성합니다."
Loading="@false"
OnCancel="@GoBack">
<MudPaper Class="pa-4 mt-4" Elevation="1">
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" OnCancel="GoBack" />
</MudPaper>
</AdminCrudPageShell>
@code {
private IReadOnlyList<Domain.Entities.Category> categories = [];
@@ -10,20 +10,13 @@
<PageTitle>포스트 수정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">포스트 수정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">블로그 포스트를 수정합니다.</MudText>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
}
else if (post == null)
<AdminCrudPageShell Title="포스트 수정"
Eyebrow="Content"
Subtitle="블로그 포스트를 수정합니다."
Loading="@isLoading"
SkeletonContent="@EditorSkeleton"
OnCancel="@GoBack">
@if (post == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
}
@@ -36,6 +29,7 @@ else
</div>
</MudPaper>
}
</AdminCrudPageShell>
@code {
[Parameter]
@@ -46,6 +40,14 @@ else
private BlogForm.BlogFormModel model = new();
private bool isLoading = true;
private RenderFragment EditorSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 3);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
try
@@ -2,39 +2,43 @@
@using TaxBaik.Domain.Entities
<MudForm @ref="form">
<MudTextField @bind-Value="Model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<AdminFormSection Title="기본 정보" Description="제목과 카테고리, 발행 여부를 먼저 설정합니다." CssClass="mb-4">
<MudTextField @bind-Value="Model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in Categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in Categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
Class="mb-4" />
<MudCheckBox @bind-Checked="Model.IsPublished" Label="즉시 발행" Class="mb-4" />
</AdminFormSection>
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<AdminFormSection Title="본문" Description="SEO와 실제 노출 본문을 함께 관리합니다." CssClass="mb-4">
<MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
Class="mb-4" />
<MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudCheckBox @bind-Checked="Model.IsPublished" Label="즉시 발행" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
</AdminFormSection>
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">@SubmitText</MudButton>
@if (OnCancel.HasDelegate)
{
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
}
</div>
<AdminFormActions SubmitText="@SubmitText"
LoadingText="저장 중..."
CancelText="취소"
SubmitIcon="@Icons.Material.Filled.Save"
OnSubmit="@HandleSubmit"
OnCancel="@OnCancel"
IsSubmitting="false" />
</MudForm>
@code {
@@ -25,47 +25,47 @@
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface mb-4" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<AdminDataPanel Loading="@isLoading">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
<MudText Typo="Typo.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
</MudStack>
</MudPaper>
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행">
<CellTemplate Context="cell">
<MudCheckBox T="bool" Value="@cell.Item.IsPublished"
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ViewCount" Title="조회수" />
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
<TemplateColumn>
<CellTemplate Context="cell">
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
@if (showArchived)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Success"
@onclick="@(async () => await RestorePost(cell.Item.Id))">복원</MudButton>
}
else
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
}
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행">
<CellTemplate Context="cell">
<MudCheckBox T="bool" Value="@cell.Item.IsPublished"
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ViewCount" Title="조회수" />
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
<TemplateColumn>
<CellTemplate Context="cell">
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
@if (showArchived)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Success"
@onclick="@(async () => await RestorePost(cell.Item.Id))">복원</MudButton>
}
else
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
}
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
</MudStack>
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
</MudStack>
</AdminDataPanel>
@code {
[CascadingParameter]
@@ -20,10 +20,9 @@
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@ClientEditSkeleton">
@if (isLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
@@ -92,7 +91,7 @@
</MudGrid>
</MudForm>
}
</MudPaper>
</AdminEditorPanel>
@code {
[Parameter] public int? Id { get; set; }
@@ -102,6 +101,14 @@
private bool isValid;
private bool isLoading = true;
private bool isSaving;
private RenderFragment ClientEditSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 6);
builder.AddAttribute(2, "Columns", 3);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
@@ -39,10 +39,9 @@
</MudGrid>
</MudPaper>
<MudPaper Class="admin-surface" Elevation="0">
<AdminDataPanel Loading="@(clients is null)" SkeletonContent="@ClientListSkeleton">
@if (clients is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!clients.Any())
{
@@ -116,7 +115,7 @@
}
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText>
}
</MudPaper>
</AdminDataPanel>
@code {
[CascadingParameter]
@@ -130,6 +129,14 @@
private int totalPages;
private const int PageSize = 20;
private RenderFragment ClientListSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 5);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
if (AuthStateTask != null)
@@ -7,73 +7,26 @@
<PageTitle>공통관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">System</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">공통관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">공통코드 그룹과 항목을 일관된 기준으로 관리합니다.</MudText>
</div>
</section>
<AdminPageHeader Title="공통관리" Eyebrow="System" Subtitle="공통코드 그룹과 항목을 일관된 기준으로 관리합니다." />
<MudGrid Spacing="2">
<MudItem XS="12" MD="4">
<MudPaper Class="admin-surface pa-4" Elevation="0">
<MudText Typo="Typo.h6" Class="mb-3">그룹</MudText>
<MudSelect T="string" Value="@selectedGroup" ValueChanged="OnGroupChanged" Label="코드 그룹" Variant="Variant.Outlined" FullWidth="true">
@foreach (var group in groups)
{
<MudSelectItem Value="@group">@group</MudSelectItem>
}
</MudSelect>
<MudButton Class="mt-3" Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate">새 코드 추가</MudButton>
</MudPaper>
<CommonCodeGroupPanel Groups="groups"
SelectedGroup="selectedGroup"
SelectedGroupChanged="OnGroupChanged"
OnCreateRequested="PrepareCreate" />
</MudItem>
<MudItem XS="12" MD="8">
<MudPaper Class="admin-surface pa-4" Elevation="0">
@if (isLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<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="@(() => EditCode(context))">수정</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Error" OnClick="@(() => DeleteCode(context))">삭제</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
<MudDivider Class="my-4" />
<MudForm @ref="form">
<MudTextField @bind-Value="editModel.CodeGroup" Label="그룹" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!isCreateMode)" Class="mb-3" />
<MudTextField @bind-Value="editModel.CodeValue" Label="값" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!isCreateMode)" Class="mb-3" />
<MudTextField @bind-Value="editModel.CodeName" Label="이름" Variant="Variant.Outlined" FullWidth="true" Required="true" 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="SaveCode">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="PrepareCreate">초기화</MudButton>
</div>
</MudForm>
}
</MudPaper>
<CommonCodeListPanel Loading="@isLoading"
Codes="codes"
EditModel="editModel"
IsCreateMode="isCreateMode"
Form="form"
EditRequested="EditCode"
DeleteRequested="DeleteCode"
SaveRequested="SaveCode"
ResetRequested="PrepareCreate" />
</MudItem>
</MudGrid>
@@ -82,7 +35,6 @@
private List<CommonCode> codes = [];
private string selectedGroup = "";
private bool isLoading = true;
private MudForm? form;
private CommonCode editModel = new();
private bool isCreateMode = true;
@@ -135,16 +87,6 @@
private async Task SaveCode()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력하세요.", Severity.Warning);
return;
}
}
if (editModel.CodeValue.Contains(' '))
{
Snackbar.Add("code_value에는 공백을 넣을 수 없습니다.", Severity.Error);
@@ -17,26 +17,24 @@
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
}
else if (formModel == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@CompanySkeleton">
@if (formModel == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
<MudDivider Class="my-4" />
<MudDivider Class="my-4" />
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
고객사 삭제
</MudButton>
</MudPaper>
}
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
고객사 삭제
</MudButton>
</MudPaper>
}
</AdminEditorPanel>
@code {
[Parameter]
@@ -45,6 +43,14 @@ else
private CompanyForm.CompanyFormModel? formModel;
private bool isLoading = true;
private RenderFragment CompanySkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 6);
builder.AddAttribute(2, "Columns", 3);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
try
@@ -20,10 +20,9 @@
</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<AdminDataPanel Loading="@(activities is null)" SkeletonContent="@ActivitySkeleton">
@if (activities is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (activities.Count == 0)
{
@@ -90,7 +89,7 @@
</Columns>
</MudDataGrid>
}
</MudPaper>
</AdminDataPanel>
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
@@ -128,6 +127,14 @@
private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new();
private RenderFragment ActivitySkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 4);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
if (AuthStateTask != null)
@@ -27,9 +27,9 @@
</MudButton>
</section>
<AdminEditorPanel Loading="@(contracts is null)" SkeletonContent="@ContractSkeleton">
@if (contracts is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
@@ -142,6 +142,7 @@ else
</MudItem>
</MudGrid>
}
</AdminEditorPanel>
@code {
[CascadingParameter]
@@ -156,6 +157,14 @@ else
private Contract? selectedContract;
private ContractForm contractForm = new();
private RenderFragment ContractSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 6);
builder.AddAttribute(2, "Columns", 4);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
if (AuthStateTask != null)
+102 -121
View File
@@ -22,151 +22,109 @@
{
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
}
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<!-- Metrics Grid -->
<div class="admin-metric-grid">
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">이번달 문의</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--primary-dark);">@summary.ThisMonthInquiries</span>
<span class="admin-metric-card-icon" style="color: var(--primary-color);">💬</span>
</div>
<span class="admin-metric-card-caption">월간 상담 유입 (클릭 시 이동)</span>
</div>
<AdminDataPanel Loading="@isLoading" SkeletonContent="@DashboardSkeleton">
<div class="admin-metric-grid">
<AdminMetricCard Label="이번달 문의" Value="@summary.ThisMonthInquiries" Caption="월간 상담 유입 (클릭 시 이동)" Accent="accent-blue" Icon="💬" ValueColor="var(--primary-dark)" IconColor="var(--primary-color)" OnClick="@GoInquiries" />
<AdminMetricCard Label="신규 문의" Value="@summary.NewInquiries" Caption="처리 대기 (클릭 시 이동)" Accent="accent-amber" Icon="⚠️" ValueColor="var(--tertiary-dark)" IconColor="var(--tertiary-color)" OnClick="@GoNewInquiries" />
<AdminMetricCard Label="전체 포스트" Value="@summary.TotalPosts" Caption="콘텐츠 자산 (클릭 시 이동)" Accent="accent-slate" Icon="📄" ValueColor="#455a64" IconColor="#607d8b" OnClick="@GoBlog" />
<AdminMetricCard Label="발행된 포스트" Value="@summary.PublishedPosts" Caption="검색 노출 대상 (클릭 시 이동)" Accent="accent-green" Icon="🌐" ValueColor="var(--secondary-dark)" IconColor="var(--secondary-color)" OnClick="@GoBlog" />
</div>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">신규 문의</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--tertiary-dark);">@summary.NewInquiries</span>
<span class="admin-metric-card-icon" style="color: var(--tertiary-color);">⚠️</span>
@if (upcomingFilings.Count > 0)
{
<MudPaper Class="admin-surface mt-4" Elevation="0">
<div class="admin-section-header">
<div>
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)</MudText>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
</div>
<span class="admin-metric-card-caption">처리 대기 (클릭 시 이동)</span>
</div>
</div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th>고객</th>
<th>신고 유형</th>
<th>기한</th>
<th>D-day</th>
</tr>
</thead>
<tbody>
@foreach (var f in upcomingFilings)
{
var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(f.DueDate));
var effectiveDueDate = BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(f.DueDate));
<tr>
<td>
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
@f.ClientName
</MudLink>
</td>
<td>@f.FilingType</td>
<td>@effectiveDueDate.ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Dark">기한 초과 (@(-dday)일)</MudChip>
}
else if (dday <= 7)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
}
else
{
<span>D-@dday</span>
}
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
}
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">전체 포스트</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: #455a64;">@summary.TotalPosts</span>
<span class="admin-metric-card-icon" style="color: #607d8b;">📄</span>
</div>
<span class="admin-metric-card-caption">콘텐츠 자산 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">발행된 포스트</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--secondary-dark);">@summary.PublishedPosts</span>
<span class="admin-metric-card-icon" style="color: var(--secondary-color);">🌐</span>
</div>
<span class="admin-metric-card-caption">검색 노출 대상 (클릭 시 이동)</span>
</div>
</div>
</div>
@if (upcomingFilings.Count > 0)
{
<MudPaper Class="admin-surface mt-4" Elevation="0">
<div class="admin-section-header">
<div>
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연)</MudText>
<MudText Typo="Typo.h6">최근 문의</MudText>
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연)</MudText>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
</div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th>고객</th>
<th>신고 유형</th>
<th>기한</th>
<th>D-day</th>
<th>이름</th>
<th>전화</th>
<th>분야</th>
<th>상태</th>
<th>날짜</th>
</tr>
</thead>
<tbody>
@foreach (var f in upcomingFilings)
@foreach (var inquiry in summary.RecentInquiries)
{
var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(f.DueDate));
var effectiveDueDate = BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(f.DueDate));
<tr>
<td>
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
@f.ClientName
<MudLink Href="@($"/taxbaik/admin/inquiries?id={inquiry.Id}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
@inquiry.Name
</MudLink>
</td>
<td>@f.FilingType</td>
<td>@effectiveDueDate.ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd")</td>
<td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td>
<td>
@if (dday < 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Dark">기한 초과 (@(-dday)일)</MudChip>
}
else if (dday <= 7)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
}
else
{
<span>D-@dday</span>
}
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
@GetStatusLabel(inquiry.Status)
</MudChip>
</td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
}
<MudPaper Class="admin-surface mt-4" Elevation="0">
<div class="admin-section-header">
<div>
<MudText Typo="Typo.h6">최근 문의</MudText>
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)</MudText>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
</div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th>이름</th>
<th>전화</th>
<th>분야</th>
<th>상태</th>
<th>날짜</th>
</tr>
</thead>
<tbody>
@foreach (var inquiry in summary.RecentInquiries)
{
<tr>
<td>
<MudLink Href="@($"/taxbaik/admin/inquiries?id={inquiry.Id}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
@inquiry.Name
</MudLink>
</td>
<td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td>
<td>
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
@GetStatusLabel(inquiry.Status)
</MudChip>
</td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</AdminDataPanel>
@code {
[CascadingParameter]
@@ -177,6 +135,29 @@
private string? errorMessage;
private bool isLoading = true;
private RenderFragment DashboardSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 6);
builder.AddAttribute(2, "Columns", 4);
builder.CloseComponent();
};
private void GoInquiries()
{
Nav.NavigateTo("/taxbaik/admin/inquiries");
}
private void GoNewInquiries()
{
Nav.NavigateTo("/taxbaik/admin/inquiries?status=new");
}
private void GoBlog()
{
Nav.NavigateTo("/taxbaik/admin/blog");
}
protected override async Task OnInitializedAsync()
{
if (AuthStateTask != null)
@@ -210,11 +191,11 @@
private static Color StatusColor(string status) => status switch
{
"new" => Color.Warning,
"new" => Color.Warning,
"consulting" => Color.Info,
"contracted" => Color.Success,
"rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
"rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
};
}
@@ -18,13 +18,8 @@
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
@if (isLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@FaqSkeleton">
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
<MudForm @ref="form" @bind-IsValid="isValid">
<MudGrid Spacing="3">
<MudItem xs="12">
@@ -68,8 +63,8 @@
</MudItem>
</MudGrid>
</MudForm>
}
</MudPaper>
</MudPaper>
</AdminEditorPanel>
@code {
[Parameter] public int? Id { get; set; }
@@ -80,6 +75,14 @@
private bool isLoading = true;
private bool isSaving;
private RenderFragment FaqSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 3);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
@@ -27,10 +27,9 @@
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface" Elevation="0">
<AdminDataPanel Loading="@(faqs is null)" SkeletonContent="@FaqListSkeleton">
@if (faqs is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!FilteredFaqs.Any())
{
@@ -101,7 +100,7 @@
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
</MudText>
}
</MudPaper>
</AdminDataPanel>
@code {
[CascadingParameter]
@@ -110,6 +109,14 @@
private List<Faq>? faqs;
private string searchQuery = "";
private RenderFragment FaqListSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 4);
builder.CloseComponent();
};
private IEnumerable<Faq> FilteredFaqs => faqs?
.Where(f => string.IsNullOrEmpty(searchQuery) ||
f.Question.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
@@ -8,18 +8,15 @@
<PageTitle>문의 등록</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">새 문의 등록</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</MudText>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
</MudPaper>
<AdminCrudPageShell Title="새 문의 등록"
Eyebrow="Customer Relations"
Subtitle="고객 문의를 등록합니다. (전화, 오프라인 등)"
Loading="@false"
OnCancel="@GoBack">
<MudPaper Class="pa-4 mt-4" Elevation="1">
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
</MudPaper>
</AdminCrudPageShell>
@code {
private void GoBack()
@@ -26,8 +26,7 @@
<MudGrid Class="mt-4">
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">문의 정보</MudText>
<AdminDetailSection Title="문의 정보">
<MudGrid>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
@@ -56,20 +55,18 @@
<MudText>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</MudText>
</MudItem>
</MudGrid>
</MudPaper>
</AdminDetailSection>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">담당자 메모</MudText>
<AdminDetailSection Title="담당자 메모" CssClass="pa-4 mt-4">
<MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)"
Lines="4" Variant="Variant.Outlined" />
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
OnClick="SaveMemo">메모 저장</MudButton>
</MudPaper>
</AdminDetailSection>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">처리 상태</MudText>
<AdminDetailSection Title="처리 상태">
<MudStack Spacing="2">
@foreach (var (key, label) in InquiryStatusMapper.Labels)
{
@@ -81,28 +78,26 @@
</MudButton>
}
</MudStack>
</MudPaper>
</AdminDetailSection>
@if (inquiry.ClientId == null)
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">고객 카드 생성</MudText>
<AdminDetailSection Title="고객 카드 생성" CssClass="pa-4 mt-4">
<MudText Typo="Typo.body2" Class="mb-3">이 문의를 고객 카드로 등록합니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
OnClick="ConvertToClient">
고객으로 등록
</MudButton>
</MudPaper>
</AdminDetailSection>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">연결된 고객</MudText>
<AdminDetailSection Title="연결된 고객" CssClass="pa-4 mt-4">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">
고객 카드 보기
</MudButton>
</MudPaper>
</AdminDetailSection>
}
</MudItem>
</MudGrid>
@@ -9,20 +9,13 @@
<PageTitle>문의 수정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 수정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의 정보를 수정합니다.</MudText>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
}
else if (inquiry == null)
<AdminCrudPageShell Title="문의 수정"
Eyebrow="Customer Relations"
Subtitle="고객 문의 정보를 수정합니다."
Loading="@isLoading"
SkeletonContent="@EditorSkeleton"
OnCancel="@GoBack">
@if (inquiry == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">문의를 찾을 수 없습니다.</MudAlert>
}
@@ -38,6 +31,7 @@ else
</MudButton>
</MudPaper>
}
</AdminCrudPageShell>
@code {
[Parameter]
@@ -47,6 +41,14 @@ else
private InquiryForm.InquiryFormModel? formModel;
private bool isLoading = true;
private RenderFragment EditorSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 3);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
try
@@ -12,13 +12,7 @@
</ChildContent>
</AdminPageHeader>
<MudPaper Class="admin-surface" Elevation="0">
@if (isLoading)
{
<MudProgressCircular Indeterminate="true" Class="ma-4" />
}
else
{
<AdminDataPanel Loading="@isLoading">
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
<MudTabPanel Text="전체">
<InquiryTable Inquiries="allInquiries" Status="" />
@@ -39,8 +33,7 @@ else
<InquiryTable Inquiries="allInquiries" Status="closed" />
</MudTabPanel>
</MudTabs>
}
</MudPaper>
</AdminDataPanel>
@code {
[CascadingParameter]
+1 -73
View File
@@ -2,77 +2,5 @@
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous]
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@inject IApiClient ApiClient
@inject ILocalStorageService LocalStorageService
@inject IJSRuntime Js
<PageTitle>로그인</PageTitle>
<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="@model.Username" />
<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 readonly LoginModel model = new();
private const string RememberedUsernameKey = "admin-remembered-username";
protected override async Task OnInitializedAsync()
{
try
{
var remembered = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey);
if (!string.IsNullOrEmpty(remembered))
{
model.Username = remembered;
}
}
catch
{
// LocalStorage may be unavailable during prerender.
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
}
private class LoginModel
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool RememberMe { get; set; }
}
}
<AdminLoginForm />
@@ -20,10 +20,9 @@
</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<AdminDataPanel Loading="@(revenues is null)" SkeletonContent="@RevenueSkeleton">
@if (revenues is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (revenues.Count == 0)
{
@@ -85,7 +84,7 @@
</Columns>
</MudDataGrid>
}
</MudPaper>
</AdminDataPanel>
<!-- Create Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
@@ -124,6 +123,14 @@
private bool isDialogOpen;
private RevenueForm revenueForm = new();
private RenderFragment RevenueSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 5);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
if (AuthStateTask != null)
@@ -21,9 +21,9 @@
</MudButton>
</section>
<AdminDataPanel Loading="@(schedules is null)" SkeletonContent="@ScheduleSkeleton">
@if (schedules is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
@@ -165,6 +165,7 @@ else
</MudItem>
</MudGrid>
}
</AdminDataPanel>
@code {
[CascadingParameter]
@@ -177,6 +178,14 @@ else
private bool isEditMode;
private TaxFilingSchedule? selectedSchedule;
private TaxFilingScheduleForm scheduleForm = new();
private RenderFragment ScheduleSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 6);
builder.AddAttribute(2, "Columns", 4);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
@@ -20,9 +20,9 @@
</MudButton>
</section>
<AdminDataPanel Loading="@(profiles == null)" SkeletonContent="@ProfileSkeleton">
@if (profiles == null)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else
{
@@ -117,6 +117,7 @@ else
</MudItem>
</MudGrid>
}
</AdminDataPanel>
@code {
[CascadingParameter]
@@ -131,6 +132,14 @@ else
private TaxProfile? selectedProfile;
private TaxProfileForm profileForm = new();
private RenderFragment ProfileSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 6);
builder.AddAttribute(2, "Columns", 4);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
if (AuthStateTask != null)
@@ -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,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,63 @@
@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)
{
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm");
}
}
}
@@ -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,137 @@
@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">대시보드</MudNavLink>
<MudNavGroup Title="CRM & 세무관리" Icon="@Icons.Material.Filled.BusinessCenter" @bind-Expanded="@expandedCRMGroup">
<MudNavLink Href="/taxbaik/admin/tax-profiles" Icon="@Icons.Material.Filled.Assignment">세무 프로필</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filing-schedules" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/contracts" Icon="@Icons.Material.Filled.Description">계약 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/consulting-activities" Icon="@Icons.Material.Filled.ChatBubble">상담 활동</MudNavLink>
<MudNavLink Href="/taxbaik/admin/revenue-trackings" Icon="@Icons.Material.Filled.Receipt">수익 추적</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup">
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.Assessment">세무신고</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/common-codes" Icon="@Icons.Material.Filled.Category">공통관리</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");
}
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,100 @@
@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)
{
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);
}
}
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,35 @@
<AdminDataPanel Loading="@false">
<AdminFormSection Title="그룹 선택" Description="코드 그룹을 먼저 선택합니다." CssClass="mb-4">
<MudSelect T="string"
Value="@SelectedGroup"
ValueChanged="OnSelectedGroupChanged"
Label="코드 그룹"
Variant="Variant.Outlined"
FullWidth="true">
@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)" Class="mb-3" />
<MudTextField @bind-Value="EditModel.CodeValue" Label="값" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!IsCreateMode)" Class="mb-3" />
<MudTextField @bind-Value="EditModel.CodeName" Label="이름" Variant="Variant.Outlined" FullWidth="true" Required="true" 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();
}