feat(admin): stabilize blog and admin patterns
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-07-02 10:46:27 +09:00
parent b3cab87539
commit cb47349a25
67 changed files with 1354 additions and 486 deletions
+1 -6
View File
@@ -11,11 +11,6 @@
<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" />
<!-- EasyMDE 마크다운 에디터 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css" />
<script src="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.js"></script>
<!-- Marked 라이브러리 (EasyMDE 미리보기용) -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script>
document.documentElement.classList.toggle(
'admin-login-route',
@@ -32,7 +27,7 @@
<span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span>
</div>
</div>
<div id="blazor-loading" class="blazor-loading-overlay show">
<div id="blazor-loading" class="blazor-loading-overlay">
<div class="blazor-loading-spinner">
<div class="spinner"></div>
<p>로드 중...</p>
@@ -1,5 +1,6 @@
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Shared
<MudForm @ref="form">
<MudTextField @bind-Value="model.Name" Label="이름"
@@ -11,25 +12,12 @@
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudSelect @bind-Value="model.ServiceType" Label="문의 유형"
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("사업자세무")">사업자세무</MudSelectItem>
<MudSelectItem Value="@("부동산세금")">부동산세금</MudSelectItem>
<MudSelectItem Value="@("가족자산")">가족자산</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<CommonCodeSelect @bind-Value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형" Class="mb-4" />
<MudTextField @bind-Value="model.Message" Label="문의 내용"
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
<MudSelect @bind-Value="model.Status" Label="상태"
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("new")">신규</MudSelectItem>
<MudSelectItem Value="@("consulting")">상담중</MudSelectItem>
<MudSelectItem Value="@("contracted")">계약완료</MudSelectItem>
<MudSelectItem Value="@("rejected")">거절</MudSelectItem>
<MudSelectItem Value="@("closed")">종결</MudSelectItem>
</MudSelect>
<CommonCodeSelect @bind-Value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" />
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
@@ -57,8 +45,7 @@
private MudForm? form;
private InquiryFormModel model = new();
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
if (InitialData != null)
{
@@ -73,6 +60,7 @@
AdminMemo = InitialData.AdminMemo
};
}
}
private async Task HandleSubmit()
@@ -1,11 +1,9 @@
@page "/admin/blog/create"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@inject BlogService BlogService
@inject ICategoryRepository CategoryRepository
@using TaxBaik.Web.Components.Admin.Pages.Blog
@inject IBlogBrowserClient BlogClient
@inject ICategoryBrowserClient CategoryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -21,62 +19,16 @@
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
</div>
</MudForm>
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" OnCancel="GoBack" />
</MudPaper>
@code {
private MudForm? form;
private List<Domain.Entities.Category> categories = [];
private CreatePostModel model = new();
private EasyMDE.Editor? editor;
[Inject]
private IJSRuntime JS { get; set; } = null!;
private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
protected override async Task OnInitializedAsync()
{
categories = (await CategoryRepository.GetAllAsync()).ToList();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
categories = await CategoryClient.GetAllAsync();
}
private void GoBack()
@@ -86,25 +38,9 @@
private async Task SavePost()
{
if (form == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
return;
try
{
await BlogService.CreateAsync(new CreateBlogPostDto
var result = await BlogClient.CreateAsync(new CreateBlogPostDto
{
Title = model.Title,
Content = model.Content,
@@ -115,6 +51,12 @@
IsPublished = model.IsPublished
});
if (result == null)
{
Snackbar.Add("포스트 저장에 실패했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
@@ -123,45 +65,4 @@
Snackbar.Add(ex.Message, Severity.Error);
}
}
private class CreatePostModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -1,11 +1,9 @@
@page "/admin/blog/{id:int}/edit"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@inject BlogService BlogService
@inject ICategoryRepository CategoryRepository
@using TaxBaik.Web.Components.Admin.Pages.Blog
@inject IBlogBrowserClient BlogClient
@inject ICategoryBrowserClient CategoryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -32,42 +30,10 @@ else if (post == null)
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error"
@onclick="DeletePost">삭제</MudButton>
</div>
</MudForm>
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" />
<div class="mt-4">
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeletePost">삭제</MudButton>
</div>
</MudPaper>
}
@@ -75,23 +41,19 @@ else
[Parameter]
public int Id { get; set; }
[Inject]
private IJSRuntime JS { get; set; } = null!;
private MudForm? form;
private Domain.Entities.BlogPost? post;
private List<Domain.Entities.Category> categories = [];
private EditPostModel model = new();
private TaxBaik.Application.DTOs.BlogPostResponseDto? post;
private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
private bool isLoading = true;
protected override async Task OnInitializedAsync()
{
try
{
post = await BlogService.GetByIdAsync(Id);
post = await BlogClient.GetByIdAsync(Id);
if (post != null)
{
categories = (await CategoryRepository.GetAllAsync()).ToList();
categories = await CategoryClient.GetAllAsync();
MapPostToModel(post);
}
}
@@ -105,15 +67,7 @@ else
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && post != null)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
}
private void MapPostToModel(Domain.Entities.BlogPost post)
private void MapPostToModel(TaxBaik.Application.DTOs.BlogPostResponseDto post)
{
model.Title = post.Title;
model.Content = post.Content;
@@ -131,25 +85,12 @@ else
private async Task SavePost()
{
if (form == null || post == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
if (post == null)
return;
try
{
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
var result = await BlogClient.UpdateAsync(post.Id, new CreateBlogPostDto
{
Title = model.Title,
Content = model.Content,
@@ -160,6 +101,12 @@ else
IsPublished = model.IsPublished
});
if (result == null)
{
Snackbar.Add("저장 실패: 포스트를 저장하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
@@ -188,7 +135,12 @@ else
try
{
await BlogService.DeleteAsync(post.Id);
var deleted = await BlogClient.DeleteAsync(post.Id);
if (!deleted)
{
Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
@@ -197,45 +149,4 @@ else
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
}
}
private class EditPostModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -0,0 +1,80 @@
@using TaxBaik.Application.DTOs
@using TaxBaik.Domain.Entities
<MudForm @ref="form">
<MudTextField @bind-Value="Model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in Categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
Class="mb-4" />
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="Model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">@SubmitText</MudButton>
@if (OnCancel.HasDelegate)
{
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
}
</div>
</MudForm>
@code {
[Parameter, EditorRequired]
public BlogFormModel Model { get; set; } = new();
[Parameter]
public IReadOnlyList<Category> Categories { get; set; } = [];
[Parameter]
public string SubmitText { get; set; } = "저장";
[Parameter]
public EventCallback OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
private MudForm? form;
private async Task HandleSubmit()
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync();
}
public class BlogFormModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
@@ -1,8 +1,9 @@
@page "/admin/clients/{ClientId:int}"
@attribute [Authorize]
@using TaxBaik.Application.Services
@inject ClientService ClientService
@inject ConsultationService ConsultationService
@using TaxBaik.Web.Services
@using TaxBaik.Web.Services.AdminClients
@inject IClientBrowserClient ClientClient
@inject IConsultingActivityBrowserClient ConsultingClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -102,8 +103,8 @@
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
@foreach (var t in ClientService.ServiceTypes)
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
@foreach (var t in serviceTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
@@ -116,7 +117,7 @@
<MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newResult" Label="결과">
<MudSelectItem Value="@("")">-</MudSelectItem>
@foreach (var r in ConsultationService.Results)
@foreach (var r in results)
{
<MudSelectItem Value="@r">@r</MudSelectItem>
}
@@ -182,6 +183,8 @@
private Domain.Entities.Client? client;
private List<Domain.Entities.Consultation> consultations = [];
private static readonly string[] serviceTypes = ["기장대리", "세무조정", "양도세", "증여세", "상속세", "부가세", "종소세", "기타"];
private static readonly string[] results = ["", "상담완료", "추가자료 요청", "견적발송", "계약전환", "보류"];
private bool showAddForm;
private DateTime? newDate = DateTime.Today;
@@ -197,8 +200,19 @@
private async Task LoadAll()
{
client = await ClientService.GetByIdAsync(ClientId);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
client = await ClientClient.GetByIdAsync(ClientId);
consultations = (await ConsultingClient.GetByClientIdAsync(ClientId))
.Select(c => new Domain.Entities.Consultation
{
Id = c.Id,
ClientId = c.ClientId,
ConsultationDate = c.ActivityDate,
ServiceType = c.ActivityType,
Summary = c.Description,
Result = null,
Fee = null
})
.ToList();
}
private void OpenAddConsultation()
@@ -215,30 +229,35 @@
{
try
{
var c = new Domain.Entities.Consultation
{
ClientId = ClientId,
ConsultationDate = newDate?.ToUniversalTime() ?? DateTime.UtcNow,
ServiceType = string.IsNullOrWhiteSpace(newServiceType) ? null : newServiceType,
Summary = newSummary,
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
Fee = newFee
};
await ConsultationService.CreateAsync(c);
var newId = await ConsultingClient.CreateAsync(
ClientId,
string.IsNullOrWhiteSpace(newServiceType) ? "기타" : newServiceType,
newDate?.ToUniversalTime() ?? DateTime.UtcNow,
newSummary,
null,
null);
if (newId <= 0)
throw new Exception("상담 생성 실패");
showAddForm = false;
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
await LoadAll();
Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
}
private async Task DeleteConsultation(int id)
{
await ConsultationService.DeleteAsync(id);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
await ConsultingClient.DeleteAsync(id);
await LoadAll();
Snackbar.Add("삭제되었습니다.", Severity.Info);
}
}
@@ -4,6 +4,7 @@
@using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared
@inject IClientBrowserClient ClientClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -54,20 +55,10 @@
<MudDivider />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true">
@foreach (var t in ClientService.ServiceTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
<CommonCodeSelect @bind-Value="dto.ServiceType" Group="CLIENT_SERVICE_TYPE" Label="서비스 유형" Clearable="true" />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true">
@foreach (var t in ClientService.TaxTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
<CommonCodeSelect @bind-Value="dto.TaxType" Group="CLIENT_TAX_TYPE" Label="세금 유형" Clearable="true" />
</MudItem>
@* 관리 정보 *@
@@ -76,18 +67,10 @@
<MudDivider />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true">
<MudSelectItem Value="@("active")">활성</MudSelectItem>
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
</MudSelect>
<CommonCodeSelect @bind-Value="dto.Status" Group="CLIENT_STATUS" Label="상태 *" Required="true" />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true">
@foreach (var s in ClientService.Sources)
{
<MudSelectItem Value="@s">@s</MudSelectItem>
}
</MudSelect>
<CommonCodeSelect @bind-Value="dto.Source" Group="CLIENT_SOURCE" Label="유입 경로" Clearable="true" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="dto.Memo" Label="메모"
@@ -119,7 +102,6 @@
private bool isValid;
private bool isLoading = true;
private bool isSaving;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
@@ -9,18 +9,15 @@
<PageTitle>고객 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PersonAdd"
Href="/taxbaik/admin/clients/create">
고객 등록
</MudButton>
</section>
<AdminPageHeader Title="고객 관리" Eyebrow="CRM" Subtitle="고객 카드를 등록하고 상담 이력을 관리합니다.">
<ChildContent>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PersonAdd"
Href="/taxbaik/admin/clients/create">
고객 등록
</MudButton>
</ChildContent>
</AdminPageHeader>
@* 검색/필터 바 *@
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
@@ -53,10 +50,7 @@
}
else if (!clients.Any())
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
</div>
<AdminEmptyState Icon="@Icons.Material.Filled.PeopleAlt" Message="등록된 고객이 없습니다." />
}
else
{
@@ -1,9 +1,8 @@
@page "/admin/inquiries/create"
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService
@inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -32,13 +31,21 @@
{
try
{
await InquiryService.SubmitAsync(
model.Name,
model.Phone,
model.ServiceType,
model.Message,
model.Email,
ipAddress: "admin-registered");
var result = await InquiryClient.CreateAsync(new SubmitInquiryDto
{
Name = model.Name,
Phone = model.Phone,
Email = model.Email,
ServiceType = model.ServiceType,
Message = model.Message,
SuppressNotification = true
});
if (result == null)
{
Snackbar.Add("문의가 등록되지 않았습니다.", Severity.Error);
return;
}
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries");
@@ -1,9 +1,8 @@
@page "/admin/inquiries/{id:int}/edit"
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService
@inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -52,7 +51,7 @@ else
{
try
{
inquiry = await InquiryService.GetByIdAsync(Id);
inquiry = await InquiryClient.GetByIdAsync(Id);
if (inquiry != null)
{
formModel = new InquiryForm.InquiryFormModel
@@ -89,19 +88,34 @@ else
try
{
inquiry.Name = model.Name;
inquiry.Phone = model.Phone;
inquiry.Email = model.Email;
inquiry.ServiceType = model.ServiceType;
inquiry.Message = model.Message;
inquiry.AdminMemo = model.AdminMemo;
if (inquiry.Status != model.Status)
var updated = await InquiryClient.UpdateAsync(inquiry.Id, new UpdateInquiryDto
{
await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status);
Name = model.Name,
Phone = model.Phone,
Email = model.Email,
ServiceType = model.ServiceType,
Message = model.Message,
Status = model.Status,
AdminMemo = model.AdminMemo
});
if (updated == null)
{
Snackbar.Add("문의 수정에 실패했습니다.", Severity.Error);
return;
}
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo);
inquiry = updated;
formModel = new InquiryForm.InquiryFormModel
{
Name = inquiry.Name,
Phone = inquiry.Phone,
Email = inquiry.Email,
ServiceType = inquiry.ServiceType,
Message = inquiry.Message,
Status = inquiry.Status,
AdminMemo = inquiry.AdminMemo
};
Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries");
@@ -131,7 +145,12 @@ else
try
{
await InquiryService.DeleteAsync(inquiry.Id);
var deleted = await InquiryClient.DeleteAsync(inquiry.Id);
if (!deleted)
{
Snackbar.Add("문의 삭제에 실패했습니다.", Severity.Error);
return;
}
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
@@ -1,5 +1,6 @@
@page "/admin/revenue-trackings"
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared
@inject IRevenueTrackingBrowserClient RevenueClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@@ -102,13 +103,7 @@
<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" />
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem>
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem>
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem>
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem>
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem>
</MudSelect>
<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>
@@ -35,7 +35,7 @@
<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="인스타그램"
@@ -1,5 +1,6 @@
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared
@inject ITaxFilingBrowserClient FilingClient
@inject ISnackbar Snackbar
@@ -21,10 +22,10 @@ else
<RowTemplate>
<MudTd>@context.ClientName</MudTd>
<MudTd>@context.FilingType</MudTd>
<MudTd>@context.DueDate.ToString("yyyy-MM-dd")</MudTd>
<MudTd>@BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(context.DueDate)).ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd")</MudTd>
<MudTd>
@{
var dday = (context.DueDate.Date - DateTime.Today).Days;
var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(context.DueDate));
}
@if (dday < 0)
{
@@ -2,6 +2,7 @@
@attribute [Authorize]
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared
@inject ITaxFilingBrowserClient FilingClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@@ -34,12 +35,7 @@
Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined">
@foreach (var t in TaxFilingService.FilingTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
<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" />
@@ -82,6 +78,10 @@
protected override async Task OnInitializedAsync() => await Reload();
protected override async Task OnParametersSetAsync()
{
}
private async Task Reload()
{
try
@@ -0,0 +1,12 @@
<div class="pa-6 text-center">
<MudIcon Icon="@Icon" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">@Message</MudText>
</div>
@code {
[Parameter, EditorRequired]
public string Icon { get; set; } = Icons.Material.Filled.Info;
[Parameter, EditorRequired]
public string Message { get; set; } = "";
}
@@ -0,0 +1,31 @@
<section class="admin-page-hero">
<div>
@if (!string.IsNullOrWhiteSpace(Eyebrow))
{
<MudText Typo="Typo.caption" Class="admin-eyebrow">@Eyebrow</MudText>
}
<MudText Typo="Typo.h4" Class="admin-page-title">@Title</MudText>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<MudText Typo="Typo.body2" Class="admin-page-subtitle">@Subtitle</MudText>
}
</div>
@if (ChildContent is not null)
{
<div>@ChildContent</div>
}
</section>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = "";
[Parameter]
public string? Eyebrow { get; set; }
[Parameter]
public string? Subtitle { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
+11 -1
View File
@@ -32,6 +32,16 @@ public class BlogController : ControllerBase
return Ok(post);
}
[HttpGet("admin/{id:int}")]
[Authorize]
public async Task<IActionResult> GetById(int id)
{
var post = await _blogService.GetByIdAsync(id);
if (post == null)
return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(post);
}
[HttpGet("admin/all")]
[Authorize]
public async Task<IActionResult> GetAll()
@@ -84,7 +94,7 @@ public class BlogController : ControllerBase
[Authorize]
public async Task<IActionResult> Delete(int id)
{
await _blogService.DeleteAsync(id);
await _blogService.ArchiveAsync(id);
return NoContent();
}
}
+19 -11
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
@@ -19,7 +20,7 @@ public class InquiryController : ControllerBase
}
[HttpPost]
public async Task<IActionResult> Submit([FromBody] SubmitInquiryRequest request)
public async Task<IActionResult> Submit([FromBody] SubmitInquiryDto request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone))
return BadRequest(new ProblemDetails { Title = "이름과 전화번호를 입력하세요.", Status = StatusCodes.Status400BadRequest });
@@ -99,6 +100,23 @@ public class InquiryController : ControllerBase
}
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] UpdateInquiryDto request)
{
try
{
var result = await _inquiryService.UpdateAsync(id, request);
if (result == null)
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(result);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPost("{id}/convert-to-client")]
[Authorize]
public async Task<IActionResult> ConvertToClient(int id, [FromBody] ConvertToClientRequest request)
@@ -129,16 +147,6 @@ public class InquiryController : ControllerBase
}
}
public class SubmitInquiryRequest
{
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string? Email { get; set; }
public string ServiceType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public bool SuppressNotification { get; set; }
}
public class UpdateStatusRequest
{
public string Status { get; set; } = string.Empty;
+1 -1
View File
@@ -142,7 +142,7 @@
<p class="text-muted mb-5">무료 상담으로 현재 상황을 진단하고 맞춤형 절세 전략을 받아보세요.</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-warning btn-lg">카카오로 문의</a>
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-warning btn-lg">카카오채널로 문의</a>
</div>
</section>
+2 -2
View File
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using Markdig;
using System.Net;
namespace TaxBaik.Web.Pages.Blog;
@@ -22,7 +22,7 @@ public class BlogPostModel : PageModel
Post = await _blogService.GetBySlugAsync(slug);
if (Post != null)
{
HtmlContent = Markdown.ToHtml(Post.Content ?? "");
HtmlContent = WebUtility.HtmlEncode(Post.Content ?? "").Replace("\r\n", "<br />").Replace("\n", "<br />");
_ = _blogService.IncrementViewCountAsync(Post.Id);
}
}
+1 -1
View File
@@ -47,7 +47,7 @@
<option value="기장">사업자 기장</option>
<option value="양도세">부동산 양도세</option>
<option value="종소세">종합소득세</option>
<option value="증여상속">증여·상속세</option>
<option value="증여상속">증여상속세</option>
<option value="기타">기타</option>
</select>
</div>
+7 -7
View File
@@ -4,8 +4,8 @@
var season = Model.CurrentSeason;
ViewData["Title"] = season != null
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
: "백원숙 세무회계 | 사업자·부동산·증여상속 세무 상담";
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여상속, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
}
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
@@ -50,7 +50,7 @@
</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의
💬 카카오채널 문의
</a>
</div>
@if (season.DaysUntilDeadline <= 7)
@@ -91,7 +91,7 @@ else
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의
💬 카카오채널 문의
</a>
</div>
</div>
@@ -176,7 +176,7 @@ else
<div class="service-icon">👨‍👩‍👧‍👦</div>
<div class="card-body pt-0">
<h3 class="card-title">가족자산 관리</h3>
<p class="text-muted small">증여·상속 사전 계획부터 대표자 리스크 관리까지 가족 자산을 지키는 전략.</p>
<p class="text-muted small">증여상속 사전 계획부터 대표자 리스크 관리까지 - 가족 자산을 지키는 전략.</p>
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
@@ -362,7 +362,7 @@ else
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오채널로 문의</a>
</div>
}
else
@@ -374,7 +374,7 @@ else
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오채널로 문의</a>
</div>
}
</div>
+5 -48
View File
@@ -21,51 +21,9 @@ window.taxbaikAdminSession = {
},
showLoading: function () {
if (document.documentElement.classList.contains('admin-login-route')) {
window.taxbaikAdminSession.hideLoading();
return;
}
const overlay = document.getElementById('blazor-loading');
if (!overlay) return;
// Show overlay immediately
overlay.classList.add('show');
// Check if page is already ready (cached state on fast nav)
const pageReady =
document.querySelector('.admin-page-hero') !== null ||
document.querySelector('.admin-login-page') !== null;
if (pageReady) {
// Page already rendered, hide immediately
window.taxbaikAdminSession.hideLoading();
return;
}
// Start observer to catch future mutations
if (window._taxbaikLoadingObserver) {
window._taxbaikLoadingObserver.disconnect();
}
window._taxbaikLoadingObserver = new MutationObserver(function () {
const pageReady =
document.querySelector('.admin-page-hero') !== null ||
document.querySelector('.admin-login-page') !== null;
if (pageReady) {
window.taxbaikAdminSession.hideLoading();
}
});
window._taxbaikLoadingObserver.observe(document.body, {
childList: true,
subtree: true
});
// Safety fallback: hide after 3 seconds regardless.
if (window._taxbaikLoadingTimeout) {
clearTimeout(window._taxbaikLoadingTimeout);
}
window._taxbaikLoadingTimeout = setTimeout(function () {
window.taxbaikAdminSession.hideLoading();
}, 3000);
// Route transitions are handled by Blazor; avoid full-screen overlays
// that block drawer interaction and make the app feel frozen.
window.taxbaikAdminSession.hideLoading();
},
hideLoading: function () {
@@ -93,9 +51,8 @@ window.taxbaikAdminSession = {
window.taxbaikAdminSession.hideLoading();
}
// Show loading on initial page load — overlay has 'show' from HTML,
// but we still need to set up the observer to detect when to hide it.
window.taxbaikAdminSession.showLoading();
// Keep the initial overlay hidden unless explicitly enabled elsewhere.
window.taxbaikAdminSession.hideLoading();
const modal = document.getElementById('components-reconnect-modal');
if (!modal) return;
+2 -2
View File
@@ -65,9 +65,9 @@
보통 <strong>1~2분</strong> 이내에 완료됩니다.
</p>
<hr class="divider" />
<p>급하신 세무 문의는 카카오 채널로 연락해 주세요.</p>
<p>급하신 세무 문의는 카카오채널로 연락해 주세요.</p>
<a class="kakao-btn" href="http://pf.kakao.com/_xoxchTX" target="_blank">
💬 카카오 채널 상담
💬 카카오채널 상담
</a>
<p class="timer">이 페이지는 15초 후 자동으로 새로고침됩니다.</p>
<p class="footer">© 2026 백원숙 세무회계</p>