diff --git a/CLAUDE.md b/CLAUDE.md index 231373e..2ca21ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,23 +12,6 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D Blazor 데이터 변경 자동 push/broadcast 금지 ``` -### UI 기준 원칙 (2026-06-29 추가) -- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/` 기준으로 한다. -- 신규 또는 리팩토링 UI는 Fluent UI Blazor v5 패턴을 우선 적용한다. -- MudBlazor는 레거시 폐기 대상이다. 새 UI나 리팩토링 UI에서는 사용하지 않는다. -- 기존 MudBlazor 잔여 코드는 Fluent v5 또는 순수 HTML/CSS로 점진 전환한다. -- 기본 로딩 상태는 `Skeleton`이다. `MudProgressCircular` / `MudProgressLinear`는 예외적으로만 사용한다. -- `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이다. 새 작업에서는 사용하지 말고 Fluent v5 또는 순수 HTML/CSS 패턴으로 대체한다. -- 목록, 카드, 대시보드, 상세 페이지의 초기 데이터 상태는 스켈톤으로 먼저 렌더링하고, 데이터 수신 후 실제 UI로 교체한다. -- 로딩 중 블로킹 스피너보다 스켈톤을 우선한다. -- 관리자와 공개 사이트는 가능한 한 같은 `design-tokens.css` / `ui-primitives.css` 기반으로 구성한다. -- Blazor 진입점은 중복 매핑하지 말고, 동일 호스트 내에서 라우트 충돌이 없도록 단일 엔트리 기준으로 구성한다. -- `@page` 중복이나 동일 경로의 Razor Pages + Blazor 중복 선언은 배포 전에 반드시 제거한다. - -### 레거시 정책 -- MudBlazor, MudDataGrid, MudDialog, MudTabs는 신규 도입 금지다. -- 남아 있는 레거시 UI는 우선순위에 따라 Fluent v5 또는 순수 HTML/CSS로 교체한다. - ### SOLID 기반 순차 마이그레이션 전략 #### Phase 1-3: API Foundations ✅ @@ -46,7 +29,6 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D - AdminDashboardClient 구현 - 서비스 inject → API 호출로 변경 - 에러 처리 & 로딩 상태 - - 기본 로딩은 Skeleton 적용 - [x] 구조: IAdminDashboardClient → HttpClient 추상화 **완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출 @@ -94,18 +76,10 @@ _refreshTokenExpirationMinutes = 10080; - 모든 API 엔드포인트 구현됨 - 모든 Browser Client 구현됨 - 16개 Blazor 페이지 API-First 마이그레이션 완료 -- 과거 기록: 관리 화면에서 그리드/모달 UX를 빠르게 안정화한 단계 -- 모달 패턴 (흰 화면 플래시 제거) +- MudDataGrid Douzone ERP 수준 UX 적용 +- MudDialog 모달 패턴 (흰 화면 플래시 제거) - ConfirmDialog 삭제 확인 컴포넌트 -### 2026-06-29 운영 기준 업데이트 -- 관리자 백오피스는 Fluent UI v5 우선 구조로 재정리한다. -- 기본 로딩은 스피너가 아니라 Skeleton이다. -- `design-tokens.css`와 `ui-primitives.css`는 사이트/관리자 공통의 기본 계층이다. -- 라우팅 충돌은 가장 먼저 확인할 항목이며, 동일 경로가 두 번 등록되는 구조를 만들지 않는다. -- 커밋은 기능/호스팅/UI/CSS처럼 주제별로 분리한다. -- 레거시 제거 우선순위는 `MudBlazor` 계열 UI가 1순위다. - --- ## 📊 **전체 프로젝트 완료 현황** @@ -144,7 +118,7 @@ _refreshTokenExpirationMinutes = 10080; **Phase 7-4: CRM & 세무관리 (신규 - 2026-06-28)** ✅ - 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking) - 5개 Browser Client (API-First 패턴) -- 5개 Blazor 페이지 (그리드 Dense, Virtualize, 모달 패턴) +- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog) - Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화) | 페이지 | API | Client | Blazor | 핵심 기능 | @@ -156,8 +130,8 @@ _refreshTokenExpirationMinutes = 10080; | RevenueTrackings | ✅ RevenueTrackingController | ✅ IRevenueTrackingBrowserClient | ✅ List + Modal | 청구/납부 추적, 상태 관리 | **UI 특성**: -- Dense 그리드 + Virtualize (1000+ 행 성능) -- Create/Edit 모달 (흰 화면 플래시 방지) +- MudDataGrid Dense (행높이 32px) + Virtualize (1000+ 행 성능) +- MudDialog Create/Edit (흰 화면 플래시 방지) - ConfirmDialog Delete (사용자 확인) - Status Color Chips (Error/Warning/Success) - Client 링크 (상세 페이지 연동) @@ -215,8 +189,8 @@ PostgreSQL Database **Blazor 페이지 & UI 고도화 (Phase 7-4)**: - [x] 5개 CRM/세무관리 Blazor 페이지 -- [x] Dense 그리드 + Virtualize (32px 행 높이) -- [x] 모달 Create/Edit (흰 화면 플래시 제거) +- [x] MudDataGrid Dense + Virtualize (32px 행 높이) +- [x] MudDialog 모달 Create/Edit (흰 화면 플래시 제거) - [x] ConfirmDialog 삭제 확인 - [x] 상태별 컬러 칩 (Status/Risk Level) - [x] 클라이언트 링크 (상세 페이지 연동) @@ -990,8 +964,6 @@ Admin 로그인 페이지만 [AllowAnonymous]: - 전역 상태 불필요 (세션 → DB에서 읽음) - 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기 - 업데이트는 `StateHasChanged()` 호출 -- 초기 렌더는 Skeleton 우선 -- 로딩이 필요한 목록/카드/대시보드는 `items == null` 또는 `summary == null` 패턴으로 스켈톤 렌더링 ### 8.6 어드민 그리드 UX (Dorsum ERP 수준) @@ -1011,11 +983,9 @@ Admin 로그인 페이지만 [AllowAnonymous]: - **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지) - **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리) -#### UI 적용 패턴 +#### MudBlazor 적용 패턴 ```razor -```razor - - - -``` + ``` #### 색상 & 상태 표시 @@ -1157,7 +1126,7 @@ Admin 로그인 페이지만 [AllowAnonymous]: @if (items == null) { - + } else if (items.Count == 0) @@ -1167,9 +1136,7 @@ else if (items.Count == 0) else { -```razor - - - -``` + } ``` **Step 3: 모달 다이얼로그 (Create/Edit)** ```razor -```razor - - + @(isEditMode ? "항목 수정" : "새 항목 추가") @@ -1202,8 +1166,7 @@ else 취소 저장 - -``` + ``` **Step 4: @code 섹션 구조** @@ -1325,10 +1288,10 @@ else - [ ] @inject로 필요한 Client 주입 - [ ] 추가 - [ ]
(캡션, 제목, 부제, 추가 버튼) -- [ ] 로딩 상태 기본값은 `Skeleton` +- [ ] 로딩 상태 (MudProgressCircular) - [ ] 빈 상태 (MudAlert) -- [ ] Dense 그리드 (Virtualize=true, RowsPerPage=30, admin-grid 클래스) -- [ ] 모달 (Create/Edit) +- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스) +- [ ] MudDialog (Create/Edit 모달) - [ ] ConfirmDialog (Delete 확인) - [ ] @code 섹션: OnInitializedAsync → LoadData() 패턴 - [ ] 모든 에러 처리 (try-catch, Snackbar 메시지) @@ -1339,7 +1302,7 @@ else ❌ **이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:** - 페이지 헤더 (admin-page-hero) 누락 - 인라인 스타일로 레이아웃 구성 -- 별도 라우트로 Create/Edit 처리 (흰 화면 플래시) +- MudDialog 없이 별도 라우트로 Create/Edit 처리 (흰 화면 플래시) - @code 섹션 구조 다름 - 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공 @@ -1760,9 +1723,7 @@ public async Task NotifyDeploymentStart() @* Components/Admin/Shared/DeploymentNotification.razor *@ @if (showNotification) { -```razor - - + 새 버전 배포 @@ -1777,8 +1738,7 @@ public async Task NotifyDeploymentStart() 지금 새로고침 나중에 - -``` + } @code { diff --git a/README.md b/README.md index 4ebbd6a..4e99196 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위 |-----|------| | **백엔드** | ASP.NET Core 10, C# | | **공개 사이트** | Razor Pages (SSR) | -| **관리자** | Blazor Server + Fluent UI Blazor v5 | +| **관리자** | Blazor Server + MudBlazor | | **데이터베이스** | PostgreSQL 18.4 | | **ORM** | Dapper | | **리버스 프록시** | Nginx | @@ -98,14 +98,6 @@ TaxBaik/ - 연락처 정보 - 소셜 미디어 링크 -- **UI 기준** - - 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/` - - 기본 로딩 상태는 `Skeleton` - - MudBlazor는 레거시 폐기 대상이며 신규 UI에 사용하지 않음 - - `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이며 신규 UI에 사용하지 않음 - - 사이트와 관리자는 `design-tokens.css` / `ui-primitives.css`를 공유 - - Blazor 라우트는 중복 선언하지 않고 단일 엔트리 기준으로 관리 - --- ## 빠른 시작 diff --git a/TaxBaik.Application/Services/RevenueTrackingService.cs b/TaxBaik.Application/Services/RevenueTrackingService.cs index 528236d..be9c66c 100644 --- a/TaxBaik.Application/Services/RevenueTrackingService.cs +++ b/TaxBaik.Application/Services/RevenueTrackingService.cs @@ -34,9 +34,6 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository) public async Task> GetByClientIdAsync(int clientId, CancellationToken ct = default) => await repository.GetByClientIdAsync(clientId, ct); - public async Task GetByIdAsync(int id, CancellationToken ct = default) => - await repository.GetByIdAsync(id, ct); - public async Task> GetAllAsync(CancellationToken ct = default) => await repository.GetAllAsync(ct); diff --git a/TaxBaik.Domain/Interfaces/IRevenueTrackingRepository.cs b/TaxBaik.Domain/Interfaces/IRevenueTrackingRepository.cs index 6a1563f..498402b 100644 --- a/TaxBaik.Domain/Interfaces/IRevenueTrackingRepository.cs +++ b/TaxBaik.Domain/Interfaces/IRevenueTrackingRepository.cs @@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities; public interface IRevenueTrackingRepository { Task CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default); - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); Task> GetAllAsync(CancellationToken cancellationToken = default); Task> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default); Task> GetPendingPaymentsAsync(CancellationToken cancellationToken = default); diff --git a/TaxBaik.Infrastructure/Repositories/RevenueTrackingRepository.cs b/TaxBaik.Infrastructure/Repositories/RevenueTrackingRepository.cs index 806d9c7..a6fe61a 100644 --- a/TaxBaik.Infrastructure/Repositories/RevenueTrackingRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/RevenueTrackingRepository.cs @@ -24,15 +24,6 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) : FROM revenue_tracking ORDER BY invoice_date DESC"); } - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - using var conn = Conn(); - return await conn.QueryFirstOrDefaultAsync( - @"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at - FROM revenue_tracking WHERE id = @Id", - new { Id = id }); - } - public async Task> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default) { using var conn = Conn(); diff --git a/TaxBaik.Web.Client/Pages/WasmPing.razor b/TaxBaik.Web.Client/Pages/WasmPing.razor deleted file mode 100644 index aeafb41..0000000 --- a/TaxBaik.Web.Client/Pages/WasmPing.razor +++ /dev/null @@ -1,13 +0,0 @@ -@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@ -@rendermode InteractiveWebAssembly - - - WebAssembly 렌더 모드 점검 - 이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다. - 카운트: @count - - -@code { - private int count; - private void Increment() => count++; -} diff --git a/TaxBaik.Web.Client/Program.cs b/TaxBaik.Web.Client/Program.cs deleted file mode 100644 index 44d5361..0000000 --- a/TaxBaik.Web.Client/Program.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using MudBlazor.Services; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); - -// MudBlazor (WASM 측 인터랙티브 컴포넌트용) -builder.Services.AddMudServices(); - -// API 호출용 HttpClient — 호스트 base(`/taxbaik/`) 기준 -builder.Services.AddScoped(sp => new HttpClient -{ - BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) -}); - -builder.Services.AddAuthorizationCore(); - -await builder.Build().RunAsync(); diff --git a/TaxBaik.Web.Client/TaxBaik.Web.Client.csproj b/TaxBaik.Web.Client/TaxBaik.Web.Client.csproj deleted file mode 100644 index a427452..0000000 --- a/TaxBaik.Web.Client/TaxBaik.Web.Client.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - TaxBaik.WasmClient - - - - - - - - - - - - - - diff --git a/TaxBaik.Web.Client/_Imports.razor b/TaxBaik.Web.Client/_Imports.razor deleted file mode 100644 index aa7052b..0000000 --- a/TaxBaik.Web.Client/_Imports.razor +++ /dev/null @@ -1,13 +0,0 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Authorization -@using Microsoft.AspNetCore.Components.Authorization -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.AspNetCore.Components.WebAssembly.Http -@using Microsoft.JSInterop -@using MudBlazor -@using TaxBaik.WasmClient -@using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/TaxBaik.Web/Components/Admin/App.razor b/TaxBaik.Web/Components/Admin/App.razor index d816e8a..2ecbf0e 100644 --- a/TaxBaik.Web/Components/Admin/App.razor +++ b/TaxBaik.Web/Components/Admin/App.razor @@ -1,5 +1,4 @@ @using Microsoft.AspNetCore.Components.Web -@using Microsoft.FluentUI.AspNetCore.Components @@ -7,11 +6,9 @@ 백원숙 세무회계 - 관리자 - + - - - + + +@code { + private bool isDarkMode = false; + private MudTheme mudTheme = new() + { + Palette = new PaletteLight() + { + Primary = "#1976D2", + PrimaryContrastText = "#FFFFFF", + Secondary = "#2D9F7E", + SecondaryContrastText = "#FFFFFF", + Tertiary = "#FF8A50", + TertiaryContrastText = "#FFFFFF", + Surface = "#F5F7FA", + Background = "#FFFFFF", + BackgroundGrey = "#F8F9FB", + DrawerBackground = "#FFFFFF", + DrawerText = "#424242", + AppbarBackground = "#FFFFFF", + AppbarText = "#424242", + TextPrimary = "#1A1A1A", + TextSecondary = "#64748B", + TextDisabled = "#94A3B8", + ActionDefault = "#1976D2", + ActionDisabled = "#BDBDBD", + Divider = "#E2E8F0", + DividerLight = "#F1F5F9", + Error = "#DC2626", + ErrorContrastText = "#FFFFFF", + Warning = "#F59E0B", + WarningContrastText = "#FFFFFF", + Info = "#06B6D4", + InfoContrastText = "#FFFFFF", + Success = "#16A34A", + SuccessContrastText = "#FFFFFF", + }, + LayoutProperties = new LayoutProperties() + { + DefaultBorderRadius = "8px" + }, + Typography = new Typography() + { + Default = new Default() + { + FontSize = ".875rem", + FontWeight = 400, + LineHeight = 1.5 + }, + H1 = new H1() + { + FontSize = "2.5rem", + FontWeight = 600, + LineHeight = 1.2 + }, + H2 = new H2() + { + FontSize = "2rem", + FontWeight = 600, + LineHeight = 1.3 + }, + H3 = new H3() + { + FontSize = "1.75rem", + FontWeight = 600, + LineHeight = 1.3 + }, + H4 = new H4() + { + FontSize = "1.5rem", + FontWeight = 600, + LineHeight = 1.4 + }, + H5 = new H5() + { + FontSize = "1.25rem", + FontWeight = 500, + LineHeight = 1.4 + }, + H6 = new H6() + { + FontSize = "1rem", + FontWeight = 500, + LineHeight = 1.5 + } + } + }; +} diff --git a/TaxBaik.Web/Components/Admin/ConfirmDialog.razor b/TaxBaik.Web/Components/Admin/ConfirmDialog.razor index 6f61086..876557d 100644 --- a/TaxBaik.Web/Components/Admin/ConfirmDialog.razor +++ b/TaxBaik.Web/Components/Admin/ConfirmDialog.razor @@ -1,17 +1,18 @@ -@using Microsoft.FluentUI.AspNetCore.Components -
-
삭제 확인
-

정말로 삭제하시겠습니까?

-
- 취소 - 삭제 -
-
+@using MudBlazor + + + + 정말로 삭제하시겠습니까? + + + 취소 + 삭제 + + @code { - [Parameter] public EventCallback OnCancel { get; set; } - [Parameter] public EventCallback OnConfirm { get; set; } + [CascadingParameter] MudDialogInstance? MudDialog { get; set; } - Task Cancel() => OnCancel.InvokeAsync(); - Task Confirm() => OnConfirm.InvokeAsync(); + void Cancel() => MudDialog?.Cancel(); + void Confirm() => MudDialog?.Close(DialogResult.Ok(true)); } diff --git a/TaxBaik.Web/Components/Admin/Forms/CompanyForm.razor b/TaxBaik.Web/Components/Admin/Forms/CompanyForm.razor index 3e2473e..54d653d 100644 --- a/TaxBaik.Web/Components/Admin/Forms/CompanyForm.razor +++ b/TaxBaik.Web/Components/Admin/Forms/CompanyForm.razor @@ -1,28 +1,49 @@ @using TaxBaik.Application.Services -@using Microsoft.FluentUI.AspNetCore.Components -
- - - - - - - -
- - + + + + + + + + + + + + + + + +
+ + @ButtonText + + 취소
- +
@code { - [Parameter, EditorRequired] public string ButtonText { get; set; } = "저장"; - [Parameter] public EventCallback OnSubmit { get; set; } - [Parameter] public EventCallback OnCancel { get; set; } - [Parameter] public CompanyFormModel? InitialData { get; set; } + [Parameter, EditorRequired] + public string ButtonText { get; set; } = "저장"; + + [Parameter] + public EventCallback OnSubmit { get; set; } + + [Parameter] + public EventCallback OnCancel { get; set; } + + [Parameter] + public CompanyFormModel? InitialData { get; set; } + + private MudForm? form; private CompanyFormModel model = new(); protected override void OnInitialized() @@ -42,7 +63,17 @@ } } - private Task HandleSubmit() => OnSubmit.InvokeAsync(model); + private async Task HandleSubmit() + { + if (form == null) + return; + + await form.Validate(); + if (!form.IsValid) + return; + + await OnSubmit.InvokeAsync(model); + } public class CompanyFormModel { diff --git a/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor b/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor index 25bb46f..4de00e4 100644 --- a/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor +++ b/TaxBaik.Web/Components/Admin/Forms/InquiryForm.razor @@ -1,38 +1,61 @@ @using TaxBaik.Application.DTOs @using TaxBaik.Application.Services -@using Microsoft.FluentUI.AspNetCore.Components -
- - - - - 사업자세무 - 부동산세금 - 가족자산 - 기타 - - - - 신규 - 상담중 - 계약완료 - 거절 - 종결 - - + + -
- - + + + + + + 사업자세무 + 부동산세금 + 가족자산 + 기타 + + + + + + 신규 + 상담중 + 계약완료 + 거절 + 종결 + + + + +
+ + @ButtonText + + 취소
- + @code { - [Parameter, EditorRequired] public string ButtonText { get; set; } = "저장"; - [Parameter] public EventCallback OnSubmit { get; set; } - [Parameter] public EventCallback OnCancel { get; set; } - [Parameter] public InquiryFormModel? InitialData { get; set; } + [Parameter, EditorRequired] + public string ButtonText { get; set; } = "저장"; + + [Parameter] + public EventCallback OnSubmit { get; set; } + + [Parameter] + public EventCallback OnCancel { get; set; } + + [Parameter] + public InquiryFormModel? InitialData { get; set; } + + private MudForm? form; private InquiryFormModel model = new(); protected override void OnInitialized() @@ -52,7 +75,17 @@ } } - private Task HandleSubmit() => OnSubmit.InvokeAsync(model); + private async Task HandleSubmit() + { + if (form == null) + return; + + await form.Validate(); + if (!form.IsValid) + return; + + await OnSubmit.InvokeAsync(model); + } public class InquiryFormModel { diff --git a/TaxBaik.Web/Components/Admin/InquiryTable.razor b/TaxBaik.Web/Components/Admin/InquiryTable.razor index 316ce79..edf43bb 100644 --- a/TaxBaik.Web/Components/Admin/InquiryTable.razor +++ b/TaxBaik.Web/Components/Admin/InquiryTable.razor @@ -1,5 +1,4 @@ -
- + @@ -19,19 +18,22 @@ } -
이름@inquiry.Phone @inquiry.ServiceType - @GetStatusLabel(inquiry.Status) + + @GetStatusLabel(inquiry.Status) + @GetPreview(inquiry.Message) @inquiry.CreatedAt.ToString("yyyy-MM-dd") - 보기 - 수정 + 보기 + 수정
-
+ @code { [Parameter, EditorRequired] @@ -64,14 +66,14 @@ return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}..."; } - private static string GetStatusClass(string status) => status switch + private static Color GetStatusColor(string status) => status switch { - "new" => "warning", - "consulting" => "info", - "contracted" => "success", - "rejected" => "danger", - "closed" => "muted", - _ => "muted" + "new" => Color.Warning, + "consulting" => Color.Info, + "contracted" => Color.Success, + "rejected" => Color.Error, + "closed" => Color.Dark, + _ => Color.Default }; private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status); diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index 7d6f217..ece20a4 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -3,86 +3,100 @@ @inject IJSRuntime JS @implements IDisposable -
-
- - + + +
- TaxBaik Admin -

