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 -1
View File
@@ -84,6 +84,9 @@ jobs:
- name: Validate admin render mode
run: bash scripts/validate_admin_render.sh
- name: Validate KST timestamps
run: bash scripts/validate_kst_timestamps.sh
- name: Generate build info
run: |
COMMIT_HASH=$(git rev-parse --short HEAD)
@@ -127,7 +130,7 @@ jobs:
run: |
set -e
export TAXBAIK_DEPLOY_FROM_CI=1
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S)
COMMIT=$(git rev-parse --short HEAD)
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
@@ -38,7 +38,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (long.TryParse(ticksStr, out var ticks))
if (TryNormalizeExpiryTicks(ticksStr, out var ticks))
{
_tokenStore.AccessToken = storedToken;
_tokenStore.RefreshToken = refreshToken;
@@ -130,6 +130,30 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private static bool TryNormalizeExpiryTicks(string? rawValue, out long ticks)
{
ticks = 0;
if (!long.TryParse(rawValue, out var parsed))
{
return false;
}
// Support both legacy Unix-millisecond storage and .NET ticks.
if (parsed > 10_000_000_000_000L && parsed < 100_000_000_000_000_000L)
{
ticks = parsed;
return true;
}
if (parsed > 1_000_000_000_000L && parsed < 100_000_000_000_000L)
{
ticks = DateTimeOffset.FromUnixTimeMilliseconds(parsed).UtcDateTime.Ticks;
return true;
}
return false;
}
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
+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;
}
+1 -1
View File
@@ -7,7 +7,7 @@ if [ "${TAXBAIK_DEPLOY_FROM_CI:-}" != "1" ]; then
fi
DEPLOY_HOME="/home/kjh2064"
WEB_TIMESTAMP=$(date +%Y%m%d_%H%M%S)
WEB_TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S)
echo "===== 🚀 TaxBaik 배포 스크립트 ====="
echo "Web Timestamp: $WEB_TIMESTAMP"
+1 -1
View File
@@ -3,7 +3,7 @@ set -e
DEPLOY_HOME="/home/kjh2064"
PORT_FILE="$DEPLOY_HOME/taxbaik_port"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S)
echo "===== 🚀 TaxBaik Green/Blue Deployment Script ====="
+11
View File
@@ -14,6 +14,7 @@
| Deploy | Gitea Actions CI/CD만 배포 경로 | 수동 SSH/복사로 운영 반영 |
| Evidence | 빌드, 테스트, E2E, API smoke 로그 | "확인함", "될 것" 같은 진술 |
| Admin Render | `InteractiveWebAssemblyRenderMode(prerender: false)` | 어드민에 `InteractiveServerRenderMode` 또는 `prerender: true` 존재 |
| KST Timestamp | CI/배포/백업 폴더명과 추적 일시는 `TZ=Asia/Seoul` | `date`가 기본 UTC 또는 서버 로캘에 종속 |
## Architecture Guardrails
@@ -26,6 +27,13 @@
- 어드민 렌더 모드는 `InteractiveWebAssemblyRenderMode(prerender: false)`를 기본값으로 둔다. `InteractiveServerRenderMode``prerender: true`는 어드민에서 허용하지 않는다.
- JavaScript는 최소화한다. 브라우저 API, 인증 토큰 저장, 서드파티 편집기처럼 Blazor/MudBlazor만으로 해결하기 부적절한 경우에만 JS module로 격리한다.
- 상속은 프레임워크 요구 또는 명확한 다형성 모델에만 사용한다. 폼/테이블/CRUD 재사용은 기본적으로 컴포넌트 합성과 작은 service/client로 처리한다.
- 과유불급을 지킨다. 실제 재사용이 2곳 미만이면 새 추상화를 만들지 말고 기존 컴포넌트를 직접 조합한다.
- CI, 배포 폴더명, 백업명, 버전 추적에 쓰는 시간 문자열은 `TZ=Asia/Seoul`을 기본으로 한다.
- 클라이언트 오류 수집은 서버/브라우저를 보호하는 목적의 제한형 수집으로만 운영한다. 건당 비동기 전송, 중복 억제, 분당 상한, 서버 rate limit, 실패 시 조용히 폐기, 재시도 폭주 금지.
- 브라우저에서 발생한 JS 오류는 운영 장애 탐지를 위한 샘플 데이터로만 취급하고, 전체 이벤트 스트림을 보존하려는 설계는 금지한다.
- 텔레그램 알림은 운영자의 주의 채널이지 이벤트 버스가 아니다. 같은 원인/같은 기간의 중복 알림은 억제하고, 리포트/오류/문의/시작 장애는 종류별 시간창을 분리한다.
- 오류 알림에는 재현성 6요소를 포함한다: 화면, 기능, 액션, 단계, 데이터 식별자, 현재 라우트. 이 정보가 없으면 운영 대응이 끝나지 않은 것으로 본다.
- 재현 맥락은 페이지별 수동 JS 호출이 아니라 `AdminTelemetryContext` 같은 공통 컴포넌트가 담당한다. 새 어드민 화면은 레이아웃 경유 기본값을 자동 상속해야 하며, 예외만 명시적으로 덮어쓴다.
## Code Quality Harness
@@ -54,6 +62,9 @@
- 상태 전이는 허용 목록을 둔다. 임의 문자열 저장을 금지한다.
- 삭제는 운영 데이터 손실 위험이 있으면 soft delete 또는 archive를 우선 검토한다.
- 콤보 값은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)를 1차 기준으로 삼는다.
- 클라이언트 로그와 장애 진단 로그는 운영 데이터가 아니라 관측 데이터로 본다. 저장 실패는 사용자 흐름을 막지 않으며, 수집 실패 자체를 재시도 루프로 증폭하지 않는다.
- 동일 오류의 텔레그램 재알림은 일정 기간 1회로 제한하고, 재전송 목적의 루프는 금지한다.
- 데이터가 오류 재현에 필요하면 `entity`, `entityId`, `dataKey` 같은 최소 식별자만 남기고, 원문 데이터 전체를 로그에 싣지 않는다.
## API-First Admin Pattern
+20
View File
@@ -22,6 +22,25 @@
| Public API | `/taxbaik/api/*` | JWT 인증, ProblemDetails 오류, DTO 입출력 |
| CI/CD | `.gitea/workflows/deploy.yml`, `.gitea/workflows/browser-e2e.yml` | 수동 배포 금지, 배포본 E2E 통과 후 완료 |
## Shared Component Map
| 컴포넌트 | 용도 | 대표 사용처 |
| --- | --- | --- |
| `AdminShell` | 관리자 상단바/드로워/버전/알림 공통 shell | `Components/Admin/Layout/MainLayout.razor` |
| `AdminLoginForm` | 관리자 로그인 입력/제출 UI | `Components/Admin/Pages/Login.razor` |
| `AdminPageHeader` | 페이지 타이틀/보조설명/주요 액션 | Blog, Inquiry, Client, FAQ 목록 |
| `AdminDataPanel` | 목록/표면/로딩 스켈레톤 공통 래퍼 | Blog, Inquiry, CommonCode, Dashboard |
| `AdminEditorPanel` | 편집형 스켈레톤 래퍼 | BlogEdit, InquiryEdit, ClientEdit, CompanyEdit, FAQEdit |
| `AdminSkeletonRows` | 반복 로딩 골격 | AdminDataPanel, AdminEditorPanel, Dashboard |
| `AdminMetricCard` | 대시보드 KPI 카드 | `Components/Admin/Pages/Dashboard.razor` |
| `AdminEmptyState` | empty/empty-filter 상태 | ClientList 등 목록 화면 |
| `AdminFormSection` | 폼 입력 섹션 구획 | BlogForm, InquiryForm |
| `AdminFormActions` | 제출/취소 버튼 묶음 | BlogForm, InquiryForm |
| `AdminDetailSection` | 상세 정보 카드 | InquiryDetail |
| `AdminCrudPageShell` | create/edit 페이지 공통 헤더+취소+편집 래퍼 | BlogCreate/Edit, InquiryCreate/Edit |
| `CommonCodeGroupPanel` | 공통코드 그룹 선택/추가 패널 | CommonCodes |
| `CommonCodeListPanel` | 공통코드 목록/편집 패널 | CommonCodes |
## Document Rules
- 문서는 짧게 유지한다. 새 문서를 만들기 전에 이 인덱스에 추가할 가치가 있는지 판단한다.
@@ -30,3 +49,4 @@
- WBS 완료 여부는 체크박스가 아니라 수치와 실행 로그로 판단한다.
- 코드 변경 시 관련 WBS ID를 커밋/PR 설명 또는 작업 메모에 남긴다.
- 공통코드 관련 규칙은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)만 1차 기준으로 사용한다.
- 공유 컴포넌트는 `INDEX.md`의 Shared Component Map을 먼저 확인한다.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
targets=(
".gitea/workflows/deploy.yml"
"deploy.sh"
"deploy_gb.sh"
)
for file in "${targets[@]}"; do
if [ ! -f "$file" ]; then
echo "Missing KST target file: $file" >&2
exit 1
fi
done
if grep -nE 'date \+%Y%m%d_%H%M%S|date \+%Y%m%d' "${targets[@]}" | grep -v 'TZ=Asia/Seoul' >/dev/null; then
echo "Timestamp generation must use TZ=Asia/Seoul." >&2
exit 1
fi
echo "KST timestamp harness passed."