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:
+2
-6
@@ -8,12 +8,8 @@
|
||||
<base href="/taxbaik/" />
|
||||
<link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" />
|
||||
<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" media="print" onload="this.media='all'" />
|
||||
<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="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
<script>
|
||||
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));
|
||||
}
|
||||
+6
-6
@@ -1,23 +1,23 @@
|
||||
@using TaxBaik.Application.Services
|
||||
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-value="model.CompanyCode" Label="회사 코드"
|
||||
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true"
|
||||
HelperText="영문/숫자, 최대 50자" />
|
||||
|
||||
<MudTextField @bind-value="model.CompanyName" Label="회사명"
|
||||
<MudTextField @bind-Value="model.CompanyName" Label="회사명"
|
||||
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" />
|
||||
|
||||
<MudTextField @bind-value="model.Phone" Label="전화번호"
|
||||
<MudTextField @bind-Value="model.Phone" Label="전화번호"
|
||||
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" />
|
||||
|
||||
<MudTextField @bind-value="model.Memo" Label="메모"
|
||||
<MudTextField @bind-Value="model.Memo" Label="메모"
|
||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||
|
||||
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
|
||||
+8
-8
@@ -1,28 +1,28 @@
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
|
||||
<MudForm @ref="form">
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
|
||||
<MudTextField @bind-value="model.Email" Label="이메일"
|
||||
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" ReadOnly="@IsEditMode" />
|
||||
</AdminFormSection>
|
||||
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
</AdminFormSection>
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -22,7 +22,7 @@
|
||||
<MudForm @ref="form">
|
||||
<MudGrid>
|
||||
<MudItem xs="12">
|
||||
<MudTextField @bind-value="model.Title"
|
||||
<MudTextField @bind-Value="model.Title"
|
||||
Label="제목"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
@@ -31,7 +31,7 @@
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudTextField @bind-value="model.Content"
|
||||
<MudTextField @bind-Value="model.Content"
|
||||
Label="상세 내용 (선택)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
@@ -39,14 +39,14 @@
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6">
|
||||
<CommonCodeSelect @bind-value="model.DisplayType"
|
||||
<CommonCodeSelect @bind-Value="model.DisplayType"
|
||||
Group="ANNOUNCEMENT_DISPLAY_TYPE"
|
||||
Label="유형"
|
||||
Class="mb-0" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField @bind-value="model.SortOrder"
|
||||
<MudNumericField @bind-Value="model.SortOrder"
|
||||
Label="노출 순서"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="숫자가 클수록 먼저 표시됩니다." />
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
</section>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Components.Admin.Pages.Blog
|
||||
@using TaxBaik.WasmClient.Components.Admin.Pages.Blog
|
||||
@inject IBlogBrowserClient BlogClient
|
||||
@inject ICategoryBrowserClient CategoryClient
|
||||
@inject NavigationManager Navigation
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Components.Admin.Pages.Blog
|
||||
@using TaxBaik.WasmClient.Components.Admin.Pages.Blog
|
||||
@inject IBlogBrowserClient BlogClient
|
||||
@inject ICategoryBrowserClient CategoryClient
|
||||
@inject NavigationManager Navigation
|
||||
+6
-6
@@ -3,10 +3,10 @@
|
||||
|
||||
<MudForm @ref="form">
|
||||
<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" />
|
||||
|
||||
<MudSelect T="int?" @bind-value="Model.CategoryId" Label="카테고리 (선택 사항)"
|
||||
<MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리 (선택 사항)"
|
||||
Variant="Variant.Outlined" Class="mb-4" Clearable="true">
|
||||
<MudSelectItem Value="@((int?)null)">분류 없음</MudSelectItem>
|
||||
@foreach (var category in Categories)
|
||||
@@ -19,17 +19,17 @@
|
||||
</AdminFormSection>
|
||||
|
||||
<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="본문 내용을 입력하세요."
|
||||
Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-value="Model.Tags" Label="태그 (쉼표로 구분)"
|
||||
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
|
||||
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" />
|
||||
|
||||
<MudTextField @bind-value="Model.SeoDescription" Label="SEO 설명"
|
||||
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
|
||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||
</AdminFormSection>
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
</AdminPageHeader>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
+6
-6
@@ -2,8 +2,8 @@
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IConsultingActivityBrowserClient ConsultingClient
|
||||
@inject NavigationManager Navigation
|
||||
@@ -105,14 +105,14 @@
|
||||
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
|
||||
</MudItem>
|
||||
<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 xs="12">
|
||||
<MudTextField T="string" @bind-value="newSummary" Label="상담 내용 *"
|
||||
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
|
||||
Lines="3" Variant="Variant.Outlined" Required="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="string" @bind-value="newResult" Label="결과">
|
||||
<MudSelect T="string" @bind-Value="newResult" Label="결과">
|
||||
<MudSelectItem Value="@("")">-</MudSelectItem>
|
||||
@foreach (var r in results)
|
||||
{
|
||||
@@ -121,7 +121,7 @@
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="decimal?" @bind-value="newFee" Label="수임료 (원)"
|
||||
<MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
|
||||
Format="N0" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
+10
-10
@@ -5,7 +5,7 @@
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -35,18 +35,18 @@
|
||||
<MudDivider />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-value="dto.Name" Label="고객명 *" Required="true"
|
||||
<MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true"
|
||||
RequiredError="고객명을 입력하세요." />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-value="dto.CompanyName" Label="회사명 (선택)" />
|
||||
<MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-value="dto.Phone" Label="연락처"
|
||||
<MudTextField @bind-Value="dto.Phone" Label="연락처"
|
||||
Placeholder="010-0000-0000" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-value="dto.Email" Label="이메일" InputType="InputType.Email" />
|
||||
<MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" />
|
||||
</MudItem>
|
||||
|
||||
@* 세무 정보 *@
|
||||
@@ -55,10 +55,10 @@
|
||||
<MudDivider />
|
||||
</MudItem>
|
||||
<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 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>
|
||||
|
||||
@* 관리 정보 *@
|
||||
@@ -67,13 +67,13 @@
|
||||
<MudDivider />
|
||||
</MudItem>
|
||||
<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 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 xs="12">
|
||||
<MudTextField @bind-value="dto.Memo" Label="메모"
|
||||
<MudTextField @bind-Value="dto.Memo" Label="메모"
|
||||
Lines="4" AutoGrow="true"
|
||||
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
|
||||
</MudItem>
|
||||
+2
-2
@@ -24,12 +24,12 @@
|
||||
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<MudTextField @bind-value="searchText" Label="검색 (이름·연락처·회사명)"
|
||||
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
|
||||
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<CommonCodeSelect @bind-value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
|
||||
<CommonCodeSelect @bind-Value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
@page "/admin/common-codes"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.Domain.Entities
|
||||
@attribute [Authorize]
|
||||
@inject ICommonCodeBrowserClient CommonCodeClient
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
@page "/admin/companies/create"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@using TaxBaik.WasmClient.Components.Admin.Forms
|
||||
@inject IApiClient ApiClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
@page "/admin/companies/{id:int}/edit"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@using TaxBaik.WasmClient.Components.Admin.Forms
|
||||
@inject IApiClient ApiClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
@page "/admin/consulting-activities"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject IConsultingActivityBrowserClient ActivityClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -98,15 +98,15 @@
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</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" />
|
||||
<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" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
@page "/admin/contracts"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject IContractBrowserClient ContractClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -117,16 +117,16 @@ else
|
||||
}
|
||||
</div>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<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" />
|
||||
<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" />
|
||||
<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">
|
||||
@if (isEditMode)
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject IAdminDashboardClient DashboardClient
|
||||
@inject NavigationManager Nav
|
||||
|
||||
+5
-5
@@ -24,7 +24,7 @@
|
||||
<MudForm @ref="form" @bind-IsValid="isValid">
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12">
|
||||
<MudTextField @bind-value="faq.Question"
|
||||
<MudTextField @bind-Value="faq.Question"
|
||||
Label="질문 *" Required="true"
|
||||
RequiredError="질문을 입력하세요."
|
||||
Counter="300" MaxLength="300"
|
||||
@@ -32,23 +32,23 @@
|
||||
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField @bind-value="faq.Answer"
|
||||
<MudTextField @bind-Value="faq.Answer"
|
||||
Label="답변 *" Required="true"
|
||||
RequiredError="답변을 입력하세요."
|
||||
Lines="5" AutoGrow="true"
|
||||
Placeholder="방문자에게 보여질 답변을 입력하세요." />
|
||||
</MudItem>
|
||||
<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 xs="12" md="3">
|
||||
<MudNumericField @bind-value="faq.SortOrder"
|
||||
<MudNumericField @bind-Value="faq.SortOrder"
|
||||
Label="정렬 순서"
|
||||
HelperText="작을수록 위에 노출"
|
||||
Min="0" Max="9999" />
|
||||
</MudItem>
|
||||
<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 ? "노출 중" : "비활성")" />
|
||||
</MudItem>
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
</section>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@using TaxBaik.WasmClient.Components.Admin.Forms
|
||||
@inject IInquiryBrowserClient InquiryClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
+1
-1
@@ -60,7 +60,7 @@
|
||||
</AdminDetailSection>
|
||||
|
||||
<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" />
|
||||
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="SaveMemo">메모 저장</MudButton>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@using TaxBaik.WasmClient.Components.Admin.Forms
|
||||
@inject IInquiryBrowserClient InquiryClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@page "/admin/login"
|
||||
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
|
||||
@layout TaxBaik.WasmClient.Components.Admin.Layout.BlankLayout
|
||||
@attribute [AllowAnonymous]
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))
|
||||
<PageTitle>로그인</PageTitle>
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
@page "/admin/revenue-trackings"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject IRevenueTrackingBrowserClient RevenueClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -94,16 +94,16 @@
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
+7
-7
@@ -30,16 +30,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<MudForm>
|
||||
<MudTextField @bind-value="phone" Label="전화번호"
|
||||
<MudTextField @bind-Value="phone" Label="전화번호"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-value="email" Label="이메일"
|
||||
<MudTextField @bind-Value="email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-value="kakaoUrl" Label="카카오채널 URL"
|
||||
<MudTextField @bind-Value="kakaoUrl" Label="카카오채널 URL"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-value="instagramUrl" Label="인스타그램"
|
||||
<MudTextField @bind-Value="instagramUrl" Label="인스타그램"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
@@ -59,13 +59,13 @@
|
||||
</div>
|
||||
|
||||
<MudForm>
|
||||
<MudTextField @bind-value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
|
||||
<MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
|
||||
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" />
|
||||
|
||||
<MudTextField @bind-value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
|
||||
<MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
@page "/admin/tax-filing-schedules"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject ITaxFilingScheduleBrowserClient TaxFilingClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -130,7 +130,7 @@ else
|
||||
</div>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?"
|
||||
@bind-value="scheduleForm.ClientId"
|
||||
@bind-Value="scheduleForm.ClientId"
|
||||
Label="고객"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
@@ -143,9 +143,9 @@ else
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</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" />
|
||||
<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">
|
||||
@if (isEditMode && selectedSchedule?.Status != "completed")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject ITaxFilingBrowserClient FilingClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject ITaxFilingBrowserClient FilingClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -29,20 +29,20 @@
|
||||
<MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudAutocomplete T="Domain.Entities.Client" @bind-value="selectedClient"
|
||||
<MudAutocomplete T="Domain.Entities.Client" @bind-Value="selectedClient"
|
||||
Label="고객 검색 *"
|
||||
SearchFunc="SearchClients"
|
||||
ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")"
|
||||
Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<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 xs="12" sm="6" md="4">
|
||||
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField T="string" @bind-value="newMemo" Label="메모" Variant="Variant.Outlined" />
|
||||
<MudTextField T="string" @bind-Value="newMemo" Label="메모" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
<MudStack Row="true" Class="mt-3" Spacing="2">
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
@page "/admin/tax-profiles"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.WasmClient.Components.Admin.Shared
|
||||
@inject ITaxProfileBrowserClient TaxProfileClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -95,16 +95,16 @@ else
|
||||
}
|
||||
</div>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<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.BusinessType" Group="BUSINESS_TYPE" Label="사업 유형" Class="mb-3" Required="true" />
|
||||
<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" />
|
||||
<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">
|
||||
@if (isEditMode)
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
@namespace TaxBaik.Web.Components.Admin
|
||||
@namespace TaxBaik.WasmClient.Components.Admin
|
||||
@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">
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.WasmClient.Components.Admin.Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
@@ -12,7 +12,7 @@
|
||||
</Found>
|
||||
<NotFound>
|
||||
<PageTitle>찾을 수 없음</PageTitle>
|
||||
<LayoutView Layout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
||||
<LayoutView Layout="@typeof(TaxBaik.WasmClient.Components.Admin.Layout.MainLayout)">
|
||||
<p>요청한 페이지를 찾을 수 없습니다.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
+118
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -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; }
|
||||
}
|
||||
}
|
||||
+221
@@ -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; }
|
||||
}
|
||||
+4
-4
@@ -27,10 +27,10 @@
|
||||
|
||||
<AdminFormSection Title="코드 편집" Description="공백 없는 값과 일관된 이름만 허용합니다.">
|
||||
<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.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" />
|
||||
<MudNumericField T="int" @bind-value="EditModel.SortOrder" Label="순서" Variant="Variant.Outlined" FullWidth="true" 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.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" />
|
||||
<MudSwitch @bind-Checked="EditModel.IsActive" Color="Color.Primary">활성</MudSwitch>
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnSaveRequested">저장</MudButton>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Web.Components.Admin.Services.AdminClients
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject ICommonCodeBrowserClient CommonCodeClient
|
||||
|
||||
<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
|
||||
@@ -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++;
|
||||
}
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services.AdminClients;
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http.Json;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services.AdminClients;
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services.AdminClients;
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services.AdminClients;
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services.AdminClients;
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services.AdminClients;
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
+1
-1
@@ -3,7 +3,7 @@ using System.Net.Http.Json;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Admin Dashboard API Client
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Text.Json;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Application.DTOs;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
+1
-1
@@ -3,7 +3,7 @@ using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
public interface ILocalStorageService
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped in-memory token store for Blazor Server.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace TaxBaik.Web.Components.Admin.Services;
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
public class LocalStorageService : ILocalStorageService
|
||||
{
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user