세무회계 관리 대시보드

+ TaxBaik Admin + 세무회계 관리 대시보드
+ + -
+ + + 공개 사이트 + + -
+ + +
T
-
TaxBaik
-
세무 운영 콘솔
+ TaxBaik + 세무 운영 콘솔
+ + 대시보드 - - - - - -
-
+ + @Body -
-
-
+ + + @code { private bool drawerOpen = true; + private bool expandedCRMGroup = true; + private bool expandedCustomerGroup = false; + private bool expandedWebsiteGroup = false; protected override void OnInitialized() { @@ -99,14 +113,15 @@ StateHasChanged(); } - private string DrawerClass => drawerOpen ? "admin-drawer open" : "admin-drawer"; - private void OnLocationChanged(object? sender, LocationChangedEventArgs args) { _ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading")); } - private void ToggleDrawer() => drawerOpen = !drawerOpen; + private void ToggleDrawer() + { + drawerOpen = !drawerOpen; + } public void Dispose() { diff --git a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor index 918da66..319dece 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor @@ -5,47 +5,101 @@ @using TaxBaik.Web.Services @inject IAnnouncementBrowserClient AnnouncementClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar @(Id.HasValue ? "공지 수정" : "공지 등록")
-
Homepage
-

@(Id.HasValue ? "공지 수정" : "공지 등록")

+ Homepage + @(Id.HasValue ? "공지 수정" : "공지 등록")
-
-
- - - - - - - -
- - + + + + + + + + + + + + + + 일반 (파란색) + 배너 (주황색) — 중요 이벤트 + 긴급 (빨간색) — 마감 임박 + + + + + + + + + + + + + + + + + + + + +
+ + @(isSaving ? "저장 중..." : "저장") + + + 취소 +
- -
+ + @code { [Parameter] public int? Id { get; set; } + + private MudForm? form; private bool isSaving; private DateTime? startsAtDate; private DateTime? endsAtDate; + private AnnouncementDto model = new(); - private string StartsAtText { get => startsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => startsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; } - private string EndsAtText { get => endsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => endsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; } protected override async Task OnInitializedAsync() { @@ -61,15 +115,15 @@ } model = new AnnouncementDto { - Id = entity.Id, - Title = entity.Title, - Content = entity.Content, + Id = entity.Id, + Title = entity.Title, + Content = entity.Content, DisplayType = entity.DisplayType, - IsActive = entity.IsActive, - SortOrder = entity.SortOrder + IsActive = entity.IsActive, + SortOrder = entity.SortOrder }; startsAtDate = entity.StartsAt?.ToLocalTime(); - endsAtDate = entity.EndsAt?.ToLocalTime(); + endsAtDate = entity.EndsAt?.ToLocalTime(); } catch { @@ -80,18 +134,41 @@ private async Task SaveAsync() { + if (form is null) return; + await form.Validate(); + if (!form.IsValid) return; + isSaving = true; try { - model.StartsAt = startsAtDate.HasValue ? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() : null; - model.EndsAt = endsAtDate.HasValue ? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime() : null; - var result = Id.HasValue ? await AnnouncementClient.UpdateAsync(Id.Value, model) : await AnnouncementClient.CreateAsync(model); - await JS.InvokeVoidAsync("alert", result != null ? "공지사항이 저장되었습니다." : "저장 실패"); + model.StartsAt = startsAtDate.HasValue + ? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() + : null; + model.EndsAt = endsAtDate.HasValue + ? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime() + : null; + + if (Id.HasValue) + { + var result = await AnnouncementClient.UpdateAsync(Id.Value, model); + if (result != null) + Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success); + else + Snackbar.Add("저장 실패", Severity.Error); + } + else + { + var result = await AnnouncementClient.CreateAsync(model); + if (result != null) + Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success); + else + Snackbar.Add("저장 실패", Severity.Error); + } Navigation.NavigateTo("/taxbaik/admin/announcements"); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}"); + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); } finally { diff --git a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor index c64d12d..df47db0 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor @@ -4,77 +4,90 @@ @using TaxBaik.Domain.Entities @inject IAnnouncementBrowserClient AnnouncementClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject IDialogService DialogService +@inject ISnackbar Snackbar 공지사항 관리
-
Homepage
-

공지사항 관리

-

홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.

+ Homepage + 공지사항 관리 + 홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.
- 공지 등록 + + 공지 등록 +
-
+ @if (announcements is null) { - + } else if (!announcements.Any()) { -
등록된 공지사항이 없습니다.
+ 등록된 공지사항이 없습니다. } else { -
- - + + + + + + + + + + + + + @foreach (var item in announcements) + { - - - - - - + + + + + + - - - @foreach (var item in announcements) - { - - - - - - - - - } - -
제목유형상태게시 기간순서
제목유형상태게시 기간순서@item.Title + + @GetTypeLabel(item.DisplayType) + + + @if (IsCurrentlyActive(item)) + { + 노출 중 + } + else if (!item.IsActive) + { + 비활성 + } + else + { + 기간 외 + } + + @FormatPeriod(item) + @item.SortOrder + + + 수정 + + + 삭제 + + +
@item.Title@GetTypeLabel(item.DisplayType) - @if (IsCurrentlyActive(item)) - { - 노출 중 - } - else if (!item.IsActive) - { - 비활성 - } - else - { - 기간 외 - } - @FormatPeriod(item)@item.SortOrder -
- - -
-
-
+ } + + } -
+ @code { [CascadingParameter] @@ -84,13 +97,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && AuthStateTask != null) + if (firstRender) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + if (AuthStateTask != null) { - await LoadAsync(); - StateHasChanged(); + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) + { + await LoadAsync(); + StateHasChanged(); + } } } } @@ -103,32 +119,36 @@ } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); announcements = []; } } private async Task DeleteAsync(Announcement item) { - var confirmed = await JS.InvokeAsync("confirm", $"'{item.Title}' 공지를 삭제하시겠습니까?"); - if (!confirmed) return; + var confirmed = await DialogService.ShowMessageBox( + "공지 삭제", + $"'{item.Title}' 공지를 삭제하시겠습니까?", + yesText: "삭제", cancelText: "취소"); + + if (confirmed != true) return; try { var success = await AnnouncementClient.DeleteAsync(item.Id); if (success) { - await JS.InvokeVoidAsync("alert", "공지사항이 삭제되었습니다."); + Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success); await LoadAsync(); } else { - await JS.InvokeVoidAsync("alert", "삭제 실패"); + Snackbar.Add("삭제 실패", Severity.Error); } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); } } @@ -137,21 +157,28 @@ if (!a.IsActive) return false; var now = DateTime.UtcNow; if (a.StartsAt.HasValue && a.StartsAt > now) return false; - if (a.EndsAt.HasValue && a.EndsAt < now) return false; + if (a.EndsAt.HasValue && a.EndsAt < now) return false; return true; } private static string FormatPeriod(Announcement a) { var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시"; - var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한"; + var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한"; return $"{start} ~ {end}"; } + private static Color GetTypeColor(string type) => type switch + { + "urgent" => Color.Error, + "banner" => Color.Warning, + _ => Color.Info + }; + private static string GetTypeLabel(string type) => type switch { "urgent" => "긴급", "banner" => "배너", - _ => "일반" + _ => "일반" }; } diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor index aa2526e..5999b08 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogCreate.razor @@ -6,53 +6,77 @@ @inject BlogService BlogService @inject ICategoryRepository CategoryRepository @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar 새 포스트 작성 +
-
Content
-

새 포스트 작성

-

새로운 블로그 포스트를 작성합니다.

+ Content + 새 포스트 작성 + 새로운 블로그 포스트를 작성합니다.
- + 취소
-
-
- - - - - - - -
- + + + + + + @foreach (var category in categories) + { + @category.Name + } + + + + + + + + + + + + +
+ 저장
- -
+ + @code { + private MudForm? form; private List categories = []; private CreatePostModel model = new(); - private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; } protected override async Task OnInitializedAsync() { categories = (await CategoryRepository.GetAllAsync()).ToList(); } + private void GoBack() + { + Navigation.NavigateTo("/taxbaik/admin/blog"); + } + private async Task SavePost() { + if (form == null) + return; + + await form.Validate(); + if (!form.IsValid) + return; + try { await BlogService.CreateAsync(new CreateBlogPostDto @@ -66,12 +90,12 @@ IsPublished = model.IsPublished }); - await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다."); + Snackbar.Add("포스트가 저장되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/blog"); } catch (ValidationException ex) { - await JS.InvokeVoidAsync("alert", ex.Message); + Snackbar.Add(ex.Message, Severity.Error); } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor index 65841bd..de0d296 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor @@ -6,60 +6,76 @@ @inject BlogService BlogService @inject ICategoryRepository CategoryRepository @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar +@inject IDialogService DialogService 포스트 수정 +
-
Content
-

포스트 수정

-

블로그 포스트를 수정합니다.

+ Content + 포스트 수정 + 블로그 포스트를 수정합니다.
- + 취소
@if (isLoading) { -
+ } else if (post == null) { -
포스트를 찾을 수 없습니다.
+ 포스트를 찾을 수 없습니다. } else { -
-
- - - - - - - -
- - + + + + + + @foreach (var category in categories) + { + @category.Name + } + + + + + + + + + + + + +
+ 저장 + 삭제
- -
+ + } @code { - [Parameter] public int Id { get; set; } + [Parameter] + public int Id { get; set; } + + private MudForm? form; private Domain.Entities.BlogPost? post; private List categories = []; private EditPostModel model = new(); private bool isLoading = true; - private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; } protected override async Task OnInitializedAsync() { @@ -74,7 +90,7 @@ else } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"포스트 로드 실패: {ex.Message}"); + Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error); } finally { @@ -93,9 +109,20 @@ else model.IsPublished = post.IsPublished; } + private void GoBack() + { + Navigation.NavigateTo("/taxbaik/admin/blog"); + } + private async Task SavePost() { - if (post == null) return; + if (form == null || post == null) + return; + + await form.Validate(); + if (!form.IsValid) + return; + try { await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto @@ -108,22 +135,43 @@ else SeoDescription = model.SeoDescription, IsPublished = model.IsPublished }); - await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다."); + + Snackbar.Add("포스트가 저장되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/blog"); } catch (ValidationException ex) { - await JS.InvokeVoidAsync("alert", ex.Message); + Snackbar.Add(ex.Message, Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); } } private async Task DeletePost() { - if (post == null) return; - if (!await JS.InvokeAsync("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return; - await BlogService.DeleteAsync(post.Id); - await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다."); - Navigation.NavigateTo("/taxbaik/admin/blog"); + if (post == null) + return; + + var result = await DialogService.ShowMessageBox( + "포스트 삭제", + "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "삭제", "취소"); + + if (result != true) + return; + + try + { + await BlogService.DeleteAsync(post.Id); + Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); + Navigation.NavigateTo("/taxbaik/admin/blog"); + } + catch (Exception ex) + { + Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); + } } private class EditPostModel diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor index a7086b8..d8d982d 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor @@ -1,72 +1,58 @@ @page "/admin/blog" @attribute [Authorize] @inject IApiClient ApiClient -@inject IJSRuntime JS +@inject ISnackbar Snackbar 블로그 관리 +
-
Content
-

