diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8f43a9b..46d91d1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -84,6 +84,9 @@ jobs: - name: Validate admin render mode run: bash scripts/validate_admin_render.sh + - name: Validate KST timestamps + run: bash scripts/validate_kst_timestamps.sh + - name: Generate build info run: | COMMIT_HASH=$(git rev-parse --short HEAD) @@ -127,7 +130,7 @@ jobs: run: | set -e export TAXBAIK_DEPLOY_FROM_CI=1 - TIMESTAMP=$(date +%Y%m%d_%H%M%S) + TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S) COMMIT=$(git rev-parse --short HEAD) DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}" DEPLOY_USER="${{ secrets.DEPLOY_USER }}" diff --git a/TaxBaik.Web.Client/Services/CustomAuthenticationStateProvider.cs b/TaxBaik.Web.Client/Services/CustomAuthenticationStateProvider.cs index e46b81f..2c3cae2 100644 --- a/TaxBaik.Web.Client/Services/CustomAuthenticationStateProvider.cs +++ b/TaxBaik.Web.Client/Services/CustomAuthenticationStateProvider.cs @@ -38,7 +38,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider { var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken"); var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry"); - if (long.TryParse(ticksStr, out var ticks)) + if (TryNormalizeExpiryTicks(ticksStr, out var ticks)) { _tokenStore.AccessToken = storedToken; _tokenStore.RefreshToken = refreshToken; @@ -130,6 +130,30 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider 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분) diff --git a/TaxBaik.Web/Components/Admin/App.razor b/TaxBaik.Web/Components/Admin/App.razor index b190a80..7a909ad 100644 --- a/TaxBaik.Web/Components/Admin/App.razor +++ b/TaxBaik.Web/Components/Admin/App.razor @@ -1,4 +1,5 @@ @using Microsoft.AspNetCore.Components.Web +@inject VersionInfo VersionInfo @@ -12,6 +13,8 @@ + diff --git a/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor b/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor index ff6cd6e..6b75cf5 100644 --- a/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor +++ b/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor @@ -3,31 +3,36 @@ @using TaxBaik.Web.Components.Admin.Shared - + + - + - + + - + + - + - + - + + -
- - @ButtonText - - 취소 -
+
@code { diff --git a/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor b/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor index 4f3f76d..e5b2bd0 100644 --- a/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor @@ -1,3 +1,5 @@ @inherits LayoutComponentBase + + @Body diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index 78b995f..11d1ac8 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -1,141 +1,7 @@ @inherits LayoutComponentBase -@inject NavigationManager Navigation -@inject IJSRuntime JS -@inject VersionInfo VersionInfo -@implements IDisposable @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) - - - - - - - -
- [TaxBaik] - 세무회계 관리 대시보드 -
- - - -
- - - 공개 사이트 - - - - - - - - 로그아웃 - - -
-
- - -
-
T
-
- TaxBaik - 세무 운영 콘솔 -
-
- - 대시보드 - - - 세무 프로필 - 신고 일정 - 계약 관리 - 상담 활동 - 수익 추적 - - - - 고객 카드 - 세무신고 - - - - 공지사항 - FAQ 관리 - 블로그 관리 - 시즌 시뮬레이터 - - - 문의 관리 - 설정 - 공통관리 - - -
-
Version
-
v@(VersionInfo.Version)
-
@VersionInfo.Built
-
-
- - - - @Body - - -
- -@code { - private bool drawerOpen = true; - private bool expandedCRMGroup = true; - private bool expandedCustomerGroup = false; - private bool expandedWebsiteGroup = false; - - protected override void OnInitialized() - { - Navigation.LocationChanged += OnLocationChanged; - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - await JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading"); - } - } - - private void OnLocationChanged(object? sender, LocationChangedEventArgs args) - { - _ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading")); - } - - private void ToggleDrawer() - { - drawerOpen = !drawerOpen; - } - - public void Dispose() - { - Navigation.LocationChanged -= OnLocationChanged; - } -} + + + @Body + diff --git a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor index 5cf1ab7..47630ee 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor @@ -27,10 +27,9 @@ AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" /> - + @if (announcements is null) { - } else if (!FilteredAnnouncements.Any()) { @@ -98,7 +97,7 @@ 검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개 } - + @code { [CascadingParameter] @@ -107,6 +106,14 @@ private List? announcements; private string searchQuery = ""; + private RenderFragment AnnouncementSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 4); + builder.CloseComponent(); + }; + private IEnumerable FilteredAnnouncements => announcements? .Where(a => string.IsNullOrEmpty(searchQuery) || a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)) diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor index 9f3986b..df5faf4 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor @@ -9,18 +9,15 @@ 새 포스트 작성 -
-
- Content - 새 포스트 작성 - 새로운 블로그 포스트를 작성합니다. -
- 취소 -
- - - - + + + + + @code { private IReadOnlyList categories = []; diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor index 2c0aa94..82de99d 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor @@ -10,20 +10,13 @@ 포스트 수정 -
-
- Content - 포스트 수정 - 블로그 포스트를 수정합니다. -
- 취소 -
- -@if (isLoading) -{ - -} -else if (post == null) + + @if (post == null) { 포스트를 찾을 수 없습니다. } @@ -36,6 +29,7 @@ else } + @code { [Parameter] @@ -46,6 +40,14 @@ else private BlogForm.BlogFormModel model = new(); private bool isLoading = true; + private RenderFragment EditorSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 3); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { try diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogForm.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogForm.razor index 029752d..41259ce 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogForm.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogForm.razor @@ -2,39 +2,43 @@ @using TaxBaik.Domain.Entities - + + - - @foreach (var category in Categories) - { - @category.Name - } - + + @foreach (var category in Categories) + { + @category.Name + } + - + + - + + - + - + - + + -
- @SubmitText - @if (OnCancel.HasDelegate) - { - 취소 - } -
+
@code { diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor index 874d650..097dba8 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor @@ -25,47 +25,47 @@ AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" /> - - + + @($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개") 페이지 @currentPage / @totalPages - - - - - - - - - - - - - - 수정하기 - @if (showArchived) - { - 복원 - } - else - { - 삭제 - } - - - - + + + + + + + + + + + + + 수정하기 + @if (showArchived) + { + 복원 + } + else + { + 삭제 + } + + + + - - 이전 - 다음 - + + 이전 + 다음 + + @code { [CascadingParameter] diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor index 8300b25..129b59b 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor @@ -20,10 +20,9 @@ StartIcon="@Icons.Material.Filled.ArrowBack">목록으로 - + @if (isLoading) { - } else { @@ -92,7 +91,7 @@ } - + @code { [Parameter] public int? Id { get; set; } @@ -102,6 +101,14 @@ private bool isValid; private bool isLoading = true; private bool isSaving; + + private RenderFragment ClientEditSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 6); + builder.AddAttribute(2, "Columns", 3); + builder.CloseComponent(); + }; protected override async Task OnInitializedAsync() { if (Id.HasValue) diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor index 17f813f..6d8001a 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor @@ -39,10 +39,9 @@ - + @if (clients is null) { - } else if (!clients.Any()) { @@ -116,7 +115,7 @@ } 총 @(totalCount)명 } - + @code { [CascadingParameter] @@ -130,6 +129,14 @@ private int totalPages; private const int PageSize = 20; + private RenderFragment ClientListSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 5); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { if (AuthStateTask != null) diff --git a/TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor b/TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor index 60a0fd6..92364dc 100644 --- a/TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor +++ b/TaxBaik.Web/Components/Admin/Pages/CommonCodes.razor @@ -7,73 +7,26 @@ 공통관리 -
-
- System - 공통관리 - 공통코드 그룹과 항목을 일관된 기준으로 관리합니다. -
-
+ - - 그룹 - - @foreach (var group in groups) - { - @group - } - - 새 코드 추가 - + - - @if (isLoading) - { - - } - else - { - - - 그룹 - - 이름 - 순서 - 상태 - 작업 - - - @context.CodeGroup - @context.CodeValue - @context.CodeName - @context.SortOrder - @(context.IsActive ? "활성" : "비활성") - - 수정 - 삭제 - - - - - - - - - - - - 활성 -
- 저장 - 초기화 -
-
- } -
+
@@ -82,7 +35,6 @@ private List codes = []; private string selectedGroup = ""; private bool isLoading = true; - private MudForm? form; private CommonCode editModel = new(); private bool isCreateMode = true; @@ -135,16 +87,6 @@ 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); diff --git a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor index deb247e..cbc8fc5 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor @@ -17,26 +17,24 @@ 취소 -@if (isLoading) -{ - -} -else if (formModel == null) -{ - 고객사를 찾을 수 없습니다. -} -else -{ - - + + @if (formModel == null) + { + 고객사를 찾을 수 없습니다. + } + else + { + + - + - - 고객사 삭제 - - -} + + 고객사 삭제 + + + } + @code { [Parameter] @@ -45,6 +43,14 @@ else private CompanyForm.CompanyFormModel? formModel; private bool isLoading = true; + private RenderFragment CompanySkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 6); + builder.AddAttribute(2, "Columns", 3); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { try diff --git a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor index 29143b9..8efdd8f 100644 --- a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor +++ b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor @@ -20,10 +20,9 @@ - + @if (activities is null) { - } else if (activities.Count == 0) { @@ -90,7 +89,7 @@ } - + @@ -128,6 +127,14 @@ private ConsultingActivity? editingActivity; private ConsultingActivityForm activityForm = new(); + private RenderFragment ActivitySkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 4); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { if (AuthStateTask != null) diff --git a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor index d78c3e1..4d620f1 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor @@ -27,9 +27,9 @@ + @if (contracts is null) { - } else { @@ -142,6 +142,7 @@ else } + @code { [CascadingParameter] @@ -156,6 +157,14 @@ else private Contract? selectedContract; private ContractForm contractForm = new(); + private RenderFragment ContractSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 6); + builder.AddAttribute(2, "Columns", 4); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { if (AuthStateTask != null) diff --git a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor index eac16a0..003dfaf 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor @@ -22,151 +22,109 @@ { @errorMessage } -@if (isLoading) -{ - -} - -
-
-
- 이번달 문의 -
- @summary.ThisMonthInquiries - 💬 -
- 월간 상담 유입 (클릭 시 이동) -
+ +
+ + + +
-
-
- 신규 문의 -
- @summary.NewInquiries - ⚠️ + @if (upcomingFilings.Count > 0) + { + +
+
+ 이번 달 마감 임박 신고 + 30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결) +
+ 전체 일정 보기
- 처리 대기 (클릭 시 이동) -
-
+ + + + 고객 + 신고 유형 + 기한 + D-day + + + + @foreach (var f in upcomingFilings) + { + var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(f.DueDate)); + var effectiveDueDate = BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(f.DueDate)); + + + + @f.ClientName + + + @f.FilingType + @effectiveDueDate.ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd") + + @if (dday < 0) + { + 기한 초과 (@(-dday)일) + } + else if (dday <= 7) + { + D-@dday + } + else + { + D-@dday + } + + + } + + + + } -
-
- 전체 포스트 -
- @summary.TotalPosts - 📄 -
- 콘텐츠 자산 (클릭 시 이동) -
-
- -
-
- 발행된 포스트 -
- @summary.PublishedPosts - 🌐 -
- 검색 노출 대상 (클릭 시 이동) -
-
-
- -@if (upcomingFilings.Count > 0) -{
- 이번 달 마감 임박 신고 - 30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결) + 최근 문의 + 최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)
- 전체 일정 보기 + 문의 전체 보기
- 고객 - 신고 유형 - 기한 - D-day + 이름 + 전화 + 분야 + 상태 + 날짜 - @foreach (var f in upcomingFilings) + @foreach (var inquiry in summary.RecentInquiries) { - var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(f.DueDate)); - var effectiveDueDate = BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(f.DueDate)); - - @f.ClientName + + @inquiry.Name - @f.FilingType - @effectiveDueDate.ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd") + @inquiry.Phone + @inquiry.ServiceType - @if (dday < 0) - { - 기한 초과 (@(-dday)일) - } - else if (dday <= 7) - { - D-@dday - } - else - { - D-@dday - } + + @GetStatusLabel(inquiry.Status) + + @inquiry.CreatedAt.ToString("yyyy-MM-dd") }
-} - - -
-
- 최근 문의 - 최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계) -
- 문의 전체 보기 -
- - - - 이름 - 전화 - 분야 - 상태 - 날짜 - - - - @foreach (var inquiry in summary.RecentInquiries) - { - - - - @inquiry.Name - - - @inquiry.Phone - @inquiry.ServiceType - - - @GetStatusLabel(inquiry.Status) - - - @inquiry.CreatedAt.ToString("yyyy-MM-dd") - - } - - -
+
@code { [CascadingParameter] @@ -177,6 +135,29 @@ private string? errorMessage; private bool isLoading = true; + private RenderFragment DashboardSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 6); + builder.AddAttribute(2, "Columns", 4); + builder.CloseComponent(); + }; + + private void GoInquiries() + { + Nav.NavigateTo("/taxbaik/admin/inquiries"); + } + + private void GoNewInquiries() + { + Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"); + } + + private void GoBlog() + { + Nav.NavigateTo("/taxbaik/admin/blog"); + } + protected override async Task OnInitializedAsync() { if (AuthStateTask != null) @@ -210,11 +191,11 @@ private static Color StatusColor(string status) => status switch { - "new" => Color.Warning, + "new" => Color.Warning, "consulting" => Color.Info, "contracted" => Color.Success, - "rejected" => Color.Error, - "closed" => Color.Dark, - _ => Color.Default + "rejected" => Color.Error, + "closed" => Color.Dark, + _ => Color.Default }; } diff --git a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor index 30f18b6..3c051ed 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor @@ -18,13 +18,8 @@ StartIcon="@Icons.Material.Filled.ArrowBack">목록으로 - - @if (isLoading) - { - - } - else - { + + @@ -68,8 +63,8 @@ - } - + + @code { [Parameter] public int? Id { get; set; } @@ -80,6 +75,14 @@ private bool isLoading = true; private bool isSaving; + private RenderFragment FaqSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 3); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { if (Id.HasValue) diff --git a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor index d844b5c..c3473ca 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor @@ -27,10 +27,9 @@ AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
- + @if (faqs is null) { - } else if (!FilteredFaqs.Any()) { @@ -101,7 +100,7 @@ 검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개 } - + @code { [CascadingParameter] @@ -110,6 +109,14 @@ private List? faqs; private string searchQuery = ""; + private RenderFragment FaqListSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 4); + builder.CloseComponent(); + }; + private IEnumerable FilteredFaqs => faqs? .Where(f => string.IsNullOrEmpty(searchQuery) || f.Question.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) || diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor index 57690f7..14c57cc 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor @@ -8,18 +8,15 @@ 문의 등록 -
-
- Customer Relations - 새 문의 등록 - 고객 문의를 등록합니다. (전화, 오프라인 등) -
- 취소 -
- - - - + + + + + @code { private void GoBack() diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor index f974cf1..a0d1084 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor @@ -26,8 +26,7 @@ - - 문의 정보 + 이름 @@ -56,20 +55,18 @@ @inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm") - + - - 담당자 메모 + 메모 저장 - + - - 처리 상태 + @foreach (var (key, label) in InquiryStatusMapper.Labels) { @@ -81,28 +78,26 @@ } - + @if (inquiry.ClientId == null) { - - 고객 카드 생성 + 이 문의를 고객 카드로 등록합니다. 고객으로 등록 - + } else { - - 연결된 고객 + 고객 카드 보기 - + } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor index 26fd419..f095722 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor @@ -9,20 +9,13 @@ 문의 수정 -
-
- Customer Relations - 문의 수정 - 고객 문의 정보를 수정합니다. -
- 취소 -
- -@if (isLoading) -{ - -} -else if (inquiry == null) + + @if (inquiry == null) { 문의를 찾을 수 없습니다. } @@ -38,6 +31,7 @@ else } + @code { [Parameter] @@ -47,6 +41,14 @@ else private InquiryForm.InquiryFormModel? formModel; private bool isLoading = true; + private RenderFragment EditorSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 3); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { try diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor index beb3c3a..ddaaf6b 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor @@ -12,13 +12,7 @@ - -@if (isLoading) -{ - -} -else -{ + @@ -39,8 +33,7 @@ else -} - + @code { [CascadingParameter] diff --git a/TaxBaik.Web/Components/Admin/Pages/Login.razor b/TaxBaik.Web/Components/Admin/Pages/Login.razor index 5a4dd8c..b6443bf 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Login.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Login.razor @@ -2,77 +2,5 @@ @layout TaxBaik.Web.Components.Admin.Layout.BlankLayout @attribute [AllowAnonymous] @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) -@inject IApiClient ApiClient -@inject ILocalStorageService LocalStorageService -@inject IJSRuntime Js - 로그인 - - - -@code { - private readonly LoginModel model = new(); - private const string RememberedUsernameKey = "admin-remembered-username"; - - protected override async Task OnInitializedAsync() - { - try - { - var remembered = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey); - if (!string.IsNullOrEmpty(remembered)) - { - model.Username = remembered; - } - } - catch - { - // LocalStorage may be unavailable during prerender. - } - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass"); - } - - private class LoginModel - { - public string Username { get; set; } = ""; - public string Password { get; set; } = ""; - public bool RememberMe { get; set; } - } -} + diff --git a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor index a58a760..910899d 100644 --- a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor +++ b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor @@ -20,10 +20,9 @@ - + @if (revenues is null) { - } else if (revenues.Count == 0) { @@ -85,7 +84,7 @@ } - + @@ -124,6 +123,14 @@ private bool isDialogOpen; private RevenueForm revenueForm = new(); + private RenderFragment RevenueSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 5); + builder.AddAttribute(2, "Columns", 5); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { if (AuthStateTask != null) diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor index 28d7422..b605b6f 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor @@ -21,9 +21,9 @@ + @if (schedules is null) { - } else { @@ -165,6 +165,7 @@ else } + @code { [CascadingParameter] @@ -177,6 +178,14 @@ else private bool isEditMode; private TaxFilingSchedule? selectedSchedule; private TaxFilingScheduleForm scheduleForm = new(); + + private RenderFragment ScheduleSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 6); + builder.AddAttribute(2, "Columns", 4); + builder.CloseComponent(); + }; protected override async Task OnInitializedAsync() { diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor index 9baf7f0..c6ba1d2 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor @@ -20,9 +20,9 @@ + @if (profiles == null) { - } else { @@ -117,6 +117,7 @@ else } + @code { [CascadingParameter] @@ -131,6 +132,14 @@ else private TaxProfile? selectedProfile; private TaxProfileForm profileForm = new(); + private RenderFragment ProfileSkeleton => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Rows", 6); + builder.AddAttribute(2, "Columns", 4); + builder.CloseComponent(); + }; + protected override async Task OnInitializedAsync() { if (AuthStateTask != null) diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminCrudPageShell.razor b/TaxBaik.Web/Components/Admin/Shared/AdminCrudPageShell.razor new file mode 100644 index 0000000..3e1dabc --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminCrudPageShell.razor @@ -0,0 +1,43 @@ +
+
+ @Eyebrow + @Title + @if (!string.IsNullOrWhiteSpace(Subtitle)) + { + @Subtitle + } +
+ + @CancelText + +
+ + + @ChildContent + + +@code { + [Parameter, EditorRequired] + public string Title { get; set; } = ""; + + [Parameter, EditorRequired] + public string Eyebrow { get; set; } = ""; + + [Parameter] + public string? Subtitle { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnCancel { get; set; } + + [Parameter] + public string CancelText { get; set; } = "취소"; + + [Parameter] + public bool Loading { get; set; } + + [Parameter] + public RenderFragment? SkeletonContent { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminDataPanel.razor b/TaxBaik.Web/Components/Admin/Shared/AdminDataPanel.razor new file mode 100644 index 0000000..b1d3371 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminDataPanel.razor @@ -0,0 +1,28 @@ + + @if (Loading) + { + @if (SkeletonContent is not null) + { + @SkeletonContent + } + else + { + + } + } + else if (ChildContent is not null) + { + @ChildContent + } + + +@code { + [Parameter] + public bool Loading { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public RenderFragment? SkeletonContent { get; set; } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminDetailSection.razor b/TaxBaik.Web/Components/Admin/Shared/AdminDetailSection.razor new file mode 100644 index 0000000..80ef814 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminDetailSection.razor @@ -0,0 +1,21 @@ + + @if (!string.IsNullOrWhiteSpace(Title)) + { + @Title + } + @ChildContent + + +@code { + [Parameter] + public string? Title { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string CssClass { get; set; } = "pa-4"; + + [Parameter] + public int Elevation { get; set; } = 1; +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminEditorPanel.razor b/TaxBaik.Web/Components/Admin/Shared/AdminEditorPanel.razor new file mode 100644 index 0000000..7d194f6 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminEditorPanel.razor @@ -0,0 +1,16 @@ + +
+ @ChildContent +
+
+ +@code { + [Parameter] + public bool Loading { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public RenderFragment? SkeletonContent { get; set; } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminFormActions.razor b/TaxBaik.Web/Components/Admin/Shared/AdminFormActions.razor new file mode 100644 index 0000000..654e075 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminFormActions.razor @@ -0,0 +1,44 @@ +
+ + @(IsSubmitting ? LoadingText : SubmitText) + + @if (OnCancel.HasDelegate) + { + + @CancelText + + } +
+ +@code { + [Parameter, EditorRequired] + public string SubmitText { get; set; } = "저장"; + + [Parameter] + public string LoadingText { get; set; } = "저장 중..."; + + [Parameter] + public string CancelText { get; set; } = "취소"; + + [Parameter] + public Variant SubmitVariant { get; set; } = Variant.Filled; + + [Parameter] + public Color SubmitColor { get; set; } = Color.Primary; + + [Parameter] + public string? SubmitIcon { get; set; } + + [Parameter] + public EventCallback OnSubmit { get; set; } + + [Parameter] + public EventCallback OnCancel { get; set; } + + [Parameter] + public bool IsSubmitting { get; set; } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminFormSection.razor b/TaxBaik.Web/Components/Admin/Shared/AdminFormSection.razor new file mode 100644 index 0000000..4f37740 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminFormSection.razor @@ -0,0 +1,25 @@ +
+ @if (!string.IsNullOrWhiteSpace(Title)) + { + @Title + } + @if (!string.IsNullOrWhiteSpace(Description)) + { + @Description + } + @ChildContent +
+ +@code { + [Parameter] + public string? Title { get; set; } + + [Parameter] + public string? Description { get; set; } + + [Parameter] + public string CssClass { get; set; } = ""; + + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor b/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor new file mode 100644 index 0000000..e4f3e75 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor @@ -0,0 +1,63 @@ +@inject ILocalStorageService LocalStorageService +@inject IJSRuntime Js + + + +@code { + private string rememberedUsername = ""; + private const string RememberedUsernameKey = "admin-remembered-username"; + + protected override async Task OnInitializedAsync() + { + try + { + rememberedUsername = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey) ?? ""; + } + catch + { + rememberedUsername = ""; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass"); + await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm"); + } + } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminMetricCard.razor b/TaxBaik.Web/Components/Admin/Shared/AdminMetricCard.razor new file mode 100644 index 0000000..c846eca --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminMetricCard.razor @@ -0,0 +1,36 @@ +
+
+ @Label +
+ @Value + @Icon +
+ @Caption +
+
+ +@code { + [Parameter, EditorRequired] + public string Label { get; set; } = ""; + + [Parameter, EditorRequired] + public object? Value { get; set; } + + [Parameter, EditorRequired] + public string Caption { get; set; } = ""; + + [Parameter, EditorRequired] + public string Accent { get; set; } = ""; + + [Parameter, EditorRequired] + public string Icon { get; set; } = ""; + + [Parameter] + public string ValueColor { get; set; } = "inherit"; + + [Parameter] + public string IconColor { get; set; } = "inherit"; + + [Parameter] + public EventCallback OnClick { get; set; } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminShell.razor b/TaxBaik.Web/Components/Admin/Shared/AdminShell.razor new file mode 100644 index 0000000..86beead --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminShell.razor @@ -0,0 +1,137 @@ +@inject NavigationManager Navigation +@inject IJSRuntime JS +@inject VersionInfo VersionInfo +@implements IDisposable + + + + + + + + +
+ TaxBaik + 세무회계 관리 대시보드 +
+ + +
+ + 공개 사이트 + + + + + + 로그아웃 + +
+
+ + +
+
T
+
+ TaxBaik + 세무 운영 콘솔 +
+
+ + 대시보드 + + 세무 프로필 + 신고 일정 + 계약 관리 + 상담 활동 + 수익 추적 + + + 고객 카드 + 세무신고 + + + 공지사항 + FAQ 관리 + 블로그 관리 + 시즌 시뮬레이터 + + 문의 관리 + 설정 + 공통관리 + +
+
Version
+
v@(VersionInfo.Version)
+
@VersionInfo.Built
+
+
+ + + + @ChildContent + + +
+ +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } + + private bool drawerOpen = true; + private bool expandedCRMGroup = true; + private bool expandedCustomerGroup = false; + private bool expandedWebsiteGroup = false; + + protected override void OnInitialized() + { + Navigation.LocationChanged += OnLocationChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("taxbaikAdminSession.setContext", "admin/shell", "navigation", "layout", "shell", "shell", "", "main"); + await JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading"); + } + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs args) + { + var route = new Uri(args.Location).AbsolutePath; + _ = JS.InvokeVoidAsync("taxbaikAdminSession.setContext", route, "navigation", "route-change", "layout", "shell", "", route); + _ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading")); + } + + private void ToggleDrawer() + { + drawerOpen = !drawerOpen; + _ = JS.InvokeVoidAsync("taxbaikAdminSession.setContext", "admin/shell", "navigation", "drawer", drawerOpen ? "opened" : "closed", "shell", "", "drawer"); + _ = JS.InvokeVoidAsync("taxbaikAdminSession.traceUiState", "admin-shell", drawerOpen ? "drawer opened" : "drawer closed"); + } + + public void Dispose() + { + Navigation.LocationChanged -= OnLocationChanged; + } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminSkeletonRows.razor b/TaxBaik.Web/Components/Admin/Shared/AdminSkeletonRows.razor new file mode 100644 index 0000000..ee5f9d4 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminSkeletonRows.razor @@ -0,0 +1,26 @@ +
+ @for (var i = 0; i < Rows; i++) + { +
+ @for (var j = 0; j < Columns; j++) + { +
+ } +
+ } +
+ +@code { + [Parameter] + public int Rows { get; set; } = 4; + + [Parameter] + public int Columns { get; set; } = 3; + + private static string GetWidthClass(int index) => index switch + { + 0 => "w-40", + 1 => "w-25", + _ => "w-20" + }; +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor b/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor new file mode 100644 index 0000000..0a423c4 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor @@ -0,0 +1,100 @@ +@using System.Text.RegularExpressions +@inject IJSRuntime Js +@inject NavigationManager Navigation + +@code { + [Parameter] public string Screen { get; set; } = ""; + [Parameter] public string Feature { get; set; } = ""; + [Parameter] public string Action { get; set; } = ""; + [Parameter] public string Step { get; set; } = ""; + [Parameter] public string Entity { get; set; } = ""; + [Parameter] public string EntityId { get; set; } = ""; + [Parameter] public string DataKey { get; set; } = ""; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + var route = GetRoute(); + var context = ResolveContext(route); + await Js.InvokeVoidAsync("taxbaikAdminSession.setContext", + string.IsNullOrWhiteSpace(Screen) ? context.Screen : Screen, + string.IsNullOrWhiteSpace(Feature) ? context.Feature : Feature, + string.IsNullOrWhiteSpace(Action) ? context.Action : Action, + string.IsNullOrWhiteSpace(Step) ? context.Step : Step, + string.IsNullOrWhiteSpace(Entity) ? context.Entity : Entity, + string.IsNullOrWhiteSpace(EntityId) ? context.EntityId : EntityId, + string.IsNullOrWhiteSpace(DataKey) ? context.DataKey : DataKey); + } + } + + private string GetRoute() + { + var path = Navigation.ToBaseRelativePath(Navigation.Uri); + return string.IsNullOrWhiteSpace(path) ? "/" : "/" + path.TrimStart('/'); + } + + private static (string Screen, string Feature, string Action, string Step, string Entity, string EntityId, string DataKey) ResolveContext(string route) + => route.ToLowerInvariant() switch + { + "/" => ("admin/index", "shell", "load", "index", "admin", "", "index"), + "/admin/login" => ("admin/login", "auth", "render", "login page", "auth", "", "login"), + "/admin/dashboard" => ("admin/dashboard", "dashboard", "load", "summary", "dashboard", "", "summary"), + "/admin/common-codes" => ("admin/common-codes", "common-code", "load", "group list", "common_code", "", "group"), + "/admin/blog" => ("admin/blog", "content", "load", "list", "blog", "", "list"), + "/admin/blog/create" => ("admin/blog/create", "content", "create", "form", "blog", "", "create"), + "/admin/blog/0/edit" => ("admin/blog/edit", "content", "edit", "form", "blog", "0", "edit"), + "/admin/inquiries" => ("admin/inquiries", "customer-request", "load", "list", "inquiry", "", "list"), + "/admin/inquiries/create" => ("admin/inquiries/create", "customer-request", "create", "form", "inquiry", "", "create"), + "/admin/settings" => ("admin/settings", "system", "load", "settings", "site_setting", "", "settings"), + "/admin/announcements" => ("admin/announcements", "content", "load", "list", "announcement", "", "list"), + "/admin/announcements/create" => ("admin/announcements/create", "content", "create", "form", "announcement", "", "create"), + "/admin/companies" => ("admin/companies", "company", "load", "list", "company", "", "list"), + "/admin/faqs" => ("admin/faqs", "faq", "load", "list", "faq", "", "list"), + "/admin/tax-profiles" => ("admin/tax-profiles", "tax-profile", "load", "list", "tax_profile", "", "list"), + "/admin/tax-filing-schedules" => ("admin/tax-filing-schedules", "schedule", "load", "list", "tax_filing_schedule", "", "list"), + "/admin/contracts" => ("admin/contracts", "crm", "load", "list", "contract", "", "list"), + "/admin/consulting-activities" => ("admin/consulting-activities", "crm", "load", "list", "consulting_activity", "", "list"), + "/admin/revenue-trackings" => ("admin/revenue-trackings", "crm", "load", "list", "revenue_tracking", "", "list"), + "/admin/clients" => ("admin/clients", "customer", "load", "list", "client", "", "list"), + "/admin/tax-filings" => ("admin/tax-filings", "tax-filing", "load", "list", "tax_filing", "", "list"), + "/admin/season-simulator" => ("admin/season-simulator", "schedule", "load", "simulator", "season", "", "simulator"), + _ => ResolveDynamicContext(route) + }; + + private static (string Screen, string Feature, string Action, string Step, string Entity, string EntityId, string DataKey) ResolveDynamicContext(string route) + { + var normalized = route.ToLowerInvariant().TrimEnd('/'); + + foreach (var pattern in new[] + { + ("/admin/blog/", "admin/blog/edit", "content", "edit", "form", "blog", "edit"), + ("/admin/announcements/", "admin/announcements/edit", "content", "edit", "form", "announcement", "edit"), + ("/admin/inquiries/", "admin/inquiries/edit", "customer-request", "edit", "form", "inquiry", "edit"), + ("/admin/clients/", "admin/clients/detail", "customer", "view", "detail", "client", "detail"), + ("/admin/companies/", "admin/companies/edit", "company", "edit", "form", "company", "edit"), + ("/admin/faqs/", "admin/faqs/edit", "faq", "edit", "form", "faq", "edit"), + ("/admin/tax-profiles/", "admin/tax-profiles/edit", "tax-profile", "edit", "form", "tax_profile", "edit"), + ("/admin/tax-filing-schedules/", "admin/tax-filing-schedules/edit", "schedule", "edit", "form", "tax_filing_schedule", "edit"), + }) + { + if (!normalized.StartsWith(pattern.Item1, StringComparison.OrdinalIgnoreCase)) + continue; + + var remainder = normalized[pattern.Item1.Length..].Trim('/'); + var id = ExtractLeadingId(remainder); + if (string.IsNullOrWhiteSpace(id)) + id = remainder.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? ""; + + return (pattern.Item2, pattern.Item3, pattern.Item4, pattern.Item5, pattern.Item6, id, pattern.Item7); + } + + return (route.Trim('/'), "admin", "load", "view", "admin", "", route.Trim('/')); + } + + private static string ExtractLeadingId(string value) + { + var match = Regex.Match(value, @"^\d+"); + return match.Success ? match.Value : ""; + } +} diff --git a/TaxBaik.Web/Components/Admin/Shared/CommonCodeGroupPanel.razor b/TaxBaik.Web/Components/Admin/Shared/CommonCodeGroupPanel.razor new file mode 100644 index 0000000..62d3215 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/CommonCodeGroupPanel.razor @@ -0,0 +1,35 @@ + + + + @foreach (var group in Groups) + { + @group + } + + + + + 새 코드 추가 + + + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Groups { get; set; } = []; + + [Parameter] + public string SelectedGroup { get; set; } = ""; + + [Parameter, EditorRequired] + public EventCallback SelectedGroupChanged { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnCreateRequested { get; set; } + + private Task OnSelectedGroupChanged(string value) => SelectedGroupChanged.InvokeAsync(value); +} diff --git a/TaxBaik.Web/Components/Admin/Shared/CommonCodeListPanel.razor b/TaxBaik.Web/Components/Admin/Shared/CommonCodeListPanel.razor new file mode 100644 index 0000000..457e07a --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/CommonCodeListPanel.razor @@ -0,0 +1,72 @@ + + + + + 그룹 + + 이름 + 순서 + 상태 + 작업 + + + @context.CodeGroup + @context.CodeValue + @context.CodeName + @context.SortOrder + @(context.IsActive ? "활성" : "비활성") + + 수정 + 삭제 + + + + + + + + + + + + + + 활성 +
+ 저장 + 초기화 +
+
+
+
+ +@code { + [Parameter] + public bool Loading { get; set; } + + [Parameter, EditorRequired] + public IReadOnlyList Codes { get; set; } = []; + + [Parameter, EditorRequired] + public CommonCode EditModel { get; set; } = new(); + + [Parameter] + public bool IsCreateMode { get; set; } + + [Parameter, EditorRequired] + public EventCallback EditRequested { get; set; } + + [Parameter, EditorRequired] + public EventCallback DeleteRequested { get; set; } + + [Parameter, EditorRequired] + public EventCallback SaveRequested { get; set; } + + [Parameter, EditorRequired] + public EventCallback ResetRequested { get; set; } + + private Task InvokeEditAsync(CommonCode code) => EditRequested.InvokeAsync(code); + private Task InvokeDeleteAsync(CommonCode code) => DeleteRequested.InvokeAsync(code); + private Task OnSaveRequested() => SaveRequested.InvokeAsync(); + private Task OnResetRequested() => ResetRequested.InvokeAsync(); +} diff --git a/TaxBaik.Web/Controllers/ClientLogsController.cs b/TaxBaik.Web/Controllers/ClientLogsController.cs new file mode 100644 index 0000000..8b5a159 --- /dev/null +++ b/TaxBaik.Web/Controllers/ClientLogsController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace TaxBaik.Web.Controllers; + +[ApiController] +[Route("api/client-logs")] +[AllowAnonymous] +[EnableRateLimiting("client-logs")] +public class ClientLogsController(ILogger logger) : ControllerBase +{ + [HttpPost] + public IActionResult Post([FromBody] ClientLogEntry entry) + { + if (string.IsNullOrWhiteSpace(entry.Message)) + { + return BadRequest(); + } + + logger.LogWarning( + "ClientLog {Level} {Source} {Message} Url={Url} Route={Route} Screen={Screen} Feature={Feature} Action={Action} Step={Step} Entity={Entity} EntityId={EntityId} DataKey={DataKey} BuildVersion={BuildVersion} UserAgent={UserAgent} Stack={Stack}", + entry.Level ?? "error", + entry.Source ?? "unknown", + entry.Message, + entry.Url ?? string.Empty, + entry.Route ?? string.Empty, + entry.Screen ?? string.Empty, + entry.Feature ?? string.Empty, + entry.Action ?? string.Empty, + entry.Step ?? string.Empty, + entry.Entity ?? string.Empty, + entry.EntityId ?? string.Empty, + entry.DataKey ?? string.Empty, + entry.BuildVersion ?? string.Empty, + entry.UserAgent ?? string.Empty, + entry.Stack ?? string.Empty); + + return Ok(); + } +} + +public sealed class ClientLogEntry +{ + public string? Level { get; set; } + public string? Source { get; set; } + public string? Message { get; set; } + public string? Url { get; set; } + public string? Route { get; set; } + public string? Screen { get; set; } + public string? Feature { get; set; } + public string? Action { get; set; } + public string? Step { get; set; } + public string? Entity { get; set; } + public string? EntityId { get; set; } + public string? DataKey { get; set; } + public string? BuildVersion { get; set; } + public string? UserAgent { get; set; } + public string? Stack { get; set; } +} diff --git a/TaxBaik.Web/Logging/TelegramSink.cs b/TaxBaik.Web/Logging/TelegramSink.cs index f1fb30b..341c6d3 100644 --- a/TaxBaik.Web/Logging/TelegramSink.cs +++ b/TaxBaik.Web/Logging/TelegramSink.cs @@ -51,6 +51,11 @@ public class TelegramSink : ILogEventSink var level = logEvent.Level.ToString().ToUpper(); var message = logEvent.RenderMessage(); var exceptionDetails = logEvent.Exception?.ToString(); + var fingerprint = $"{level}|{message}|{exceptionDetails ?? string.Empty}"; + if (!TaxBaik.Web.Services.TelegramAlertGate.ShouldSend("telegram:sink:error", fingerprint, TimeSpan.FromMinutes(10))) + { + return; + } var sb = new StringBuilder(); sb.AppendLine($"🚨 [{level}] 에러 발생"); diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index ee03f21..90c172f 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -7,10 +7,12 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.IdentityModel.Tokens; using MudBlazor.Services; using Serilog; +using System.Threading.RateLimiting; using TaxBaik.Application; using TaxBaik.Application.Services; using TaxBaik.Infrastructure; @@ -51,6 +53,23 @@ builder.Host.UseSerilog((context, config) => builder.Services.AddControllers(); builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy("client-logs", httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"client-logs:{ip}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + }); + }); +}); // Razor Pages + Blazor Server 통합 builder.Services.AddRazorPages(); @@ -351,6 +370,7 @@ app.UsePathBase("/taxbaik"); app.UseResponseCompression(); app.UseStaticFiles(); app.UseRouting(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); @@ -387,12 +407,14 @@ catch (Exception ex) { try { - using (var scope = app.Services.CreateScope()) + var fatalMessage = $"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}"; + if (TaxBaik.Web.Services.TelegramAlertGate.ShouldSend("telegram:fatal", fatalMessage, TimeSpan.FromMinutes(30))) { + using var scope = app.Services.CreateScope(); var telegramService = scope.ServiceProvider.GetRequiredService(); await telegramService.SendErrorAsync( "❌ 서버 오류", - $"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}"); + fatalMessage); } } catch (Exception telegramEx) diff --git a/TaxBaik.Web/Services/TelegramAlertGate.cs b/TaxBaik.Web/Services/TelegramAlertGate.cs new file mode 100644 index 0000000..482b29f --- /dev/null +++ b/TaxBaik.Web/Services/TelegramAlertGate.cs @@ -0,0 +1,57 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace TaxBaik.Web.Services; + +internal static class TelegramAlertGate +{ + private sealed record GateEntry(DateTimeOffset WindowStart, int Count); + + private static readonly ConcurrentDictionary Gates = new(); + + public static bool ShouldSend(string category, string content, TimeSpan window, int maxPerWindow = 1) + { + if (string.IsNullOrWhiteSpace(category)) + return false; + + var now = DateTimeOffset.UtcNow; + var key = $"{category}:{Fingerprint(content)}"; + + while (true) + { + if (!Gates.TryGetValue(key, out var current)) + { + var initial = new GateEntry(now, 1); + if (Gates.TryAdd(key, initial)) + return true; + continue; + } + + if (now - current.WindowStart >= window) + { + var reset = new GateEntry(now, 1); + if (Gates.TryUpdate(key, reset, current)) + return true; + continue; + } + + if (current.Count >= maxPerWindow) + return false; + + var incremented = current with { Count = current.Count + 1 }; + if (Gates.TryUpdate(key, incremented, current)) + return true; + } + } + + private static string Fingerprint(string content) + { + if (string.IsNullOrEmpty(content)) + return "empty"; + + var normalized = content.Length > 1500 ? content[..1500] : content; + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexString(bytes); + } +} diff --git a/TaxBaik.Web/Services/TelegramNotificationService.cs b/TaxBaik.Web/Services/TelegramNotificationService.cs index 990ff43..1eb47e6 100644 --- a/TaxBaik.Web/Services/TelegramNotificationService.cs +++ b/TaxBaik.Web/Services/TelegramNotificationService.cs @@ -47,14 +47,29 @@ public class TelegramNotificationService : ITelegramNotificationService return; } + if (!TelegramAlertGate.ShouldSend("telegram:default", message, TimeSpan.FromMinutes(5))) + return; + await SendToChat(_defaultChatId, message, ct); } - public async Task SendInquiryNotificationAsync(string message, CancellationToken ct = default) => - await SendToChat(_inquiryChatId, $"📋 문의 사항\n\n{message}", ct); + public async Task SendInquiryNotificationAsync(string message, CancellationToken ct = default) + { + var text = $"📋 문의 사항\n\n{message}"; + if (!TelegramAlertGate.ShouldSend("telegram:inquiry", text, TimeSpan.FromMinutes(10))) + return; - public async Task SendSystemNotificationAsync(string message, CancellationToken ct = default) => - await SendToChat(_systemChatId, $"🔧 시스템 알림\n\n{message}", ct); + await SendToChat(_inquiryChatId, text, ct); + } + + public async Task SendSystemNotificationAsync(string message, CancellationToken ct = default) + { + var text = $"🔧 시스템 알림\n\n{message}"; + if (!TelegramAlertGate.ShouldSend("telegram:system", text, TimeSpan.FromMinutes(10))) + return; + + await SendToChat(_systemChatId, text, ct); + } private async Task SendToChat(string chatId, string message, CancellationToken ct) { @@ -89,18 +104,27 @@ public class TelegramNotificationService : ITelegramNotificationService public async Task SendErrorAsync(string title, string details, CancellationToken ct = default) { var message = $"❌ {title}\n\n{details}\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"; + if (!TelegramAlertGate.ShouldSend("telegram:error", message, TimeSpan.FromMinutes(15))) + return; + await SendToChat(_systemChatId, message, ct); } public async Task SendInfoAsync(string title, string message, CancellationToken ct = default) { var text = $"ℹ️ {title}\n\n{message}\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"; - await SendMessageAsync(text, ct); + if (!TelegramAlertGate.ShouldSend("telegram:info", text, TimeSpan.FromMinutes(30))) + return; + + await SendToChat(_defaultChatId, text, ct); } public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default) { var text = $"📊 {reportTitle}\n\n{reportContent}\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"; + if (!TelegramAlertGate.ShouldSend("telegram:report", text, TimeSpan.FromHours(20))) + return; + await SendToChat(_systemChatId, text, ct); } } diff --git a/TaxBaik.Web/wwwroot/css/admin.css b/TaxBaik.Web/wwwroot/css/admin.css index 84687d2..d90cafa 100644 --- a/TaxBaik.Web/wwwroot/css/admin.css +++ b/TaxBaik.Web/wwwroot/css/admin.css @@ -12,21 +12,21 @@ :root { /* Color System */ - --primary-color: #1976D2; - --primary-light: #E3F2FD; - --primary-lighter: #BBDEFB; - --primary-dark: #1565C0; - --primary-darker: #0D47A1; + --primary-color: #1F4E79; + --primary-light: #E8F0F7; + --primary-lighter: #D6E3F0; + --primary-dark: #163A5C; + --primary-darker: #102D47; --primary-contrast: #FFFFFF; - --secondary-color: #2D9F7E; - --secondary-light: #E8F7F3; - --secondary-dark: #1D7A64; + --secondary-color: #2B6F6A; + --secondary-light: #E6F2F1; + --secondary-dark: #1F5854; --secondary-contrast: #FFFFFF; - --tertiary-color: #FF8A50; - --tertiary-light: #FFEBEE; - --tertiary-dark: #E65100; + --tertiary-color: #A96A3B; + --tertiary-light: #F4E9DF; + --tertiary-dark: #7E4D28; --tertiary-contrast: #FFFFFF; --success-color: #16A34A; @@ -53,14 +53,14 @@ --text-inverse: #FFFFFF; --bg-primary: #FFFFFF; - --bg-secondary: #F8F9FB; - --bg-tertiary: #F1F5F9; + --bg-secondary: #F4F7FA; + --bg-tertiary: #E9EEF4; --bg-overlay: rgba(15, 23, 42, 0.08); --bg-overlay-strong: rgba(15, 23, 42, 0.12); - --border-color: #E2E8F0; - --border-color-light: #F1F5F9; - --border-color-strong: #CBD5E1; + --border-color: #D6DFE8; + --border-color-light: #E6EDF3; + --border-color-strong: #B7C4D1; /* Spacing Scale */ --space-0: 0; @@ -445,9 +445,9 @@ textarea:focus-visible { display: flex; align-items: center; gap: 12px; - padding: 0px 12px; - height: 38px !important; - background-color: var(--bg-primary); + padding: 0 14px; + min-height: 44px !important; + background: linear-gradient(180deg, #FFFFFF 0%, #FAFCFE 100%); border-bottom: 1px solid var(--border-color); z-index: var(--z-dropdown); box-shadow: none !important; @@ -460,17 +460,23 @@ textarea:focus-visible { .admin-topbar-title { display: flex; flex-direction: column; - gap: 0; + gap: 1px; } .admin-topbar-title span { color: var(--text-primary); } -.admin-topbar-title .mud-typography--h6 { - font-size: 0.85rem; - line-height: 1.15; - font-weight: var(--font-weight-semibold); +.admin-brand-text { + font-size: 0.82rem !important; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.admin-brand-subtitle { + font-size: 0.86rem !important; + font-weight: 600 !important; + color: #1F2937 !important; } .admin-topbar-action { @@ -486,7 +492,7 @@ textarea:focus-visible { } .admin-drawer { - width: 208px; + width: 228px; background-color: var(--bg-primary); border-right: 1px solid var(--border-color); display: flex; @@ -667,8 +673,8 @@ textarea:focus-visible { /* Metric Card - Enterprise Grade */ .admin-metric-card { - padding: 10px; - border-radius: var(--radius-md); + padding: 12px; + border-radius: var(--radius-lg); background-color: var(--bg-primary); border: 1px solid var(--border-color); transition: all var(--transition-base); @@ -750,8 +756,8 @@ textarea:focus-visible { /* Card Accent Colors */ .accent-blue { - background: linear-gradient(135deg, var(--primary-light) 0%, #E3F2FD 100%); - border-color: #BBDEFB; + background: linear-gradient(135deg, var(--primary-light) 0%, #F7FAFC 100%); + border-color: #C9D8E6; color: var(--primary-dark); } @@ -761,7 +767,7 @@ textarea:focus-visible { } .accent-amber { - background: linear-gradient(135deg, #FFEBEE 0%, #FFE0B2 100%); + background: linear-gradient(135deg, #F7EFE8 0%, #F1E3D7 100%); border-color: var(--tertiary-color); color: var(--tertiary-dark); } @@ -783,7 +789,7 @@ textarea:focus-visible { } .accent-green { - background: linear-gradient(135deg, #DCFCE7 0%, #C8E6C9 100%); + background: linear-gradient(135deg, #E7F2EE 0%, #D7E8E3 100%); border-color: var(--success-color); color: var(--success-dark); } @@ -910,6 +916,32 @@ textarea:focus-visible { animation: loading 1.5s infinite; } +.admin-skeleton-stack { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.admin-skeleton-row { + display: grid; + grid-template-columns: 1.4fr 0.8fr 0.6fr; + gap: 12px; + align-items: center; +} + +.admin-skeleton-block { + height: 14px; + border-radius: 999px; + background: linear-gradient(90deg, var(--bg-overlay) 0%, var(--bg-overlay-strong) 50%, var(--bg-overlay) 100%); + background-size: 200% 100%; + animation: loading 1.4s infinite; +} + +.admin-skeleton-block.w-40 { width: 40%; } +.admin-skeleton-block.w-25 { width: 25%; } +.admin-skeleton-block.w-20 { width: 20%; } + @keyframes loading { 0% { background-position: 200% 0; diff --git a/TaxBaik.Web/wwwroot/js/admin-session.js b/TaxBaik.Web/wwwroot/js/admin-session.js index 8ac0352..4e64768 100644 --- a/TaxBaik.Web/wwwroot/js/admin-session.js +++ b/TaxBaik.Web/wwwroot/js/admin-session.js @@ -1,4 +1,191 @@ window.taxbaikAdminSession = { + clientLogState: { + enabled: true, + windowStart: 0, + sentCount: 0, + suppressedCount: 0, + fingerprints: {}, + eventCounts: {}, + screen: '', + feature: '', + action: '', + step: '', + entity: '', + entityId: '', + dataKey: '' + }, + + initErrorLogging: function () { + if (window._taxbaikClientLogInitialized) return; + window._taxbaikClientLogInitialized = true; + + const postLog = function (payload) { + try { + if (!window.taxbaikAdminSession.shouldSendClientLog(payload)) { + return; + } + + const body = JSON.stringify(payload); + if (navigator.sendBeacon) { + const blob = new Blob([body], { type: 'application/json' }); + if (navigator.sendBeacon('/taxbaik/api/client-logs', blob)) { + return; + } + } + + fetch('/taxbaik/api/client-logs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true + }).catch(function () { }); + } catch { + // Logging must never break the UI. + } + }; + + window.taxbaikAdminSession.postClientLog = postLog; + + window.addEventListener('error', function (event) { + postLog({ + level: 'error', + source: 'window.error', + message: event.message || 'unknown error', + url: event.filename || window.location.href, + route: window.location.pathname + window.location.search, + screen: window.taxbaikAdminSession.clientLogState.screen || '', + feature: window.taxbaikAdminSession.clientLogState.feature || '', + action: window.taxbaikAdminSession.clientLogState.action || '', + step: window.taxbaikAdminSession.clientLogState.step || '', + entity: window.taxbaikAdminSession.clientLogState.entity || '', + entityId: window.taxbaikAdminSession.clientLogState.entityId || '', + dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '', + buildVersion: window.taxbaikAdminBuildVersion || '', + component: window.taxbaikAdminComponent || '', + viewportWidth: window.taxbaikAdminSession.getViewportWidth(), + userAgent: navigator.userAgent || '', + stack: event.error?.stack || '' + }); + }); + + window.addEventListener('unhandledrejection', function (event) { + const reason = event.reason; + postLog({ + level: 'error', + source: 'window.unhandledrejection', + message: reason?.message || String(reason || 'unknown rejection'), + url: window.location.href, + route: window.location.pathname + window.location.search, + screen: window.taxbaikAdminSession.clientLogState.screen || '', + feature: window.taxbaikAdminSession.clientLogState.feature || '', + action: window.taxbaikAdminSession.clientLogState.action || '', + step: window.taxbaikAdminSession.clientLogState.step || '', + entity: window.taxbaikAdminSession.clientLogState.entity || '', + entityId: window.taxbaikAdminSession.clientLogState.entityId || '', + dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '', + buildVersion: window.taxbaikAdminBuildVersion || '', + component: window.taxbaikAdminComponent || '', + viewportWidth: window.taxbaikAdminSession.getViewportWidth(), + userAgent: navigator.userAgent || '', + stack: reason?.stack || '' + }); + }); + }, + + setContext: function (screen, feature, action, step, entity, entityId, dataKey) { + const state = window.taxbaikAdminSession.clientLogState; + state.screen = screen || ''; + state.feature = feature || ''; + state.action = action || ''; + state.step = step || ''; + state.entity = entity || ''; + state.entityId = entityId || ''; + state.dataKey = dataKey || ''; + }, + + shouldSendClientLog: function (payload) { + try { + const state = window.taxbaikAdminSession.clientLogState; + if (!state.enabled) return false; + + const now = Date.now(); + if (!state.windowStart || now - state.windowStart >= 60000) { + state.windowStart = now; + state.sentCount = 0; + state.suppressedCount = 0; + state.fingerprints = {}; + } + + const fingerprint = [ + payload?.source || '', + payload?.message || '', + payload?.route || '', + payload?.component || '', + payload?.screen || '', + payload?.feature || '', + payload?.action || '', + payload?.entity || '', + payload?.entityId || '' + ].join('|').slice(0, 256); + + state.fingerprints[fingerprint] = (state.fingerprints[fingerprint] || 0) + 1; + + if (state.sentCount >= 8) { + state.suppressedCount += 1; + return false; + } + + if (state.fingerprints[fingerprint] > 2) { + state.suppressedCount += 1; + return false; + } + + state.sentCount += 1; + return true; + } catch { + return false; + } + }, + + traceUiState: function (source, details) { + try { + const payload = { + level: 'info', + source: source || 'ui-state', + message: details || '', + url: window.location.href, + route: window.location.pathname + window.location.search, + screen: window.taxbaikAdminSession.clientLogState.screen || '', + feature: window.taxbaikAdminSession.clientLogState.feature || '', + action: window.taxbaikAdminSession.clientLogState.action || '', + step: window.taxbaikAdminSession.clientLogState.step || '', + entity: window.taxbaikAdminSession.clientLogState.entity || '', + entityId: window.taxbaikAdminSession.clientLogState.entityId || '', + dataKey: window.taxbaikAdminSession.clientLogState.dataKey || '', + buildVersion: window.taxbaikAdminBuildVersion || '', + component: window.taxbaikAdminComponent || '', + viewportWidth: window.taxbaikAdminSession.getViewportWidth(), + userAgent: navigator.userAgent || '', + stack: '' + }; + + const state = window.taxbaikAdminSession.clientLogState; + const key = `${payload.source}|${payload.route}|${payload.message}`.slice(0, 256); + state.eventCounts[key] = (state.eventCounts[key] || 0) + 1; + if (state.eventCounts[key] > 1) { + return; + } + + window.taxbaikAdminSession.postClientLog(payload); + } catch { + // diagnostics must never break UI. + } + }, + + postClientLog: function () { + // Replaced during initialization. + }, + syncRouteClass: function () { document.documentElement.classList.toggle( 'admin-login-route', @@ -23,6 +210,7 @@ window.taxbaikAdminSession = { showLoading: function () { // Route transitions are handled by Blazor; avoid full-screen overlays // that block drawer interaction and make the app feel frozen. + window.taxbaikAdminSession.traceUiState('admin-loading', 'showLoading requested'); window.taxbaikAdminSession.hideLoading(); }, @@ -41,11 +229,14 @@ window.taxbaikAdminSession = { window._taxbaikLoadingObserver.disconnect(); window._taxbaikLoadingObserver = null; } + + window.taxbaikAdminSession.traceUiState('admin-loading', 'hideLoading completed'); }, watchReconnect: function () { window.taxbaikAdminSession.syncRouteClass(); window.addEventListener('popstate', window.taxbaikAdminSession.syncRouteClass); + window.addEventListener('hashchange', window.taxbaikAdminSession.syncRouteClass); if (document.documentElement.classList.contains('admin-login-route')) { window.taxbaikAdminSession.hideLoading(); @@ -74,6 +265,7 @@ window.taxbaikAdminSession = { if (!form || form.dataset.bound === '1') return; form.dataset.bound = '1'; + window.taxbaikAdminSession.traceUiState('admin-login', 'bindLoginForm attached'); form.addEventListener('submit', async function (event) { event.preventDefault(); @@ -87,6 +279,11 @@ window.taxbaikAdminSession = { if (submitButton) submitButton.disabled = true; try { + if (!username || !password) { + throw new Error('username/password missing'); + } + + window.taxbaikAdminSession.traceUiState('admin-login', 'submit started'); const response = await fetch('/taxbaik/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -102,9 +299,11 @@ window.taxbaikAdminSession = { throw new Error('invalid response'); } + window.taxbaikAdminSession.traceUiState('admin-login', 'submit success'); + const expiryTicks = 621355968000000000 + ((Date.now() + (data.expiresIn || 3600) * 1000) * 10000); localStorage.setItem('accessToken', data.accessToken); localStorage.setItem('refreshToken', data.refreshToken); - localStorage.setItem('tokenExpiry', String(Date.now() + (data.expiresIn || 3600) * 1000)); + localStorage.setItem('tokenExpiry', String(expiryTicks)); if (rememberMe) { localStorage.setItem('admin-remembered-username', username); @@ -113,11 +312,24 @@ window.taxbaikAdminSession = { } window.location.href = '/taxbaik/admin/dashboard'; - } catch { - const error = document.createElement('div'); - error.className = 'mud-alert mud-alert-filled-error login-error-message mb-4'; - error.textContent = '로그인 중 오류가 발생했습니다.'; - form.parentElement.insertBefore(error, form); + } catch (error) { + window.taxbaikAdminSession.traceUiState('admin-login', `submit failed: ${error?.message || 'login failed'}`); + postLog({ + level: 'error', + source: 'admin-login-form', + message: error?.message || 'login failed', + url: window.location.href, + route: window.location.pathname + window.location.search, + buildVersion: window.taxbaikAdminBuildVersion || '', + component: 'AdminLoginForm', + viewportWidth: window.taxbaikAdminSession.getViewportWidth(), + userAgent: navigator.userAgent || '', + stack: error?.stack || '' + }); + const errorMessage = document.createElement('div'); + errorMessage.className = 'mud-alert mud-alert-filled-error login-error-message mb-4'; + errorMessage.textContent = '로그인 중 오류가 발생했습니다.'; + form.parentElement.insertBefore(errorMessage, form); } finally { if (submitButton) submitButton.disabled = false; } diff --git a/deploy.sh b/deploy.sh index 35b0e68..35be070 100644 --- a/deploy.sh +++ b/deploy.sh @@ -7,7 +7,7 @@ if [ "${TAXBAIK_DEPLOY_FROM_CI:-}" != "1" ]; then fi DEPLOY_HOME="/home/kjh2064" -WEB_TIMESTAMP=$(date +%Y%m%d_%H%M%S) +WEB_TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S) echo "===== 🚀 TaxBaik 배포 스크립트 =====" echo "Web Timestamp: $WEB_TIMESTAMP" diff --git a/deploy_gb.sh b/deploy_gb.sh index 48ef350..77b234f 100644 --- a/deploy_gb.sh +++ b/deploy_gb.sh @@ -3,7 +3,7 @@ set -e DEPLOY_HOME="/home/kjh2064" PORT_FILE="$DEPLOY_HOME/taxbaik_port" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) +TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S) echo "===== 🚀 TaxBaik Green/Blue Deployment Script =====" diff --git a/docs/ENGINEERING_HARNESS.md b/docs/ENGINEERING_HARNESS.md index ebd5109..959a62b 100644 --- a/docs/ENGINEERING_HARNESS.md +++ b/docs/ENGINEERING_HARNESS.md @@ -14,6 +14,7 @@ | Deploy | Gitea Actions CI/CD만 배포 경로 | 수동 SSH/복사로 운영 반영 | | Evidence | 빌드, 테스트, E2E, API smoke 로그 | "확인함", "될 것" 같은 진술 | | Admin Render | `InteractiveWebAssemblyRenderMode(prerender: false)` | 어드민에 `InteractiveServerRenderMode` 또는 `prerender: true` 존재 | +| KST Timestamp | CI/배포/백업 폴더명과 추적 일시는 `TZ=Asia/Seoul` | `date`가 기본 UTC 또는 서버 로캘에 종속 | ## Architecture Guardrails @@ -26,6 +27,13 @@ - 어드민 렌더 모드는 `InteractiveWebAssemblyRenderMode(prerender: false)`를 기본값으로 둔다. `InteractiveServerRenderMode`와 `prerender: true`는 어드민에서 허용하지 않는다. - JavaScript는 최소화한다. 브라우저 API, 인증 토큰 저장, 서드파티 편집기처럼 Blazor/MudBlazor만으로 해결하기 부적절한 경우에만 JS module로 격리한다. - 상속은 프레임워크 요구 또는 명확한 다형성 모델에만 사용한다. 폼/테이블/CRUD 재사용은 기본적으로 컴포넌트 합성과 작은 service/client로 처리한다. +- 과유불급을 지킨다. 실제 재사용이 2곳 미만이면 새 추상화를 만들지 말고 기존 컴포넌트를 직접 조합한다. +- CI, 배포 폴더명, 백업명, 버전 추적에 쓰는 시간 문자열은 `TZ=Asia/Seoul`을 기본으로 한다. +- 클라이언트 오류 수집은 서버/브라우저를 보호하는 목적의 제한형 수집으로만 운영한다. 건당 비동기 전송, 중복 억제, 분당 상한, 서버 rate limit, 실패 시 조용히 폐기, 재시도 폭주 금지. +- 브라우저에서 발생한 JS 오류는 운영 장애 탐지를 위한 샘플 데이터로만 취급하고, 전체 이벤트 스트림을 보존하려는 설계는 금지한다. +- 텔레그램 알림은 운영자의 주의 채널이지 이벤트 버스가 아니다. 같은 원인/같은 기간의 중복 알림은 억제하고, 리포트/오류/문의/시작 장애는 종류별 시간창을 분리한다. +- 오류 알림에는 재현성 6요소를 포함한다: 화면, 기능, 액션, 단계, 데이터 식별자, 현재 라우트. 이 정보가 없으면 운영 대응이 끝나지 않은 것으로 본다. +- 재현 맥락은 페이지별 수동 JS 호출이 아니라 `AdminTelemetryContext` 같은 공통 컴포넌트가 담당한다. 새 어드민 화면은 레이아웃 경유 기본값을 자동 상속해야 하며, 예외만 명시적으로 덮어쓴다. ## Code Quality Harness @@ -54,6 +62,9 @@ - 상태 전이는 허용 목록을 둔다. 임의 문자열 저장을 금지한다. - 삭제는 운영 데이터 손실 위험이 있으면 soft delete 또는 archive를 우선 검토한다. - 콤보 값은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)를 1차 기준으로 삼는다. +- 클라이언트 로그와 장애 진단 로그는 운영 데이터가 아니라 관측 데이터로 본다. 저장 실패는 사용자 흐름을 막지 않으며, 수집 실패 자체를 재시도 루프로 증폭하지 않는다. +- 동일 오류의 텔레그램 재알림은 일정 기간 1회로 제한하고, 재전송 목적의 루프는 금지한다. +- 데이터가 오류 재현에 필요하면 `entity`, `entityId`, `dataKey` 같은 최소 식별자만 남기고, 원문 데이터 전체를 로그에 싣지 않는다. ## API-First Admin Pattern diff --git a/docs/INDEX.md b/docs/INDEX.md index 06a9a5a..ebbfa66 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -22,6 +22,25 @@ | Public API | `/taxbaik/api/*` | JWT 인증, ProblemDetails 오류, DTO 입출력 | | CI/CD | `.gitea/workflows/deploy.yml`, `.gitea/workflows/browser-e2e.yml` | 수동 배포 금지, 배포본 E2E 통과 후 완료 | +## Shared Component Map + +| 컴포넌트 | 용도 | 대표 사용처 | +| --- | --- | --- | +| `AdminShell` | 관리자 상단바/드로워/버전/알림 공통 shell | `Components/Admin/Layout/MainLayout.razor` | +| `AdminLoginForm` | 관리자 로그인 입력/제출 UI | `Components/Admin/Pages/Login.razor` | +| `AdminPageHeader` | 페이지 타이틀/보조설명/주요 액션 | Blog, Inquiry, Client, FAQ 목록 | +| `AdminDataPanel` | 목록/표면/로딩 스켈레톤 공통 래퍼 | Blog, Inquiry, CommonCode, Dashboard | +| `AdminEditorPanel` | 편집형 스켈레톤 래퍼 | BlogEdit, InquiryEdit, ClientEdit, CompanyEdit, FAQEdit | +| `AdminSkeletonRows` | 반복 로딩 골격 | AdminDataPanel, AdminEditorPanel, Dashboard | +| `AdminMetricCard` | 대시보드 KPI 카드 | `Components/Admin/Pages/Dashboard.razor` | +| `AdminEmptyState` | empty/empty-filter 상태 | ClientList 등 목록 화면 | +| `AdminFormSection` | 폼 입력 섹션 구획 | BlogForm, InquiryForm | +| `AdminFormActions` | 제출/취소 버튼 묶음 | BlogForm, InquiryForm | +| `AdminDetailSection` | 상세 정보 카드 | InquiryDetail | +| `AdminCrudPageShell` | create/edit 페이지 공통 헤더+취소+편집 래퍼 | BlogCreate/Edit, InquiryCreate/Edit | +| `CommonCodeGroupPanel` | 공통코드 그룹 선택/추가 패널 | CommonCodes | +| `CommonCodeListPanel` | 공통코드 목록/편집 패널 | CommonCodes | + ## Document Rules - 문서는 짧게 유지한다. 새 문서를 만들기 전에 이 인덱스에 추가할 가치가 있는지 판단한다. @@ -30,3 +49,4 @@ - WBS 완료 여부는 체크박스가 아니라 수치와 실행 로그로 판단한다. - 코드 변경 시 관련 WBS ID를 커밋/PR 설명 또는 작업 메모에 남긴다. - 공통코드 관련 규칙은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)만 1차 기준으로 사용한다. +- 공유 컴포넌트는 `INDEX.md`의 Shared Component Map을 먼저 확인한다. diff --git a/scripts/validate_kst_timestamps.sh b/scripts/validate_kst_timestamps.sh new file mode 100644 index 0000000..d29da16 --- /dev/null +++ b/scripts/validate_kst_timestamps.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +targets=( + ".gitea/workflows/deploy.yml" + "deploy.sh" + "deploy_gb.sh" +) + +for file in "${targets[@]}"; do + if [ ! -f "$file" ]; then + echo "Missing KST target file: $file" >&2 + exit 1 + fi +done + +if grep -nE 'date \+%Y%m%d_%H%M%S|date \+%Y%m%d' "${targets[@]}" | grep -v 'TZ=Asia/Seoul' >/dev/null; then + echo "Timestamp generation must use TZ=Asia/Seoul." >&2 + exit 1 +fi + +echo "KST timestamp harness passed."