feat: standalone Blazor WebAssembly admin + SEO enhancements

Architecture:
- Admin UI: /admin (Standalone Blazor WebAssembly, 219 WASM files)
- Portal: /portal (Razor Pages, Cookie/OAuth auth)
- Homepage: / (Razor Pages, SSR)
- API: /api (FastEndpoints + JWT)

SEO:
- Sitemap: Public content only (blog, FAQ, announcements, contact)
- robots.txt: Exclude /admin and /portal, reference production domain
- Naver verification: naverb1813cd79ddc2ded5c5291fca5cb46c2.html ready

Technical:
- TaxBaik.Web.Client: StaticWebAssetBasePath=admin
- Server Program.cs: UseBlazorFrameworkFiles + MapFallback for SPA routing
- base href="/admin/" for client-side navigation
- blazor.webassembly.js (standalone, not web.js)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 04:03:18 +09:00
parent 64e462e57e
commit 54367696dc
112 changed files with 2701 additions and 207 deletions
@@ -8,12 +8,8 @@
<base href="/taxbaik/" /> <base href="/taxbaik/" />
<link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" />
<link rel="alternate icon" href="/taxbaik/favicon.ico" /> <link rel="alternate icon" href="/taxbaik/favicon.ico" />
<!-- 외부 폰트는 비동기로 로드 (페이지 로딩 차단 방지) --> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<noscript><link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" /></noscript>
<!-- Material Icons는 비동기 로드 -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /></noscript>
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" /> <link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<script> <script>
window.taxbaikAdminBuildVersion = 'unknown'; window.taxbaikAdminBuildVersion = 'unknown';
@@ -0,0 +1,18 @@
@using MudBlazor
<MudDialog>
<DialogContent>
<MudText>정말로 삭제하시겠습니까?</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="@Cancel">취소</MudButton>
<MudButton Color="Color.Error" OnClick="@Confirm">삭제</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] MudDialogInstance? MudDialog { get; set; }
void Cancel() => MudDialog?.Cancel();
void Confirm() => MudDialog?.Close(DialogResult.Ok(true));
}
@@ -1,23 +1,23 @@
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
<MudForm @ref="form"> <MudForm @ref="form">
<MudTextField @bind-value="model.CompanyCode" Label="회사 코드" <MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
Variant="Variant.Outlined" Class="mb-4" Required="true" Variant="Variant.Outlined" Class="mb-4" Required="true"
HelperText="영문/숫자, 최대 50자" /> HelperText="영문/숫자, 최대 50자" />
<MudTextField @bind-value="model.CompanyName" Label="회사명" <MudTextField @bind-Value="model.CompanyName" Label="회사명"
Variant="Variant.Outlined" Class="mb-4" Required="true" /> Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-value="model.ContactPerson" Label="담당자명" <MudTextField @bind-Value="model.ContactPerson" Label="담당자명"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="model.Phone" Label="전화번호" <MudTextField @bind-Value="model.Phone" Label="전화번호"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="model.Email" Label="이메일" <MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" /> Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudTextField @bind-value="model.Memo" Label="메모" <MudTextField @bind-Value="model.Memo" Label="메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" /> Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" /> <MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
@@ -1,28 +1,28 @@
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
<MudForm @ref="form"> <MudForm @ref="form">
<AdminFormSection Title="연락처" Description="고객 식별과 기본 회신 정보입니다." CssClass="mb-4"> <AdminFormSection Title="연락처" Description="고객 식별과 기본 회신 정보입니다." CssClass="mb-4">
<MudTextField @bind-value="model.Name" Label="이름" <MudTextField @bind-Value="model.Name" Label="이름"
Variant="Variant.Outlined" Class="mb-4" Required="true" ReadOnly="@IsEditMode" /> Variant="Variant.Outlined" Class="mb-4" Required="true" ReadOnly="@IsEditMode" />
<MudTextField @bind-value="model.Phone" Label="전화번호 (예: 010-1234-5678)" <MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
Variant="Variant.Outlined" Class="mb-4" Required="true" ReadOnly="@IsEditMode" /> Variant="Variant.Outlined" Class="mb-4" Required="true" ReadOnly="@IsEditMode" />
<MudTextField @bind-value="model.Email" Label="이메일" <MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" ReadOnly="@IsEditMode" /> Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" ReadOnly="@IsEditMode" />
</AdminFormSection> </AdminFormSection>
<AdminFormSection Title="문의 내용" Description="@(IsEditMode ? "상태와 메모만 변경 가능합니다." : "운영 분류와 처리 메모를 함께 관리합니다.")" CssClass="mb-4"> <AdminFormSection Title="문의 내용" Description="@(IsEditMode ? "상태와 메모만 변경 가능합니다." : "운영 분류와 처리 메모를 함께 관리합니다.")" CssClass="mb-4">
<CommonCodeSelect @bind-value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형 *" Class="mb-4" Required="true" Disabled="@IsEditMode" Placeholder="문의 유형을 선택하세요" /> <CommonCodeSelect @bind-Value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형 *" Class="mb-4" Required="true" Disabled="@IsEditMode" Placeholder="문의 유형을 선택하세요" />
<MudTextField @bind-value="model.Message" Label="문의 내용" <MudTextField @bind-Value="model.Message" Label="문의 내용"
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" ReadOnly="@IsEditMode" /> Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" ReadOnly="@IsEditMode" />
<CommonCodeSelect @bind-value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" Clearable="true" Placeholder="상태를 선택하세요" /> <CommonCodeSelect @bind-Value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" Clearable="true" Placeholder="상태를 선택하세요" />
<MudTextField @bind-value="model.AdminMemo" Label="관리 메모" <MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" /> Variant="Variant.Outlined" Lines="3" Class="mb-4" />
</AdminFormSection> </AdminFormSection>
@@ -4,7 +4,7 @@
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject IAnnouncementBrowserClient AnnouncementClient @inject IAnnouncementBrowserClient AnnouncementClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -22,7 +22,7 @@
<MudForm @ref="form"> <MudForm @ref="form">
<MudGrid> <MudGrid>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField @bind-value="model.Title" <MudTextField @bind-Value="model.Title"
Label="제목" Label="제목"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Required="true" Required="true"
@@ -31,7 +31,7 @@
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField @bind-value="model.Content" <MudTextField @bind-Value="model.Content"
Label="상세 내용 (선택)" Label="상세 내용 (선택)"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Lines="3" Lines="3"
@@ -39,14 +39,14 @@
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<CommonCodeSelect @bind-value="model.DisplayType" <CommonCodeSelect @bind-Value="model.DisplayType"
Group="ANNOUNCEMENT_DISPLAY_TYPE" Group="ANNOUNCEMENT_DISPLAY_TYPE"
Label="유형" Label="유형"
Class="mb-0" /> Class="mb-0" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudNumericField @bind-value="model.SortOrder" <MudNumericField @bind-Value="model.SortOrder"
Label="노출 순서" Label="노출 순서"
Variant="Variant.Outlined" Variant="Variant.Outlined"
HelperText="숫자가 클수록 먼저 표시됩니다." /> HelperText="숫자가 클수록 먼저 표시됩니다." />
@@ -24,7 +24,7 @@
</section> </section>
<div class="d-flex pa-4 gap-4 align-center"> <div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start" <MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" /> AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div> </div>
@@ -2,7 +2,7 @@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Components.Admin.Pages.Blog @using TaxBaik.WasmClient.Components.Admin.Pages.Blog
@inject IBlogBrowserClient BlogClient @inject IBlogBrowserClient BlogClient
@inject ICategoryBrowserClient CategoryClient @inject ICategoryBrowserClient CategoryClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -2,7 +2,7 @@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Components.Admin.Pages.Blog @using TaxBaik.WasmClient.Components.Admin.Pages.Blog
@inject IBlogBrowserClient BlogClient @inject IBlogBrowserClient BlogClient
@inject ICategoryBrowserClient CategoryClient @inject ICategoryBrowserClient CategoryClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -3,10 +3,10 @@
<MudForm @ref="form"> <MudForm @ref="form">
<AdminFormSection Title="기본 정보" Description="제목과 카테고리, 발행 여부를 먼저 설정합니다." CssClass="mb-4"> <AdminFormSection Title="기본 정보" Description="제목과 카테고리, 발행 여부를 먼저 설정합니다." CssClass="mb-4">
<MudTextField @bind-value="Model.Title" Label="제목 *" <MudTextField @bind-Value="Model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" /> Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-value="Model.CategoryId" Label="카테고리 (선택 사항)" <MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리 (선택 사항)"
Variant="Variant.Outlined" Class="mb-4" Clearable="true"> Variant="Variant.Outlined" Class="mb-4" Clearable="true">
<MudSelectItem Value="@((int?)null)">분류 없음</MudSelectItem> <MudSelectItem Value="@((int?)null)">분류 없음</MudSelectItem>
@foreach (var category in Categories) @foreach (var category in Categories)
@@ -19,17 +19,17 @@
</AdminFormSection> </AdminFormSection>
<AdminFormSection Title="본문" Description="SEO와 실제 노출 본문을 함께 관리합니다." CssClass="mb-4"> <AdminFormSection Title="본문" Description="SEO와 실제 노출 본문을 함께 관리합니다." CssClass="mb-4">
<MudTextField @bind-value="Model.Content" Label="본문 내용 *" <MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요." Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
Class="mb-4" /> Class="mb-4" />
<MudTextField @bind-value="Model.Tags" Label="태그 (쉼표로 구분)" <MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="Model.SeoTitle" Label="SEO 제목" <MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="Model.SeoDescription" Label="SEO 설명" <MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" /> Variant="Variant.Outlined" Lines="3" Class="mb-4" />
</AdminFormSection> </AdminFormSection>
@@ -22,7 +22,7 @@
</AdminPageHeader> </AdminPageHeader>
<div class="d-flex pa-4 gap-4 align-center"> <div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start" <MudTextField @bind-Value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" /> AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div> </div>
@@ -2,8 +2,8 @@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject IConsultingActivityBrowserClient ConsultingClient @inject IConsultingActivityBrowserClient ConsultingClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -105,14 +105,14 @@
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" /> <MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<CommonCodeSelect @bind-value="newServiceType" Group="CONSULTING_ACTIVITY_TYPE" Label="서비스 분야" Placeholder="선택" Clearable="true" /> <CommonCodeSelect @bind-Value="newServiceType" Group="CONSULTING_ACTIVITY_TYPE" Label="서비스 분야" Placeholder="선택" Clearable="true" />
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField T="string" @bind-value="newSummary" Label="상담 내용 *" <MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
Lines="3" Variant="Variant.Outlined" Required="true" /> Lines="3" Variant="Variant.Outlined" Required="true" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudSelect T="string" @bind-value="newResult" Label="결과"> <MudSelect T="string" @bind-Value="newResult" Label="결과">
<MudSelectItem Value="@("")">-</MudSelectItem> <MudSelectItem Value="@("")">-</MudSelectItem>
@foreach (var r in results) @foreach (var r in results)
{ {
@@ -121,7 +121,7 @@
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudNumericField T="decimal?" @bind-value="newFee" Label="수임료 (원)" <MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
Format="N0" /> Format="N0" />
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@@ -5,7 +5,7 @@
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -35,18 +35,18 @@
<MudDivider /> <MudDivider />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudTextField @bind-value="dto.Name" Label="고객명 *" Required="true" <MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true"
RequiredError="고객명을 입력하세요." /> RequiredError="고객명을 입력하세요." />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudTextField @bind-value="dto.CompanyName" Label="회사명 (선택)" /> <MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudTextField @bind-value="dto.Phone" Label="연락처" <MudTextField @bind-Value="dto.Phone" Label="연락처"
Placeholder="010-0000-0000" /> Placeholder="010-0000-0000" />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudTextField @bind-value="dto.Email" Label="이메일" InputType="InputType.Email" /> <MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" />
</MudItem> </MudItem>
@* 세무 정보 *@ @* 세무 정보 *@
@@ -55,10 +55,10 @@
<MudDivider /> <MudDivider />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<CommonCodeSelect @bind-value="dto.ServiceType" Group="CLIENT_SERVICE_TYPE" Label="서비스 유형" Clearable="true" /> <CommonCodeSelect @bind-Value="dto.ServiceType" Group="CLIENT_SERVICE_TYPE" Label="서비스 유형" Clearable="true" />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<CommonCodeSelect @bind-value="dto.TaxType" Group="CLIENT_TAX_TYPE" Label="세금 유형" Clearable="true" /> <CommonCodeSelect @bind-Value="dto.TaxType" Group="CLIENT_TAX_TYPE" Label="세금 유형" Clearable="true" />
</MudItem> </MudItem>
@* 관리 정보 *@ @* 관리 정보 *@
@@ -67,13 +67,13 @@
<MudDivider /> <MudDivider />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<CommonCodeSelect @bind-value="dto.Status" Group="CLIENT_STATUS" Label="상태 *" Required="true" /> <CommonCodeSelect @bind-Value="dto.Status" Group="CLIENT_STATUS" Label="상태 *" Required="true" />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<CommonCodeSelect @bind-value="dto.Source" Group="CLIENT_SOURCE" Label="유입 경로" Clearable="true" /> <CommonCodeSelect @bind-Value="dto.Source" Group="CLIENT_SOURCE" Label="유입 경로" Clearable="true" />
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField @bind-value="dto.Memo" Label="메모" <MudTextField @bind-Value="dto.Memo" Label="메모"
Lines="4" AutoGrow="true" Lines="4" AutoGrow="true"
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" /> Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
</MudItem> </MudItem>
@@ -24,12 +24,12 @@
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0"> <MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
<MudGrid> <MudGrid>
<MudItem xs="12" md="5"> <MudItem xs="12" md="5">
<MudTextField @bind-value="searchText" Label="검색 (이름·연락처·회사명)" <MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
Immediate="false" OnKeyUp="@OnSearchKeyUp" /> Immediate="false" OnKeyUp="@OnSearchKeyUp" />
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="3">
<CommonCodeSelect @bind-value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" /> <CommonCodeSelect @bind-Value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
</MudItem> </MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center"> <MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton> <MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
@@ -1,6 +1,6 @@
@page "/admin/common-codes" @page "/admin/common-codes"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@attribute [Authorize] @attribute [Authorize]
@inject ICommonCodeBrowserClient CommonCodeClient @inject ICommonCodeBrowserClient CommonCodeClient
@@ -1,7 +1,7 @@
@page "/admin/companies/create" @page "/admin/companies/create"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.WasmClient.Components.Admin.Forms
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -1,7 +1,7 @@
@page "/admin/companies/{id:int}/edit" @page "/admin/companies/{id:int}/edit"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.WasmClient.Components.Admin.Forms
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -1,7 +1,7 @@
@page "/admin/consulting-activities" @page "/admin/consulting-activities"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject IConsultingActivityBrowserClient ActivityClient @inject IConsultingActivityBrowserClient ActivityClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -98,15 +98,15 @@
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int" @bind-value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4"> <MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
} }
</MudSelect> </MudSelect>
<CommonCodeSelect @bind-value="activityForm.ActivityType" Group="CONSULTING_ACTIVITY_TYPE" Label="활동 유형" Class="mb-4" Required="true" /> <CommonCodeSelect @bind-Value="activityForm.ActivityType" Group="CONSULTING_ACTIVITY_TYPE" Label="활동 유형" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" /> <MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" /> <MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm> </MudForm>
</DialogContent> </DialogContent>
@@ -1,7 +1,7 @@
@page "/admin/contracts" @page "/admin/contracts"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject IContractBrowserClient ContractClient @inject IContractBrowserClient ContractClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -117,16 +117,16 @@ else
} }
</div> </div>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int?" @bind-value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode"> <MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField T="string" @bind-value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" /> <MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<CommonCodeSelect @bind-value="contractForm.ServiceType" Group="CONTRACT_SERVICE_TYPE" Label="서비스 유형" Class="mb-3" Required="true" /> <CommonCodeSelect @bind-Value="contractForm.ServiceType" Group="CONTRACT_SERVICE_TYPE" Label="서비스 유형" Class="mb-3" Required="true" />
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" /> <MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudNumericField T="decimal?" @bind-value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" /> <MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" />
<div class="d-flex justify-end gap-2"> <div class="d-flex justify-end gap-2">
@if (isEditMode) @if (isEditMode)
@@ -2,8 +2,7 @@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@using TaxBaik.Application.Services
@inject IAdminDashboardClient DashboardClient @inject IAdminDashboardClient DashboardClient
@inject NavigationManager Nav @inject NavigationManager Nav
@@ -24,7 +24,7 @@
<MudForm @ref="form" @bind-IsValid="isValid"> <MudForm @ref="form" @bind-IsValid="isValid">
<MudGrid Spacing="3"> <MudGrid Spacing="3">
<MudItem xs="12"> <MudItem xs="12">
<MudTextField @bind-value="faq.Question" <MudTextField @bind-Value="faq.Question"
Label="질문 *" Required="true" Label="질문 *" Required="true"
RequiredError="질문을 입력하세요." RequiredError="질문을 입력하세요."
Counter="300" MaxLength="300" Counter="300" MaxLength="300"
@@ -32,23 +32,23 @@
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" /> Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField @bind-value="faq.Answer" <MudTextField @bind-Value="faq.Answer"
Label="답변 *" Required="true" Label="답변 *" Required="true"
RequiredError="답변을 입력하세요." RequiredError="답변을 입력하세요."
Lines="5" AutoGrow="true" Lines="5" AutoGrow="true"
Placeholder="방문자에게 보여질 답변을 입력하세요." /> Placeholder="방문자에게 보여질 답변을 입력하세요." />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<CommonCodeSelect @bind-value="faq.Category" Group="FAQ_CATEGORY" Label="카테고리" Clearable="true" Placeholder="전체" /> <CommonCodeSelect @bind-Value="faq.Category" Group="FAQ_CATEGORY" Label="카테고리" Clearable="true" Placeholder="전체" />
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="3">
<MudNumericField @bind-value="faq.SortOrder" <MudNumericField @bind-Value="faq.SortOrder"
Label="정렬 순서" Label="정렬 순서"
HelperText="작을수록 위에 노출" HelperText="작을수록 위에 노출"
Min="0" Max="9999" /> Min="0" Max="9999" />
</MudItem> </MudItem>
<MudItem xs="12" md="3" Class="d-flex align-center"> <MudItem xs="12" md="3" Class="d-flex align-center">
<MudSwitch T="bool" @bind-value="faq.IsActive" Color="Color.Success" <MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
Label="@(faq.IsActive ? "노출 중" : "비활성")" /> Label="@(faq.IsActive ? "노출 중" : "비활성")" />
</MudItem> </MudItem>
@@ -24,7 +24,7 @@
</section> </section>
<div class="d-flex pa-4 gap-4 align-center"> <div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start" <MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" /> AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div> </div>
@@ -2,7 +2,7 @@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.WasmClient.Components.Admin.Forms
@inject IInquiryBrowserClient InquiryClient @inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -60,7 +60,7 @@
</AdminDetailSection> </AdminDetailSection>
<AdminDetailSection Title="담당자 메모" CssClass="pa-4 mt-4"> <AdminDetailSection Title="담당자 메모" CssClass="pa-4 mt-4">
<MudTextField T="string" @bind-value="adminMemo" Label="내부 메모 (고객에게 미노출)" <MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)"
Lines="4" Variant="Variant.Outlined" /> Lines="4" Variant="Variant.Outlined" />
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary" <MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
OnClick="SaveMemo">메모 저장</MudButton> OnClick="SaveMemo">메모 저장</MudButton>
@@ -2,7 +2,7 @@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.WasmClient.Components.Admin.Forms
@inject IInquiryBrowserClient InquiryClient @inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -1,5 +1,5 @@
@page "/admin/login" @page "/admin/login"
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout @layout TaxBaik.WasmClient.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
<PageTitle>로그인</PageTitle> <PageTitle>로그인</PageTitle>
@@ -1,7 +1,7 @@
@page "/admin/revenue-trackings" @page "/admin/revenue-trackings"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject IRevenueTrackingBrowserClient RevenueClient @inject IRevenueTrackingBrowserClient RevenueClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -94,16 +94,16 @@
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int" @bind-value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4"> <MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField T="string" @bind-value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal" @bind-value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<CommonCodeSelect @bind-value="revenueForm.ServiceType" Group="REVENUE_SERVICE_TYPE" Label="서비스 유형" Class="mb-4" /> <CommonCodeSelect @bind-Value="revenueForm.ServiceType" Group="REVENUE_SERVICE_TYPE" Label="서비스 유형" Class="mb-4" />
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" /> <MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm> </MudForm>
</DialogContent> </DialogContent>
@@ -30,16 +30,16 @@
</div> </div>
</div> </div>
<MudForm> <MudForm>
<MudTextField @bind-value="phone" Label="전화번호" <MudTextField @bind-Value="phone" Label="전화번호"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="email" Label="이메일" <MudTextField @bind-Value="email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="kakaoUrl" Label="카카오채널 URL" <MudTextField @bind-Value="kakaoUrl" Label="카카오채널 URL"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="instagramUrl" Label="인스타그램" <MudTextField @bind-Value="instagramUrl" Label="인스타그램"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" <MudButton Variant="Variant.Filled" Color="Color.Primary"
@@ -59,13 +59,13 @@
</div> </div>
<MudForm> <MudForm>
<MudTextField @bind-value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password" <MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="newPassword" Label="새 비밀번호" InputType="InputType.Password" <MudTextField @bind-Value="newPassword" Label="새 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password" <MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" <MudButton Variant="Variant.Filled" Color="Color.Primary"
@@ -1,8 +1,8 @@
@page "/admin/tax-filing-schedules" @page "/admin/tax-filing-schedules"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject ITaxFilingScheduleBrowserClient TaxFilingClient @inject ITaxFilingScheduleBrowserClient TaxFilingClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -130,7 +130,7 @@ else
</div> </div>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int?" <MudSelect T="int?"
@bind-value="scheduleForm.ClientId" @bind-Value="scheduleForm.ClientId"
Label="고객" Label="고객"
Required="true" Required="true"
Variant="Variant.Outlined" Variant="Variant.Outlined"
@@ -143,9 +143,9 @@ else
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
} }
</MudSelect> </MudSelect>
<CommonCodeSelect @bind-value="scheduleForm.FilingType" Group="FILING_TYPE" Label="신고 유형" Class="mb-3" Required="true" /> <CommonCodeSelect @bind-Value="scheduleForm.FilingType" Group="FILING_TYPE" Label="신고 유형" Class="mb-3" Required="true" />
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" /> <MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudNumericField T="int" @bind-value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" Required="true" /> <MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" Required="true" />
<div class="d-flex justify-end gap-2"> <div class="d-flex justify-end gap-2">
@if (isEditMode && selectedSchedule?.Status != "completed") @if (isEditMode && selectedSchedule?.Status != "completed")
@@ -1,6 +1,6 @@
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject ITaxFilingBrowserClient FilingClient @inject ITaxFilingBrowserClient FilingClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -3,7 +3,7 @@
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject ITaxFilingBrowserClient FilingClient @inject ITaxFilingBrowserClient FilingClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -29,20 +29,20 @@
<MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText> <MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText>
<MudGrid Spacing="2"> <MudGrid Spacing="2">
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
<MudAutocomplete T="Domain.Entities.Client" @bind-value="selectedClient" <MudAutocomplete T="Domain.Entities.Client" @bind-Value="selectedClient"
Label="고객 검색 *" Label="고객 검색 *"
SearchFunc="SearchClients" SearchFunc="SearchClients"
ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")" ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")"
Variant="Variant.Outlined" /> Variant="Variant.Outlined" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
<CommonCodeSelect @bind-value="newFilingType" Group="FILING_TYPE" Label="신고 유형 *" /> <CommonCodeSelect @bind-Value="newFilingType" Group="FILING_TYPE" Label="신고 유형 *" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" /> <MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField T="string" @bind-value="newMemo" Label="메모" Variant="Variant.Outlined" /> <MudTextField T="string" @bind-Value="newMemo" Label="메모" Variant="Variant.Outlined" />
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<MudStack Row="true" Class="mt-3" Spacing="2"> <MudStack Row="true" Class="mt-3" Spacing="2">
@@ -1,7 +1,7 @@
@page "/admin/tax-profiles" @page "/admin/tax-profiles"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared @using TaxBaik.WasmClient.Components.Admin.Shared
@inject ITaxProfileBrowserClient TaxProfileClient @inject ITaxProfileBrowserClient TaxProfileClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -95,16 +95,16 @@ else
} }
</div> </div>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int?" @bind-value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode"> <MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
} }
</MudSelect> </MudSelect>
<CommonCodeSelect @bind-value="profileForm.BusinessType" Group="BUSINESS_TYPE" Label="사업 유형" Class="mb-3" Required="true" /> <CommonCodeSelect @bind-Value="profileForm.BusinessType" Group="BUSINESS_TYPE" Label="사업 유형" Class="mb-3" Required="true" />
<CommonCodeSelect @bind-value="profileForm.TaxRiskLevel" Group="TAX_RISK_LEVEL" Label="위험도" Class="mb-3" /> <CommonCodeSelect @bind-Value="profileForm.TaxRiskLevel" Group="TAX_RISK_LEVEL" Label="위험도" Class="mb-3" />
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" /> <MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" />
<MudTextField T="string" @bind-value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="@true" Lines="3" Class="mb-4" /> <MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="@true" Lines="3" Class="mb-4" />
<div class="d-flex justify-end gap-2"> <div class="d-flex justify-end gap-2">
@if (isEditMode) @if (isEditMode)
@@ -1,9 +1,9 @@
@namespace TaxBaik.Web.Components.Admin @namespace TaxBaik.WasmClient.Components.Admin
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
<Router AppAssembly="typeof(TaxBaik.Web.Components.Admin.Pages.Dashboard).Assembly"> <Router AppAssembly="@typeof(TaxBaik.WasmClient._Imports).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.WasmClient.Components.Admin.Layout.MainLayout)">
<NotAuthorized> <NotAuthorized>
<RedirectToLogin /> <RedirectToLogin />
</NotAuthorized> </NotAuthorized>
@@ -12,7 +12,7 @@
</Found> </Found>
<NotFound> <NotFound>
<PageTitle>찾을 수 없음</PageTitle> <PageTitle>찾을 수 없음</PageTitle>
<LayoutView Layout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)"> <LayoutView Layout="@typeof(TaxBaik.WasmClient.Components.Admin.Layout.MainLayout)">
<p>요청한 페이지를 찾을 수 없습니다.</p> <p>요청한 페이지를 찾을 수 없습니다.</p>
</LayoutView> </LayoutView>
</NotFound> </NotFound>
@@ -0,0 +1,118 @@
namespace TaxBaik.Web.Client.Components.Admin.Services.AdminClients;
using System.Collections.Generic;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
using Microsoft.Extensions.Logging;
public interface ICommonCodeBrowserClient
{
Task<List<string>> GetGroupsAsync(CancellationToken ct = default);
Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default);
Task<CommonCode?> GetAsync(string group, string value, CancellationToken ct = default);
Task<bool> UpsertAsync(CommonCode code, CancellationToken ct = default);
Task<bool> DeleteAsync(string group, string value, CancellationToken ct = default);
}
public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<CommonCodeBrowserClient> logger) : ICommonCodeBrowserClient
{
private const string BaseUrl = "/api/commoncode";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get all active common codes");
return [];
}
}
public async Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}/group/{group}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get common codes for group {Group}", group);
return [];
}
}
public async Task<List<string>> GetGroupsAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<string>>($"{BaseUrl}/groups", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get common code groups");
return [];
}
}
public async Task<CommonCode?> GetAsync(string group, string value, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<CommonCode>($"{BaseUrl}/{group}/{value}", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get common code {Group}/{Value}", group, value);
return null;
}
}
public async Task<bool> UpsertAsync(CommonCode code, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.PostAsJsonAsync(BaseUrl, code, ct);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to upsert common code {Group}/{Value}", code.CodeGroup, code.CodeValue);
return false;
}
}
public async Task<bool> DeleteAsync(string group, string value, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{group}/{value}", ct);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete common code {Group}/{Value}", group, value);
return false;
}
}
}
@@ -0,0 +1,122 @@
namespace TaxBaik.Web.Client.Components.Admin.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface IConsultingActivityBrowserClient
{
Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default);
Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
: IConsultingActivityBrowserClient
{
private const string BaseUrl = "/api/consultingactivity";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get consulting activities");
return [];
}
}
public async Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get activities for client {ClientId}", clientId);
return [];
}
}
public async Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get pending followups");
return [];
}
}
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create consulting activity");
return 0;
}
}
public async Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { outcome, nextFollowupDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update consulting activity {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete consulting activity {Id}", id);
}
}
}
@@ -0,0 +1,157 @@
namespace TaxBaik.Web.Client.Components.Admin.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface IContractBrowserClient
{
Task<List<Contract>> GetAllAsync(CancellationToken ct = default);
Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default);
Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default);
Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default);
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
: IContractBrowserClient
{
private const string BaseUrl = "/api/contract";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get contracts");
return [];
}
}
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get contract {Id}", id);
return null;
}
}
public async Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get contracts for client {ClientId}", clientId);
return [];
}
}
public async Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get active contracts");
return [];
}
}
public async Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get expiring contracts");
return [];
}
}
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
if (response.TryGetProperty("mrr", out var mrrValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get MRR");
return 0;
}
}
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create contract");
return 0;
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete contract {Id}", id);
}
}
}
@@ -0,0 +1,159 @@
namespace TaxBaik.Web.Client.Components.Admin.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface IRevenueTrackingBrowserClient
{
Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default);
Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default);
Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default);
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default);
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
: IRevenueTrackingBrowserClient
{
private const string BaseUrl = "/api/revenuetracking";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get revenue tracking");
return [];
}
}
public async Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get revenue for client {ClientId}", clientId);
return [];
}
}
public async Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get pending payments");
return [];
}
}
public async Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get monthly revenue {Year}-{Month}", year, month);
return [];
}
}
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>(
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
if (response.TryGetProperty("total", out var totalValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(totalValue.GetRawText());
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get total revenue");
return 0;
}
}
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create revenue tracking");
return 0;
}
}
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { paymentDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to mark payment {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete revenue tracking {Id}", id);
}
}
}
@@ -0,0 +1,136 @@
namespace TaxBaik.Web.Client.Components.Admin.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface ITaxFilingScheduleBrowserClient
{
Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default);
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default);
Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
int? assignedTo = null, CancellationToken ct = default);
Task MarkCompletedAsync(int id, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
: ITaxFilingScheduleBrowserClient
{
private const string BaseUrl = "/api/taxfilingschedule";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax filing schedules");
return [];
}
}
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax filing schedule {Id}", id);
return null;
}
}
public async Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get filing schedules for client {ClientId}", clientId);
return [];
}
}
public async Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get upcoming filings");
return [];
}
}
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
int? assignedTo = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create tax filing schedule");
return 0;
}
}
public async Task MarkCompletedAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to mark filing as completed {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete tax filing schedule {Id}", id);
}
}
}
@@ -0,0 +1,156 @@
namespace TaxBaik.Web.Client.Components.Admin.Services.AdminClients;
using System.Text.Json;
using TaxBaik.Domain.Entities;
public interface ITaxProfileBrowserClient
{
Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default);
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default);
Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default);
Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default);
Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
{
private const string BaseUrl = "/api/taxprofile";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax profiles");
return [];
}
}
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax profile {Id}", id);
return null;
}
}
public async Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get tax profiles for client {ClientId}", clientId);
return [];
}
}
public async Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get high-risk profiles");
return [];
}
}
public async Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
return [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get upcoming filings");
return [];
}
}
public async Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create tax profile");
return 0;
}
}
public async Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update tax profile {Id}", id);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete tax profile {Id}", id);
}
}
}
@@ -0,0 +1,119 @@
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Client.Components.Admin.Services;
/// <summary>
/// Admin Dashboard API Client
/// SOLID: Single Responsibility - Dashboard API 호출만 담당
/// Dependency Inversion - 추상화된 인터페이스 사용
/// </summary>
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);
}
public class AdminDashboardClient : IAdminDashboardClient
{
private readonly HttpClient _http;
private readonly ILogger<AdminDashboardClient> _logger;
private readonly ITokenStore _tokenStore;
public AdminDashboardClient(HttpClient http, ILogger<AdminDashboardClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<AdminDashboardSummary>(
"admin-dashboard/summary", cancellationToken: ct);
return result ?? new(0, 0, 0, 0, []);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch dashboard summary");
throw;
}
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingFilingsAsync(int days = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<ApiResponse<TaxFiling>>(
$"admin-dashboard/upcoming-filings?days={days}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch upcoming filings");
throw;
}
}
public async Task<IEnumerable<Inquiry>> GetRecentInquiriesAsync(int limit = 10, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<ApiResponse<Inquiry>>(
$"admin-dashboard/recent-inquiries?limit={limit}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch recent inquiries");
throw;
}
}
public async Task<object> GetMonthlyStatsAsync(string? month = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var url = "admin-dashboard/monthly-stats";
if (!string.IsNullOrEmpty(month))
url += $"?month={month}";
var result = await _http.GetFromJsonAsync<object>(url, cancellationToken: ct);
return result ?? new();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch monthly stats");
throw;
}
}
}
/// <summary>
/// API Response wrapper
/// </summary>
internal class ApiResponse<T>
{
public IEnumerable<T>? Data { get; set; }
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
@@ -0,0 +1,126 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
public interface IAnnouncementBrowserClient
{
Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default);
Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Announcement?> CreateAsync(AnnouncementDto dto, CancellationToken ct = default);
Task<Announcement?> UpdateAsync(int id, AnnouncementDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class AnnouncementBrowserClient : IAnnouncementBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<AnnouncementBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public AnnouncementBrowserClient(HttpClient http, ILogger<AnnouncementBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<AnnouncementListResponse>("announcement", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch announcements");
throw;
}
}
public async Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Announcement>($"announcement/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch announcement {AnnouncementId}", id);
throw;
}
}
public async Task<Announcement?> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("announcement", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Announcement>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create announcement");
throw;
}
}
public async Task<Announcement?> UpdateAsync(int id, AnnouncementDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"announcement/{id}", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Announcement>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update announcement {AnnouncementId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"announcement/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete announcement {AnnouncementId}", id);
throw;
}
}
private class AnnouncementListResponse
{
public List<Announcement> Data { get; set; } = [];
}
}
@@ -0,0 +1,110 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using Microsoft.AspNetCore.Components;
using System.Text.Json;
public interface IApiClient
{
Task<T?> GetAsync<T>(string endpoint);
Task<T?> PostAsync<T>(string endpoint, object data);
Task<T?> PutAsync<T>(string endpoint, object data);
Task DeleteAsync(string endpoint);
Task SetAuthToken(string? token);
}
public class ApiClient : IApiClient
{
private readonly HttpClient _httpClient;
private readonly NavigationManager _navigationManager;
private string? _authToken;
public ApiClient(HttpClient httpClient, NavigationManager navigationManager)
{
_httpClient = httpClient;
_navigationManager = navigationManager;
}
public async Task SetAuthToken(string? token)
{
_authToken = token;
if (token != null)
_httpClient.DefaultRequestHeaders.Authorization = new("Bearer", token);
else
_httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<T?> GetAsync<T>(string endpoint)
{
try
{
var response = await _httpClient.GetAsync(BuildApiUri(endpoint));
if (!response.IsSuccessStatusCode)
return default;
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return default;
}
}
public async Task<T?> PostAsync<T>(string endpoint, object data)
{
try
{
var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(BuildApiUri(endpoint), content);
if (!response.IsSuccessStatusCode)
return default;
var responseContent = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return default;
}
}
public async Task<T?> PutAsync<T>(string endpoint, object data)
{
try
{
var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PutAsync(BuildApiUri(endpoint), content);
if (!response.IsSuccessStatusCode)
return default;
var responseContent = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return default;
}
}
public async Task DeleteAsync(string endpoint)
{
try
{
await _httpClient.DeleteAsync(BuildApiUri(endpoint));
}
catch
{
// Ignore
}
}
private Uri BuildApiUri(string endpoint)
{
var relative = $"api/{endpoint.TrimStart('/')}";
return new Uri(new Uri(_navigationManager.BaseUri), relative);
}
}
@@ -0,0 +1,104 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
public interface IBlogBrowserClient
{
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default);
Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default);
Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
Task<bool> RestoreAsync(int id, CancellationToken ct = default);
Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
}
public class BlogBrowserClient : IBlogBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<BlogBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public BlogBrowserClient(HttpClient http, ILogger<BlogBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin?page={page}&pageSize={pageSize}", ct);
return result != null ? (result.Data, result.Total) : ([], 0);
}
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin/archived?page={page}&pageSize={pageSize}", ct);
return result != null ? (result.Data, result.Total) : ([], 0);
}
public async Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default)
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<BlogPostResponseDto>($"blog/{id}", ct);
}
public async Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("blog", dto, ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
public async Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"blog/{id}", dto, ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"blog/{id}", ct);
return response.IsSuccessStatusCode;
}
public async Task<bool> RestoreAsync(int id, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.PostAsync($"blog/{id}/restore", null, ct);
return response.IsSuccessStatusCode;
}
public async Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
{
var result = await UpdateAsync(id, dto, ct);
return result != null;
}
private sealed class PagedResponse
{
public List<BlogPostResponseDto> Data { get; set; } = [];
public int Total { get; set; }
}
}
@@ -0,0 +1,35 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net.Http.Json;
using TaxBaik.Domain.Entities;
public interface ICategoryBrowserClient
{
Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default);
}
public class CategoryBrowserClient : ICategoryBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<CategoryBrowserClient> _logger;
public CategoryBrowserClient(HttpClient http, ILogger<CategoryBrowserClient> logger)
{
_http = http;
_logger = logger;
}
public async Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default)
{
try
{
var result = await _http.GetFromJsonAsync<List<Category>>("category", cancellationToken: ct);
return result ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch categories");
throw;
}
}
}
@@ -0,0 +1,141 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
/// <summary>
/// Client API Client for Admin Blazor
/// SOLID: Single Responsibility - Client API calls only
/// </summary>
public interface IClientBrowserClient
{
Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
int page = 1, int pageSize = 20, string? status = null, string? search = null, CancellationToken ct = default);
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Client?> CreateAsync(CreateClientDto dto, CancellationToken ct = default);
Task<Client?> UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class ClientBrowserClient : IClientBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<ClientBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public ClientBrowserClient(HttpClient http, ILogger<ClientBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
int page = 1, int pageSize = 20, string? status = null, string? search = null, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var query = $"client?page={page}&pageSize={pageSize}";
if (!string.IsNullOrEmpty(status))
query += $"&status={status}";
if (!string.IsNullOrEmpty(search))
query += $"&search={Uri.EscapeDataString(search)}";
var result = await _http.GetFromJsonAsync<ClientPagedResponse>(query, cancellationToken: ct);
return result != null ? (result.Data, result.Total) : ([], 0);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch clients");
throw;
}
}
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Client>($"client/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch client {ClientId}", id);
throw;
}
}
public async Task<Client?> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("client", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Client>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create client");
throw;
}
}
public async Task<Client?> UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"client/{id}", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Client>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update client {ClientId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"client/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete client {ClientId}", id);
throw;
}
}
private class ClientPagedResponse
{
public List<Client> Data { get; set; } = [];
public int Total { get; set; }
}
}
@@ -0,0 +1,221 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Client.Components.Admin.Services;
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly ITokenStore _tokenStore;
private readonly IApiClient _apiClient;
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
public CustomAuthenticationStateProvider(
ILocalStorageService localStorage,
ITokenStore tokenStore,
IApiClient apiClient,
ILogger<CustomAuthenticationStateProvider> logger)
{
_localStorage = localStorage;
_tokenStore = tokenStore;
_apiClient = apiClient;
_logger = logger;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var accessToken = _tokenStore.AccessToken;
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken))
{
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken))
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (TryNormalizeExpiryTicks(ticksStr, out var ticks))
{
_tokenStore.AccessToken = storedToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
}
}
}
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
// 토큰이 만료되면 로그아웃
if (_tokenStore.IsAccessTokenExpired())
{
_logger.LogWarning("Access token 만료됨 - 자동 로그아웃");
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
// 토큰이 5분 이내로 만료되면 자동 갱신 시도 (사용자 경험 향상)
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
{
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
var request = new { RefreshToken = _tokenStore.RefreshToken };
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
{
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
_logger.LogInformation("토큰 자동 갱신 성공");
accessToken = newTokenPair.AccessToken;
}
else
{
_logger.LogWarning("토큰 자동 갱신 실패 - 로그아웃");
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
}
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
if (principal == null)
{
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
return new AuthenticationState(principal);
}
catch (Exception ex)
{
_logger.LogError(ex, "인증 상태 조회 중 오류 발생");
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
}
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
return new ClaimsPrincipal(identity);
}
catch
{
return null;
}
}
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
{
// 토큰 만료 시간을 .NET ticks로 계산 (JavaScript와 일치)
// JavaScript: 621355968000000000 + ((Date.now() + expiresIn*1000) * 10000)
// 이를 C#로 변환하면 DateTime.UtcNow.AddSeconds(expiresIn).Ticks
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
// TokenStore에 저장 (DelegatingHandler에서 사용)
_tokenStore.AccessToken = accessToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = tokenExpiryTicks;
// localStorage에도 저장 (페이지 리로드 후 복원)
await _localStorage.SetItemAsStringAsync("accessToken", accessToken);
await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken);
await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString());
// Blazor에 인증 상태 변경을 알림 - 이 호출 자체는 async이지만 fire-and-forget OK
// (NotifyAuthenticationStateChanged는 내부적으로 Task를 구독함)
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분)
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
}
catch
{
return false;
}
}
public async Task LogoutAsync()
{
// TokenStore 초기화
_tokenStore.Clear();
// localStorage 초기화
await _localStorage.RemoveItemAsync("accessToken");
await _localStorage.RemoveItemAsync("refreshToken");
await _localStorage.RemoveItemAsync("tokenExpiry");
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private bool IsTokenExpired(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
return jwtToken.ValidTo < DateTime.UtcNow;
}
catch
{
return true;
}
}
}
public class WasmAuthTokenPair
{
public WasmAuthTokenPair() { }
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresIn = expiresIn;
}
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}
@@ -0,0 +1,125 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Domain.Entities;
public interface IFaqBrowserClient
{
Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default);
Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Faq?> CreateAsync(Faq faq, CancellationToken ct = default);
Task<Faq?> UpdateAsync(int id, Faq faq, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class FaqBrowserClient : IFaqBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<FaqBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public FaqBrowserClient(HttpClient http, ILogger<FaqBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<FaqListResponse>("faq", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch FAQs");
throw;
}
}
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Faq>($"faq/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch FAQ {FaqId}", id);
throw;
}
}
public async Task<Faq?> CreateAsync(Faq faq, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("faq", faq, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Faq>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create FAQ");
throw;
}
}
public async Task<Faq?> UpdateAsync(int id, Faq faq, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"faq/{id}", faq, cancellationToken: ct);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Faq>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update FAQ {FaqId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"faq/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete FAQ {FaqId}", id);
throw;
}
}
private class FaqListResponse
{
public List<Faq> Data { get; set; } = [];
}
}
@@ -0,0 +1,8 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
public interface ILocalStorageService
{
Task<string?> GetItemAsStringAsync(string key);
Task SetItemAsStringAsync(string key, string value);
Task RemoveItemAsync(string key);
}
@@ -0,0 +1,39 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
/// <summary>
/// Scoped in-memory token store for Blazor Server.
/// SOLID: Single Responsibility - Token lifecycle management
/// Avoids JS interop from DelegatingHandler (which runs on non-circuit thread)
/// </summary>
public interface ITokenStore
{
string? AccessToken { get; set; }
string? RefreshToken { get; set; }
long? TokenExpiryTicks { get; set; }
bool IsAccessTokenExpired();
void Clear();
}
public class TokenStore : ITokenStore
{
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
public long? TokenExpiryTicks { get; set; }
public bool IsAccessTokenExpired()
{
if (TokenExpiryTicks == null)
return true;
var expiryTime = new DateTime(TokenExpiryTicks.Value, DateTimeKind.Utc);
return expiryTime <= DateTime.UtcNow;
}
public void Clear()
{
AccessToken = null;
RefreshToken = null;
TokenExpiryTicks = null;
}
}
@@ -0,0 +1,219 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities;
/// <summary>
/// Inquiry API Client for Admin Blazor
/// SOLID: Single Responsibility - Inquiry API calls only
/// Dependency Inversion - abstraction via interface
/// </summary>
public interface IInquiryBrowserClient
{
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default);
Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default);
Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default);
Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default);
Task<Inquiry?> CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class InquiryBrowserClient : IInquiryBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<InquiryBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public InquiryBrowserClient(HttpClient http, ILogger<InquiryBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
int page = 1, int pageSize = 20, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<InquiryPagedResponse>(
$"inquiry?page={page}&pageSize={pageSize}",
cancellationToken: ct);
return result != null
? (result.Data, result.Total)
: ([], 0);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch inquiries");
throw;
}
}
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<Inquiry>(
$"inquiry/{id}",
cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch inquiry {InquiryId}", id);
throw;
}
}
public async Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { status };
var response = await _http.PutAsJsonAsync(
$"inquiry/{id}/status",
request,
cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update inquiry {InquiryId} status", id);
throw;
}
}
public async Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var request = new { adminMemo };
var response = await _http.PutAsJsonAsync(
$"inquiry/{id}/memo",
request,
cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update inquiry {InquiryId} memo", id);
throw;
}
}
public async Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"inquiry/{id}", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Inquiry>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update inquiry {InquiryId}", id);
throw;
}
}
public async Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync(
$"inquiry/{id}/convert-to-client",
new { name, phone, serviceType },
cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return 0;
var content = await response.Content.ReadAsStringAsync(ct);
var result = System.Text.Json.JsonSerializer.Deserialize<ConvertToClientResponse>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result?.ClientId ?? 0;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to convert inquiry {InquiryId} to client", id);
throw;
}
}
public async Task<Inquiry?> CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("inquiry", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Inquiry>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create inquiry");
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"inquiry/{id}", ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete inquiry {InquiryId}", id);
throw;
}
}
private class InquiryPagedResponse
{
public List<Inquiry> Data { get; set; } = [];
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
private class ConvertToClientResponse
{
public int ClientId { get; set; }
}
}
@@ -0,0 +1,43 @@
using Microsoft.JSInterop;
namespace TaxBaik.Web.Client.Components.Admin.Services;
public class LocalStorageService : ILocalStorageService
{
private readonly IJSRuntime _jsRuntime;
public LocalStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<string?> GetItemAsStringAsync(string key)
{
try
{
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", key);
}
catch
{
return null;
}
}
public async Task SetItemAsStringAsync(string key, string value)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
}
catch { }
}
public async Task RemoveItemAsync(string key)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
}
catch { }
}
}
@@ -0,0 +1,149 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net.Http;
using System.Net.Http.Json;
using TaxBaik.Domain.Entities;
/// <summary>
/// TaxFiling API Client for Admin Blazor
/// </summary>
public interface ITaxFilingBrowserClient
{
Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default);
Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default);
Task<TaxFiling?> CreateAsync(TaxFiling filing, CancellationToken ct = default);
Task<TaxFiling?> UpdateAsync(int id, TaxFiling filing, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<TaxFilingBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public TaxFilingBrowserClient(HttpClient http, ILogger<TaxFilingBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch upcoming filings");
throw;
}
}
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"taxfiling/client/{clientId}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch filings for client {ClientId}", clientId);
throw;
}
}
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<TaxFiling>(
$"taxfiling/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch filing {FilingId}", id);
throw;
}
}
public async Task<TaxFiling?> CreateAsync(TaxFiling filing, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<TaxFiling>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create filing");
throw;
}
}
public async Task<TaxFiling?> UpdateAsync(int id, TaxFiling filing, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<TaxFiling>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update filing {FilingId}", id);
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete filing {FilingId}", id);
throw;
}
}
private class TaxFilingListResponse
{
public List<TaxFiling> Data { get; set; } = [];
}
}
@@ -0,0 +1,106 @@
namespace TaxBaik.Web.Client.Components.Admin.Services;
using System.Net;
using System.Text.Json;
/// <summary>
/// HTTP 요청 시 자동으로 access token을 추가하고,
/// 401 응답을 받으면 refresh token으로 새 토큰을 획득한 후 재시도합니다.
/// SOLID: Single Responsibility - 토큰 갱신 로직만 담당
/// </summary>
public class TokenRefreshHandler : DelegatingHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
// 요청에 access token 추가
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
{
request.Headers.Authorization = new("Bearer", tokenStore.AccessToken);
}
var response = await base.SendAsync(request, cancellationToken);
// 401 응답이면 토큰 갱신 시도
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!string.IsNullOrEmpty(tokenStore.RefreshToken))
{
var newTokenPair = await RefreshTokenAsync(tokenStore.RefreshToken, request, cancellationToken);
if (newTokenPair != null)
{
// TokenStore에 토큰 저장
tokenStore.AccessToken = newTokenPair.AccessToken;
tokenStore.RefreshToken = newTokenPair.RefreshToken;
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
// 새 토큰으로 재요청
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
response = await base.SendAsync(request, cancellationToken);
}
else
{
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
tokenStore.Clear();
}
}
}
return response;
}
private async Task<WasmAuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
{
try
{
// 원래 요청의 호스트 정보 추출
var authority = originalRequest.RequestUri?.Authority ?? "localhost:5001";
var scheme = originalRequest.RequestUri?.Scheme ?? "http";
using var httpClient = new HttpClient();
var refreshUri = new Uri($"{scheme}://{authority}/taxbaik/api/auth/refresh");
var json = JsonSerializer.Serialize(new { refreshToken });
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(refreshUri, content, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning($"Token refresh failed with status {response.StatusCode}");
return null;
}
var responseContent = await response.Content.ReadAsStringAsync(ct);
var result = JsonSerializer.Deserialize<AuthTokenResponse>(responseContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result != null
? new WasmAuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
: null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during token refresh");
return null;
}
}
}
internal class AuthTokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
}
@@ -27,10 +27,10 @@
<AdminFormSection Title="코드 편집" Description="공백 없는 값과 일관된 이름만 허용합니다."> <AdminFormSection Title="코드 편집" Description="공백 없는 값과 일관된 이름만 허용합니다.">
<MudForm> <MudForm>
<MudTextField @bind-value="EditModel.CodeGroup" Label="그룹" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!IsCreateMode)" MaxLength="80" Class="mb-3" /> <MudTextField @bind-Value="EditModel.CodeGroup" Label="그룹" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!IsCreateMode)" MaxLength="80" Class="mb-3" />
<MudTextField @bind-value="EditModel.CodeValue" Label="값" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!IsCreateMode)" MaxLength="120" Class="mb-3" /> <MudTextField @bind-Value="EditModel.CodeValue" Label="값" Variant="Variant.Outlined" FullWidth="true" Required="true" Disabled="@(!IsCreateMode)" MaxLength="120" Class="mb-3" />
<MudTextField @bind-value="EditModel.CodeName" Label="이름" Variant="Variant.Outlined" FullWidth="true" Required="true" MaxLength="200" Class="mb-3" /> <MudTextField @bind-Value="EditModel.CodeName" Label="이름" Variant="Variant.Outlined" FullWidth="true" Required="true" MaxLength="200" Class="mb-3" />
<MudNumericField T="int" @bind-value="EditModel.SortOrder" Label="순서" Variant="Variant.Outlined" FullWidth="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> <MudSwitch @bind-Checked="EditModel.IsActive" Color="Color.Primary">활성</MudSwitch>
<div class="d-flex gap-2 mt-4"> <div class="d-flex gap-2 mt-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnSaveRequested">저장</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnSaveRequested">저장</MudButton>
@@ -1,5 +1,5 @@
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@inject ICommonCodeBrowserClient CommonCodeClient @inject ICommonCodeBrowserClient CommonCodeClient
<MudSelect T="string" <MudSelect T="string"
@@ -0,0 +1,18 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop
@using MudBlazor
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Application.Utils
@using TaxBaik.Domain.Entities
@using TaxBaik.Web.Services
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.WasmClient.Components.Admin.Shared
@using TaxBaik.WasmClient.Components.Admin.Layout
+2
View File
@@ -0,0 +1,2 @@
global using System.Net.Http;
global using System.Net.Http.Json;
@@ -0,0 +1,13 @@
@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@
@rendermode InteractiveWebAssembly
<MudPaper Class="pa-6 ma-4" Elevation="2">
<MudText Typo="Typo.h5" GutterBottom="true">WebAssembly 렌더 모드 점검</MudText>
<MudText Typo="Typo.body2" Class="mb-4">이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Increment">카운트: @count</MudButton>
</MudPaper>
@code {
private int count;
private void Increment() => count++;
}
+53
View File
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
using TaxBaik.Web.Services.AdminClients;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// MudBlazor (WASM 측 인터랙티브 컴포넌트용)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
// API Base Url 동적 구성 (호스트 기준 /taxbaik/api/)
var apiBaseUrl = builder.HostEnvironment.BaseAddress.TrimEnd('/') + "/taxbaik/api/";
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped<TokenRefreshHandler>();
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// 각 Browser API Client 등록
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IBlogBrowserClient, BlogBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICategoryBrowserClient, CategoryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl));
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
// Blazor 인증 (WASM 측 클라이언트)
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services.AdminClients; namespace TaxBaik.Web.Services.AdminClients;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services.AdminClients; namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json; using System.Text.Json;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services.AdminClients; namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json; using System.Text.Json;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services.AdminClients; namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json; using System.Text.Json;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services.AdminClients; namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json; using System.Text.Json;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services.AdminClients; namespace TaxBaik.Web.Services.AdminClients;
using System.Text.Json; using System.Text.Json;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
@@ -3,7 +3,7 @@ using System.Net.Http.Json;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
/// <summary> /// <summary>
/// Admin Dashboard API Client /// Admin Dashboard API Client
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using System.Text.Json; using System.Text.Json;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
using System.Net.Http.Json; using System.Net.Http.Json;
using TaxBaik.Application.DTOs; using TaxBaik.Application.DTOs;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
using System.Net.Http.Json; using System.Net.Http.Json;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -3,7 +3,7 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
public class CustomAuthenticationStateProvider : AuthenticationStateProvider public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{ {
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
public interface ILocalStorageService public interface ILocalStorageService
{ {
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
/// <summary> /// <summary>
/// Scoped in-memory token store for Blazor Server. /// Scoped in-memory token store for Blazor Server.
@@ -1,4 +1,4 @@
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -1,6 +1,6 @@
using Microsoft.JSInterop; using Microsoft.JSInterop;
namespace TaxBaik.Web.Components.Admin.Services; namespace TaxBaik.Web.Services;
public class LocalStorageService : ILocalStorageService public class LocalStorageService : ILocalStorageService
{ {

Some files were not shown because too many files have changed in this diff Show More