블로그 관리

-

검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.

+ Content + 블로그 관리 + 검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.
- + 새 포스트 작성
-
-
- 전체 포스트: @($"{totalPosts}개") - 페이지 @currentPage / @totalPages -
-
+ + + @($"전체 포스트 {totalPosts}개") + 페이지 @currentPage / @totalPages + + -
- @if (isLoading) - { - - } - else - { -
- - - - - - - - - - - - @foreach (var post in posts) - { - - - - - - - - } - -
제목발행조회수작성일
@post.Title@post.ViewCount@post.CreatedAt.ToString("yyyy-MM-dd") -
- 수정 - -
-
-
- } -
+ + + + + + + + + + + + + 수정하기 + 삭제 + + + + -
- - -
+ + 이전 + 다음 + @code { - [CascadingParameter] private Task? AuthStateTask { get; set; } + [CascadingParameter] + private Task? AuthStateTask { get; set; } + private List posts = []; private bool isLoading = true; private int currentPage = 1; @@ -76,19 +62,20 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && AuthStateTask != null) + if (firstRender) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + if (AuthStateTask != null) { - await LoadPosts(); - StateHasChanged(); + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) + { + await LoadPosts(); + StateHasChanged(); + } } } } - private string NavTo(string url) => url; - private async Task LoadPosts() { isLoading = true; @@ -105,33 +92,58 @@ totalPosts = 0; totalPages = 1; } - finally - { - isLoading = false; - } + isLoading = false; } - private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadPosts(); } } - private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadPosts(); } } + private async Task PreviousPage() + { + if (currentPage <= 1) + return; + + currentPage--; + await LoadPosts(); + } + + private async Task NextPage() + { + if (currentPage >= totalPages) + return; + + currentPage++; + await LoadPosts(); + } private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished) { var previous = post.IsPublished; post.IsPublished = isPublished; - var result = await ApiClient.PutAsync($"blog/{post.Id}", new { post.Title, post.Content, post.CategoryId, post.Tags, post.SeoTitle, post.SeoDescription, post.ThumbnailUrl, IsPublished = isPublished, post.AuthorId }); + var result = await ApiClient.PutAsync($"blog/{post.Id}", new + { + post.Title, + post.Content, + post.CategoryId, + post.Tags, + post.SeoTitle, + post.SeoDescription, + post.ThumbnailUrl, + IsPublished = isPublished, + post.AuthorId + }); + if (result == null) { post.IsPublished = previous; - await JS.InvokeVoidAsync("alert", "발행 상태 변경에 실패했습니다."); + Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error); return; } - await JS.InvokeVoidAsync("alert", "발행 상태가 변경되었습니다."); + + Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success); } private async Task DeletePost(int postId) { await ApiClient.DeleteAsync($"blog/{postId}"); - await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다."); + Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); await LoadPosts(); } diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor index b45542c..2fe9c75 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor @@ -4,123 +4,185 @@ @inject ClientService ClientService @inject ConsultationService ConsultationService @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar 고객 상세 +
-
Client Details
-

고객 상세

-

고객 정보와 상담 이력을 관리합니다.

+ Client Details + 고객 상세 + 고객 정보와 상담 이력을 관리합니다.
@if (client == null) { -
고객을 찾을 수 없습니다.
+ 고객을 찾을 수 없습니다. + return; } -else -{ -
- - 수정 -
-
-
-

고객 정보

-
-
이름@client.Name
-
상호@(client.CompanyName ?? "-")
-
연락처@(client.Phone ?? "-")
-
이메일@(client.Email ?? "-")
-
서비스@(client.ServiceType ?? "-")
-
사업자 유형@(client.TaxType ?? "-")
-
유입 경로@(client.Source ?? "-")
-
등록일@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")
+ + + 목록으로 + + + 수정 + + + + + + + 고객 정보 + + + 이름 + @client.Name + + + 상호 + @(client.CompanyName ?? "-") + + + 연락처 + @(client.Phone ?? "-") + + + 이메일 + @(client.Email ?? "-") + + + 서비스 + @(client.ServiceType ?? "-") + + + 사업자 유형 + @(client.TaxType ?? "-") + + + 유입 경로 + @(client.Source ?? "-") + + + 등록일 + @client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd") + @if (!string.IsNullOrWhiteSpace(client.Memo)) { -
메모@client.Memo
+ + 메모 + @client.Memo + } -
-
+ + + -
-
-
-

상담 이력

-
- -
+ + + + 상담 이력 + + + 상담 추가 + + @if (showAddForm) { -
- - - - - -
- - -
-
+ + + + + + + + @foreach (var t in ClientService.ServiceTypes) + { + @t + } + + + + + + + + - + @foreach (var r in ConsultationService.Results) + { + @r + } + + + + + + + + 저장 + 취소 + + } @if (consultations.Count == 0) { -

상담 이력이 없습니다.

+ 상담 이력이 없습니다. } else { -
+ @foreach (var c in consultations) { -
-
-
- @c.ConsultationDate.ToString("yyyy-MM-dd") @(string.IsNullOrEmpty(c.ServiceType) ? "" : $"· {c.ServiceType}") -
- -
-

@c.Summary

- @if (!string.IsNullOrEmpty(c.Result)) - { - @c.Result - } - @if (c.Fee.HasValue) - { -
수임료: @c.Fee.Value.ToString("N0")원
- } -
+ + + +
+ + @c.ConsultationDate.ToString("yyyy-MM-dd") + @if (!string.IsNullOrEmpty(c.ServiceType)) { · @c.ServiceType } + + @c.Summary + @if (!string.IsNullOrEmpty(c.Result)) + { + @c.Result + } + @if (c.Fee.HasValue) + { + + 수임료: @c.Fee.Value.ToString("N0")원 + + } +
+ +
+
+
} -
+ } -
-
-} + + + @code { - [Parameter] public int ClientId { get; set; } + [Parameter] + public int ClientId { get; set; } + private Domain.Entities.Client? client; private List consultations = []; + private bool showAddForm; private DateTime? newDate = DateTime.Today; private string newServiceType = ""; @@ -128,10 +190,10 @@ else private string newResult = ""; private decimal? newFee; - private string ConsultationDateText { get => newDate?.ToString("yyyy-MM-dd") ?? ""; set => newDate = DateTime.TryParse(value, out var dt) ? dt : null; } - private string FeeText { get => newFee?.ToString() ?? ""; set => newFee = decimal.TryParse(value, out var d) ? d : null; } - - protected override async Task OnInitializedAsync() => await LoadAll(); + protected override async Task OnInitializedAsync() + { + await LoadAll(); + } private async Task LoadAll() { @@ -153,12 +215,6 @@ else { try { - if (string.IsNullOrWhiteSpace(newSummary)) - { - await JS.InvokeVoidAsync("alert", "상담 내용을 입력하세요."); - return; - } - var c = new Domain.Entities.Consultation { ClientId = ClientId, @@ -168,23 +224,21 @@ else Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult, Fee = newFee }; - await ConsultationService.CreateAsync(c); showAddForm = false; consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); - await JS.InvokeVoidAsync("alert", "상담이 추가되었습니다."); + Snackbar.Add("상담이 추가되었습니다.", Severity.Success); } catch (ValidationException ex) { - await JS.InvokeVoidAsync("alert", ex.Message); + Snackbar.Add(ex.Message, Severity.Error); } } private async Task DeleteConsultation(int id) { - if (!await JS.InvokeAsync("confirm", "이 상담을 삭제하시겠습니까?")) return; await ConsultationService.DeleteAsync(id); consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); - await JS.InvokeVoidAsync("alert", "삭제되었습니다."); + Snackbar.Add("삭제되었습니다.", Severity.Info); } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor index 8966d13..c31862e 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor @@ -6,74 +6,117 @@ @using TaxBaik.Domain.Entities @inject IClientBrowserClient ClientClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar @(Id.HasValue ? "고객 수정" : "고객 등록") +
-
CRM
-

