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();
}
@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/client-logs")]
[AllowAnonymous]
[EnableRateLimiting("client-logs")]
public class ClientLogsController(ILogger<ClientLogsController> logger) : ControllerBase
{
[HttpPost]
public IActionResult Post([FromBody] ClientLogEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.Message))
{
return BadRequest();
}
logger.LogWarning(
"ClientLog {Level} {Source} {Message} Url={Url} Route={Route} Screen={Screen} Feature={Feature} Action={Action} Step={Step} Entity={Entity} EntityId={EntityId} DataKey={DataKey} BuildVersion={BuildVersion} UserAgent={UserAgent} Stack={Stack}",
entry.Level ?? "error",
entry.Source ?? "unknown",
entry.Message,
entry.Url ?? string.Empty,
entry.Route ?? string.Empty,
entry.Screen ?? string.Empty,
entry.Feature ?? string.Empty,
entry.Action ?? string.Empty,
entry.Step ?? string.Empty,
entry.Entity ?? string.Empty,
entry.EntityId ?? string.Empty,
entry.DataKey ?? string.Empty,
entry.BuildVersion ?? string.Empty,
entry.UserAgent ?? string.Empty,
entry.Stack ?? string.Empty);
return Ok();
}
}
public sealed class ClientLogEntry
{
public string? Level { get; set; }
public string? Source { get; set; }
public string? Message { get; set; }
public string? Url { get; set; }
public string? Route { get; set; }
public string? Screen { get; set; }
public string? Feature { get; set; }
public string? Action { get; set; }
public string? Step { get; set; }
public string? Entity { get; set; }
public string? EntityId { get; set; }
public string? DataKey { get; set; }
public string? BuildVersion { get; set; }
public string? UserAgent { get; set; }
public string? Stack { get; set; }
}
+5
View File
@@ -51,6 +51,11 @@ public class TelegramSink : ILogEventSink
var level = logEvent.Level.ToString().ToUpper();
var message = logEvent.RenderMessage();
var exceptionDetails = logEvent.Exception?.ToString();
var fingerprint = $"{level}|{message}|{exceptionDetails ?? string.Empty}";
if (!TaxBaik.Web.Services.TelegramAlertGate.ShouldSend("telegram:sink:error", fingerprint, TimeSpan.FromMinutes(10)))
{
return;
}
var sb = new StringBuilder();
sb.AppendLine($"<b>🚨 [{level}] 에러 발생</b>");
+24 -2
View File
@@ -7,10 +7,12 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services;
using Serilog;
using System.Threading.RateLimiting;
using TaxBaik.Application;
using TaxBaik.Application.Services;
using TaxBaik.Infrastructure;
@@ -51,6 +53,23 @@ builder.Host.UseSerilog((context, config) =>
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("client-logs", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"client-logs:{ip}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
@@ -351,6 +370,7 @@ app.UsePathBase("/taxbaik");
app.UseResponseCompression();
app.UseStaticFiles();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
@@ -387,12 +407,14 @@ catch (Exception ex)
{
try
{
using (var scope = app.Services.CreateScope())
var fatalMessage = $"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}";
if (TaxBaik.Web.Services.TelegramAlertGate.ShouldSend("telegram:fatal", fatalMessage, TimeSpan.FromMinutes(30)))
{
using var scope = app.Services.CreateScope();
var telegramService = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
await telegramService.SendErrorAsync(
"❌ 서버 오류",
$"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}");
fatalMessage);
}
}
catch (Exception telegramEx)
+57
View File
@@ -0,0 +1,57 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace TaxBaik.Web.Services;
internal static class TelegramAlertGate
{
private sealed record GateEntry(DateTimeOffset WindowStart, int Count);
private static readonly ConcurrentDictionary<string, GateEntry> Gates = new();
public static bool ShouldSend(string category, string content, TimeSpan window, int maxPerWindow = 1)
{
if (string.IsNullOrWhiteSpace(category))
return false;
var now = DateTimeOffset.UtcNow;
var key = $"{category}:{Fingerprint(content)}";
while (true)
{
if (!Gates.TryGetValue(key, out var current))
{
var initial = new GateEntry(now, 1);
if (Gates.TryAdd(key, initial))
return true;
continue;
}
if (now - current.WindowStart >= window)
{
var reset = new GateEntry(now, 1);
if (Gates.TryUpdate(key, reset, current))
return true;
continue;
}
if (current.Count >= maxPerWindow)
return false;
var incremented = current with { Count = current.Count + 1 };
if (Gates.TryUpdate(key, incremented, current))
return true;
}
}
private static string Fingerprint(string content)
{
if (string.IsNullOrEmpty(content))
return "empty";
var normalized = content.Length > 1500 ? content[..1500] : content;
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(bytes);
}
}
@@ -47,14 +47,29 @@ public class TelegramNotificationService : ITelegramNotificationService
return;
}
if (!TelegramAlertGate.ShouldSend("telegram:default", message, TimeSpan.FromMinutes(5)))
return;
await SendToChat(_defaultChatId, message, ct);
}
public async Task SendInquiryNotificationAsync(string message, CancellationToken ct = default) =>
await SendToChat(_inquiryChatId, $"<b>📋 문의 사항</b>\n\n{message}", ct);
public async Task SendInquiryNotificationAsync(string message, CancellationToken ct = default)
{
var text = $"<b>📋 문의 사항</b>\n\n{message}";
if (!TelegramAlertGate.ShouldSend("telegram:inquiry", text, TimeSpan.FromMinutes(10)))
return;
public async Task SendSystemNotificationAsync(string message, CancellationToken ct = default) =>
await SendToChat(_systemChatId, $"<b>🔧 시스템 알림</b>\n\n{message}", ct);
await SendToChat(_inquiryChatId, text, ct);
}
public async Task SendSystemNotificationAsync(string message, CancellationToken ct = default)
{
var text = $"<b>🔧 시스템 알림</b>\n\n{message}";
if (!TelegramAlertGate.ShouldSend("telegram:system", text, TimeSpan.FromMinutes(10)))
return;
await SendToChat(_systemChatId, text, ct);
}
private async Task SendToChat(string chatId, string message, CancellationToken ct)
{
@@ -89,18 +104,27 @@ public class TelegramNotificationService : ITelegramNotificationService
public async Task SendErrorAsync(string title, string details, CancellationToken ct = default)
{
var message = $"<b>❌ {title}</b>\n\n{details}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
if (!TelegramAlertGate.ShouldSend("telegram:error", message, TimeSpan.FromMinutes(15)))
return;
await SendToChat(_systemChatId, message, ct);
}
public async Task SendInfoAsync(string title, string message, CancellationToken ct = default)
{
var text = $"<b>️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
await SendMessageAsync(text, ct);
if (!TelegramAlertGate.ShouldSend("telegram:info", text, TimeSpan.FromMinutes(30)))
return;
await SendToChat(_defaultChatId, text, ct);
}
public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default)
{
var text = $"<b>📊 {reportTitle}</b>\n\n{reportContent}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
if (!TelegramAlertGate.ShouldSend("telegram:report", text, TimeSpan.FromHours(20)))
return;
await SendToChat(_systemChatId, text, ct);
}
}
+63 -31
View File
@@ -12,21 +12,21 @@
:root {
/* Color System */
--primary-color: #1976D2;
--primary-light: #E3F2FD;
--primary-lighter: #BBDEFB;
--primary-dark: #1565C0;
--primary-darker: #0D47A1;
--primary-color: #1F4E79;
--primary-light: #E8F0F7;
--primary-lighter: #D6E3F0;
--primary-dark: #163A5C;
--primary-darker: #102D47;
--primary-contrast: #FFFFFF;
--secondary-color: #2D9F7E;
--secondary-light: #E8F7F3;
--secondary-dark: #1D7A64;
--secondary-color: #2B6F6A;
--secondary-light: #E6F2F1;
--secondary-dark: #1F5854;
--secondary-contrast: #FFFFFF;
--tertiary-color: #FF8A50;
--tertiary-light: #FFEBEE;
--tertiary-dark: #E65100;
--tertiary-color: #A96A3B;
--tertiary-light: #F4E9DF;
--tertiary-dark: #7E4D28;
--tertiary-contrast: #FFFFFF;
--success-color: #16A34A;
@@ -53,14 +53,14 @@
--text-inverse: #FFFFFF;
--bg-primary: #FFFFFF;
--bg-secondary: #F8F9FB;
--bg-tertiary: #F1F5F9;
--bg-secondary: #F4F7FA;
--bg-tertiary: #E9EEF4;
--bg-overlay: rgba(15, 23, 42, 0.08);
--bg-overlay-strong: rgba(15, 23, 42, 0.12);
--border-color: #E2E8F0;
--border-color-light: #F1F5F9;
--border-color-strong: #CBD5E1;
--border-color: #D6DFE8;
--border-color-light: #E6EDF3;
--border-color-strong: #B7C4D1;
/* Spacing Scale */
--space-0: 0;
@@ -445,9 +445,9 @@ textarea:focus-visible {
display: flex;
align-items: center;
gap: 12px;
padding: 0px 12px;
height: 38px !important;
background-color: var(--bg-primary);
padding: 0 14px;
min-height: 44px !important;
background: linear-gradient(180deg, #FFFFFF 0%, #FAFCFE 100%);
border-bottom: 1px solid var(--border-color);
z-index: var(--z-dropdown);
box-shadow: none !important;
@@ -460,17 +460,23 @@ textarea:focus-visible {
.admin-topbar-title {
display: flex;
flex-direction: column;
gap: 0;
gap: 1px;
}
.admin-topbar-title span {
color: var(--text-primary);
}
.admin-topbar-title .mud-typography--h6 {
font-size: 0.85rem;
line-height: 1.15;
font-weight: var(--font-weight-semibold);
.admin-brand-text {
font-size: 0.82rem !important;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.admin-brand-subtitle {
font-size: 0.86rem !important;
font-weight: 600 !important;
color: #1F2937 !important;
}
.admin-topbar-action {
@@ -486,7 +492,7 @@ textarea:focus-visible {
}
.admin-drawer {
width: 208px;
width: 228px;
background-color: var(--bg-primary);
border-right: 1px solid var(--border-color);
display: flex;
@@ -667,8 +673,8 @@ textarea:focus-visible {
/* Metric Card - Enterprise Grade */
.admin-metric-card {
padding: 10px;
border-radius: var(--radius-md);
padding: 12px;
border-radius: var(--radius-lg);
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
transition: all var(--transition-base);
@@ -750,8 +756,8 @@ textarea:focus-visible {
/* Card Accent Colors */
.accent-blue {
background: linear-gradient(135deg, var(--primary-light) 0%, #E3F2FD 100%);
border-color: #BBDEFB;
background: linear-gradient(135deg, var(--primary-light) 0%, #F7FAFC 100%);
border-color: #C9D8E6;
color: var(--primary-dark);
}
@@ -761,7 +767,7 @@ textarea:focus-visible {
}
.accent-amber {
background: linear-gradient(135deg, #FFEBEE 0%, #FFE0B2 100%);
background: linear-gradient(135deg, #F7EFE8 0%, #F1E3D7 100%);
border-color: var(--tertiary-color);
color: var(--tertiary-dark);
}
@@ -783,7 +789,7 @@ textarea:focus-visible {
}
.accent-green {
background: linear-gradient(135deg, #DCFCE7 0%, #C8E6C9 100%);
background: linear-gradient(135deg, #E7F2EE 0%, #D7E8E3 100%);
border-color: var(--success-color);
color: var(--success-dark);
}
@@ -910,6 +916,32 @@ textarea:focus-visible {
animation: loading 1.5s infinite;
}
.admin-skeleton-stack {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
}
.admin-skeleton-row {
display: grid;
grid-template-columns: 1.4fr 0.8fr 0.6fr;
gap: 12px;
align-items: center;
}
.admin-skeleton-block {
height: 14px;
border-radius: 999px;
background: linear-gradient(90deg, var(--bg-overlay) 0%, var(--bg-overlay-strong) 50%, var(--bg-overlay) 100%);
background-size: 200% 100%;
animation: loading 1.4s infinite;
}
.admin-skeleton-block.w-40 { width: 40%; }
.admin-skeleton-block.w-25 { width: 25%; }
.admin-skeleton-block.w-20 { width: 20%; }
@keyframes loading {
0% {
background-position: 200% 0;
+218 -6
View File
@@ -1,4 +1,191 @@
window.taxbaikAdminSession = {
clientLogState: {
enabled: true,
windowStart: 0,
sentCount: 0,
suppressedCount: 0,
fingerprints: {},
eventCounts: {},
screen: '',
feature: '',
action: '',
step: '',
entity: '',
entityId: '',
dataKey: ''
},
initErrorLogging: function () {
if (window._taxbaikClientLogInitialized) return;
window._taxbaikClientLogInitialized = true;
const postLog = function (payload) {
try {
if (!window.taxbaikAdminSession.shouldSendClientLog(payload)) {
return;
}
const body = JSON.stringify(payload);
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
if (navigator.sendBeacon('/taxbaik/api/client-logs', blob)) {
return;
}
}
fetch('/taxbaik/api/client-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true
}).catch(function () { });
} catch {
// Logging must never break the UI.
}
};
window.taxbaikAdminSession.postClientLog = postLog;
window.addEventListener('error', function (event) {
postLog({
level: 'error',
source: 'window.error',
message: event.message || 'unknown error',
url: event.filename || window.location.href,
route: window.location.pathname + window.location.search,
screen: window.taxbaikAdminSession.clientLogState.screen || '',
feature: window.taxbaikAdminSession.clientLogState.feature || '',
action: window.taxbaikAdminSession.clientLogState.action || '',
step: window.taxbaikAdminSession.clientLogState.step || '',
entity: window.taxbaikAdminSession.clientLogState.entity || '',
entityId: window.taxbaikAdminSession.clientLogState.entityId || '',
dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '',
buildVersion: window.taxbaikAdminBuildVersion || '',
component: window.taxbaikAdminComponent || '',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: event.error?.stack || ''
});
});
window.addEventListener('unhandledrejection', function (event) {
const reason = event.reason;
postLog({
level: 'error',
source: 'window.unhandledrejection',
message: reason?.message || String(reason || 'unknown rejection'),
url: window.location.href,
route: window.location.pathname + window.location.search,
screen: window.taxbaikAdminSession.clientLogState.screen || '',
feature: window.taxbaikAdminSession.clientLogState.feature || '',
action: window.taxbaikAdminSession.clientLogState.action || '',
step: window.taxbaikAdminSession.clientLogState.step || '',
entity: window.taxbaikAdminSession.clientLogState.entity || '',
entityId: window.taxbaikAdminSession.clientLogState.entityId || '',
dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '',
buildVersion: window.taxbaikAdminBuildVersion || '',
component: window.taxbaikAdminComponent || '',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: reason?.stack || ''
});
});
},
setContext: function (screen, feature, action, step, entity, entityId, dataKey) {
const state = window.taxbaikAdminSession.clientLogState;
state.screen = screen || '';
state.feature = feature || '';
state.action = action || '';
state.step = step || '';
state.entity = entity || '';
state.entityId = entityId || '';
state.dataKey = dataKey || '';
},
shouldSendClientLog: function (payload) {
try {
const state = window.taxbaikAdminSession.clientLogState;
if (!state.enabled) return false;
const now = Date.now();
if (!state.windowStart || now - state.windowStart >= 60000) {
state.windowStart = now;
state.sentCount = 0;
state.suppressedCount = 0;
state.fingerprints = {};
}
const fingerprint = [
payload?.source || '',
payload?.message || '',
payload?.route || '',
payload?.component || '',
payload?.screen || '',
payload?.feature || '',
payload?.action || '',
payload?.entity || '',
payload?.entityId || ''
].join('|').slice(0, 256);
state.fingerprints[fingerprint] = (state.fingerprints[fingerprint] || 0) + 1;
if (state.sentCount >= 8) {
state.suppressedCount += 1;
return false;
}
if (state.fingerprints[fingerprint] > 2) {
state.suppressedCount += 1;
return false;
}
state.sentCount += 1;
return true;
} catch {
return false;
}
},
traceUiState: function (source, details) {
try {
const payload = {
level: 'info',
source: source || 'ui-state',
message: details || '',
url: window.location.href,
route: window.location.pathname + window.location.search,
screen: window.taxbaikAdminSession.clientLogState.screen || '',
feature: window.taxbaikAdminSession.clientLogState.feature || '',
action: window.taxbaikAdminSession.clientLogState.action || '',
step: window.taxbaikAdminSession.clientLogState.step || '',
entity: window.taxbaikAdminSession.clientLogState.entity || '',
entityId: window.taxbaikAdminSession.clientLogState.entityId || '',
dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '',
buildVersion: window.taxbaikAdminBuildVersion || '',
component: window.taxbaikAdminComponent || '',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: ''
};
const state = window.taxbaikAdminSession.clientLogState;
const key = `${payload.source}|${payload.route}|${payload.message}`.slice(0, 256);
state.eventCounts[key] = (state.eventCounts[key] || 0) + 1;
if (state.eventCounts[key] > 1) {
return;
}
window.taxbaikAdminSession.postClientLog(payload);
} catch {
// diagnostics must never break UI.
}
},
postClientLog: function () {
// Replaced during initialization.
},
syncRouteClass: function () {
document.documentElement.classList.toggle(
'admin-login-route',
@@ -23,6 +210,7 @@ window.taxbaikAdminSession = {
showLoading: function () {
// Route transitions are handled by Blazor; avoid full-screen overlays
// that block drawer interaction and make the app feel frozen.
window.taxbaikAdminSession.traceUiState('admin-loading', 'showLoading requested');
window.taxbaikAdminSession.hideLoading();
},
@@ -41,11 +229,14 @@ window.taxbaikAdminSession = {
window._taxbaikLoadingObserver.disconnect();
window._taxbaikLoadingObserver = null;
}
window.taxbaikAdminSession.traceUiState('admin-loading', 'hideLoading completed');
},
watchReconnect: function () {
window.taxbaikAdminSession.syncRouteClass();
window.addEventListener('popstate', window.taxbaikAdminSession.syncRouteClass);
window.addEventListener('hashchange', window.taxbaikAdminSession.syncRouteClass);
if (document.documentElement.classList.contains('admin-login-route')) {
window.taxbaikAdminSession.hideLoading();
@@ -74,6 +265,7 @@ window.taxbaikAdminSession = {
if (!form || form.dataset.bound === '1') return;
form.dataset.bound = '1';
window.taxbaikAdminSession.traceUiState('admin-login', 'bindLoginForm attached');
form.addEventListener('submit', async function (event) {
event.preventDefault();
@@ -87,6 +279,11 @@ window.taxbaikAdminSession = {
if (submitButton) submitButton.disabled = true;
try {
if (!username || !password) {
throw new Error('username/password missing');
}
window.taxbaikAdminSession.traceUiState('admin-login', 'submit started');
const response = await fetch('/taxbaik/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -102,9 +299,11 @@ window.taxbaikAdminSession = {
throw new Error('invalid response');
}
window.taxbaikAdminSession.traceUiState('admin-login', 'submit success');
const expiryTicks = 621355968000000000 + ((Date.now() + (data.expiresIn || 3600) * 1000) * 10000);
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('tokenExpiry', String(Date.now() + (data.expiresIn || 3600) * 1000));
localStorage.setItem('tokenExpiry', String(expiryTicks));
if (rememberMe) {
localStorage.setItem('admin-remembered-username', username);
@@ -113,11 +312,24 @@ window.taxbaikAdminSession = {
}
window.location.href = '/taxbaik/admin/dashboard';
} catch {
const error = document.createElement('div');
error.className = 'mud-alert mud-alert-filled-error login-error-message mb-4';
error.textContent = '로그인 중 오류가 발생했습니다.';
form.parentElement.insertBefore(error, form);
} catch (error) {
window.taxbaikAdminSession.traceUiState('admin-login', `submit failed: ${error?.message || 'login failed'}`);
postLog({
level: 'error',
source: 'admin-login-form',
message: error?.message || 'login failed',
url: window.location.href,
route: window.location.pathname + window.location.search,
buildVersion: window.taxbaikAdminBuildVersion || '',
component: 'AdminLoginForm',
viewportWidth: window.taxbaikAdminSession.getViewportWidth(),
userAgent: navigator.userAgent || '',
stack: error?.stack || ''
});
const errorMessage = document.createElement('div');
errorMessage.className = 'mud-alert mud-alert-filled-error login-error-message mb-4';
errorMessage.textContent = '로그인 중 오류가 발생했습니다.';
form.parentElement.insertBefore(errorMessage, form);
} finally {
if (submitButton) submitButton.disabled = false;
}