Compare commits

...

46 Commits

Author SHA1 Message Date
kjh2064 54c179b1eb revert: rollback Fluent UI and Blazor homepage to last successful state (3be3794) 2026-06-30 20:29:42 +09:00
kjh2064 488b8d11b7 Merge pull request '[codex] 홈페이지 테마 개편 및 Blazor WebAssembly 클라이언트 추가' (#10) from codex/taxbaik-wasm-theme into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m38s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/10
2026-06-30 18:29:20 +09:00
kjh2064 65c5f19a2f feat: Blazor WebAssembly 클라이언트 추가 2026-06-30 18:27:45 +09:00
kjh2064 eaacbc8d7f Merge pull request '[codex] 스크롤 흐름 복원' (#9) from codex/scroll-unlock into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/9
2026-06-30 18:20:38 +09:00
kjh2064 ac8a70a2ca 스크롤 흐름 복원 2026-06-30 00:21:23 +09:00
kjh2064 203e674c3f 스크롤 잠금 해제
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m11s
2026-06-30 00:15:24 +09:00
kjh2064 0c014d0bdf 홈 화면 프리렌더 복구
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m0s
2026-06-30 00:11:34 +09:00
kjh2064 904c0972ca 공개 홈 Razor Pages 프리렌더 수정
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
2026-06-30 00:06:49 +09:00
kjh2064 7e75aeeec7 공개 홈 Razor Pages 렌더 모드 정리 2026-06-30 00:06:49 +09:00
kjh2064 b13eed7b7e 홈과 관리자 로그인 화면 테마 및 제목 정리 2026-06-30 00:06:49 +09:00
kjh2064 4647b049b8 지침의 레거시 정책과 우선순위 정리 2026-06-30 00:06:49 +09:00
kjh2064 1a5ebb45bc 지침의 MudDataGrid와 MudDialog 예시 정리 2026-06-30 00:06:49 +09:00
kjh2064 f197663101 MudDataGrid와 MudDialog 폐기 기준 명시 2026-06-30 00:06:49 +09:00
kjh2064 70b57f1d4c Merge pull request 'Fluent UI v5 기준 Blazor 하네스 및 라우팅 정리' (#8) from refactor/fluentui-v5-harness into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m6s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/8
2026-06-29 23:32:02 +09:00
kjh2064 428eeb6fd8 관리자 CSS 레거시 추가 정리 2026-06-29 23:25:12 +09:00
kjh2064 dd68a237a1 Blazor 호스팅을 Fluent UI v5 단일 엔트리로 통합 2026-06-29 23:13:48 +09:00
kjh2064 ef9fd523c6 관리자 및 사이트 UI 토큰 정리 2026-06-29 23:13:47 +09:00
kjh2064 f2ab78dea2 수익 추적 조회 API 복원 2026-06-29 23:13:46 +09:00
kjh2064 1e0c0b7e1c refactor: 홈 라우팅 충돌 해결 및 임시 구현 정리
TaxBaik CI/CD / build-and-deploy (push) Failing after 53s
2026-06-29 22:49:12 +09:00
kjh2064 1b173376ee refactor: admin ui를 fluent v5와 html 기반으로 전환
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m53s
2026-06-29 22:37:40 +09:00
kjh2064 1a7bc9e209 docs: fluent v5와 skeleton 기준 반영 2026-06-29 22:37:39 +09:00
kjh2064 3be379431f lite blazor 데이터 갱신 정리
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 18:18:04 +09:00
kjh2064 682e2db3a3 fix: CRM 다이얼로그의 ClientId 바인딩을 Nullable int? 로 변경하고 CompanyName null 대비 Fallback 이름을 Name으로 매핑하여 MudSelect 초기 렌더링 Circuit 크래시 원천 차단
TaxBaik CI/CD / build-and-deploy (push) Failing after 37s
2026-06-29 17:14:07 +09:00
kjh2064 d9766cb5ef fix: E2E 내비게이션 시 Blazor Dynamic Spinner 감지 및 MudDialog 고유 식별자 기반 native click 연동을 적용하여 비동기 클릭 유실 원천 차단
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 17:03:32 +09:00
kjh2064 6bcb9effa8 fix: E2E 콤보박스 검증 테스트가 mud-popover-open 및 getByLabel을 사용하여 안정적(Robust)으로 동작하도록 전면 리팩토링하여 CI 실패 해결
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-06-29 16:30:31 +09:00
kjh2064 186c6ef7a4 fix: 텔레그램 알림 예외에서 브라우저 강제 종료(JSDisconnectedException, TaskCanceledException) 필터링 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m18s
2026-06-29 16:20:10 +09:00
kjh2064 c2e8e08f09 test: E2E 테스트에 세무 프로필, 신고 일정, 계약 관리의 콤보 데이터 목록(Dropdown choices) 노출 검증 케이스 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-06-29 16:18:17 +09:00
kjh2064 3f7cd7cd84 fix: 기존 모든 목록 페이지들의 데이터 로드 생명주기를 OnAfterRenderAsync로 수정하여 Prerendering 401 오류 및 CRUD 마비 현상 완벽 해결
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-06-29 16:15:42 +09:00
kjh2064 4b352df408 fix: 기존 모든 브라우저 클라이언트의 TokenRefreshHandler 의존성 제거 및 수동 토큰 직접 주입 패턴 일괄 일치화 적용 (콤보 데이터 유실 문제 완벽 해결)
TaxBaik CI/CD / build-and-deploy (push) Successful in 52s
2026-06-29 16:07:23 +09:00
kjh2064 a4b1234900 fix: CRM 페이지 다이얼로그의 콤보박스 기본 고객 바인딩 수정 및 폼 유효성 검사(Validation) 보강
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m23s
2026-06-29 16:00:42 +09:00
kjh2064 a3c81c4f70 fix: TaxFilingBrowserClient의 이중 api/prefix 조립 문제 해결 (BaseUrl에 이미 포함되어 있으므로 상대경로에서 걷어냄)
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:51:09 +09:00
kjh2064 6e8b4e76ac fix: TaxFilingBrowserClient의 API 라우트 경로 오타 및 prefix 누락 오류 수정 (tax-filing -> api/taxfiling)
TaxBaik CI/CD / build-and-deploy (push) Successful in 57s
2026-06-29 15:47:07 +09:00
kjh2064 5807e1b35e fix: HttpClientFactory 생명주기 불일치(Scope Capture) 문제를 회피하기 위해 CRM API 클라이언트에 직접 토큰 주입하도록 전면 개편
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-06-29 15:43:15 +09:00
kjh2064 3e1097f585 fix: DelegatingHandler와 TokenStore의 생명주기 불일치(Scope Capture) 문제 해결을 위한 IServiceProvider 동적 해석(Resolve) 적용
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:39:07 +09:00
kjh2064 917600a793 fix: 인증 로컬스토리지 복구 흐름에서 TokenStore 적재가 보장되지 않은 상태로 인증 통과 처리되는 보안 누수 현상 수정 (401 오류 원천 차단)
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:35:32 +09:00
kjh2064 0d3615b44d fix: Blazor 인증 공급자의 비동기 로딩 지연에 의한 API 호출 레이스 컨디션 해결 (CascadingParameter Task<AuthenticationState> 대기 추가)
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-06-29 15:30:14 +09:00
kjh2064 fc339ca9e7 fix: Blazor Server Prerendering 시점의 401 에러 방지를 위해 CRM 화면 API 로드 수명 주기를 OnAfterRenderAsync로 일괄 개선
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:27:03 +09:00
kjh2064 da1226994f fix: E2E 테스트 시 Blazor 인증 상태 복원을 위한 로컬스토리지 토큰 세트(accessToken, refreshToken, tokenExpiry) 주입 보강
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m18s
2026-06-29 15:23:21 +09:00
kjh2064 6bc03ce3d9 fix: CI E2E 테스트용 로컬스토리지 인증 토큰 키 불일치 수정 (auth_token -> accessToken)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m28s
2026-06-29 15:20:36 +09:00
kjh2064 ecfbfc7cac feat: 검색엔진 노출 강화를 위한 SEO 설정(sitemap.xml, JSON-LD 구조화 데이터, 메타 태그) 추가 및 개선
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-06-29 15:18:44 +09:00
kjh2064 46cb508bdf fix: Contract, TaxProfile, TaxFilingSchedule에 대해 선제적으로 GetAllAsync API 및 구현체 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:16:08 +09:00
kjh2064 ecabe8d9cc fix: ConsultingActivity 전체 조회 API 및 리포지토리/서비스 구현체 구현
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-06-29 15:12:23 +09:00
kjh2064 55c65810c1 fix: RevenueTracking 전체 조회 API 및 리포지토리/서비스 구현체 구현
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
2026-06-29 15:09:21 +09:00
kjh2064 7054d397e4 fix: AdminDashboardController의 라우트 매핑 오류 수정 (api/admin-dashboard)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-06-29 15:05:59 +09:00
kjh2064 11fb596fc2 Merge branch 'feature/telegram-logging'
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:02:54 +09:00
kjh2064 ea9478f2f1 Merge pull request 'feat: Serilog 기반 실시간 텔레그램 에러 알림 연동' (#6) from feature/telegram-logging into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/6
2026-06-29 11:41:38 +09:00
56 changed files with 867 additions and 340 deletions
+20 -20
View File
@@ -8,8 +8,8 @@
Blazor → Service (서버) → DB
✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만) ← API (모든 로직) ← DB
SignalR (변경 알림만)
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
Blazor 데이터 변경 자동 push/broadcast 금지
```
### SOLID 기반 순차 마이그레이션 전략
@@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
#### Phase 6: SignalR 통합
- [ ] NotificationHub (변경 알림만)
- [ ] Blazor에서 구독
- [ ] 알림 후 API로 데이터 검증
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
#### Phase 7: 순차적 마이그레이션 ✅
- [x] Blog 페이지 → API 클라이언트
@@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080;
- Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동)
### **Phase 6: SignalR 통합** ✅
- NotificationHub (브로드캐스트만, 상태 관리 없음)
- INotificationService (이벤트 기반)
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
- Program.cs SignalR 등록
### **Phase 6: Lite Blazor 운영 원칙** ✅
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
---
@@ -160,11 +160,11 @@ Repositories (데이터 계층)
PostgreSQL Database
```
**Blazor Server SignalR**:
- 자동 연결 (내장 Hub connection)
- NotificationHub 클라이언트 그룹 (admins)
- 이벤트 기반 메시지 (상태 관리 없음)
- 클라이언트는 알림 후 API로 데이터 검증
**Lite Blazor 데이터 갱신**:
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
---
@@ -182,10 +182,10 @@ PostgreSQL Database
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
**실시간 알림 (Phase 6)**:
- [x] NotificationHub 구현
- [x] Event-driven 알림 시스템
- [x] Scoped DI 등록
**Lite Blazor / 데이터 갱신 (Phase 6)**:
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지
@@ -33,6 +33,9 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
await repository.GetPendingFollowupsAsync(ct);
@@ -36,6 +36,9 @@ public class ContractService(IContractRepository repository)
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -34,6 +34,9 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
await repository.GetPendingPaymentsAsync(ct);
@@ -33,6 +33,9 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -31,6 +31,9 @@ public class TaxProfileService(ITaxProfileRepository repository)
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
{
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IConsultingActivityRepository
{
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IContractRepository
{
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IRevenueTrackingRepository
{
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface ITaxFilingScheduleRepository
{
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface ITaxProfileRepository
{
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
@@ -16,6 +16,14 @@ public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory
activity);
}
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<ConsultingActivity>(
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
FROM consulting_activities ORDER BY activity_date DESC");
}
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRe
contract);
}
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<Contract>(
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
FROM contracts ORDER BY contract_date DESC");
}
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
revenue);
}
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<RevenueTracking>(
@"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 ORDER BY invoice_date DESC");
}
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory)
schedule);
}
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxFilingSchedule>(
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
FROM tax_filing_schedules ORDER BY due_date DESC");
}
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -20,6 +20,16 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
profile);
}
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxProfile>(
@"SELECT id, client_id, business_registration, business_type, establishment_date,
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
FROM tax_profiles ORDER BY id DESC");
}
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
+1
View File
@@ -32,6 +32,7 @@
</div>
</div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
@@ -90,11 +90,25 @@
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Announcement>? announcements;
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadAsync();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync()
@@ -50,6 +50,9 @@
</MudStack>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
private bool isLoading = true;
private int currentPage = 1;
@@ -57,9 +60,20 @@
private int totalPosts = 0;
private const int PageSize = 20;
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadPosts();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadPosts();
StateHasChanged();
}
}
}
}
private async Task LoadPosts()
@@ -129,6 +129,9 @@
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Client>? clients;
private string searchText = "";
private string statusFilter = "";
@@ -137,7 +140,21 @@
private int totalPages;
private const int PageSize = 20;
protected override async Task OnInitializedAsync() => await LoadAsync();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync()
{
@@ -100,10 +100,17 @@
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("방문 상담")">방문 상담</MudSelectItem>
<MudSelectItem Value="@("전화 상담")">전화 상담</MudSelectItem>
<MudSelectItem Value="@("세무조사 대응 미팅")">세무조사 대응 미팅</MudSelectItem>
<MudSelectItem Value="@("카카오톡 상담")">카카오톡 상담</MudSelectItem>
<MudSelectItem Value="@("이메일 자료 접수")">이메일 자료 접수</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
@@ -116,6 +123,9 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<ConsultingActivity>? activities;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -124,9 +134,20 @@
private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -134,9 +155,9 @@
try
{
activities = await ActivityClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -147,7 +168,11 @@
private void OpenCreateDialog()
{
editingActivity = null;
activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
activityForm = new ConsultingActivityForm
{
ActivityDate = DateTime.Now,
ClientId = clients.FirstOrDefault()?.Id ?? 0
};
isDialogOpen = true;
}
@@ -167,6 +192,16 @@
private async Task SaveActivity()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
if (editingActivity == null)
@@ -238,6 +273,12 @@
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; }
@@ -107,14 +107,21 @@
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요.">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudSelect T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem>
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem>
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem>
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem>
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem>
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
@@ -126,6 +133,9 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Contract>? contracts;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -134,9 +144,20 @@
private bool isDialogOpen;
private ContractForm contractForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -144,9 +165,9 @@
try
{
contracts = await ContractClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
}
catch (Exception ex)
@@ -157,16 +178,31 @@
private void OpenCreateDialog()
{
contractForm = new();
contractForm = new ContractForm
{
ClientId = clients.FirstOrDefault()?.Id,
StartDate = DateTime.Today
};
isDialogOpen = true;
}
private async Task SaveContract()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
if (contractForm.ClientId == null) return;
var newId = await ContractClient.CreateAsync(
contractForm.ClientId,
contractForm.ClientId.Value,
contractForm.ContractNumber,
contractForm.ServiceType,
contractForm.StartDate ?? DateTime.Now,
@@ -217,9 +253,15 @@
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 int? ClientId { get; set; }
public string ContractNumber { get; set; } = "";
public string ServiceType { get; set; } = "";
public DateTime? StartDate { get; set; }
@@ -158,31 +158,45 @@
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
private string? errorMessage;
private bool isLoading = true;
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try
if (firstRender)
{
// API 클라이언트 사용 (서비스 직접 호출 X)
var summaryTask = DashboardClient.GetSummaryAsync();
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
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;
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();
}
}
}
}
}
@@ -95,9 +95,26 @@
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Faq>? faqs;
protected override async Task OnInitializedAsync() => await LoadAsync();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync()
{
@@ -46,11 +46,31 @@ else
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private bool isLoading = true;
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
{
isLoading = true;
try
{
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
@@ -96,13 +96,19 @@
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem>
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem>
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem>
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem>
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
@@ -113,6 +119,9 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<RevenueTracking>? revenues;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -120,9 +129,20 @@
private bool isDialogOpen;
private RevenueForm revenueForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -130,9 +150,9 @@
try
{
revenues = await RevenueClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -142,12 +162,27 @@
private void OpenCreateDialog()
{
revenueForm = new();
revenueForm = new RevenueForm
{
ClientId = clients.FirstOrDefault()?.Id ?? 0,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(14)
};
isDialogOpen = true;
}
private async Task SaveRevenue()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
var newId = await RevenueClient.CreateAsync(
@@ -217,6 +252,12 @@
revenueForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class RevenueForm
{
public int ClientId { get; set; }
@@ -117,19 +117,29 @@
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int"
<MudSelect T="int?"
@bind-Value="scheduleForm.ClientId"
Label="고객"
Required="true"
Variant="Variant.Outlined"
FullWidth="true"
Class="mb-4">
Class="mb-4"
RequiredError="고객을 선택하세요.">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("종합소득세")">종합소득세</MudSelectItem>
<MudSelectItem Value="@("부가가치세")">부가가치세</MudSelectItem>
<MudSelectItem Value="@("법인세")">법인세</MudSelectItem>
<MudSelectItem Value="@("원천세")">원천세</MudSelectItem>
<MudSelectItem Value="@("종합부동산세")">종합부동산세</MudSelectItem>
<MudSelectItem Value="@("양도소득세")">양도소득세</MudSelectItem>
<MudSelectItem Value="@("상속·증여세")">상속·증여세</MudSelectItem>
<MudSelectItem Value="@("세무조정")">세무조정</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
</MudForm>
@@ -141,23 +151,40 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxFilingSchedule>? schedules;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen;
private TaxFilingScheduleForm scheduleForm = new();
protected override async Task OnInitializedAsync() => await LoadData();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
{
try
{
schedules = await TaxFilingClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -167,16 +194,32 @@
private void OpenCreateDialog()
{
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
scheduleForm = new TaxFilingScheduleForm
{
FilingYear = DateTime.Now.Year,
DueDate = DateTime.Today,
ClientId = clients.FirstOrDefault()?.Id
};
isDialogOpen = true;
}
private async Task SaveSchedule()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
if (scheduleForm.ClientId == null) return;
var newId = await TaxFilingClient.CreateAsync(
scheduleForm.ClientId,
scheduleForm.ClientId.Value,
scheduleForm.FilingType,
scheduleForm.DueDate ?? DateTime.Today,
scheduleForm.FilingYear);
@@ -243,9 +286,15 @@
scheduleForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class TaxFilingScheduleForm
{
public int ClientId { get; set; }
public int? ClientId { get; set; }
public string FilingType { get; set; } = "";
public DateTime? DueDate { get; set; }
public int FilingYear { get; set; } = DateTime.Now.Year;
@@ -101,7 +101,7 @@
{
try
{
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
return items;
}
catch
@@ -110,6 +110,12 @@
}
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private async Task AddFiling()
{
try
@@ -84,13 +84,23 @@ else
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요.">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("일반제조업")">일반제조업</MudSelectItem>
<MudSelectItem Value="@("도소매업")">도소매업</MudSelectItem>
<MudSelectItem Value="@("서비스업")">서비스업</MudSelectItem>
<MudSelectItem Value="@("정보통신업")">정보통신업</MudSelectItem>
<MudSelectItem Value="@("부동산업")">부동산업</MudSelectItem>
<MudSelectItem Value="@("건설업")">건설업</MudSelectItem>
<MudSelectItem Value="@("음식점업")">음식점업</MudSelectItem>
<MudSelectItem Value="@("프리랜서")">프리랜서</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("low")">낮음</MudSelectItem>
<MudSelectItem Value="@("normal")">보통</MudSelectItem>
@@ -107,6 +117,9 @@ else
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxProfile>? profiles;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -116,9 +129,20 @@ else
private TaxProfile? editingProfile;
private TaxProfileForm profileForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -126,9 +150,9 @@ else
try
{
profiles = await TaxProfileClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -140,7 +164,12 @@ else
{
isEditMode = false;
editingProfile = null;
profileForm = new();
profileForm = new TaxProfileForm
{
ClientId = clients.FirstOrDefault()?.Id,
TaxRiskLevel = "normal",
NextFilingDueDate = DateTime.Today.AddMonths(1)
};
isDialogOpen = true;
}
@@ -161,25 +190,43 @@ else
private async Task SaveProfile()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
}
try
{
if (isEditMode)
if (isEditMode && editingProfile != null)
{
await TaxProfileClient.UpdateAsync(
editingProfile!.Id,
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType,
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success);
}
else
{
if (!profileForm.ClientId.HasValue)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
var newId = await TaxProfileClient.CreateAsync(
profileForm.ClientId,
profileForm.ClientId.Value,
profileForm.BusinessType);
if (newId > 0)
{
// 생성 후 상태 업데이트 처리
await TaxProfileClient.UpdateAsync(
newId,
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
}
}
@@ -232,9 +279,15 @@ else
_ => Color.Default
};
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class TaxProfileForm
{
public int ClientId { get; set; }
public int? ClientId { get; set; }
public string BusinessType { get; set; } = "";
public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; }
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Route("api/admin-dashboard")]
[Authorize]
public class AdminDashboardController : ControllerBase
{
@@ -24,6 +24,20 @@ public class ConsultingActivityController(ConsultingActivityService service) : C
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var activities = await service.GetAllAsync();
return Ok(activities);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class ContractController(ContractService service) : ControllerBase
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var contracts = await service.GetAllAsync();
return Ok(contracts);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class RevenueTrackingController(RevenueTrackingService service) : Control
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var revenues = await service.GetAllAsync();
return Ok(revenues);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class TaxFilingScheduleController(TaxFilingScheduleService service) : Con
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var schedules = await service.GetAllAsync();
return Ok(schedules);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class TaxProfileController(TaxProfileService taxProfileService) : Control
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var profiles = await taxProfileService.GetAllAsync();
return Ok(profiles);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
-87
View File
@@ -1,87 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace TaxBaik.Web.Hubs;
/// <summary>
/// Real-time notification hub for admin dashboard
/// SOLID: Single Responsibility - Only broadcasts change notifications
/// No state management - stateless broadcast pattern
/// </summary>
[Authorize]
public class NotificationHub : Hub
{
private const string AdminGroup = "admins";
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
await base.OnConnectedAsync();
}
/// <summary>
/// Broadcast inquiry status changed to all connected admins
/// Clients should re-fetch from API to verify
/// </summary>
public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
{
await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
{
InquiryId = inquiryId,
Status = newStatus,
ChangedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast inquiry submitted (new inquiry created)
/// </summary>
public async Task NotifyInquiryCreated(int inquiryId, string name)
{
await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
{
InquiryId = inquiryId,
Name = name,
CreatedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast client created
/// </summary>
public async Task NotifyClientCreated(int clientId, string name)
{
await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
{
ClientId = clientId,
Name = name,
CreatedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast announcement published
/// </summary>
public async Task NotifyAnnouncementPublished(int announcementId, string title)
{
await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
{
AnnouncementId = announcementId,
Title = title,
PublishedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast tax filing completed
/// </summary>
public async Task NotifyFilingCompleted(int filingId, string filingType)
{
await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
{
FilingId = filingId,
FilingType = filingType,
CompletedAt = DateTime.UtcNow
});
}
}
+14
View File
@@ -28,6 +28,20 @@ public class TelegramSink : ILogEventSink
return;
}
// Filter out harmless client disconnect and task cancellation exceptions
if (logEvent.Exception != null)
{
var exTypeName = logEvent.Exception.GetType().FullName ?? "";
var exMessage = logEvent.Exception.Message ?? "";
if (exTypeName.Contains("JSDisconnectedException") ||
exTypeName.Contains("TaskCanceledException") ||
exMessage.Contains("JavaScript interop calls cannot be issued") ||
exMessage.Contains("circuit has disconnected"))
{
return;
}
}
// Emit is a synchronous method, so we dispatch the network call asynchronously
Task.Run(async () =>
{
+43 -7
View File
@@ -3,21 +3,57 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계")</title>
<meta name="description" content="@(ViewData["Description"] ?? "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담.")" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["Description"]" />
<meta property="og:image" content="@ViewData["OgImage"]" />
<meta property="og:url" content="@ViewData["OgUrl"]" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title>
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="og:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#C89D6E" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="canonical" href="@ViewData["CanonicalUrl"]" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<!-- 구조화된 데이터 (JSON-LD Schema Markup) -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "ProfessionalService",
"name": "백원숙 세무회계",
"description": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담 세무사",
"url": "http://178.104.200.7/taxbaik/",
"telephone": "010-4122-8268",
"email": "taxbaik5668@gmail.com",
"address": {
"@@type": "PostalAddress",
"addressCountry": "KR"
},
"sameAs": [
"https://www.instagram.com/taxtory5668/",
"http://pf.kakao.com/_xoxchTX"
]
}
</script>
</head>
<body class="with-mobile-cta">
<partial name="_Header" />
+11 -30
View File
@@ -52,9 +52,6 @@ builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
// SignalR (Notifications only, no state management)
builder.Services.AddSignalR();
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
@@ -197,9 +194,6 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
// Notifications (SignalR)
builder.Services.AddScoped<INotificationService, NotificationService>();
// Telegram Notification
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
@@ -214,64 +208,53 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
// Phase 5: Tax Accounting & CRM Browser Clients
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
// UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config =>
@@ -361,8 +344,6 @@ app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages();
// SignalR Hub
app.MapHub<TaxBaik.Web.Hubs.NotificationHub>("/taxbaik/notifications");
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
@@ -14,15 +14,24 @@ public interface IConsultingActivityBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<ConsultingActivityBrowserClient> logger)
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
: IConsultingActivityBrowserClient
{
private const string BaseUrl = "/api/consultingactivity";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -36,6 +45,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -49,6 +59,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
@@ -66,6 +77,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -83,6 +95,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var request = new { outcome, nextFollowupDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -97,6 +110,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,15 +16,24 @@ public interface IContractBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowserClient> logger)
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
: IContractBrowserClient
{
private const string BaseUrl = "/api/contract";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -64,6 +75,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -96,6 +109,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
if (response.TryGetProperty("mrr", out var mrrValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
@@ -113,6 +127,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -130,6 +145,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,15 +16,24 @@ public interface IRevenueTrackingBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<RevenueTrackingBrowserClient> logger)
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
: IRevenueTrackingBrowserClient
{
private const string BaseUrl = "/api/revenuetracking";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -67,6 +78,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -83,6 +95,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>(
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
if (response.TryGetProperty("total", out var totalValue))
@@ -101,6 +114,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -118,6 +132,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var request = new { paymentDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
response.EnsureSuccessStatusCode();
@@ -132,6 +147,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -15,15 +15,24 @@ public interface ITaxFilingScheduleBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFilingScheduleBrowserClient> logger)
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
: ITaxFilingScheduleBrowserClient
{
private const string BaseUrl = "/api/taxfilingschedule";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -37,6 +46,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -50,6 +60,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -63,6 +74,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -97,6 +110,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
response.EnsureSuccessStatusCode();
}
@@ -110,6 +124,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -17,14 +17,23 @@ public interface ITaxProfileBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
{
private const string BaseUrl = "/api/taxprofile";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -64,6 +75,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -97,6 +110,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -115,6 +129,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -129,6 +144,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
+3 -3
View File
@@ -33,10 +33,10 @@ public class AdminDashboardClient : IAdminDashboardClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
@@ -29,10 +29,10 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
+3 -3
View File
@@ -34,10 +34,10 @@ public class ClientBrowserClient : IClientBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
@@ -32,21 +32,22 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken))
{
accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(accessToken))
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken))
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (long.TryParse(ticksStr, out var ticks))
{
_tokenStore.AccessToken = accessToken;
_tokenStore.AccessToken = storedToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
}
}
}
if (string.IsNullOrEmpty(accessToken))
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
+3 -3
View File
@@ -28,10 +28,10 @@ public class FaqBrowserClient : IFaqBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
+3 -3
View File
@@ -33,10 +33,10 @@ public class InquiryBrowserClient : IInquiryBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
@@ -1,72 +0,0 @@
namespace TaxBaik.Web.Services;
/// <summary>
/// Notification service for real-time admin updates
/// SOLID: Single Responsibility - Event notification only
/// Uses Blazor Server's built-in SignalR for real-time communication
/// </summary>
public interface INotificationService
{
event Func<int, string, Task>? OnInquiryStatusChanged;
event Func<int, string, Task>? OnInquiryCreated;
event Func<int, string, Task>? OnClientCreated;
event Func<int, string, Task>? OnAnnouncementPublished;
event Func<int, string, Task>? OnFilingCompleted;
Task TriggerInquiryStatusChanged(int inquiryId, string status);
Task TriggerInquiryCreated(int inquiryId, string name);
Task TriggerClientCreated(int clientId, string name);
Task TriggerAnnouncementPublished(int announcementId, string title);
Task TriggerFilingCompleted(int filingId, string filingType);
}
public class NotificationService : INotificationService
{
private readonly ILogger<NotificationService> _logger;
public NotificationService(ILogger<NotificationService> logger)
{
_logger = logger;
}
public event Func<int, string, Task>? OnInquiryStatusChanged;
public event Func<int, string, Task>? OnInquiryCreated;
public event Func<int, string, Task>? OnClientCreated;
public event Func<int, string, Task>? OnAnnouncementPublished;
public event Func<int, string, Task>? OnFilingCompleted;
public async Task TriggerInquiryStatusChanged(int inquiryId, string status)
{
_logger.LogInformation($"Inquiry {inquiryId} status changed to {status}");
if (OnInquiryStatusChanged != null)
await OnInquiryStatusChanged(inquiryId, status);
}
public async Task TriggerInquiryCreated(int inquiryId, string name)
{
_logger.LogInformation($"New inquiry {inquiryId} from {name}");
if (OnInquiryCreated != null)
await OnInquiryCreated(inquiryId, name);
}
public async Task TriggerClientCreated(int clientId, string name)
{
_logger.LogInformation($"New client {clientId}: {name}");
if (OnClientCreated != null)
await OnClientCreated(clientId, name);
}
public async Task TriggerAnnouncementPublished(int announcementId, string title)
{
_logger.LogInformation($"Announcement {announcementId} published: {title}");
if (OnAnnouncementPublished != null)
await OnAnnouncementPublished(announcementId, title);
}
public async Task TriggerFilingCompleted(int filingId, string filingType)
{
_logger.LogInformation($"Filing {filingId} ({filingType}) completed");
if (OnFilingCompleted != null)
await OnFilingCompleted(filingId, filingType);
}
}
@@ -32,10 +32,10 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
@@ -44,7 +44,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"tax-filing/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
@@ -60,7 +60,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"tax-filing/client/{clientId}", cancellationToken: ct);
$"taxfiling/client/{clientId}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
@@ -76,7 +76,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<TaxFiling>(
$"tax-filing/{id}", cancellationToken: ct);
$"taxfiling/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
@@ -90,7 +90,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("tax-filing", filing, cancellationToken: ct);
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -111,7 +111,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"tax-filing/{id}", filing, cancellationToken: ct);
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -132,7 +132,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"tax-filing/{id}", cancellationToken: ct);
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
+14 -11
View File
@@ -10,12 +10,12 @@ using System.Text.Json;
/// </summary>
public class TokenRefreshHandler : DelegatingHandler
{
private readonly ITokenStore _tokenStore;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(ITokenStore tokenStore, ILogger<TokenRefreshHandler> logger)
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger)
{
_tokenStore = tokenStore;
_serviceProvider = serviceProvider;
_logger = logger;
}
@@ -23,10 +23,13 @@ public class TokenRefreshHandler : DelegatingHandler
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
// 요청에 access token 추가
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
{
request.Headers.Authorization = new("Bearer", _tokenStore.AccessToken);
request.Headers.Authorization = new("Bearer", tokenStore.AccessToken);
}
var response = await base.SendAsync(request, cancellationToken);
@@ -34,15 +37,15 @@ public class TokenRefreshHandler : DelegatingHandler
// 401 응답이면 토큰 갱신 시도
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken))
if (!string.IsNullOrEmpty(tokenStore.RefreshToken))
{
var newTokenPair = await RefreshTokenAsync(_tokenStore.RefreshToken, request, cancellationToken);
var newTokenPair = await RefreshTokenAsync(tokenStore.RefreshToken, request, cancellationToken);
if (newTokenPair != null)
{
// TokenStore에 토큰 저장
_tokenStore.AccessToken = newTokenPair.AccessToken;
_tokenStore.RefreshToken = newTokenPair.RefreshToken;
_tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
tokenStore.AccessToken = newTokenPair.AccessToken;
tokenStore.RefreshToken = newTokenPair.RefreshToken;
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
// 새 토큰으로 재요청
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
@@ -51,7 +54,7 @@ public class TokenRefreshHandler : DelegatingHandler
else
{
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
_tokenStore.Clear();
tokenStore.Clear();
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<!-- 메인 홈 -->
<url>
<loc>http://178.104.200.7/taxbaik/</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<!-- 고객 포털 -->
<url>
<loc>http://178.104.200.7/taxbaik/portal</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<!-- 이용약관 -->
<url>
<loc>http://178.104.200.7/taxbaik/terms</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<!-- 개인정보처리방침 -->
<url>
<loc>http://178.104.200.7/taxbaik/privacy</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+59
View File
@@ -6,6 +6,8 @@ const password = process.env.E2E_ADMIN_PASSWORD;
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
test.describe('admin CRM pages', () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => {
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
await loginThroughAdminUi(page, baseUrl, username, password);
@@ -121,4 +123,61 @@ test.describe('admin CRM pages', () => {
expect(consoleErrors, 'no console errors during CRM navigation').toEqual([]);
});
test('TaxProfiles form displays valid business type combo choices', async ({ page }) => {
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
// JS 네이티브 클릭으로 강제 격발하여 오프셋 씹힘 소멸
await addButton.evaluate(el => (el as HTMLButtonElement).click());
// 대화상자(MudDialog) 자체의 노출 대기
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
// mud-select 컨테이너 자체 클릭 (이벤트 핸들러 직접 격발)
const select = page.locator('.mud-select').filter({ hasText: '사업 유형' }).first();
await select.evaluate(el => (el as HTMLDivElement).click());
// 활성화된 팝오버(.mud-popover-open) 내에서 텍스트 노출 검증
const popover = page.locator('.mud-popover-open');
await expect(popover.getByText('일반제조업')).toBeVisible({ timeout: 5000 });
await expect(popover.getByText('도소매업')).toBeVisible({ timeout: 5000 });
await expect(popover.getByText('서비스업')).toBeVisible({ timeout: 5000 });
});
test('TaxFilingSchedules form displays filing type combo choices', async ({ page }) => {
await navigateInBlazor(page, `${baseUrl}/admin/tax-filing-schedules`);
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
const addButton = page.getByRole('button', { name: /새 일정 추가/ });
await addButton.evaluate(el => (el as HTMLButtonElement).click());
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
const select = page.locator('.mud-select').filter({ hasText: '신고 유형' }).first();
await select.evaluate(el => (el as HTMLDivElement).click());
const popover = page.locator('.mud-popover-open');
await expect(popover.getByText('종합소득세')).toBeVisible({ timeout: 5000 });
await expect(popover.getByText('부가가치세')).toBeVisible({ timeout: 5000 });
});
test('Contracts form displays service type combo choices', async ({ page }) => {
await navigateInBlazor(page, `${baseUrl}/admin/contracts`);
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
const addButton = page.getByRole('button', { name: /새 계약 추가/ });
await addButton.evaluate(el => (el as HTMLButtonElement).click());
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
const select = page.locator('.mud-select').filter({ hasText: '서비스 유형' }).first();
await select.evaluate(el => (el as HTMLDivElement).click());
const popover = page.locator('.mud-popover-open');
await expect(popover.getByText('개인 기장대리')).toBeVisible({ timeout: 5000 });
await expect(popover.getByText('법인 기장대리')).toBeVisible({ timeout: 5000 });
});
});
+24 -1
View File
@@ -24,7 +24,14 @@ export async function getAdminToken(
}
export async function installAdminToken(page: Page, token: string) {
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
await page.addInitScript(value => {
localStorage.setItem('accessToken', value);
localStorage.setItem('refreshToken', 'ci-test-refresh-token');
// Calculate C# Ticks for 1 hour from now: (JS_ms * 10000) + 621355968000000000
const expiryMs = Date.now() + 3600 * 1000;
const ticks = (expiryMs * 10000) + 621355968000000000;
localStorage.setItem('tokenExpiry', ticks.toString());
}, token);
}
export async function loginThroughAdminUi(
@@ -51,6 +58,22 @@ export async function navigateInBlazor(page: Page, targetUrl: string) {
window.location.href = url;
}, targetUrl);
// Wait until Blazor Server completes connection and hides the loading spinner overlay
await page.locator('#blazor-loading').waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {});
// Give the SPA router a brief window to unmount the previous page and mount the loading spinner
await page.waitForTimeout(500);
// Also wait for MudBlazor's dynamic loading spinners to disappear (ensuring the grid is interactive)
const spinner = page.locator('.mud-progress-circular, .mud-progress-linear-bar');
try {
if (await spinner.count() > 0) {
await spinner.first().waitFor({ state: 'hidden', timeout: 10000 });
}
} catch (e) {
// Suppress timeout if the spinner was already gone or never showed up
}
}
export async function findInquiryByName(