@(Id.HasValue ? "고객 수정" : "고객 등록")

+ CRM + @(Id.HasValue ? "고객 수정" : "고객 등록")
- + 목록으로
-
+ @if (isLoading) { - + } else { -
- - - - - - - - - -
- - -
-
+ + + @* 기본 정보 *@ + + 기본 정보 + + + + + + + + + + + + + + + + @* 세무 정보 *@ + + 세무 정보 + + + + + @foreach (var t in ClientService.ServiceTypes) + { + @t + } + + + + + @foreach (var t in ClientService.TaxTypes) + { + @t + } + + + + @* 관리 정보 *@ + + 관리 정보 + + + + + 활성 + 비활성 + + + + + @foreach (var s in ClientService.Sources) + { + @s + } + + + + + + + @* 저장 버튼 *@ + + + @(isSaving ? "저장 중..." : "저장") + + + 취소 + + + + } -
+ @code { [Parameter] public int? Id { get; set; } + + private MudForm form = null!; private CreateClientDto dto = new() { Status = "active" }; + private bool isValid; private bool isLoading = true; private bool isSaving; @@ -86,7 +129,7 @@ var client = await ClientClient.GetByIdAsync(Id.Value); if (client is null) { - await JS.InvokeVoidAsync("alert", "고객을 찾을 수 없습니다."); + Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error); Navigation.NavigateTo("/taxbaik/admin/clients"); return; } @@ -102,42 +145,46 @@ Source = client.Source, Memo = client.Memo }; - } - catch (Exception ex) - { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); - Navigation.NavigateTo("/taxbaik/admin/clients"); - return; - } + } + catch (Exception ex) + { + Snackbar.Add($"오류: {ex.Message}", Severity.Error); + Navigation.NavigateTo("/taxbaik/admin/clients"); + return; + } } isLoading = false; } private async Task SaveAsync() { + await form.Validate(); + if (!isValid) return; + isSaving = true; try { - if (string.IsNullOrWhiteSpace(dto.Name)) - { - await JS.InvokeVoidAsync("alert", "고객명을 입력하세요."); - return; - } if (Id.HasValue) { var result = await ClientClient.UpdateAsync(Id.Value, dto); - await JS.InvokeVoidAsync("alert", result != null ? "고객 정보가 수정되었습니다." : "수정에 실패했습니다."); + if (result != null) + Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success); + else + Snackbar.Add("수정에 실패했습니다.", Severity.Error); } else { var result = await ClientClient.CreateAsync(dto); - await JS.InvokeVoidAsync("alert", result != null ? "고객이 등록되었습니다." : "등록에 실패했습니다."); + if (result != null) + Snackbar.Add("고객이 등록되었습니다.", Severity.Success); + else + Snackbar.Add("등록에 실패했습니다.", Severity.Error); } Navigation.NavigateTo("/taxbaik/admin/clients"); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}"); + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); } finally { diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor index e0b14a0..790e06a 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor @@ -4,94 +4,134 @@ @using TaxBaik.Domain.Entities @inject IClientBrowserClient ClientClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject IDialogService DialogService +@inject ISnackbar Snackbar 고객 관리 +
-
CRM
-

