Refactor admin dashboard and standardize UI shells
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m46s

This commit is contained in:
2026-07-07 23:16:31 +09:00
parent fc655e20c1
commit 08347a4e20
35 changed files with 625 additions and 230 deletions
@@ -12,14 +12,9 @@
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</MudText>
</div>
</section>
<AdminPageHeader Title="@(Id.HasValue ? "공지 수정" : "공지 등록")" Eyebrow="Homepage" />
<MudPaper Class="admin-surface" Elevation="0">
<AdminEditorPanel Loading="@isSaving" SkeletonContent="@null">
<MudForm @ref="form">
<MudGrid>
<MudItem xs="12">
@@ -89,7 +84,7 @@
</MudButton>
</div>
</MudForm>
</MudPaper>
</AdminEditorPanel>
@code {
[Parameter] public int? Id { get; set; }
@@ -10,23 +10,20 @@
<PageTitle>공지사항 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">공지사항 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</MudText>
</div>
<AdminPageHeader Title="공지사항 관리" Eyebrow="Homepage" Subtitle="홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/announcements/create">
공지 등록
</MudButton>
</section>
</AdminPageHeader>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<AdminSearchBar>
<SearchContent>
<MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</SearchContent>
</AdminSearchBar>
<AdminDataPanel Loading="@(announcements is null)" SkeletonContent="@AnnouncementSkeleton">
@if (announcements is null)
@@ -11,13 +11,7 @@
<PageTitle>고객 상세</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Client Details</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객 상세</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</MudText>
</div>
</section>
<AdminPageHeader Title="고객 상세" Eyebrow="Client Details" Subtitle="고객 정보와 상담 이력을 관리합니다." />
@if (client == null)
{
@@ -12,14 +12,10 @@
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText>
</div>
<AdminPageHeader Title="@(Id.HasValue ? "고객 수정" : "고객 등록")" Eyebrow="CRM">
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients"
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
</section>
</AdminPageHeader>
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@ClientEditSkeleton">
@if (isLoading)
@@ -21,24 +21,24 @@
</AdminPageHeader>
@* 검색/필터 바 *@
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
<MudGrid>
<MudItem xs="12" md="5">
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
</MudItem>
<MudItem xs="12" md="3">
<CommonCodeSelect @bind-Value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
</MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
</MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Text" OnClick="@ResetAsync" FullWidth="true">초기화</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
<AdminSearchBar>
<SearchContent>
<MudGrid>
<MudItem xs="12" md="5">
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
</MudItem>
<MudItem xs="12" md="3">
<CommonCodeSelect @bind-Value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
</MudItem>
</MudGrid>
</SearchContent>
<Actions>
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync">검색</MudButton>
<MudButton Variant="Variant.Text" OnClick="@ResetAsync">초기화</MudButton>
</Actions>
</AdminSearchBar>
<AdminDataPanel Loading="@(clients is null)" SkeletonContent="@ClientListSkeleton">
@if (clients is null)
@@ -8,14 +8,9 @@
<PageTitle>고객사 등록</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">새 고객사 등록</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 고객사를 추가합니다.</MudText>
</div>
<AdminPageHeader Title="새 고객사 등록" Eyebrow="Settings" Subtitle="새로운 고객사를 추가합니다.">
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
</AdminPageHeader>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
@@ -9,14 +9,9 @@
<PageTitle>고객사 수정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 수정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객사 정보를 수정합니다.</MudText>
</div>
<AdminPageHeader Title="고객사 수정" Eyebrow="Settings" Subtitle="고객사 정보를 수정합니다.">
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
</AdminPageHeader>
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@CompanySkeleton">
@if (formModel == null)
@@ -6,15 +6,10 @@
<PageTitle>고객사 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</MudText>
</div>
<AdminPageHeader Title="고객사 관리" Eyebrow="Settings" Subtitle="등록된 고객사를 관리하고 새로운 고객사를 추가합니다.">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
</section>
</AdminPageHeader>
<MudPaper Class="admin-surface mb-4 mt-4" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
@@ -9,13 +9,7 @@
<PageTitle>상담 활동 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">상담 활동 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">초기 렌더 진단용 최소 화면입니다.</MudText>
</div>
</section>
<AdminPageHeader Title="상담 활동 관리" Eyebrow="CRM & 세무관리" Subtitle="초기 렌더 진단용 최소 화면입니다." />
<AdminDataPanel Loading="@(activities is null)" SkeletonContent="@ActivitySkeleton">
@if (activities is null)
@@ -10,23 +10,17 @@
<PageTitle>계약 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">계약 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</MudText>
<AdminPageHeader Title="계약 관리" Eyebrow="CRM & 세무관리" Subtitle="고객 계약과 월 정기수익을 함께 관리합니다.">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@if (mrr > 0)
{
<MudText Typo="Typo.body2" Class="mt-2">
월 정기수익:
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
</MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">월 정기수익 ₩@mrr.ToString("N0")</MudChip>
}
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
새 계약 추가
</MudButton>
</section>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
새 계약 추가
</MudButton>
</MudStack>
</AdminPageHeader>
<AdminEditorPanel Loading="@(contracts is null)" SkeletonContent="@ContractSkeleton">
@if (contracts is null)
@@ -1,14 +1,5 @@
@page "/admin/dashboard"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
<PageTitle>대시보드</PageTitle>
<section style="padding: 24px;">
<h1 style="margin: 0 0 12px 0; font-size: 1.8rem;">대시보드</h1>
<p style="margin: 0 0 12px 0;">관리자 SPA 부팅 검증용 최소 화면입니다.</p>
<ul>
<li>WASM 부팅 성공 여부</li>
<li>라우팅 성공 여부</li>
<li>JS interop 예외 여부</li>
</ul>
</section>
<DashboardOverview />
@@ -0,0 +1,240 @@
@inject TaxBaik.Web.Client.Components.Admin.Services.IAdminDashboardClient DashboardClient
@inject NavigationManager Navigation
<PageTitle>대시보드</PageTitle>
<AdminPageHeader Title="대시보드" Eyebrow="Home" Subtitle="운영 현황과 바로가기 작업을 한 화면에서 확인합니다.">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" Disabled="@loading" @onclick="ReloadAsync">
새로고침
</MudButton>
</AdminPageHeader>
<MudGrid Class="admin-dashboard-grid">
@if (loading)
{
<MudItem xs="12">
<AdminDataPanel Loading="true" />
</MudItem>
}
else
{
<MudItem xs="12" sm="6" md="3">
<AdminMetricCard Label="이번 달 문의" Value="@summary?.ThisMonthInquiries" Caption="이번 달 접수된 문의" Accent="accent-blue" Icon="@Icons.Material.Filled.MarkUnreadChatAlt" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<AdminMetricCard Label="신규 문의" Value="@summary?.NewInquiries" Caption="처리 대기 중인 문의" Accent="accent-amber" Icon="@Icons.Material.Filled.PriorityHigh" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<AdminMetricCard Label="총 게시글" Value="@summary?.TotalPosts" Caption="홈페이지 블로그 누적" Accent="accent-slate" Icon="@Icons.Material.Filled.Article" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<AdminMetricCard Label="게시중 글" Value="@summary?.PublishedPosts" Caption="현재 공개 중인 글" Accent="accent-green" Icon="@Icons.Material.Filled.Public" />
</MudItem>
<MudItem xs="12" md="5">
<AdminDataPanel>
<AdminPageHeader Title="빠른 작업" Subtitle="자주 쓰는 관리 화면으로 바로 이동합니다." />
<MudStack Spacing="1">
@foreach (var action in QuickActions)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true" StartIcon="@action.Icon" OnClick="@(() => Navigation.NavigateTo(action.Href))">
@action.Label
</MudButton>
}
</MudStack>
</AdminDataPanel>
</MudItem>
<MudItem xs="12" md="7">
<AdminDataPanel>
<AdminPageHeader Title="최근 문의" Subtitle="최신 처리 흐름을 빠르게 확인합니다." />
@if (summary?.RecentInquiries is null || !summary.RecentInquiries.Any())
{
<AdminEmptyState Icon="@Icons.Material.Filled.Inbox" Message="새로운 문의가 들어오면 이 영역에 표시됩니다." />
}
else
{
<MudSimpleTable Dense="true" Class="admin-table">
<thead>
<tr>
<th>제목</th>
<th>상태</th>
<th>접수일</th>
</tr>
</thead>
<tbody>
@foreach (var inquiry in summary.RecentInquiries)
{
<tr>
<td>@inquiry.Name</td>
<td>@inquiry.Status</td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</AdminDataPanel>
</MudItem>
<MudItem xs="12" md="6">
<AdminDataPanel Loading="@statsLoading">
<AdminPageHeader Title="월간 통계" Subtitle="문의 처리 전환과 상태 분포를 확인합니다." />
@if (monthlyStats is not null)
{
<div class="admin-stats-grid">
<div class="admin-stat">
<span class="admin-stat-label">총 문의</span>
<strong class="admin-stat-value">@monthlyStats.TotalInquiries</strong>
</div>
<div class="admin-stat">
<span class="admin-stat-label">상담 중</span>
<strong class="admin-stat-value">@monthlyStats.ConsultingCount</strong>
</div>
<div class="admin-stat">
<span class="admin-stat-label">완료</span>
<strong class="admin-stat-value">@monthlyStats.CompletedCount</strong>
</div>
<div class="admin-stat">
<span class="admin-stat-label">완료율</span>
<strong class="admin-stat-value">@monthlyStats.CompletionRate.ToString("0.#")%</strong>
</div>
</div>
<div class="admin-chart">
<div class="admin-chart-header">
<span>최근 6개월 문의 추세</span>
<span>@monthlyStats.Month</span>
</div>
<div class="admin-chart-bars">
@foreach (var point in trendPoints)
{
<div class="admin-chart-row">
<span>@point.Label</span>
<div class="admin-chart-track">
<div class="admin-chart-fill accent-blue" style="width:@BarWidth(point.Total, trendMax)"></div>
</div>
<span>@point.Total</span>
</div>
}
</div>
</div>
}
</AdminDataPanel>
</MudItem>
<MudItem xs="12" md="6">
<AdminDataPanel>
<AdminPageHeader Title="바로가기" Subtitle="운영 중 자주 사용하는 화면으로 이동합니다." />
<div class="admin-quick-grid">
@foreach (var action in QuickActions)
{
<MudPaper Class="admin-quick-card" Elevation="0" @onclick="@(() => Navigation.NavigateTo(action.Href))">
<MudIcon Icon="@action.Icon" />
<span>@action.Label</span>
</MudPaper>
}
</div>
</AdminDataPanel>
</MudItem>
<MudItem xs="12" md="6">
<AdminDataPanel>
<AdminPageHeader Title="운영 메모" Subtitle="이번 배포에서 확인해야 할 핵심 포인트입니다." />
<div class="admin-note-list">
<div>대시보드와 메뉴가 역할 기반으로 필터링됩니다.</div>
<div>공통 CRUD 화면은 `AdminPageHeader`/`AdminCrudPageShell` 슬롯 패턴을 따릅니다.</div>
<div>월간 통계는 문의 전환 흐름을 빠르게 보는 용도입니다.</div>
</div>
</AdminDataPanel>
</MudItem>
<MudItem xs="12" md="6">
<AdminDataPanel>
<AdminPageHeader Title="알림" Subtitle="운영 상태를 빠르게 점검합니다." />
<MudStack Spacing="1">
<MudAlert Severity="@(summary is not null && summary.NewInquiries > 0 ? Severity.Warning : Severity.Info)">
@(summary is not null && summary.NewInquiries > 0
? $"처리 대기 중인 신규 문의가 {summary.NewInquiries}건 있습니다."
: "현재 신규 문의가 없습니다.")
</MudAlert>
<MudAlert Severity="@(summary is not null && summary.PublishedPosts < summary.TotalPosts ? Severity.Info : Severity.Success)">
@(summary is not null && summary.PublishedPosts < summary.TotalPosts
? "비공개 중인 게시글이 있어 발행 상태 확인이 필요합니다."
: "게시글 발행 상태가 안정적입니다.")
</MudAlert>
</MudStack>
</AdminDataPanel>
</MudItem>
}
</MudGrid>
@code {
private bool loading = true;
private bool statsLoading = true;
private AdminDashboardSummary? summary;
private TaxBaik.Web.Client.Components.Admin.Services.MonthlyStatsDto? monthlyStats;
private readonly List<(string Label, int Total)> trendPoints = [];
private static readonly (string Label, string Href, string Icon)[] QuickActions =
[
("공지 등록", "/taxbaik/admin/announcements/create", Icons.Material.Filled.Campaign),
("고객사 등록", "/taxbaik/admin/companies/create", Icons.Material.Filled.Business),
("문의 관리", "/taxbaik/admin/inquiries", Icons.Material.Filled.Inbox),
("설정 열기", "/taxbaik/admin/settings", Icons.Material.Filled.Settings),
];
protected override async Task OnInitializedAsync()
{
await LoadAsync();
await LoadStatsAsync();
await LoadTrendAsync();
}
private async Task ReloadAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
summary = await DashboardClient.GetSummaryAsync();
}
finally
{
loading = false;
}
}
private async Task LoadStatsAsync()
{
statsLoading = true;
try
{
monthlyStats = await DashboardClient.GetMonthlyStatsAsync();
}
finally
{
statsLoading = false;
}
}
private async Task LoadTrendAsync()
{
trendPoints.Clear();
var today = DateTime.Today;
for (var i = 5; i >= 0; i--)
{
var month = today.AddMonths(-i);
var monthly = await DashboardClient.GetMonthlyStatsAsync(month.ToString("yyyy-MM"));
trendPoints.Add(($"{month:MM}", monthly.TotalInquiries));
}
}
private static string BarWidth(int value, int total)
=> total <= 0 ? "0%" : $"{Math.Max(6, (int)Math.Round(value * 100.0 / total))}%";
private int trendMax => Math.Max(1, trendPoints.Count == 0 ? 1 : trendPoints.Max(x => x.Total));
}
@@ -9,14 +9,10 @@
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText>
</div>
<AdminPageHeader Title="@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")" Eyebrow="홈페이지">
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs"
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
</section>
</AdminPageHeader>
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@FaqSkeleton">
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
@@ -10,23 +10,20 @@
<PageTitle>FAQ 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">FAQ 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</MudText>
</div>
<AdminPageHeader Title="FAQ 관리" Eyebrow="홈페이지" Subtitle="홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/faqs/create">
FAQ 등록
</MudButton>
</section>
</AdminPageHeader>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<AdminSearchBar>
<SearchContent>
<MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</SearchContent>
</AdminSearchBar>
<AdminDataPanel Loading="@(faqs is null)" SkeletonContent="@FaqListSkeleton">
@if (faqs is null)
@@ -9,13 +9,7 @@
<PageTitle>문의 상세</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Inquiry Details</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 상세</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</MudText>
</div>
</section>
<AdminPageHeader Title="문의 상세" Eyebrow="Inquiry Details" Subtitle="문의 정보를 확인하고 처리 상태를 관리합니다." />
@if (inquiry != null)
{
@@ -9,13 +9,7 @@
<PageTitle>수익 추적 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">수익 추적 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">초기 렌더 진단용 최소 화면입니다.</MudText>
</div>
</section>
<AdminPageHeader Title="수익 추적 관리" Eyebrow="CRM & 세무관리" Subtitle="청구 기록과 수익 흐름을 확인합니다." />
<AdminDataPanel Loading="@(revenues is null)" SkeletonContent="@RevenueSkeleton">
@if (revenues is null)
@@ -6,13 +6,7 @@
<PageTitle>시즌 시뮬레이터</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Season Preview</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">시즌 시뮬레이터</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</MudText>
</div>
</section>
<AdminPageHeader Title="시즌 시뮬레이터" Eyebrow="Season Preview" Subtitle="날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다." />
<MudGrid>
<MudItem xs="12" md="4">
@@ -9,19 +9,11 @@
<PageTitle>설정</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-6">
<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>
</MudContainer>
<AdminPageHeader Title="설정" Eyebrow="System" Subtitle="공개 사이트 연락처와 관리자 계정 보안을 관리합니다." />
<MudGrid>
<MudItem xs="12" md="7">
<MudPaper Class="admin-surface" Elevation="0">
<AdminEditorPanel Loading="@isLoadingSettings">
<div class="admin-section-header compact">
<div>
<MudText Typo="Typo.h6">사이트 정보</MudText>
@@ -45,11 +37,11 @@
StartIcon="@Icons.Material.Filled.Save"
@onclick="SaveSettings">사이트 정보 저장</MudButton>
</MudForm>
</MudPaper>
</AdminEditorPanel>
</MudItem>
<MudItem xs="12" md="5">
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
<AdminEditorPanel Loading="@isLoadingSettings">
<div class="admin-section-header compact">
<div>
<MudText Typo="Typo.h6">계정 관리</MudText>
@@ -74,7 +66,7 @@
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
</MudButton>
</MudForm>
</MudPaper>
</AdminEditorPanel>
</MudItem>
</MudGrid>
@@ -11,16 +11,11 @@
<PageTitle>신고 일정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText>
</div>
<AdminPageHeader Title="신고 일정" Eyebrow="CRM & 세무관리" Subtitle="고객별 마감일과 처리 상태를 한 화면에서 관리합니다.">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-schedule">
새 일정 추가
</MudButton>
</section>
</AdminPageHeader>
<AdminDataPanel Loading="@(schedules is null)" SkeletonContent="@ScheduleSkeleton">
@if (schedules is null)
@@ -10,18 +10,13 @@
<PageTitle>신고 일정 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Tax Schedule</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</MudText>
</div>
<AdminPageHeader Title="신고 일정" Eyebrow="Tax Schedule" Subtitle="고객별 세금 신고 마감일을 관리하고 완료 처리합니다.">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(() => showAddForm = !showAddForm)"
StartIcon="@Icons.Material.Filled.Add">
일정 추가
</MudButton>
</section>
</AdminPageHeader>
@if (showAddForm)
{
@@ -11,16 +11,11 @@
<PageTitle>세무 프로필</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText>
</div>
<AdminPageHeader Title="세무 프로필" Eyebrow="CRM & 세무관리" Subtitle="고객별 세무 프로필, 신고 일정, 위험도 추적">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-profile">
새 프로필 추가
</MudButton>
</section>
</AdminPageHeader>
<AdminDataPanel Loading="@(profiles == null)" SkeletonContent="@ProfileSkeleton">
@if (profiles == null)
@@ -15,7 +15,7 @@ public interface IAdminDashboardClient
Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default);
Task<IEnumerable<TaxFiling>> GetUpcomingFilingsAsync(int days = 30, CancellationToken ct = default);
Task<IEnumerable<Inquiry>> GetRecentInquiriesAsync(int limit = 10, CancellationToken ct = default);
Task<object> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default);
Task<MonthlyStatsDto> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default);
}
public class AdminDashboardClient : IAdminDashboardClient
@@ -87,7 +87,7 @@ public class AdminDashboardClient : IAdminDashboardClient
}
}
public async Task<object> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default)
public async Task<MonthlyStatsDto> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default)
{
try
{
@@ -96,8 +96,8 @@ public class AdminDashboardClient : IAdminDashboardClient
if (!string.IsNullOrEmpty(month))
url += $"?month={month}";
var result = await _http.GetFromJsonAsync<object>(url, cancellationToken: ct);
return result ?? new();
var result = await _http.GetFromJsonAsync<MonthlyStatsDto>(url, cancellationToken: ct);
return result ?? new MonthlyStatsDto();
}
catch (HttpRequestException ex)
{
@@ -107,6 +107,16 @@ public class AdminDashboardClient : IAdminDashboardClient
}
}
public sealed class MonthlyStatsDto
{
public string Month { get; set; } = string.Empty;
public int TotalInquiries { get; set; }
public int ConsultingCount { get; set; }
public int CompletedCount { get; set; }
public int NewCount { get; set; }
public double CompletionRate { get; set; }
}
/// <summary>
/// API Response wrapper
/// </summary>
@@ -1,7 +1,13 @@
<AdminPageHeader Title="@Title" Eyebrow="@Eyebrow" Subtitle="@Subtitle">
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="OnCancel">
@CancelText
</MudButton>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.FlexEnd" Spacing="1">
@if (Actions is not null)
{
@Actions
}
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="OnCancel">
@CancelText
</MudButton>
</MudStack>
</AdminPageHeader>
<AdminEditorPanel Loading="@Loading" SkeletonContent="@SkeletonContent">
@@ -24,6 +30,9 @@
[Parameter]
public string CancelText { get; set; } = "취소";
[Parameter]
public RenderFragment? Actions { get; set; }
[Parameter]
public bool Loading { get; set; }
@@ -0,0 +1,45 @@
using MudBlazor;
namespace TaxBaik.Web.Client.Components.Admin.Shared;
public sealed record AdminNavigationItem(
string Label,
string Href,
string Icon,
string? Badge = null,
bool IsExact = false,
IReadOnlyList<string>? AllowedRoles = null);
public sealed record AdminNavigationSection(
string Label,
IReadOnlyList<AdminNavigationItem> Items);
public static class AdminNavigationCatalog
{
public static IReadOnlyList<AdminNavigationSection> Sections { get; } =
[
new("개요", [
new("대시보드", "/taxbaik/admin/dashboard", Icons.Material.Filled.Dashboard, IsExact: true),
]),
new("CRM & 세무관리", [
new("세무 프로필", "/taxbaik/admin/tax-profiles", Icons.Material.Filled.Badge, AllowedRoles: ["Admin", "Manager"]),
new("신고 일정", "/taxbaik/admin/tax-filing-schedules", Icons.Material.Filled.EventNote, AllowedRoles: ["Admin", "Manager"]),
new("계약 관리", "/taxbaik/admin/contracts", Icons.Material.Filled.Description, AllowedRoles: ["Admin", "Manager"]),
new("상담 활동", "/taxbaik/admin/consulting-activities", Icons.Material.Filled.SupportAgent, AllowedRoles: ["Admin", "Manager"]),
new("수익 추적", "/taxbaik/admin/revenue-trackings", Icons.Material.Filled.Payments, AllowedRoles: ["Admin"]),
]),
new("고객 관리", [
new("고객 카드", "/taxbaik/admin/clients", Icons.Material.Filled.Groups, AllowedRoles: ["Admin", "Manager", "Staff"]),
new("세무신고", "/taxbaik/admin/tax-filings", Icons.Material.Filled.Assignment, AllowedRoles: ["Admin", "Manager", "Staff"]),
]),
new("홈페이지", [
new("공지사항", "/taxbaik/admin/announcements", Icons.Material.Filled.Campaign, AllowedRoles: ["Admin", "ContentManager"]),
new("FAQ 관리", "/taxbaik/admin/faqs", Icons.Material.Filled.HelpOutline, AllowedRoles: ["Admin", "ContentManager"]),
new("블로그 관리", "/taxbaik/admin/blog", Icons.Material.Filled.Article, AllowedRoles: ["Admin", "ContentManager"]),
new("시즌 시뮬레이터", "/taxbaik/admin/season-simulator", Icons.Material.Filled.CalendarMonth, AllowedRoles: ["Admin", "ContentManager"]),
new("문의 관리", "/taxbaik/admin/inquiries", Icons.Material.Filled.Inbox, AllowedRoles: ["Admin", "Manager", "Staff"]),
new("설정", "/taxbaik/admin/settings", Icons.Material.Filled.Settings, AllowedRoles: ["Admin"]),
new("공통관리", "/taxbaik/admin/common-codes", Icons.Material.Filled.Apps, AllowedRoles: ["Admin"]),
]),
];
}
@@ -0,0 +1,21 @@
<MudPaper Class="admin-surface admin-search-bar" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="admin-search-bar-inner">
<div class="admin-search-field">
@SearchContent
</div>
@if (Actions is not null)
{
<div class="admin-search-actions">
@Actions
</div>
}
</MudStack>
</MudPaper>
@code {
[Parameter]
public RenderFragment? SearchContent { get; set; }
[Parameter]
public RenderFragment? Actions { get; set; }
}
@@ -21,37 +21,7 @@
<div class="admin-body">
@if (drawerOpen)
{
<aside class="admin-drawer">
<div class="admin-drawer-brand">
<div class="admin-brand-mark">T</div>
<div>
<div class="admin-drawer-title">TaxBaik</div>
<div class="admin-drawer-subtitle">세무 운영 콘솔</div>
</div>
</div>
<nav class="admin-nav">
<MudNavMenu Dense="true">
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All">대시보드</MudNavLink>
<MudText Typo="Typo.caption" Class="admin-nav-group">CRM & 세무관리</MudText>
<MudNavLink Href="/taxbaik/admin/tax-profiles">세무 프로필</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filing-schedules">신고 일정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/contracts">계약 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/consulting-activities">상담 활동</MudNavLink>
<MudNavLink Href="/taxbaik/admin/revenue-trackings">수익 추적</MudNavLink>
<MudText Typo="Typo.caption" Class="admin-nav-group">고객 관리</MudText>
<MudNavLink Href="/taxbaik/admin/clients">고객 카드</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filings">세무신고</MudNavLink>
<MudText Typo="Typo.caption" Class="admin-nav-group">홈페이지</MudText>
<MudNavLink Href="/taxbaik/admin/announcements">공지사항</MudNavLink>
<MudNavLink Href="/taxbaik/admin/faqs">FAQ 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/blog">블로그 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/season-simulator">시즌 시뮬레이터</MudNavLink>
<MudNavLink Href="/taxbaik/admin/inquiries">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings">설정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/common-codes">공통관리</MudNavLink>
</MudNavMenu>
</nav>
</aside>
<AdminSidebar />
}
<main class="admin-main">
@@ -67,7 +37,6 @@
public RenderFragment? ChildContent { get; set; }
private bool drawerOpen = true;
private bool firstRenderCompleted;
protected override void OnInitialized()
{
@@ -83,14 +52,6 @@
drawerOpen = !drawerOpen;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !firstRenderCompleted)
{
firstRenderCompleted = true;
}
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
@@ -0,0 +1,62 @@
@using Microsoft.AspNetCore.Components.Authorization
@using TaxBaik.Web.Client.Components.Admin.Shared
@inject AuthenticationStateProvider AuthenticationStateProvider
<aside class="admin-drawer">
<div class="admin-drawer-brand">
<div class="admin-brand-mark">T</div>
<div>
<div class="admin-drawer-title">TaxBaik</div>
<div class="admin-drawer-subtitle">세무 운영 콘솔</div>
</div>
</div>
<nav class="admin-nav" aria-label="관리자 메뉴">
<MudNavMenu Dense="true">
@foreach (var section in visibleSections)
{
<MudText Typo="Typo.caption" Class="admin-nav-group">@section.Label</MudText>
@foreach (var item in section.Items)
{
<MudNavLink Href="@item.Href" Match="@(item.IsExact ? NavLinkMatch.All : NavLinkMatch.Prefix)">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@item.Icon" Size="Size.Small" />
<span>@item.Label</span>
@if (!string.IsNullOrWhiteSpace(item.Badge))
{
<MudChip T="string" Size="Size.Small" Class="admin-nav-badge">@item.Badge</MudChip>
}
</MudStack>
</MudNavLink>
}
}
</MudNavMenu>
</nav>
<div class="admin-drawer-footer">
<div class="admin-footer-item">
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" />
<MudLink Href="/taxbaik" Target="_blank" Underline="Underline.None">공개 사이트</MudLink>
</div>
<div class="admin-footer-item">
<MudIcon Icon="@Icons.Material.Filled.Logout" Size="Size.Small" />
<MudLink Href="/taxbaik/admin/logout" Underline="Underline.None">로그아웃</MudLink>
</div>
</div>
</aside>
@code {
private IReadOnlyList<AdminNavigationSection> visibleSections = [];
protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
visibleSections = AdminNavigationCatalog.Sections
.Select(section => section with
{
Items = section.Items.Where(item => item.AllowedRoles is null || item.AllowedRoles.Count == 0 || item.AllowedRoles.Any(user.IsInRole)).ToList()
})
.Where(section => section.Items.Count > 0)
.ToList();
}
}
@@ -18,7 +18,7 @@ public class ValidateSitemapEndpoint : EndpointWithoutRequest
public override void Configure()
{
Get("/api/admin/validate/sitemap");
Roles("admin");
Policies("AdminOnly");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -40,7 +40,7 @@ public class ValidateRssEndpoint : EndpointWithoutRequest
public override void Configure()
{
Get("/api/admin/validate/rss");
Roles("admin");
Policies("AdminOnly");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -62,7 +62,7 @@ public class ValidateConsistencyEndpoint : EndpointWithoutRequest
public override void Configure()
{
Get("/api/admin/validate/consistency");
Roles("admin");
Policies("AdminOnly");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -15,7 +15,7 @@ public class GetMonthlyStatsEndpoint : Endpoint<MonthlyStatsQuery, MonthlyStatsR
public override void Configure()
{
Get("/api/admin-dashboard/monthly-stats");
Policies("Bearer");
Policies("AdminOnly");
}
public override async Task HandleAsync(MonthlyStatsQuery request, CancellationToken ct)
@@ -15,7 +15,7 @@ public class GetRecentInquiriesEndpoint : Endpoint<RecentInquiriesQuery, RecentI
public override void Configure()
{
Get("/api/admin-dashboard/recent-inquiries");
Policies("Bearer");
Policies("AdminOnly");
}
public override async Task HandleAsync(RecentInquiriesQuery request, CancellationToken ct)
@@ -15,7 +15,7 @@ public class GetSummaryEndpoint : EndpointWithoutRequest<AdminDashboardSummaryRe
public override void Configure()
{
Get("/api/admin-dashboard/summary");
Policies("Bearer");
Policies("AdminOnly");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -15,7 +15,7 @@ public class GetUpcomingFilingsEndpoint : Endpoint<UpcomingFilingsQuery, Upcomin
public override void Configure()
{
Get("/api/admin-dashboard/upcoming-filings");
Policies("Bearer");
Policies("AdminOnly");
}
public override async Task HandleAsync(UpcomingFilingsQuery request, CancellationToken ct)
+4
View File
@@ -69,6 +69,10 @@ builder.Services.AddAuthorization(opts =>
opts.AddPolicy("Bearer", new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build());
opts.AddPolicy("AdminOnly", new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.RequireRole("Admin")
.Build());
});
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
+2 -1
View File
@@ -173,7 +173,8 @@ public class AuthService
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("username", user.Username)
new Claim("username", user.Username),
new Claim(ClaimTypes.Role, "Admin")
};
var accessToken = new JwtSecurityToken(
+144
View File
@@ -497,6 +497,9 @@ textarea:focus-visible {
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.admin-drawer-brand {
@@ -539,6 +542,23 @@ textarea:focus-visible {
font-size: 0.78rem;
}
.admin-nav .mud-nav-link .mud-stack {
width: 100%;
gap: 8px;
}
.admin-nav-badge {
margin-left: auto;
height: 18px;
font-size: 0.65rem;
}
.admin-body {
display: flex;
min-height: 0;
flex: 1;
}
.admin-nav .mud-nav-group-header {
min-height: 32px;
font-size: 0.78rem;
@@ -618,6 +638,130 @@ textarea:focus-visible {
width: 100%;
}
.admin-dashboard-grid {
margin-top: 0;
}
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.admin-stat {
padding: 14px;
border: 1px solid var(--border-color-light);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, #fff 0%, #f8fbff 100%);
}
.admin-stat-label {
display: block;
margin-bottom: 4px;
color: var(--text-secondary);
font-size: 0.75rem;
}
.admin-stat-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
.admin-chart {
margin-top: 14px;
padding: 14px;
border: 1px solid var(--border-color-light);
border-radius: var(--radius-lg);
background: #fff;
}
.admin-chart-header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.admin-chart-bars {
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-chart-row {
display: grid;
grid-template-columns: 56px 1fr 28px;
gap: 10px;
align-items: center;
font-size: 0.75rem;
}
.admin-chart-track {
height: 10px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
overflow: hidden;
}
.admin-chart-fill {
height: 100%;
border-radius: inherit;
}
.admin-quick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.admin-quick-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--border-color-light);
border-radius: var(--radius-lg);
cursor: pointer;
transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
background: linear-gradient(180deg, #fff 0%, #f8fbff 100%);
}
.admin-quick-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--border-color-strong);
}
.admin-quick-card .mud-icon-root {
color: var(--primary-color);
}
.admin-note-list {
display: grid;
gap: 10px;
color: var(--text-secondary);
font-size: 0.8rem;
}
.admin-note-list div {
padding-left: 14px;
position: relative;
}
.admin-note-list div::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--primary-color);
}
/* ============================================================================
Dashboard Page Styles
============================================================================ */