From 1839c2c3d1e79a329e6b6e490b0df9a28a9d849a Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Thu, 2 Jul 2026 10:27:57 +0900 Subject: [PATCH] admin: add common-code crud and business-day rules --- .../BusinessDayCalculatorTests.cs | 34 ++++ .../TaxBaik.Application.Tests.csproj | 1 + .../Services/CommonCodeService.cs | 28 +++ .../Interfaces/ICommonCodeRepository.cs | 4 + .../Repositories/CommonCodeRepository.cs | 39 ++++ .../AdminClients/ICommonCodeBrowserClient.cs | 62 ++++++ .../Components/Admin/Layout/MainLayout.razor | 5 +- .../Admin/Pages/Blog/BlogList.razor | 80 ++++---- .../Components/Admin/Pages/CommonCodes.razor | 177 ++++++++++++++++++ .../Components/Admin/Pages/Contracts.razor | 26 +-- .../Components/Admin/Pages/Dashboard.razor | 49 +++-- .../Admin/Pages/Inquiries/InquiryList.razor | 29 ++- .../Admin/Pages/TaxFilingSchedules.razor | 21 +-- .../Components/Admin/Pages/TaxProfiles.razor | 58 +----- .../Admin/Shared/BusinessDayCalculator.cs | 88 +++++++++ .../Admin/Shared/CommonCodeSelect.razor | 56 ++++++ .../Controllers/CommonCodeController.cs | 41 ++++ 17 files changed, 628 insertions(+), 170 deletions(-) create mode 100644 TaxBaik.Application.Tests/BusinessDayCalculatorTests.cs create mode 100644 TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor create mode 100644 TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs create mode 100644 TaxBaik.Web/Components/Admin/Shared/CommonCodeSelect.razor diff --git a/TaxBaik.Application.Tests/BusinessDayCalculatorTests.cs b/TaxBaik.Application.Tests/BusinessDayCalculatorTests.cs new file mode 100644 index 0000000..14ac179 --- /dev/null +++ b/TaxBaik.Application.Tests/BusinessDayCalculatorTests.cs @@ -0,0 +1,34 @@ +namespace TaxBaik.Application.Tests; + +using TaxBaik.Web.Components.Admin.Shared; +using Xunit; + +public class BusinessDayCalculatorTests +{ + [Theory] + [InlineData(2026, 2, 14, 2026, 2, 19)] + [InlineData(2026, 8, 15, 2026, 8, 20)] + [InlineData(2026, 9, 24, 2026, 9, 29)] + [InlineData(2026, 10, 3, 2026, 10, 8)] + public void GetEffectiveDueDate_SkipsWeekendHolidayAndSubstituteHoliday( + int dueYear, int dueMonth, int dueDay, + int expectedYear, int expectedMonth, int expectedDay) + { + var effective = BusinessDayCalculator.GetEffectiveDueDate(new DateOnly(dueYear, dueMonth, dueDay)); + + Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), effective); + } + + [Theory] + [InlineData(2026, 2, 19, 0)] + [InlineData(2026, 2, 20, -1)] + [InlineData(2026, 2, 18, 1)] + public void GetDday_UsesEffectiveDueDate( + int refYear, int refMonth, int refDay, + int expectedDays) + { + var dday = BusinessDayCalculator.GetDday(new DateOnly(2026, 2, 14), new DateOnly(refYear, refMonth, refDay)); + + Assert.Equal(expectedDays, dday); + } +} diff --git a/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj b/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj index 472db1e..c1555fb 100644 --- a/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj +++ b/TaxBaik.Application.Tests/TaxBaik.Application.Tests.csproj @@ -18,5 +18,6 @@ + diff --git a/TaxBaik.Application/Services/CommonCodeService.cs b/TaxBaik.Application/Services/CommonCodeService.cs index 1f25338..7101f2c 100644 --- a/TaxBaik.Application/Services/CommonCodeService.cs +++ b/TaxBaik.Application/Services/CommonCodeService.cs @@ -8,6 +8,11 @@ namespace TaxBaik.Application.Services; public class CommonCodeService(ICommonCodeRepository commonCodeRepository) { + public async Task> GetAllGroupsAsync(CancellationToken ct = default) + { + return await commonCodeRepository.GetAllGroupsAsync(ct); + } + public async Task> GetByGroupAsync(string codeGroup, CancellationToken ct = default) { return await commonCodeRepository.GetByGroupAsync(codeGroup, ct); @@ -17,4 +22,27 @@ public class CommonCodeService(ICommonCodeRepository commonCodeRepository) { return await commonCodeRepository.GetAllActiveAsync(ct); } + + public async Task GetAsync(string codeGroup, string codeValue, CancellationToken ct = default) + { + return await commonCodeRepository.GetAsync(codeGroup, codeValue, ct); + } + + public async Task UpsertAsync(CommonCode code, CancellationToken ct = default) + { + Normalize(code); + await commonCodeRepository.UpsertAsync(code, ct); + } + + public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) + { + await commonCodeRepository.DeleteAsync(codeGroup.Trim(), codeValue.Trim(), ct); + } + + private static void Normalize(CommonCode code) + { + code.CodeGroup = code.CodeGroup.Trim(); + code.CodeValue = code.CodeValue.Trim(); + code.CodeName = code.CodeName.Trim(); + } } diff --git a/TaxBaik.Domain/Interfaces/ICommonCodeRepository.cs b/TaxBaik.Domain/Interfaces/ICommonCodeRepository.cs index e805585..ab87492 100644 --- a/TaxBaik.Domain/Interfaces/ICommonCodeRepository.cs +++ b/TaxBaik.Domain/Interfaces/ICommonCodeRepository.cs @@ -7,6 +7,10 @@ namespace TaxBaik.Domain.Interfaces; public interface ICommonCodeRepository { + Task> GetAllGroupsAsync(CancellationToken ct = default); Task> GetByGroupAsync(string codeGroup, CancellationToken ct = default); Task> GetAllActiveAsync(CancellationToken ct = default); + Task GetAsync(string codeGroup, string codeValue, CancellationToken ct = default); + Task UpsertAsync(CommonCode code, CancellationToken ct = default); + Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default); } diff --git a/TaxBaik.Infrastructure/Repositories/CommonCodeRepository.cs b/TaxBaik.Infrastructure/Repositories/CommonCodeRepository.cs index ea594e0..1d1f662 100644 --- a/TaxBaik.Infrastructure/Repositories/CommonCodeRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/CommonCodeRepository.cs @@ -10,6 +10,13 @@ namespace TaxBaik.Infrastructure.Repositories; public class CommonCodeRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICommonCodeRepository { + public async Task> GetAllGroupsAsync(CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + "SELECT DISTINCT code_group FROM common_codes WHERE is_active = TRUE ORDER BY code_group"); + } + public async Task> GetByGroupAsync(string codeGroup, CancellationToken ct = default) { using var conn = Conn(); @@ -30,4 +37,36 @@ public class CommonCodeRepository(IDbConnectionFactory connectionFactory) : Base WHERE is_active = TRUE ORDER BY code_group, sort_order"); } + + public async Task GetAsync(string codeGroup, string codeValue, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QuerySingleOrDefaultAsync( + @"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive + FROM common_codes + WHERE code_group = @CodeGroup AND code_value = @CodeValue", + new { CodeGroup = codeGroup, CodeValue = codeValue }); + } + + public async Task UpsertAsync(CommonCode code, CancellationToken ct = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"INSERT INTO common_codes (code_group, code_value, code_name, sort_order, is_active) + VALUES (@CodeGroup, @CodeValue, @CodeName, @SortOrder, @IsActive) + ON CONFLICT (code_group, code_value) DO UPDATE + SET code_name = EXCLUDED.code_name, + sort_order = EXCLUDED.sort_order, + is_active = EXCLUDED.is_active", + code); + } + + public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"DELETE FROM common_codes + WHERE code_group = @CodeGroup AND code_value = @CodeValue", + new { CodeGroup = codeGroup, CodeValue = codeValue }); + } } diff --git a/TaxBaik.Web.Client/Services/AdminClients/ICommonCodeBrowserClient.cs b/TaxBaik.Web.Client/Services/AdminClients/ICommonCodeBrowserClient.cs index 0fa0498..5384438 100644 --- a/TaxBaik.Web.Client/Services/AdminClients/ICommonCodeBrowserClient.cs +++ b/TaxBaik.Web.Client/Services/AdminClients/ICommonCodeBrowserClient.cs @@ -10,8 +10,12 @@ using Microsoft.Extensions.Logging; public interface ICommonCodeBrowserClient { + Task> GetGroupsAsync(CancellationToken ct = default); Task> GetAllActiveAsync(CancellationToken ct = default); Task> GetByGroupAsync(string group, CancellationToken ct = default); + Task GetAsync(string group, string value, CancellationToken ct = default); + Task UpsertAsync(CommonCode code, CancellationToken ct = default); + Task DeleteAsync(string group, string value, CancellationToken ct = default); } public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger logger) : ICommonCodeBrowserClient @@ -53,4 +57,62 @@ public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenSto return []; } } + + public async Task> GetGroupsAsync(CancellationToken ct = default) + { + try + { + EnsureAuthHeader(); + return await httpClient.GetFromJsonAsync>($"{BaseUrl}/groups", ct) ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get common code groups"); + return []; + } + } + + public async Task GetAsync(string group, string value, CancellationToken ct = default) + { + try + { + EnsureAuthHeader(); + return await httpClient.GetFromJsonAsync($"{BaseUrl}/{group}/{value}", ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get common code {Group}/{Value}", group, value); + return null; + } + } + + public async Task 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 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; + } + } } diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index 16cce88..34de9e1 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -3,7 +3,7 @@ @inject IJSRuntime JS @inject VersionInfo VersionInfo @implements IDisposable -@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) +@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) @@ -88,6 +88,7 @@ 문의 관리 설정 + 공통관리
@@ -127,7 +128,7 @@ private void OnLocationChanged(object? sender, LocationChangedEventArgs args) { - _ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading")); + _ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading")); } private void ToggleDrawer() diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor index 0a480a2..c9266a0 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor @@ -1,19 +1,16 @@ @page "/admin/blog" @attribute [Authorize] -@inject IApiClient ApiClient +@inject IBlogBrowserClient BlogClient @inject ISnackbar Snackbar 블로그 관리 -
-
- Content - 블로그 관리 - 검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다. -
- 새 포스트 작성 -
+ + + 새 포스트 작성 + +
? AuthStateTask { get; set; } - private List posts = []; + private List posts = []; private string searchQuery = ""; private bool isLoading = true; private int currentPage = 1; @@ -66,23 +63,19 @@ private int totalPosts = 0; private const int PageSize = 20; - private IEnumerable FilteredPosts => posts? - .Where(p => string.IsNullOrEmpty(searchQuery) || - p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) || - (p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))) ?? Enumerable.Empty(); + private IEnumerable FilteredPosts => posts + .Where(p => string.IsNullOrEmpty(searchQuery) || + p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) || + (p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))); - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) + if (AuthStateTask != null) { - if (AuthStateTask != null) + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) - { - await LoadPosts(); - StateHasChanged(); - } + await LoadPosts(); } } } @@ -92,9 +85,9 @@ isLoading = true; try { - var result = await ApiClient.GetAsync($"blog/admin?page={currentPage}&pageSize={PageSize}"); - posts = result?.Data ?? []; - totalPosts = result?.Total ?? 0; + var result = await BlogClient.GetAdminPagedAsync(currentPage, PageSize); + posts = result.Items.ToList(); + totalPosts = result.Total; totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize)); } catch @@ -124,21 +117,21 @@ await LoadPosts(); } - private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished) + private async Task TogglePublish(TaxBaik.Application.DTOs.BlogPostResponseDto post, bool isPublished) { var previous = post.IsPublished; post.IsPublished = isPublished; - var result = await ApiClient.PutAsync($"blog/{post.Id}", new + var result = await BlogClient.UpdateAsync(post.Id, new TaxBaik.Application.DTOs.CreateBlogPostDto { - post.Title, - post.Content, - post.CategoryId, - post.Tags, - post.SeoTitle, - post.SeoDescription, - post.ThumbnailUrl, + Title = post.Title, + Content = post.Content, + CategoryId = post.CategoryId, + Tags = post.Tags, + SeoTitle = post.SeoTitle, + SeoDescription = post.SeoDescription, + ThumbnailUrl = post.ThumbnailUrl, IsPublished = isPublished, - post.AuthorId + AuthorId = post.AuthorId }); if (result == null) @@ -153,14 +146,13 @@ private async Task DeletePost(int postId) { - await ApiClient.DeleteAsync($"blog/{postId}"); + var deleted = await BlogClient.DeleteAsync(postId); + if (!deleted) + { + Snackbar.Add("포스트 삭제에 실패했습니다.", Severity.Error); + return; + } Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); await LoadPosts(); } - - private class PagedBlogResponse - { - public List Data { get; set; } = []; - public int Total { get; set; } - } } diff --git a/TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor b/TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor new file mode 100644 index 0000000..60a0fd6 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor @@ -0,0 +1,177 @@ +@page "/admin/common-codes" +@using TaxBaik.Web.Services.AdminClients +@using TaxBaik.Domain.Entities +@attribute [Authorize] +@inject ICommonCodeBrowserClient CommonCodeClient +@inject ISnackbar Snackbar + +공통관리 + +
+
+ System + 공통관리 + 공통코드 그룹과 항목을 일관된 기준으로 관리합니다. +
+
+ + + + + 그룹 + + @foreach (var group in groups) + { + @group + } + + 새 코드 추가 + + + + + + @if (isLoading) + { + + } + else + { + + + 그룹 + + 이름 + 순서 + 상태 + 작업 + + + @context.CodeGroup + @context.CodeValue + @context.CodeName + @context.SortOrder + @(context.IsActive ? "활성" : "비활성") + + 수정 + 삭제 + + + + + + + + + + + + 활성 +
+ 저장 + 초기화 +
+
+ } +
+
+
+ +@code { + private List groups = []; + private List codes = []; + private string selectedGroup = ""; + private bool isLoading = true; + private MudForm? form; + private CommonCode editModel = new(); + private bool isCreateMode = true; + + protected override async Task OnInitializedAsync() + { + groups = await CommonCodeClient.GetGroupsAsync(); + selectedGroup = groups.FirstOrDefault() ?? ""; + await LoadCodes(); + PrepareCreate(); + } + + private async Task OnGroupChanged(string value) + { + selectedGroup = value; + await LoadCodes(); + PrepareCreate(); + } + + private async Task LoadCodes() + { + isLoading = true; + codes = string.IsNullOrWhiteSpace(selectedGroup) + ? [] + : await CommonCodeClient.GetByGroupAsync(selectedGroup); + isLoading = false; + } + + private void PrepareCreate() + { + isCreateMode = true; + editModel = new CommonCode + { + CodeGroup = selectedGroup, + IsActive = true + }; + } + + private void EditCode(CommonCode code) + { + isCreateMode = false; + editModel = new CommonCode + { + CodeGroup = code.CodeGroup, + CodeValue = code.CodeValue, + CodeName = code.CodeName, + SortOrder = code.SortOrder, + IsActive = code.IsActive + }; + } + + private async Task SaveCode() + { + if (form != null) + { + await form.Validate(); + if (!form.IsValid) + { + Snackbar.Add("필수 항목을 입력하세요.", Severity.Warning); + return; + } + } + + if (editModel.CodeValue.Contains(' ')) + { + Snackbar.Add("code_value에는 공백을 넣을 수 없습니다.", Severity.Error); + return; + } + + if (!await CommonCodeClient.UpsertAsync(editModel)) + { + Snackbar.Add("저장 실패", Severity.Error); + return; + } + + Snackbar.Add("저장되었습니다.", Severity.Success); + await LoadCodes(); + PrepareCreate(); + } + + private async Task DeleteCode(CommonCode code) + { + if (!await CommonCodeClient.DeleteAsync(code.CodeGroup, code.CodeValue)) + { + Snackbar.Add("삭제 실패", Severity.Error); + return; + } + + Snackbar.Add("삭제되었습니다.", Severity.Success); + await LoadCodes(); + PrepareCreate(); + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor index 239504b..d78c3e1 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor @@ -1,5 +1,6 @@ @page "/admin/contracts" @using TaxBaik.Web.Services.AdminClients +@using TaxBaik.Web.Components.Admin.Shared @inject IContractBrowserClient ContractClient @inject IClientBrowserClient ClientClient @inject ISnackbar Snackbar @@ -122,14 +123,7 @@ else } - - 개인 기장대리 - 법인 기장대리 - 세무조정 대행 - 양도세 신고대리 - 상속·증여 자문 - 세무조사 대응 - + @@ -162,19 +156,15 @@ else private Contract? selectedContract; private ContractForm contractForm = new(); - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) + if (AuthStateTask != null) { - if (AuthStateTask != null) + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) - { - await LoadData(); - PrepareCreate(); - StateHasChanged(); - } + await LoadData(); + PrepareCreate(); } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor index 50826e1..eac16a0 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor @@ -1,6 +1,7 @@ @page "/admin/dashboard" @attribute [Authorize] @using TaxBaik.Web.Services +@using TaxBaik.Web.Components.Admin.Shared @inject IAdminDashboardClient DashboardClient @inject NavigationManager Nav @@ -95,7 +96,8 @@ @foreach (var f in upcomingFilings) { - var dday = (f.DueDate.Date - DateTime.Today).Days; + var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(f.DueDate)); + var effectiveDueDate = BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(f.DueDate)); @@ -103,7 +105,7 @@ @f.FilingType - @f.DueDate.ToString("yyyy-MM-dd") + @effectiveDueDate.ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd") @if (dday < 0) { @@ -175,35 +177,30 @@ private string? errorMessage; private bool isLoading = true; - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) + if (AuthStateTask != null) { - if (AuthStateTask != null) + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + try { - try - { - // API 클라이언트 사용 (서비스 직접 호출 X) - var summaryTask = DashboardClient.GetSummaryAsync(); - var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30); + var summaryTask = DashboardClient.GetSummaryAsync(); + var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30); - await Task.WhenAll(summaryTask, filingsTask); - summary = await summaryTask; - upcomingFilings = (await filingsTask).ToList(); - } - catch (Exception ex) - { - errorMessage = "대시보드 데이터를 불러올 수 없습니다."; - Console.Error.WriteLine($"Dashboard error: {ex.Message}"); - } - finally - { - isLoading = false; - StateHasChanged(); - } + await Task.WhenAll(summaryTask, filingsTask); + summary = await summaryTask; + upcomingFilings = (await filingsTask).ToList(); + } + catch (Exception ex) + { + errorMessage = "대시보드 데이터를 불러올 수 없습니다."; + Console.Error.WriteLine($"Dashboard error: {ex.Message}"); + } + finally + { + isLoading = false; } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor index 7a40ff8..beb3c3a 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor @@ -5,15 +5,12 @@ 문의 관리 -
-
- Customer Requests - 문의 관리 - 상담 요청을 상태별로 확인하고 후속 조치를 기록합니다. -
- 새 문의 등록 -
+ + + 새 문의 등록 + + @if (isLoading) @@ -52,18 +49,14 @@ else private bool isLoading = true; private IReadOnlyList allInquiries = []; - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) + if (AuthStateTask != null) { - if (AuthStateTask != null) + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) - { - await LoadData(); - StateHasChanged(); - } + await LoadData(); } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor index 48404b0..c1358c5 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor @@ -1,5 +1,7 @@ @page "/admin/tax-filing-schedules" @using TaxBaik.Web.Services.AdminClients +@using TaxBaik.Domain.Entities +@using TaxBaik.Web.Components.Admin.Shared @inject ITaxFilingScheduleBrowserClient TaxFilingClient @inject IClientBrowserClient ClientClient @inject ISnackbar Snackbar @@ -61,11 +63,12 @@ else @{ - var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days; + var effectiveDueDate = BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(context.Item.DueDate)); + var daysLeft = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(context.Item.DueDate)); var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success; } - @context.Item.DueDate.ToString("yyyy-MM-dd") + @effectiveDueDate.ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd") @if (daysLeft >= 0) { (D-@daysLeft) @@ -139,16 +142,7 @@ else @GetClientDisplayName(client) } - - 종합소득세 - 부가가치세 - 법인세 - 원천세 - 종합부동산세 - 양도소득세 - 상속·증여세 - 세무조정 - + @@ -224,7 +218,8 @@ else { FilingYear = DateTime.Now.Year, DueDate = DateTime.Today, - ClientId = clients.FirstOrDefault()?.Id + ClientId = clients.FirstOrDefault()?.Id, + FilingType = string.Empty }; } diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor index fa13abd..9baf7f0 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor @@ -1,8 +1,8 @@ @page "/admin/tax-profiles" @using TaxBaik.Web.Services.AdminClients +@using TaxBaik.Web.Components.Admin.Shared @inject ITaxProfileBrowserClient TaxProfileClient @inject IClientBrowserClient ClientClient -@inject ICommonCodeBrowserClient CommonCodeClient @inject ISnackbar Snackbar @inject IDialogService DialogService @attribute [Authorize] @@ -100,18 +100,8 @@ else @GetClientDisplayName(client) } - - @foreach (var type in businessTypes) - { - @type.CodeName - } - - - @foreach (var level in riskLevels) - { - @level.CodeName - } - + + @@ -135,26 +125,21 @@ else private List? profiles; private List clients = []; private Dictionary clientMap = new(); - private List businessTypes = []; private List riskLevels = []; private MudForm? form; private bool isEditMode; private TaxProfile? selectedProfile; private TaxProfileForm profileForm = new(); - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnInitializedAsync() { - if (firstRender) + if (AuthStateTask != null) { - if (AuthStateTask != null) + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) - { - await LoadData(); - PrepareCreate(); - StateHasChanged(); - } + await LoadData(); + PrepareCreate(); } } } @@ -168,31 +153,6 @@ else clients = clientItems.ToList(); clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); - businessTypes = await CommonCodeClient.GetByGroupAsync("BUSINESS_TYPE"); - if (businessTypes.Count == 0) - { - businessTypes = [ - new() { CodeValue = "일반제조업", CodeName = "일반제조업" }, - new() { CodeValue = "도소매업", CodeName = "도소매업" }, - new() { CodeValue = "서비스업", CodeName = "서비스업" }, - new() { CodeValue = "정보통신업", CodeName = "정보통신업" }, - new() { CodeValue = "부동산업", CodeName = "부동산업" }, - new() { CodeValue = "건설업", CodeName = "건설업" }, - new() { CodeValue = "음식점업", CodeName = "음식점업" }, - new() { CodeValue = "프리랜서", CodeName = "프리랜서" }, - new() { CodeValue = "기타", CodeName = "기타" } - ]; - } - - riskLevels = await CommonCodeClient.GetByGroupAsync("TAX_RISK_LEVEL"); - if (riskLevels.Count == 0) - { - riskLevels = [ - new() { CodeValue = "low", CodeName = "낮음" }, - new() { CodeValue = "normal", CodeName = "보통" }, - new() { CodeValue = "high", CodeName = "높음" } - ]; - } } catch (Exception ex) { diff --git a/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs b/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs new file mode 100644 index 0000000..087c9c7 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs @@ -0,0 +1,88 @@ +namespace TaxBaik.Web.Components.Admin.Shared; + +public static class BusinessDayCalculator +{ + private sealed record HolidayWindow(DateOnly Start, DateOnly End) + { + public IEnumerable Dates() + { + for (var date = Start; date <= End; date = date.AddDays(1)) + { + yield return date; + } + } + } + + private static readonly HolidayWindow[] HolidayWindows = + { + new(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 1)), + new(new DateOnly(2026, 2, 16), new DateOnly(2026, 2, 18)), + new(new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 2)), + new(new DateOnly(2026, 5, 5), new DateOnly(2026, 5, 5)), + new(new DateOnly(2026, 6, 6), new DateOnly(2026, 6, 6)), + new(new DateOnly(2026, 8, 15), new DateOnly(2026, 8, 17)), + new(new DateOnly(2026, 9, 24), new DateOnly(2026, 9, 26)), + new(new DateOnly(2026, 10, 3), new DateOnly(2026, 10, 5)), + new(new DateOnly(2026, 10, 9), new DateOnly(2026, 10, 9)), + new(new DateOnly(2026, 12, 25), new DateOnly(2026, 12, 25)) + }; + + private static readonly HashSet HolidayDates = BuildHolidayDates(); + + public static DateOnly GetEffectiveDueDate(DateOnly dueDate) + { + var effectiveDate = dueDate; + while (!IsBusinessDay(effectiveDate)) + { + effectiveDate = effectiveDate.AddDays(1); + } + + return effectiveDate; + } + + public static int GetDday(DateOnly dueDate, DateOnly? referenceDate = null) + { + var today = referenceDate ?? DateOnly.FromDateTime(DateTime.Today); + var effectiveDueDate = GetEffectiveDueDate(dueDate); + return effectiveDueDate.DayNumber - today.DayNumber; + } + + public static bool IsBusinessDay(DateOnly date) + => date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday + && !HolidayDates.Contains(date); + + private static HashSet BuildHolidayDates() + { + var holidays = new HashSet(); + + foreach (var window in HolidayWindows) + { + foreach (var date in window.Dates()) + { + holidays.Add(date); + } + } + + // 주말과 연속 공휴일 뒤에 붙는 대체휴일을 다음 영업일로 자동 확장한다. + foreach (var window in HolidayWindows) + { + foreach (var date in window.Dates()) + { + if (date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) + { + continue; + } + + var substitute = date.AddDays(1); + while (substitute.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(substitute)) + { + substitute = substitute.AddDays(1); + } + + holidays.Add(substitute); + } + } + + return holidays; + } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/CommonCodeSelect.razor b/TaxBaik.Web/Components/Admin/Shared/CommonCodeSelect.razor new file mode 100644 index 0000000..f0e4fdf --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/CommonCodeSelect.razor @@ -0,0 +1,56 @@ +@using TaxBaik.Domain.Entities +@using TaxBaik.Web.Services.AdminClients +@inject ICommonCodeBrowserClient CommonCodeClient + + + @if (!string.IsNullOrWhiteSpace(Placeholder)) + { + @Placeholder + } + @foreach (var item in items) + { + @item.CodeName + } + + +@code { + [Parameter] public string? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public string Group { get; set; } = string.Empty; + [Parameter] public string Label { get; set; } = string.Empty; + [Parameter] public Variant Variant { get; set; } = Variant.Outlined; + [Parameter] public bool FullWidth { get; set; } = true; + [Parameter] public string? Class { get; set; } + [Parameter] public bool Required { get; set; } + [Parameter] public bool Clearable { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public string? Placeholder { get; set; } + + private List items = []; + + protected override async Task OnParametersSetAsync() + { + var normalizedGroup = Group?.Trim() ?? string.Empty; + if (!string.Equals(normalizedGroup, _loadedGroup, StringComparison.OrdinalIgnoreCase)) + { + _loadedGroup = normalizedGroup; + items = string.IsNullOrWhiteSpace(normalizedGroup) + ? [] + : (await CommonCodeClient.GetByGroupAsync(normalizedGroup)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.CodeName) + .ToList(); + } + } + + private string? _loadedGroup; +} diff --git a/TaxBaik.Web/Controllers/CommonCodeController.cs b/TaxBaik.Web/Controllers/CommonCodeController.cs index 4cdc09b..8d70f49 100644 --- a/TaxBaik.Web/Controllers/CommonCodeController.cs +++ b/TaxBaik.Web/Controllers/CommonCodeController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using TaxBaik.Application.Services; +using TaxBaik.Domain.Entities; namespace TaxBaik.Web.Controllers; @@ -36,4 +37,44 @@ public class CommonCodeController(CommonCodeService commonCodeService) : Control return StatusCode(500, new { error = "그룹별 공통코드 조회 실패", message = ex.Message }); } } + + [HttpGet("groups")] + public async Task GetGroups() + { + try + { + var groups = await commonCodeService.GetAllGroupsAsync(); + return Ok(groups); + } + catch (Exception ex) + { + return StatusCode(500, new { error = "공통코드 그룹 조회 실패", message = ex.Message }); + } + } + + [HttpGet("{group}/{value}")] + public async Task Get(string group, string value) + { + var code = await commonCodeService.GetAsync(group, value); + return code is null ? NotFound() : Ok(code); + } + + [HttpPost] + public async Task Upsert([FromBody] CommonCode code) + { + if (string.IsNullOrWhiteSpace(code.CodeGroup) || string.IsNullOrWhiteSpace(code.CodeValue) || string.IsNullOrWhiteSpace(code.CodeName)) + return BadRequest(new { error = "코드 그룹, 값, 이름은 필수입니다." }); + if (code.CodeValue.Contains(' ')) + return BadRequest(new { error = "code_value에는 공백을 사용할 수 없습니다." }); + + await commonCodeService.UpsertAsync(code); + return Ok(code); + } + + [HttpDelete("{group}/{value}")] + public async Task Delete(string group, string value) + { + await commonCodeService.DeleteAsync(group, value); + return NoContent(); + } }