고객 관리

-

고객 카드를 등록하고 상담 이력을 관리합니다.

+ CRM + 고객 관리 + 고객 카드를 등록하고 상담 이력을 관리합니다.
- + + 고객 등록 +
-
-
- - - - -
-
+@* 검색/필터 바 *@ + + + + + + + + 전체 + 활성 + 비활성 + + + + 검색 + + + 초기화 + + + -
+ @if (clients is null) { - + } else if (!clients.Any()) { -
등록된 고객이 없습니다.
+
+ + 등록된 고객이 없습니다. +
} else { -
- - + + + + + + + + + + + + + + + + @foreach (var c in clients) + { - - - - - - - - - + + + + + + + + + - - - @foreach (var c in clients) - { - - - - - - - - - - - - } - -
이름회사명연락처서비스세금 유형상태유입 경로등록일
이름회사명연락처서비스세금 유형상태유입 경로등록일@c.Name@(c.CompanyName ?? "—")@(c.Phone ?? "—") + @if (!string.IsNullOrEmpty(c.ServiceType)) + { + @c.ServiceType + } + @(c.TaxType ?? "—") + @if (c.Status == "active") + { + 활성 + } + else + { + 비활성 + } + @(c.Source ?? "—")@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd") + + + 수정 + + + 삭제 + + +
@c.Name@(c.CompanyName ?? "—")@(c.Phone ?? "—")@(c.ServiceType ?? "—")@(c.TaxType ?? "—")@(c.Status == "active" ? "활성" : "비활성")@(c.Source ?? "—")@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd") -
- - -
-
-
+ } + + + + @* 페이징 *@ @if (totalPages > 1) { -
- - @currentPage / @totalPages - +
+
} - + 총 @(totalCount)명 } -
+
@code { - [CascadingParameter] private Task? AuthStateTask { get; set; } + [CascadingParameter] + private Task? AuthStateTask { get; set; } + private List? clients; private string searchText = ""; private string statusFilter = ""; @@ -102,13 +142,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && AuthStateTask != null) + if (firstRender) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + if (AuthStateTask != null) { - await LoadAsync(); - StateHasChanged(); + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) + { + await LoadAsync(); + StateHasChanged(); + } } } } @@ -117,39 +160,75 @@ { try { - var (items, total) = await ClientClient.GetPagedAsync(currentPage, PageSize, string.IsNullOrEmpty(statusFilter) ? null : statusFilter, string.IsNullOrEmpty(searchText) ? null : searchText); + var (items, total) = await ClientClient.GetPagedAsync( + currentPage, PageSize, + string.IsNullOrEmpty(statusFilter) ? null : statusFilter, + string.IsNullOrEmpty(searchText) ? null : searchText); + clients = items.ToList(); totalCount = total; totalPages = (int)Math.Ceiling((double)total / PageSize); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); clients = []; + totalCount = 0; + totalPages = 0; } } - private async Task SearchAsync() { currentPage = 1; await LoadAsync(); } - private async Task ResetAsync() { searchText = ""; statusFilter = ""; currentPage = 1; await LoadAsync(); } - private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadAsync(); } } - private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadAsync(); } } - private async Task OnSearchKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") await SearchAsync(); } + private async Task SearchAsync() + { + currentPage = 1; + await LoadAsync(); + } + + private async Task ResetAsync() + { + searchText = ""; + statusFilter = ""; + currentPage = 1; + await LoadAsync(); + } + + private async Task OnPageChanged(int page) + { + currentPage = page; + await LoadAsync(); + } + + private async Task OnSearchKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter") await SearchAsync(); + } + private async Task DeleteAsync(Client client) { - var confirmed = await JS.InvokeAsync("confirm", $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다."); - if (!confirmed) return; + var confirmed = await DialogService.ShowMessageBox( + "고객 삭제", + $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.", + yesText: "삭제", cancelText: "취소"); + + if (confirmed != true) return; + try { var success = await ClientClient.DeleteAsync(client.Id); if (success) { - await JS.InvokeVoidAsync("alert", $"{client.Name} 고객이 삭제되었습니다."); + Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success); await LoadAsync(); } + else + { + Snackbar.Add("삭제에 실패했습니다.", Severity.Error); + } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); } + await LoadAsync(); } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyCreate.razor b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyCreate.razor index 8e515a6..2534826 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyCreate.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyCreate.razor @@ -3,22 +3,22 @@ @using TaxBaik.Web.Components.Admin.Forms @inject IApiClient ApiClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar 고객사 등록
-
Settings
-

새 고객사 등록

-

새로운 고객사를 추가합니다.

+ Settings + 새 고객사 등록 + 새로운 고객사를 추가합니다.
- + 취소
-
+ -
+ @code { private void GoBack() @@ -40,12 +40,12 @@ memo = model.Memo }); - await JS.InvokeVoidAsync("alert", "고객사가 등록되었습니다."); + Snackbar.Add("고객사가 등록되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/companies"); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}"); + Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error); } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor index a5793c9..deb247e 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyEdit.razor @@ -3,37 +3,39 @@ @using TaxBaik.Web.Components.Admin.Forms @inject IApiClient ApiClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar +@inject IDialogService DialogService 고객사 수정
-
Settings
-

고객사 수정

-

고객사 정보를 수정합니다.

