Harden admin telemetry and deployment safeguards
TaxBaik CI/CD / build-and-deploy (push) Successful in 4m30s
TaxBaik CI/CD / build-and-deploy (push) Successful in 4m30s
This commit is contained in:
@@ -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분)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ====="
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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을 먼저 확인한다.
|
||||
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user