+ Settings + 고객사 수정 + 고객사 정보를 수정합니다.
- + 취소
@if (isLoading) { -
- -
+ } else if (formModel == null) { -
고객사를 찾을 수 없습니다.
+ 고객사를 찾을 수 없습니다. } else { -
+ -
- -
-
+ + + + + 고객사 삭제 + + } @code { @@ -65,7 +67,7 @@ else } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}"); + Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error); } finally { @@ -93,29 +95,34 @@ else isActive = model.IsActive }); - await JS.InvokeVoidAsync("alert", "고객사가 수정되었습니다."); + Snackbar.Add("고객사가 수정되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/companies"); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}"); + Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error); } } private async Task DeleteCompany() { - if (!await JS.InvokeAsync("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) + var result = await DialogService.ShowMessageBox( + "고객사 삭제", + "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "삭제", "취소"); + + if (result != true) return; try { await ApiClient.DeleteAsync($"company/{Id}"); - await JS.InvokeVoidAsync("alert", "고객사가 삭제되었습니다."); + Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/companies"); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}"); + Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyList.razor b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyList.razor index 5156b9e..b0cd304 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Companies/CompanyList.razor @@ -1,71 +1,53 @@ @page "/admin/companies" @attribute [Authorize] @inject IApiClient ApiClient -@inject IJSRuntime JS +@inject ISnackbar Snackbar 고객사 관리
-
Settings
-

고객사 관리

-

등록된 고객사를 관리하고 새로운 고객사를 추가합니다.

+ Settings + 고객사 관리 + 등록된 고객사를 관리하고 새로운 고객사를 추가합니다.
- + 새 고객사 등록
-
-
- @($"전체 고객사 {totalCompanies}개") - 페이지 @currentPage / @totalPages -
-
+ + + @($"전체 고객사 {totalCompanies}개") + 페이지 @currentPage / @totalPages + + -
- @if (isLoading) - { - - } - else - { -
- - - - - - - - - - - - - - - @foreach (var item in companies) - { - - - - - - - - - - - } - -
회사코드회사명담당자전화이메일활성등록일
@item.CompanyCode@item.CompanyName@(item.ContactPerson ?? "—")@(item.Phone ?? "—")@(item.Email ?? "—")@(item.IsActive ? "활성" : "비활성")@item.CreatedAt.ToString("yyyy-MM-dd")수정
-
- } -
+ + + + + + + + + + + + + + + + 수정 + + + + -
- - -
+ + 이전 + 다음 + @code { private List companies = []; @@ -118,7 +100,7 @@ } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}"); + Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error); } finally { @@ -149,6 +131,4 @@ public bool IsActive { get; set; } public DateTime CreatedAt { get; set; } } - - private string NavTo(string url) => url; } diff --git a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor index 0095e82..bb5cb62 100644 --- a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor +++ b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor @@ -2,122 +2,150 @@ @using TaxBaik.Web.Services.AdminClients @inject IConsultingActivityBrowserClient ActivityClient @inject IClientBrowserClient ClientClient -@inject IJSRuntime JS +@inject ISnackbar Snackbar +@inject IDialogService DialogService @attribute [Authorize] 상담 활동 관리 +
-
CRM & 세무관리
-

상담 활동 관리

-

고객별 상담 이력과 팔로업을 추적합니다.

+ CRM & 세무관리 + 상담 활동 관리 + 고객별 상담 이력과 팔로업을 추적합니다.
- + + 새 활동 기록 +
-
+ @if (activities is null) { - + } else if (activities.Count == 0) { -
상담 활동이 없습니다.
+ + + 상담 활동이 없습니다. + } else { -
- - - - - - - - - - - - - - @foreach (var item in activities) - { - - - - - - - - - - } - -
ID고객활동 유형활동일시설명다음 팔로업작업
@item.Id@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")@item.ActivityType@item.ActivityDate.ToString("g")@Truncate(item.Description)@(item.NextFollowupDate?.ToString("yyyy-MM-dd") ?? "—") -
- - -
-
-
+ + + + + + @if (clientMap.TryGetValue(context.Item.ClientId, out var clientName)) + { + + @clientName + + } + + + + + + + @{ + var desc = context.Item.Description ?? ""; + if (desc.Length > 30) desc = desc.Substring(0, 30) + "..."; + } + @desc + + + + + @if (context.Item.NextFollowupDate.HasValue) + { + var daysLeft = (context.Item.NextFollowupDate.Value.Date - DateTime.Today).Days; + + @context.Item.NextFollowupDate.Value.ToString("yyyy-MM-dd") + + } + + + + + + + + + + + + } -
+ - -
-

@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")

- - - - - -
- - -
-
-
+ + + 방문 상담 + 전화 상담 + 세무조사 대응 미팅 + 카카오톡 상담 + 이메일 자료 접수 + 기타 + + + + + + + + 취소 + 저장 + + @code { - [CascadingParameter] private Task? AuthStateTask { get; set; } + [CascadingParameter] + private Task? AuthStateTask { get; set; } + private List? activities; private List clients = []; private Dictionary clientMap = new(); + private MudForm? form; private bool isDialogOpen; private ConsultingActivity? editingActivity; private ConsultingActivityForm activityForm = new(); - private string ClientIdText { get => activityForm.ClientId > 0 ? activityForm.ClientId.ToString() : ""; set => activityForm.ClientId = int.TryParse(value, out var id) ? id : 0; } - private string ActivityDateText { get => activityForm.ActivityDate?.ToString("yyyy-MM-dd HH:mm") ?? ""; set => activityForm.ActivityDate = DateTime.TryParse(value, out var dt) ? dt : null; } - private string NextFollowupText { get => activityForm.NextFollowupDate?.ToString("yyyy-MM-dd") ?? ""; set => activityForm.NextFollowupDate = DateTime.TryParse(value, out var dt) ? dt : null; } - protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && AuthStateTask != null) + if (firstRender) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + if (AuthStateTask != null) { - await LoadData(); - StateHasChanged(); + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) + { + await LoadData(); + StateHasChanged(); + } } } } @@ -133,14 +161,18 @@ } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}"); + Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error); } } private void OpenCreateDialog() { editingActivity = null; - activityForm = new ConsultingActivityForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, ActivityDate = DateTime.Now }; + activityForm = new ConsultingActivityForm + { + ActivityDate = DateTime.Now, + ClientId = clients.FirstOrDefault()?.Id ?? 0 + }; isDialogOpen = true; } @@ -156,60 +188,103 @@ NextFollowupDate = activity.NextFollowupDate }; isDialogOpen = true; - await Task.CompletedTask; } private async Task SaveActivity() { - if (activityForm.ClientId <= 0 || string.IsNullOrWhiteSpace(activityForm.ActivityType) || string.IsNullOrWhiteSpace(activityForm.Description)) + if (form != null) { - await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요."); - return; + await form.Validate(); + if (!form.IsValid) + { + Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning); + return; + } } try { if (editingActivity == null) { - var newId = await ActivityClient.CreateAsync(activityForm.ClientId, activityForm.ActivityType, activityForm.ActivityDate ?? DateTime.Now, activityForm.Description, null, activityForm.NextFollowupDate); + var actDate = activityForm.ActivityDate ?? DateTime.Now; + var newId = await ActivityClient.CreateAsync( + activityForm.ClientId, + activityForm.ActivityType, + actDate, + activityForm.Description, + null, + activityForm.NextFollowupDate); + if (newId > 0) { - await JS.InvokeVoidAsync("alert", "활동이 기록되었습니다."); + Snackbar.Add("활동이 기록되었습니다.", Severity.Success); CloseDialog(); await LoadData(); } } else { - await ActivityClient.UpdateAsync(editingActivity.Id, null, activityForm.NextFollowupDate); - await JS.InvokeVoidAsync("alert", "활동이 업데이트되었습니다."); + await ActivityClient.UpdateAsync( + editingActivity.Id, + null, + activityForm.NextFollowupDate); + + Snackbar.Add("활동이 업데이트되었습니다.", Severity.Success); CloseDialog(); await LoadData(); } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}"); + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); } } private async Task DeleteActivity(int id) { - if (!await JS.InvokeAsync("confirm", "이 활동을 삭제하시겠습니까?")) return; + var parameters = new DialogParameters + { + { "Title", "삭제 확인" }, + { "Message", "이 활동을 삭제하시겠습니까?" } + }; + + var dialog = await DialogService.ShowAsync("", parameters); + var result = await dialog.Result; + + if (result?.Canceled ?? true) + return; + try { await ActivityClient.DeleteAsync(id); - await JS.InvokeVoidAsync("alert", "활동이 삭제되었습니다."); + Snackbar.Add("활동이 삭제되었습니다.", Severity.Success); await LoadData(); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}"); + Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); } } - private void CloseDialog() { isDialogOpen = false; editingActivity = null; activityForm = new(); } - private static string Truncate(string? text) => string.IsNullOrWhiteSpace(text) ? "—" : text.Length > 30 ? text[..30] + "..." : text; - private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}"; - private sealed class ConsultingActivityForm { public int ClientId { get; set; } public string ActivityType { get; set; } = ""; public DateTime? ActivityDate { get; set; } = DateTime.Now; public string Description { get; set; } = ""; public DateTime? NextFollowupDate { get; set; } } + private void CloseDialog() + { + isDialogOpen = false; + editingActivity = null; + activityForm = new(); + } + + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; + private class ConsultingActivityForm + { + public int ClientId { get; set; } + public string ActivityType { get; set; } = ""; + public DateTime? ActivityDate { get; set; } = DateTime.Now; + public string Description { get; set; } = ""; + public DateTime? NextFollowupDate { get; set; } + } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor index c924ff0..9d480be 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor @@ -2,123 +2,160 @@ @using TaxBaik.Web.Services.AdminClients @inject IContractBrowserClient ContractClient @inject IClientBrowserClient ClientClient -@inject IJSRuntime JS +@inject ISnackbar Snackbar +@inject IDialogService DialogService @attribute [Authorize] 계약 관리
-
CRM & 세무관리
-

계약 관리

-

고객 계약과 월 정기수익을 함께 관리합니다.

+ CRM & 세무관리 + 계약 관리 + 고객 계약과 월 정기수익을 함께 관리합니다. @if (mrr > 0) { -

월 정기수익: ₩@mrr.ToString("N0")

+ + 월 정기수익: + ₩@mrr.ToString("N0") + }
- + + 새 계약 추가 +
-
+ @if (contracts is null) { - + } else if (contracts.Count == 0) { -
계약이 없습니다.
+ + + 계약이 없습니다. + } else { -
- - - - - - - - - - - - - - - @foreach (var item in contracts) - { - var isActive = !item.EndDate.HasValue || item.EndDate.Value >= DateTime.Today; - - - - - - - - - - - } - -
ID고객계약번호서비스 유형월 수수료계약기간상태작업
@item.Id@(clientMap.TryGetValue(item.ClientId, out var clientName) ? clientName : "")@item.ContractNumber@item.ServiceType@(item.MonthlyFee?.ToString("C") ?? "—")@item.StartDate@if (item.EndDate.HasValue){~ @item.EndDate.Value}@(isActive ? "활성" : "만료")
-
+ + + + + + @if (clientMap.TryGetValue(context.Item.ClientId, out var clientName)) + { + + @clientName + + } + + + + + + + + @context.Item.StartDate.ToString("yyyy-MM-dd") + @if (context.Item.EndDate.HasValue) + { + ~@context.Item.EndDate.Value.ToString("yyyy-MM-dd") + } + + + + + @{ + var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today; + } + @if (isActive) + { + 활성 + } + else + { + 만료 + } + + + + + + + + + + + } -
+ - -
-

새 계약 추가

- - - - - -
- - -
-
-
+ + + + 개인 기장대리 + 법인 기장대리 + 세무조정 대행 + 양도세 신고대리 + 상속·증여 자문 + 세무조사 대응 + + + + + + + 취소 + 저장 + + @code { - [CascadingParameter] private Task? AuthStateTask { get; set; } + [CascadingParameter] + private Task? AuthStateTask { get; set; } + private List? contracts; private List clients = []; private Dictionary clientMap = new(); private decimal mrr = 0; + private MudForm? form; private bool isDialogOpen; private ContractForm contractForm = new(); - private string ClientIdText { get => contractForm.ClientId > 0 ? contractForm.ClientId.ToString() : ""; set => contractForm.ClientId = int.TryParse(value, out var id) ? id : 0; } - private string StartDateText { get => contractForm.StartDate?.ToString("yyyy-MM-dd") ?? ""; set => contractForm.StartDate = DateTime.TryParse(value, out var dt) ? dt : null; } - private string MonthlyFeeText { get => contractForm.MonthlyFee?.ToString() ?? ""; set => contractForm.MonthlyFee = decimal.TryParse(value, out var amount) ? amount : null; } protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && AuthStateTask != null) + if (firstRender) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + if (AuthStateTask != null) { - await LoadData(); - StateHasChanged(); + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) + { + await LoadData(); + StateHasChanged(); + } } } } @@ -135,56 +172,99 @@ } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}"); + Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error); } } private void OpenCreateDialog() { - contractForm = new ContractForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, StartDate = DateTime.Today }; + contractForm = new ContractForm + { + ClientId = clients.FirstOrDefault()?.Id, + StartDate = DateTime.Today + }; isDialogOpen = true; } private async Task SaveContract() { - try + if (form != null) { - if (contractForm.ClientId <= 0) + await form.Validate(); + if (!form.IsValid) { - await JS.InvokeVoidAsync("alert", "고객을 선택하세요."); + Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning); return; } + } + + try + { + if (contractForm.ClientId == null) return; + var newId = await ContractClient.CreateAsync( + contractForm.ClientId.Value, + contractForm.ContractNumber, + contractForm.ServiceType, + contractForm.StartDate ?? DateTime.Now, + contractForm.MonthlyFee); - var newId = await ContractClient.CreateAsync(contractForm.ClientId, contractForm.ContractNumber, contractForm.ServiceType, contractForm.StartDate ?? DateTime.Today, contractForm.MonthlyFee); if (newId > 0) { - await JS.InvokeVoidAsync("alert", "계약이 추가되었습니다."); + Snackbar.Add("계약이 추가되었습니다.", Severity.Success); CloseDialog(); await LoadData(); } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}"); + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); } } private async Task DeleteContract(int id) { - if (!await JS.InvokeAsync("confirm", "이 계약을 삭제하시겠습니까?")) return; + var parameters = new DialogParameters + { + { "Title", "삭제 확인" }, + { "Message", "이 계약을 삭제하시겠습니까?" } + }; + + var dialog = await DialogService.ShowAsync("", parameters); + var result = await dialog.Result; + + if (result?.Canceled ?? true) + return; + try { await ContractClient.DeleteAsync(id); - await JS.InvokeVoidAsync("alert", "계약이 삭제되었습니다."); + Snackbar.Add("계약이 삭제되었습니다.", Severity.Success); await LoadData(); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}"); + Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); } } - private void CloseDialog() { isDialogOpen = false; contractForm = new(); } - private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}"; - private sealed class ContractForm { public int ClientId { get; set; } public string ContractNumber { get; set; } = ""; public string ServiceType { get; set; } = ""; public DateTime? StartDate { get; set; } public decimal? MonthlyFee { get; set; } } + private void CloseDialog() + { + isDialogOpen = false; + contractForm = new(); + } + + private static string GetClientDisplayName(Client client) + => !string.IsNullOrWhiteSpace(client.CompanyName) + ? client.CompanyName + : !string.IsNullOrWhiteSpace(client.Name) + ? client.Name + : $"Client #{client.Id}"; + private class ContractForm + { + public int? ClientId { get; set; } + public string ContractNumber { get; set; } = ""; + public string ServiceType { get; set; } = ""; + public DateTime? StartDate { get; set; } + public decimal? MonthlyFee { get; set; } + } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor index 0b55d62..993a391 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor @@ -8,205 +8,207 @@
-
Overview
-

대시보드

-

문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.

+ Overview + 대시보드 + 문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.
- + + 새 포스트 작성 +
-@if (summary is null) -{ -
- -
-
- -
-
- -
-} -else -{ -
-
-
- 이번달 문의 -
- @summary.ThisMonthInquiries - 💬 -
- 월간 상담 유입 -
-
- -
-
- 신규 문의 -
- @summary.NewInquiries - ⚠️ -
- 처리 대기 -
-
- -
-
- 전체 포스트 -
- @summary.TotalPosts - 📄 -
- 콘텐츠 자산 -
-
- -
-
- 발행된 포스트 -
- @summary.PublishedPosts - 🌐 -
- 검색 노출 대상 + +
+
+
+ 이번달 문의 +
+ @summary.ThisMonthInquiries + 💬
+ 월간 상담 유입 (클릭 시 이동)
-} -@if (upcomingFilings.Count == 0) +
+
+ 신규 문의 +
+ @summary.NewInquiries + ⚠️ +
+ 처리 대기 (클릭 시 이동) +
+
+ +
+
+ 전체 포스트 +
+ @summary.TotalPosts + 📄 +
+ 콘텐츠 자산 (클릭 시 이동) +
+
+ +
+
+ 발행된 포스트 +
+ @summary.PublishedPosts + 🌐 +
+ 검색 노출 대상 (클릭 시 이동) +
+
+
+ +@if (upcomingFilings.Count > 0) { -
이번 달 마감 임박 신고가 없습니다.
-} -else -{ -
+
-

이번 달 마감 임박 신고

-

30일 이내 신고 예정 건

+ 이번 달 마감 임박 신고 + 30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)
- 전체 일정 보기 + 전체 일정 보기
-
- - + + + + + + + + + + + @foreach (var f in upcomingFilings) + { + var dday = (f.DueDate.Date - DateTime.Today).Days; - - - - + + + + - - - @foreach (var f in upcomingFilings) - { - var dday = (f.DueDate.Date - DateTime.Today).Days; - - - - - - - } - -
고객신고 유형기한D-day
고객신고 유형기한D-day + + @f.ClientName + + @f.FilingType@f.DueDate.ToString("yyyy-MM-dd") + @if (dday < 0) + { + 기한 초과 (@(-dday)일) + } + else if (dday <= 7) + { + D-@dday + } + else + { + D-@dday + } +
@f.ClientName@f.FilingType@f.DueDate.ToString("yyyy-MM-dd") - @if (dday < 0) - { - 기한 초과 (@(-dday)일) - } - else if (dday <= 7) - { - D-@dday - } - else - { - D-@dday - } -
-
-
+ } + + + } -@if (summary is not null) -{ -
-
-
-

최근 문의

-

최근 유입된 상담 요청을 빠르게 확인합니다.

-
- 문의 전체 보기 -
-
- - - - - - - - - - - - @foreach (var inquiry in summary.RecentInquiries) - { - - - - - - - - } - -
이름전화분야상태날짜
@inquiry.Name@inquiry.Phone@inquiry.ServiceType@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] private Task? AuthStateTask { get; set; } - private AdminDashboardSummary? summary; + private AdminDashboardSummary summary = new(0, 0, 0, 0, []); private List upcomingFilings = []; + private string? errorMessage; + private bool isLoading = true; protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && AuthStateTask != null) + if (firstRender) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + if (AuthStateTask != null) { - try + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) { - var summaryTask = DashboardClient.GetSummaryAsync(); - var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30); - await Task.WhenAll(summaryTask, filingsTask); - summary = await summaryTask; - upcomingFilings = (await filingsTask).ToList(); + try + { + // API 클라이언트 사용 (서비스 직접 호출 X) + 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(); + } } - catch (Exception ex) - { - Console.Error.WriteLine($"Dashboard error: {ex.Message}"); - } - StateHasChanged(); } } } private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status); - private static string GetStatusClass(string status) => status switch + + private static Color StatusColor(string status) => status switch { - "new" => "warning", - "consulting" => "info", - "contracted" => "success", - "rejected" => "danger", - "closed" => "dark", - _ => "default" + "new" => Color.Warning, + "consulting" => Color.Info, + "contracted" => Color.Success, + "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 5e4c6e3..f19d520 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor @@ -5,52 +5,85 @@ @using TaxBaik.Domain.Entities @inject IFaqBrowserClient FaqClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar @(Id.HasValue ? "FAQ 수정" : "FAQ 등록") +
-
홈페이지
-

@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")

+ 홈페이지 + @(Id.HasValue ? "FAQ 수정" : "FAQ 등록")
- + 목록으로
-
+ @if (isLoading) { - + } else { -
- - - - - -
- - -
-
+ + + + + + + + + + + @foreach (var cat in FaqService.Categories) + { + @cat + } + + + + + + + + + + + + @(isSaving ? "저장 중..." : "저장") + + + 취소 + + + + } -
+ @code { [Parameter] public int? Id { get; set; } + + private MudForm form = null!; private Faq faq = new() { SortOrder = 10, IsActive = true }; + private bool isValid; private bool isLoading = true; private bool isSaving; - private string SortOrderText { get => faq.SortOrder.ToString(); set => faq.SortOrder = int.TryParse(value, out var n) ? n : 0; } protected override async Task OnInitializedAsync() { @@ -61,7 +94,7 @@ var existing = await FaqClient.GetByIdAsync(Id.Value); if (existing is null) { - await JS.InvokeVoidAsync("alert", "FAQ를 찾을 수 없습니다."); + Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error); Navigation.NavigateTo("/taxbaik/admin/faqs"); return; } @@ -69,7 +102,7 @@ } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); Navigation.NavigateTo("/taxbaik/admin/faqs"); return; } @@ -79,30 +112,33 @@ private async Task SaveAsync() { + await form.Validate(); + if (!isValid) return; + isSaving = true; try { - if (string.IsNullOrWhiteSpace(faq.Question) || string.IsNullOrWhiteSpace(faq.Answer)) - { - await JS.InvokeVoidAsync("alert", "질문과 답변을 입력하세요."); - return; - } - if (Id.HasValue) { var result = await FaqClient.UpdateAsync(Id.Value, faq); - await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 수정되었습니다." : "수정 실패"); + if (result != null) + Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success); + else + Snackbar.Add("수정 실패", Severity.Error); } else { var result = await FaqClient.CreateAsync(faq); - await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 등록되었습니다." : "등록 실패"); + if (result != null) + Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success); + else + Snackbar.Add("등록 실패", Severity.Error); } Navigation.NavigateTo("/taxbaik/admin/faqs"); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}"); + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); } finally { diff --git a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor index d207c1c..678d87b 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor @@ -4,63 +4,95 @@ @using TaxBaik.Domain.Entities @inject IFaqBrowserClient FaqClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject IDialogService DialogService +@inject ISnackbar Snackbar FAQ 관리
-
홈페이지
-

FAQ 관리

-

홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.

+ 홈페이지 + FAQ 관리 + 홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.
- FAQ 등록 + + FAQ 등록 +
-
+ @if (faqs is null) { - + } else if (!faqs.Any()) { -
등록된 FAQ가 없습니다.
+
+ + 등록된 FAQ가 없습니다. +
} else { -
- - + + + + + + + + + + + + @foreach (var item in faqs) + { - - - - - + + + + + - - - @foreach (var item in faqs) - { - - - - - - - - } - -
순서질문카테고리상태
순서질문카테고리상태 + @item.SortOrder + + + @item.Question + + + @if (!string.IsNullOrEmpty(item.Category)) + { + @item.Category + } + + @if (item.IsActive) + { + 노출 중 + } + else + { + 비활성 + } + + + + 수정 + + + 삭제 + + +
@item.SortOrder@item.Question@(string.IsNullOrEmpty(item.Category) ? "" : item.Category)@(item.IsActive ? "노출 중" : "비활성") -
- - -
-
-
-
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
+ } + + + + 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개 + } -
+ @code { [CascadingParameter] @@ -70,13 +102,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && AuthStateTask != null) + if (firstRender) { - var authState = await AuthStateTask; - if (authState.User.Identity?.IsAuthenticated == true) + if (AuthStateTask != null) { - await LoadAsync(); - StateHasChanged(); + var authState = await AuthStateTask; + if (authState.User.Identity?.IsAuthenticated == true) + { + await LoadAsync(); + StateHasChanged(); + } } } } @@ -89,32 +124,36 @@ } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); faqs = []; } } private async Task DeleteAsync(Faq item) { - var confirmed = await JS.InvokeAsync("confirm", $"'{item.Question}' 항목을 삭제하시겠습니까?"); - if (!confirmed) return; + var confirmed = await DialogService.ShowMessageBox( + "FAQ 삭제", + $"'{item.Question}' 항목을 삭제하시겠습니까?", + yesText: "삭제", cancelText: "취소"); + + if (confirmed != true) return; try { var success = await FaqClient.DeleteAsync(item.Id); if (success) { - await JS.InvokeVoidAsync("alert", "FAQ가 삭제되었습니다."); + Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success); await LoadAsync(); } else { - await JS.InvokeVoidAsync("alert", "삭제 실패"); + Snackbar.Add("삭제 실패", Severity.Error); } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor index cace22a..dbe453e 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor @@ -5,41 +5,51 @@ @using TaxBaik.Web.Components.Admin.Forms @inject InquiryService InquiryService @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar 문의 등록
-
Customer Relations
-

새 문의 등록

-

고객 문의를 등록합니다. (전화, 오프라인 등)

+ Customer Relations + 새 문의 등록 + 고객 문의를 등록합니다. (전화, 오프라인 등)
- + 취소
-
+ -
+ @code { - private void GoBack() => Navigation.NavigateTo("/taxbaik/admin/inquiries"); + private void GoBack() + { + Navigation.NavigateTo("/taxbaik/admin/inquiries"); + } private async Task HandleCreate(InquiryForm.InquiryFormModel model) { try { - await InquiryService.SubmitAsync(model.Name, model.Phone, model.ServiceType, model.Message, model.Email, ipAddress: "admin-registered"); - await JS.InvokeVoidAsync("alert", "문의가 등록되었습니다."); + await InquiryService.SubmitAsync( + model.Name, + model.Phone, + model.ServiceType, + model.Message, + model.Email, + ipAddress: "admin-registered"); + + Snackbar.Add("문의가 등록되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/inquiries"); } catch (ValidationException ex) { - await JS.InvokeVoidAsync("alert", ex.Message); + Snackbar.Add(ex.Message, Severity.Error); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}"); + Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error); } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor index 5f036f3..f974cf1 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor @@ -3,75 +3,113 @@ @using TaxBaik.Web.Services @inject IInquiryBrowserClient InquiryClient @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar 문의 상세
-
Inquiry Details
-

문의 상세

-

문의 정보를 확인하고 처리 상태를 관리합니다.

+ Inquiry Details + 문의 상세 + 문의 정보를 확인하고 처리 상태를 관리합니다.
@if (inquiry != null) { -
- -
+ + 문의 목록으로 + -
-
-

문의 정보

-
-
이름@inquiry.Name
-
연락처@inquiry.Phone
-
이메일@(inquiry.Email ?? "-")
-
분야@inquiry.ServiceType
-
문의 내용@inquiry.Message
-
접수일시@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")
-
-
+ + + + 문의 정보 + + + 이름 + @inquiry.Name + + + 연락처 + @inquiry.Phone + + + 이메일 + @(inquiry.Email ?? "-") + + + 분야 + @inquiry.ServiceType + + + 문의 내용 + + @inquiry.Message + + + + 접수일시 + @inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + + + -
-

담당자 메모

- -
- -
-
+ + 담당자 메모 + + 메모 저장 + +
-
-

처리 상태

-
- @foreach (var (key, label) in InquiryStatusMapper.Labels) - { - - } -
-
+ + + 처리 상태 + + @foreach (var (key, label) in InquiryStatusMapper.Labels) + { + + @label + + } + + - @if (inquiry.ClientId == null) - { -
-

고객 카드 생성

-

이 문의를 고객 카드로 등록합니다.

- -
- } - else - { -
-

연결된 고객

- 고객 카드 보기 -
- } -
+ @if (inquiry.ClientId == null) + { + + 고객 카드 생성 + 이 문의를 고객 카드로 등록합니다. + + 고객으로 등록 + + + } + else + { + + 연결된 고객 + + 고객 카드 보기 + + + } + + } else { -
문의를 찾을 수 없습니다.
+ 문의를 찾을 수 없습니다. } @code { @@ -96,16 +134,16 @@ else if (success) { inquiry.Status = status; - await JS.InvokeVoidAsync("alert", "상태가 변경되었습니다."); + Snackbar.Add("상태가 변경되었습니다.", Severity.Success); } else { - await JS.InvokeVoidAsync("alert", "상태 변경에 실패했습니다."); + Snackbar.Add("상태 변경에 실패했습니다.", Severity.Error); } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); } } @@ -118,16 +156,16 @@ else if (success) { inquiry.AdminMemo = adminMemo; - await JS.InvokeVoidAsync("alert", "메모가 저장되었습니다."); + Snackbar.Add("메모가 저장되었습니다.", Severity.Success); } else { - await JS.InvokeVoidAsync("alert", "메모 저장에 실패했습니다."); + Snackbar.Add("메모 저장에 실패했습니다.", Severity.Error); } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); } } @@ -146,19 +184,26 @@ else { inquiry.ClientId = clientId; inquiry.Status = "consulting"; - await JS.InvokeVoidAsync("alert", "고객 카드가 생성되었습니다."); + Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success); } else { - await JS.InvokeVoidAsync("alert", "고객 카드 생성에 실패했습니다."); + Snackbar.Add("고객 카드 생성에 실패했습니다.", Severity.Error); } } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}"); + Snackbar.Add($"오류: {ex.Message}", Severity.Error); } } - private string GetStatusButtonClass(string status) - => inquiry?.Status == status ? "site-button primary" : "site-button secondary"; + private Color StatusColor(string status) => status switch + { + "new" => Color.Default, + "consulting" => Color.Info, + "contracted" => Color.Success, + "rejected" => Color.Error, + "closed" => Color.Dark, + _ => Color.Default + }; } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor index 7dd7075..5eb11ea 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor @@ -5,39 +5,45 @@ @using TaxBaik.Web.Components.Admin.Forms @inject InquiryService InquiryService @inject NavigationManager Navigation -@inject IJSRuntime JS +@inject ISnackbar Snackbar +@inject IDialogService DialogService 문의 수정
-
Customer Relations
-

문의 수정

-

고객 문의 정보를 수정합니다.

+ Customer Relations + 문의 수정 + 고객 문의 정보를 수정합니다.
- + 취소
@if (isLoading) { -
+ } else if (inquiry == null) { -
문의를 찾을 수 없습니다.
+ 문의를 찾을 수 없습니다. } else { -
+ -
- -
-
+ + + + + 문의 삭제 + + } @code { - [Parameter] public int Id { get; set; } + [Parameter] + public int Id { get; set; } + private Domain.Entities.Inquiry? inquiry; private InquiryForm.InquiryFormModel? formModel; private bool isLoading = true; @@ -63,7 +69,7 @@ else } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"문의 로드 실패: {ex.Message}"); + Snackbar.Add($"문의 로드 실패: {ex.Message}", Severity.Error); } finally { @@ -71,11 +77,16 @@ else } } - private void GoBack() => Navigation.NavigateTo("/taxbaik/admin/inquiries"); + private void GoBack() + { + Navigation.NavigateTo("/taxbaik/admin/inquiries"); + } private async Task HandleUpdate(InquiryForm.InquiryFormModel model) { - if (inquiry == null) return; + if (inquiry == null) + return; + try { inquiry.Name = model.Name; @@ -86,35 +97,47 @@ else inquiry.AdminMemo = model.AdminMemo; if (inquiry.Status != model.Status) + { await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status); + } await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo); - await JS.InvokeVoidAsync("alert", "문의가 수정되었습니다."); + + Snackbar.Add("문의가 수정되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/inquiries"); } catch (ValidationException ex) { - await JS.InvokeVoidAsync("alert", ex.Message); + Snackbar.Add(ex.Message, Severity.Error); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}"); + Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error); } } private async Task DeleteInquiry() { - if (inquiry == null) return; - if (!await JS.InvokeAsync("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return; + if (inquiry == null) + return; + + var result = await DialogService.ShowMessageBox( + "문의 삭제", + "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "삭제", "취소"); + + if (result != true) + return; + try { await InquiryService.DeleteAsync(inquiry.Id); - await JS.InvokeVoidAsync("alert", "문의가 삭제되었습니다."); + Snackbar.Add("문의가 삭제되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/inquiries"); } catch (Exception ex) { - await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}"); + Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); } } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor index af6c90a..7a40ff8 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor @@ -7,36 +7,47 @@
-
Customer Requests
-

문의 관리

-

상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.

+ Customer Requests + 문의 관리 + 상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.
- + 새 문의 등록
-
- @if (isLoading) - { - - } - else - { -
- - - - - - -
- - } -
+ +@if (isLoading) +{ + +} +else +{ + + + + + + + + + + + + + + + + + + + + +} + @code { [CascadingParameter] private Task? AuthStateTask { get; set; } - [Inject] private NavigationManager Navigation { get; set; } = default!; private bool isLoading = true; private IReadOnlyList allInquiries = []; diff --git a/TaxBaik.Web/Components/Admin/Pages/Login.razor b/TaxBaik.Web/Components/Admin/Pages/Login.razor index e471ff2..5ea44a0 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Login.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Login.razor @@ -1,5 +1,4 @@ @page "/admin/login" -@using Microsoft.FluentUI.AspNetCore.Components @using System.ComponentModel.DataAnnotations @layout TaxBaik.Web.Components.Admin.Layout.BlankLayout @attribute [AllowAnonymous] @@ -11,40 +10,41 @@ 로그인 -