Compare commits

..

1 Commits

62 changed files with 368 additions and 994 deletions
+20 -20
View File
@@ -8,8 +8,8 @@
Blazor → Service (서버) → DB Blazor → Service (서버) → DB
✅ 현재: API-First (클라이언트-서버 분리) ✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB Blazor (UI만) ← API (모든 로직) ← DB
Blazor 데이터 변경 자동 push/broadcast 금지 SignalR (변경 알림만)
``` ```
### SOLID 기반 순차 마이그레이션 전략 ### SOLID 기반 순차 마이그레이션 전략
@@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴 **완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거 #### Phase 6: SignalR 통합
- [x] NotificationHub 제거 - [ ] NotificationHub (변경 알림만)
- [x] 데이터 변경용 INotificationService 제거 - [ ] Blazor에서 구독
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거 - [ ] 알림 후 API로 데이터 검증
#### Phase 7: 순차적 마이그레이션 ✅ #### Phase 7: 순차적 마이그레이션 ✅
- [x] Blog 페이지 → API 클라이언트 - [x] Blog 페이지 → API 클라이언트
@@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080;
- Status Color Chips (Error/Warning/Success) - Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동) - Client 링크 (상세 페이지 연동)
### **Phase 6: Lite Blazor 운영 원칙** ✅ ### **Phase 6: SignalR 통합** ✅
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다. - NotificationHub (브로드캐스트만, 상태 관리 없음)
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다. - INotificationService (이벤트 기반)
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다. - 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다. - Program.cs SignalR 등록
--- ---
@@ -160,11 +160,11 @@ Repositories (데이터 계층)
PostgreSQL Database PostgreSQL Database
``` ```
**Lite Blazor 데이터 갱신**: **Blazor Server SignalR**:
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다. - 자동 연결 (내장 Hub connection)
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다. - NotificationHub 클라이언트 그룹 (admins)
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다. - 이벤트 기반 메시지 (상태 관리 없음)
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다. - 클라이언트는 알림 후 API로 데이터 검증
--- ---
@@ -182,10 +182,10 @@ PostgreSQL Database
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료** - [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion) - [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
**Lite Blazor / 데이터 갱신 (Phase 6)**: **실시간 알림 (Phase 6)**:
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거 - [x] NotificationHub 구현
- [x] NotificationHub 제거 - [x] Event-driven 알림 시스템
- [x] 데이터 변경용 INotificationService 제거 - [x] Scoped DI 등록
**Blazor 페이지 & UI 고도화 (Phase 7-4)**: **Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지 - [x] 5개 CRM/세무관리 Blazor 페이지
+16 -16
View File
@@ -425,9 +425,9 @@ Todo:
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지 - 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
Todo: Todo:
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가 - [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [x] 일간/주간 리포트 메시지 템플릿 - [ ] 일간/주간 리포트 메시지 템플릿
- [x] TelegramNotificationService에 리포트 메서드 추가 - [ ] TelegramNotificationService에 리포트 메서드 추가
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3 ## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
@@ -439,9 +439,9 @@ Todo:
- 개인정보 열람 범위는 세무사가 허용한 항목만 - 개인정보 열람 범위는 세무사가 허용한 항목만
Todo: Todo:
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행) - [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [x] 고객 전용 Razor Pages 추가 - [ ] 고객 전용 Razor Pages 추가
- [x] 세무사 허용 권한 설정 UI - [ ] 세무사 허용 권한 설정 UI
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3 ## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
@@ -485,16 +485,16 @@ DB 스키마:
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` - `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
Todo: Todo:
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행) - [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔) - [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨) - [ ] V011__CreatePortalUsers.sql 마이그레이션
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository - [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [x] 네이버 OAuth Handler 구현 - [ ] 네이버 OAuth Handler 구현
- [x] 카카오·구글 패키지 추가 및 설정 - [ ] 카카오·구글 패키지 추가 및 설정
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`) - [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성 - [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성 - [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼 - [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [ ] Gitea Secrets에 OAuth 키 추가 - [ ] Gitea Secrets에 OAuth 키 추가
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트 - [ ] 배포 후 소셜 로그인 3종 E2E 테스트
@@ -33,9 +33,6 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) => public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct); 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) => public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
await repository.GetPendingFollowupsAsync(ct); await repository.GetPendingFollowupsAsync(ct);
@@ -36,9 +36,6 @@ public class ContractService(IContractRepository repository)
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) => public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct); 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) => public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct); await repository.GetByClientIdAsync(clientId, ct);
@@ -34,9 +34,6 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) => public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct); 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) => public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
await repository.GetPendingPaymentsAsync(ct); await repository.GetPendingPaymentsAsync(ct);
@@ -33,9 +33,6 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) => public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct); 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) => public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct); await repository.GetByClientIdAsync(clientId, ct);
@@ -31,9 +31,6 @@ public class TaxProfileService(ITaxProfileRepository repository)
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) => public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct); 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, public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default) DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
{ {
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface IConsultingActivityRepository public interface IConsultingActivityRepository
{ {
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default); 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>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default); Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default); Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface IContractRepository public interface IContractRepository
{ {
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default); Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default); Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default); Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default); Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface IRevenueTrackingRepository public interface IRevenueTrackingRepository
{ {
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default); 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>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default); Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface ITaxFilingScheduleRepository public interface ITaxFilingScheduleRepository
{ {
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default); Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default); Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default); Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default); Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface ITaxProfileRepository public interface ITaxProfileRepository
{ {
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default); Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default); Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default); Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default); Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
@@ -16,14 +16,6 @@ public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory
activity); 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) public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
@@ -16,14 +16,6 @@ public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRe
contract); 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) public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
@@ -16,14 +16,6 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
revenue); 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) public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
@@ -16,14 +16,6 @@ public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory)
schedule); 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) public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
@@ -20,16 +20,6 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
profile); 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) public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
-1
View File
@@ -32,7 +32,6 @@
</div> </div>
</div> </div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" /> <MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<MudPopoverProvider />
<MudDialogProvider /> <MudDialogProvider />
<MudSnackbarProvider /> <MudSnackbarProvider />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" /> <Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
@@ -90,25 +90,11 @@
</MudPaper> </MudPaper>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Announcement>? announcements; private List<Announcement>? announcements;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) await LoadAsync();
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
} }
private async Task LoadAsync() private async Task LoadAsync()
@@ -24,11 +24,11 @@
<MudTextField @bind-Value="model.Title" Label="제목" <MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" /> Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리" <MudSelect @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4"> Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories) @foreach (var category in categories)
{ {
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem> <MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
@@ -35,11 +35,11 @@ else
<MudTextField @bind-Value="model.Title" Label="제목" <MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" /> Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리" <MudSelect @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4"> Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories) @foreach (var category in categories)
{ {
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem> <MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
@@ -50,9 +50,6 @@
</MudStack> </MudStack>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxBaik.Domain.Entities.BlogPost> posts = []; private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
private bool isLoading = true; private bool isLoading = true;
private int currentPage = 1; private int currentPage = 1;
@@ -60,20 +57,9 @@
private int totalPosts = 0; private int totalPosts = 0;
private const int PageSize = 20; private const int PageSize = 20;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) await LoadPosts();
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadPosts();
StateHasChanged();
}
}
}
} }
private async Task LoadPosts() private async Task LoadPosts()
@@ -129,9 +129,6 @@
</MudPaper> </MudPaper>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Client>? clients; private List<Client>? clients;
private string searchText = ""; private string searchText = "";
private string statusFilter = ""; private string statusFilter = "";
@@ -140,21 +137,7 @@
private int totalPages; private int totalPages;
private const int PageSize = 20; private const int PageSize = 20;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync() => await LoadAsync();
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync() private async Task LoadAsync()
{ {
@@ -100,17 +100,10 @@
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4"> <MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true"> <MudTextField 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" /> <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" /> <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" /> <MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
@@ -123,9 +116,6 @@
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<ConsultingActivity>? activities; private List<ConsultingActivity>? activities;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
@@ -134,20 +124,9 @@
private ConsultingActivity? editingActivity; private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new(); private ConsultingActivityForm activityForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) await LoadData();
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
} }
private async Task LoadData() private async Task LoadData()
@@ -155,9 +134,9 @@
try try
{ {
activities = await ActivityClient.GetAllAsync(); activities = await ActivityClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList(); clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -168,11 +147,7 @@
private void OpenCreateDialog() private void OpenCreateDialog()
{ {
editingActivity = null; editingActivity = null;
activityForm = new ConsultingActivityForm activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
{
ActivityDate = DateTime.Now,
ClientId = clients.FirstOrDefault()?.Id ?? 0
};
isDialogOpen = true; isDialogOpen = true;
} }
@@ -192,16 +167,6 @@
private async Task SaveActivity() private async Task SaveActivity()
{ {
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try try
{ {
if (editingActivity == null) if (editingActivity == null)
@@ -273,12 +238,6 @@
activityForm = new(); 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 private class ConsultingActivityForm
{ {
public int ClientId { get; set; } public int ClientId { get; set; }
@@ -107,21 +107,14 @@
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요."> <MudSelect T="int" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
} }
</MudSelect> </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.ContractNumber" 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"> <MudTextField 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" /> <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" /> <MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm> </MudForm>
@@ -133,9 +126,6 @@
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Contract>? contracts; private List<Contract>? contracts;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
@@ -144,20 +134,9 @@
private bool isDialogOpen; private bool isDialogOpen;
private ContractForm contractForm = new(); private ContractForm contractForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) await LoadData();
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
} }
private async Task LoadData() private async Task LoadData()
@@ -165,9 +144,9 @@
try try
{ {
contracts = await ContractClient.GetAllAsync(); contracts = await ContractClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList(); clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync(); mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
} }
catch (Exception ex) catch (Exception ex)
@@ -178,31 +157,16 @@
private void OpenCreateDialog() private void OpenCreateDialog()
{ {
contractForm = new ContractForm contractForm = new();
{
ClientId = clients.FirstOrDefault()?.Id,
StartDate = DateTime.Today
};
isDialogOpen = true; isDialogOpen = true;
} }
private async Task SaveContract() private async Task SaveContract()
{ {
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try try
{ {
if (contractForm.ClientId == null) return;
var newId = await ContractClient.CreateAsync( var newId = await ContractClient.CreateAsync(
contractForm.ClientId.Value, contractForm.ClientId,
contractForm.ContractNumber, contractForm.ContractNumber,
contractForm.ServiceType, contractForm.ServiceType,
contractForm.StartDate ?? DateTime.Now, contractForm.StartDate ?? DateTime.Now,
@@ -253,15 +217,9 @@
contractForm = new(); 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 private class ContractForm
{ {
public int? ClientId { get; set; } public int ClientId { get; set; }
public string ContractNumber { get; set; } = ""; public string ContractNumber { get; set; } = "";
public string ServiceType { get; set; } = ""; public string ServiceType { get; set; } = "";
public DateTime? StartDate { get; set; } public DateTime? StartDate { get; set; }
@@ -158,45 +158,31 @@
</MudPaper> </MudPaper>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private AdminDashboardSummary summary = new(0, 0, 0, 0, []); private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
private List<Domain.Entities.TaxFiling> upcomingFilings = []; private List<Domain.Entities.TaxFiling> upcomingFilings = [];
private string? errorMessage; private string? errorMessage;
private bool isLoading = true; private bool isLoading = true;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) try
{ {
if (AuthStateTask != null) // API 클라이언트 사용 (서비스 직접 호출 X)
{ var summaryTask = DashboardClient.GetSummaryAsync();
var authState = await AuthStateTask; var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
if (authState.User.Identity?.IsAuthenticated == true)
{
try
{
// API 클라이언트 사용 (서비스 직접 호출 X)
var summaryTask = DashboardClient.GetSummaryAsync();
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
await Task.WhenAll(summaryTask, filingsTask); await Task.WhenAll(summaryTask, filingsTask);
summary = await summaryTask; summary = await summaryTask;
upcomingFilings = (await filingsTask).ToList(); upcomingFilings = (await filingsTask).ToList();
} }
catch (Exception ex) catch (Exception ex)
{ {
errorMessage = "대시보드 데이터를 불러올 수 없습니다."; errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
Console.Error.WriteLine($"Dashboard error: {ex.Message}"); Console.Error.WriteLine($"Dashboard error: {ex.Message}");
} }
finally finally
{ {
isLoading = false; isLoading = false;
StateHasChanged();
}
}
}
} }
} }
@@ -95,26 +95,9 @@
</MudPaper> </MudPaper>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Faq>? faqs; private List<Faq>? faqs;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync() => await LoadAsync();
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync() private async Task LoadAsync()
{ {
@@ -46,31 +46,11 @@ else
</MudPaper> </MudPaper>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private bool isLoading = true; private bool isLoading = true;
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = []; private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
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 try
{ {
var (items, _) = await InquiryClient.GetPagedAsync(1, 200); var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
@@ -96,19 +96,13 @@
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4"> <MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <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" /> <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" /> <MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4"> <MudTextField 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" /> <MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm> </MudForm>
</DialogContent> </DialogContent>
@@ -119,9 +113,6 @@
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<RevenueTracking>? revenues; private List<RevenueTracking>? revenues;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
@@ -129,20 +120,9 @@
private bool isDialogOpen; private bool isDialogOpen;
private RevenueForm revenueForm = new(); private RevenueForm revenueForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) await LoadData();
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
} }
private async Task LoadData() private async Task LoadData()
@@ -150,9 +130,9 @@
try try
{ {
revenues = await RevenueClient.GetAllAsync(); revenues = await RevenueClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList(); clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -162,27 +142,12 @@
private void OpenCreateDialog() private void OpenCreateDialog()
{ {
revenueForm = new RevenueForm revenueForm = new();
{
ClientId = clients.FirstOrDefault()?.Id ?? 0,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(14)
};
isDialogOpen = true; isDialogOpen = true;
} }
private async Task SaveRevenue() private async Task SaveRevenue()
{ {
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try try
{ {
var newId = await RevenueClient.CreateAsync( var newId = await RevenueClient.CreateAsync(
@@ -252,12 +217,6 @@
revenueForm = new(); 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 private class RevenueForm
{ {
public int ClientId { get; set; } public int ClientId { get; set; }
@@ -117,29 +117,19 @@
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int?" <MudSelect T="int"
@bind-Value="scheduleForm.ClientId" @bind-Value="scheduleForm.ClientId"
Label="고객" Label="고객"
Required="true" Required="true"
Variant="Variant.Outlined" Variant="Variant.Outlined"
FullWidth="true" FullWidth="true"
Class="mb-4" Class="mb-4">
RequiredError="고객을 선택하세요.">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true"> <MudTextField 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" /> <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" /> <MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
</MudForm> </MudForm>
@@ -151,40 +141,23 @@
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxFilingSchedule>? schedules; private List<TaxFilingSchedule>? schedules;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
private MudForm? form; private MudForm? form;
private bool isDialogOpen; private bool isDialogOpen;
private TaxFilingScheduleForm scheduleForm = new(); private TaxFilingScheduleForm scheduleForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync() => await LoadData();
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData() private async Task LoadData()
{ {
try try
{ {
schedules = await TaxFilingClient.GetAllAsync(); schedules = await TaxFilingClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList(); clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -194,32 +167,16 @@
private void OpenCreateDialog() private void OpenCreateDialog()
{ {
scheduleForm = new TaxFilingScheduleForm scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
{
FilingYear = DateTime.Now.Year,
DueDate = DateTime.Today,
ClientId = clients.FirstOrDefault()?.Id
};
isDialogOpen = true; isDialogOpen = true;
} }
private async Task SaveSchedule() private async Task SaveSchedule()
{ {
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try try
{ {
if (scheduleForm.ClientId == null) return;
var newId = await TaxFilingClient.CreateAsync( var newId = await TaxFilingClient.CreateAsync(
scheduleForm.ClientId.Value, scheduleForm.ClientId,
scheduleForm.FilingType, scheduleForm.FilingType,
scheduleForm.DueDate ?? DateTime.Today, scheduleForm.DueDate ?? DateTime.Today,
scheduleForm.FilingYear); scheduleForm.FilingYear);
@@ -286,15 +243,9 @@
scheduleForm = new(); 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 private class TaxFilingScheduleForm
{ {
public int? ClientId { get; set; } public int ClientId { get; set; }
public string FilingType { get; set; } = ""; public string FilingType { get; set; } = "";
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
public int FilingYear { get; set; } = DateTime.Now.Year; public int FilingYear { get; set; } = DateTime.Now.Year;
@@ -101,7 +101,7 @@
{ {
try try
{ {
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value); var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
return items; return items;
} }
catch catch
@@ -110,12 +110,6 @@
} }
} }
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() private async Task AddFiling()
{ {
try try
@@ -84,23 +84,13 @@ else
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudForm @ref="form"> <MudForm @ref="form">
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요."> <MudSelect T="int" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true"> <MudTextField T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<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"> <MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("low")">낮음</MudSelectItem> <MudSelectItem Value="@("low")">낮음</MudSelectItem>
<MudSelectItem Value="@("normal")">보통</MudSelectItem> <MudSelectItem Value="@("normal")">보통</MudSelectItem>
@@ -117,9 +107,6 @@ else
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxProfile>? profiles; private List<TaxProfile>? profiles;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
@@ -129,20 +116,9 @@ else
private TaxProfile? editingProfile; private TaxProfile? editingProfile;
private TaxProfileForm profileForm = new(); private TaxProfileForm profileForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) await LoadData();
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
} }
private async Task LoadData() private async Task LoadData()
@@ -150,9 +126,9 @@ else
try try
{ {
profiles = await TaxProfileClient.GetAllAsync(); profiles = await TaxProfileClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList(); clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -164,12 +140,7 @@ else
{ {
isEditMode = false; isEditMode = false;
editingProfile = null; editingProfile = null;
profileForm = new TaxProfileForm profileForm = new();
{
ClientId = clients.FirstOrDefault()?.Id,
TaxRiskLevel = "normal",
NextFilingDueDate = DateTime.Today.AddMonths(1)
};
isDialogOpen = true; isDialogOpen = true;
} }
@@ -190,43 +161,25 @@ else
private async Task SaveProfile() private async Task SaveProfile()
{ {
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
}
try try
{ {
if (isEditMode && editingProfile != null) if (isEditMode)
{ {
await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType, await TaxProfileClient.UpdateAsync(
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel); editingProfile!.Id,
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success); profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
} }
else else
{ {
if (!profileForm.ClientId.HasValue)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
var newId = await TaxProfileClient.CreateAsync( var newId = await TaxProfileClient.CreateAsync(
profileForm.ClientId.Value, profileForm.ClientId,
profileForm.BusinessType); profileForm.BusinessType);
if (newId > 0) if (newId > 0)
{ {
// 생성 후 상태 업데이트 처리
await TaxProfileClient.UpdateAsync(
newId,
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success); Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
} }
} }
@@ -279,15 +232,9 @@ else
_ => Color.Default _ => 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 private class TaxProfileForm
{ {
public int? ClientId { get; set; } public int ClientId { get; set; }
public string BusinessType { get; set; } = ""; public string BusinessType { get; set; } = "";
public string TaxRiskLevel { get; set; } = "normal"; public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; } public DateTime? NextFilingDueDate { get; set; }
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
/// SOLID: Single Responsibility - 대시보드 데이터만 담당 /// SOLID: Single Responsibility - 대시보드 데이터만 담당
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/admin-dashboard")] [Route("api/[controller]")]
[Authorize] [Authorize]
public class AdminDashboardController : ControllerBase public class AdminDashboardController : ControllerBase
{ {
@@ -24,20 +24,6 @@ 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}")] [HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
@@ -24,20 +24,6 @@ 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}")] [HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
@@ -24,20 +24,6 @@ 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}")] [HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
@@ -24,20 +24,6 @@ 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}")] [HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
@@ -24,20 +24,6 @@ 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}")] [HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId) public async Task<IActionResult> GetByClientId(int clientId)
{ {
+87
View File
@@ -0,0 +1,87 @@
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
});
}
}
-99
View File
@@ -1,99 +0,0 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using Serilog.Core;
using Serilog.Events;
namespace TaxBaik.Web.Logging;
public class TelegramSink : ILogEventSink
{
private readonly string _botToken;
private readonly string _chatId;
private readonly HttpClient _httpClient;
public TelegramSink(string botToken, string chatId)
{
_botToken = botToken;
_chatId = chatId;
_httpClient = new HttpClient();
}
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < LogEventLevel.Error)
{
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 () =>
{
try
{
var timestamp = logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz");
var level = logEvent.Level.ToString().ToUpper();
var message = logEvent.RenderMessage();
var exceptionDetails = logEvent.Exception?.ToString();
var sb = new StringBuilder();
sb.AppendLine($"<b>🚨 [{level}] 에러 발생</b>");
sb.AppendLine($"<b>시간:</b> {timestamp}");
sb.AppendLine($"<b>메시지:</b> {EscapeHtml(message)}");
if (!string.IsNullOrEmpty(exceptionDetails))
{
var escapedException = EscapeHtml(exceptionDetails);
if (escapedException.Length > 3000)
{
escapedException = escapedException.Substring(0, 3000) + "\n[이하 생략]";
}
sb.AppendLine($"<b>Exception 상세:</b>\n<pre>{escapedException}</pre>");
}
var url = $"https://api.telegram.org/bot{_botToken}/sendMessage";
var payload = new
{
chat_id = _chatId,
text = sb.ToString(),
parse_mode = "HTML"
};
var response = await _httpClient.PostAsJsonAsync(url, payload);
if (!response.IsSuccessStatusCode)
{
var errorResponse = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[TelegramSink] Failed to send log to Telegram: {response.StatusCode} - {errorResponse}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[TelegramSink] Error in TelegramSink: {ex.Message}");
}
});
}
private static string EscapeHtml(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
}
+1 -1
View File
@@ -52,5 +52,5 @@ public class LoginModel : PageModel
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme); public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
private static AuthenticationProperties BuildProps(string provider) => private static AuthenticationProperties BuildProps(string provider) =>
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" }; new() { RedirectUri = $"/portal/external-callback?provider={provider}" };
} }
+7 -43
View File
@@ -3,57 +3,21 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title> <title>@(ViewData["Title"] ?? "백원숙 세무회계")</title>
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" /> <meta name="description" content="@(ViewData["Description"] ?? "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담.")" />
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" /> <meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["Description"]" />
<!-- Open Graph / Facebook --> <meta property="og:image" content="@ViewData["OgImage"]" />
<meta property="og:type" content="website" /> <meta property="og:url" content="@ViewData["OgUrl"]" />
<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="robots" content="index, follow" />
<meta name="theme-color" content="#C89D6E" /> <meta name="theme-color" content="#C89D6E" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" /> <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 href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" /> <link rel="canonical" href="@ViewData["CanonicalUrl"]" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" /> <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" /> <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> </head>
<body class="with-mobile-cta"> <body class="with-mobile-cta">
<partial name="_Header" /> <partial name="_Header" />
+35 -23
View File
@@ -38,13 +38,6 @@ builder.Host.UseSerilog((context, config) =>
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}") outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.Enrich.FromLogContext() .Enrich.FromLogContext()
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName); .Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
var botToken = context.Configuration["Telegram:BotToken"];
var systemChatId = context.Configuration["Telegram:SystemChatId"] ?? context.Configuration["Telegram:ChatId"];
if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(systemChatId))
{
config.WriteTo.Sink(new TaxBaik.Web.Logging.TelegramSink(botToken, systemChatId), Serilog.Events.LogEventLevel.Error);
}
}); });
// Controllers (API) // Controllers (API)
@@ -52,6 +45,9 @@ builder.Services.AddControllers();
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
// SignalR (Notifications only, no state management)
builder.Services.AddSignalR();
// Razor Pages + Blazor Server 통합 // Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddRazorComponents().AddInteractiveServerComponents();
@@ -93,8 +89,8 @@ var authenticationBuilder = builder.Services.AddAuthentication(opts =>
opts.Cookie.HttpOnly = true; opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax; opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
opts.LoginPath = "/taxbaik/portal/login"; opts.LoginPath = "/portal/login";
opts.AccessDeniedPath = "/taxbaik/portal/login"; opts.AccessDeniedPath = "/portal/login";
opts.SlidingExpiration = true; opts.SlidingExpiration = true;
opts.ExpireTimeSpan = TimeSpan.FromDays(7); opts.ExpireTimeSpan = TimeSpan.FromDays(7);
}) })
@@ -115,7 +111,7 @@ if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(goo
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme; opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = googleClientId; opts.ClientId = googleClientId;
opts.ClientSecret = googleClientSecret; opts.ClientSecret = googleClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-google"; opts.CallbackPath = "/portal/signin-google";
}); });
} }
@@ -128,7 +124,7 @@ if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(nave
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme; opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = naverClientId; opts.ClientId = naverClientId;
opts.ClientSecret = naverClientSecret; opts.ClientSecret = naverClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-naver"; opts.CallbackPath = "/portal/signin-naver";
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize"; opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token"; opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me"; opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
@@ -160,7 +156,7 @@ if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kaka
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme; opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = kakaoClientId; opts.ClientId = kakaoClientId;
opts.ClientSecret = kakaoClientSecret; opts.ClientSecret = kakaoClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-kakao"; opts.CallbackPath = "/portal/signin-kakao";
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize"; opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token"; opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me"; opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
@@ -194,6 +190,9 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore(); builder.Services.AddAuthorizationCore();
// Notifications (SignalR)
builder.Services.AddScoped<INotificationService, NotificationService>();
// Telegram Notification // Telegram Notification
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>(); builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
@@ -208,53 +207,64 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client => builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client => builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
// Phase 5: Tax Accounting & CRM Browser Clients // Phase 5: Tax Accounting & CRM Browser Clients
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client => builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client => builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client => builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client => builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client => builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); })
.AddHttpMessageHandler<TokenRefreshHandler>();
// UI & 캐시 (MudBlazor Theme Customization) // UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config => builder.Services.AddMudServices(config =>
@@ -344,6 +354,8 @@ app.MapControllers();
app.MapHealthChecks("/healthz"); app.MapHealthChecks("/healthz");
app.MapRazorPages(); app.MapRazorPages();
// SignalR Hub
app.MapHub<TaxBaik.Web.Hubs.NotificationHub>("/taxbaik/notifications");
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다. // AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다. // 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>() app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
@@ -14,24 +14,15 @@ public interface IConsultingActivityBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default); Task DeleteAsync(int id, CancellationToken ct = default);
} }
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger) public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<ConsultingActivityBrowserClient> logger)
: IConsultingActivityBrowserClient : IConsultingActivityBrowserClient
{ {
private const string BaseUrl = "/api/consultingactivity"; 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) public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -45,7 +36,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -59,7 +49,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
@@ -77,7 +66,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{ {
try try
{ {
EnsureAuthHeader();
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate }; var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct); var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -95,7 +83,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{ {
try try
{ {
EnsureAuthHeader();
var request = new { outcome, nextFollowupDate }; var request = new { outcome, nextFollowupDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct); var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -110,7 +97,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct); var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
@@ -16,24 +16,15 @@ public interface IContractBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default); Task DeleteAsync(int id, CancellationToken ct = default);
} }
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger) public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowserClient> logger)
: IContractBrowserClient : IContractBrowserClient
{ {
private const string BaseUrl = "/api/contract"; 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) public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -47,7 +38,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct); return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
} }
catch (Exception ex) catch (Exception ex)
@@ -61,7 +51,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -75,7 +64,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -92,7 +80,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -109,7 +96,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
if (response.TryGetProperty("mrr", out var mrrValue)) if (response.TryGetProperty("mrr", out var mrrValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText()); return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
@@ -127,7 +113,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{ {
try try
{ {
EnsureAuthHeader();
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount }; var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct); var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -145,7 +130,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct); var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
@@ -16,24 +16,15 @@ public interface IRevenueTrackingBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default); Task DeleteAsync(int id, CancellationToken ct = default);
} }
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger) public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<RevenueTrackingBrowserClient> logger)
: IRevenueTrackingBrowserClient : IRevenueTrackingBrowserClient
{ {
private const string BaseUrl = "/api/revenuetracking"; 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) public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -47,7 +38,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -61,7 +51,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -78,7 +67,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -95,7 +83,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>( var response = await httpClient.GetFromJsonAsync<JsonElement>(
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct); $"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
if (response.TryGetProperty("total", out var totalValue)) if (response.TryGetProperty("total", out var totalValue))
@@ -114,7 +101,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{ {
try try
{ {
EnsureAuthHeader();
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate }; var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct); var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -132,7 +118,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{ {
try try
{ {
EnsureAuthHeader();
var request = new { paymentDate }; var request = new { paymentDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct); var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -147,7 +132,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct); var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
@@ -15,24 +15,15 @@ public interface ITaxFilingScheduleBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default); Task DeleteAsync(int id, CancellationToken ct = default);
} }
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger) public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFilingScheduleBrowserClient> logger)
: ITaxFilingScheduleBrowserClient : ITaxFilingScheduleBrowserClient
{ {
private const string BaseUrl = "/api/taxfilingschedule"; 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) public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -46,7 +37,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct); return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
} }
catch (Exception ex) catch (Exception ex)
@@ -60,7 +50,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -74,7 +63,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
@@ -92,7 +80,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{ {
try try
{ {
EnsureAuthHeader();
var request = new { clientId, filingType, dueDate, filingYear, assignedTo }; var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct); var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -110,7 +97,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct); var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
@@ -124,7 +110,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct); var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
@@ -17,23 +17,14 @@ public interface ITaxProfileBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default); Task DeleteAsync(int id, CancellationToken ct = default);
} }
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
{ {
private const string BaseUrl = "/api/taxprofile"; 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) public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -47,7 +38,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct); return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
} }
catch (Exception ex) catch (Exception ex)
@@ -61,7 +51,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{ {
try try
{ {
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? []; return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -75,7 +64,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -92,7 +80,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct); var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data)) if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? []; return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -110,7 +97,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{ {
try try
{ {
EnsureAuthHeader();
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate }; var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct); var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -129,7 +115,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{ {
try try
{ {
EnsureAuthHeader();
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel }; var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct); var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -144,7 +129,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{ {
try try
{ {
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct); var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
+3 -3
View File
@@ -33,10 +33,10 @@ public class AdminDashboardClient : IAdminDashboardClient
private void EnsureAuthHeader() private void EnsureAuthHeader()
{ {
if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken); _http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else }
_http.DefaultRequestHeaders.Authorization = null;
} }
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default) public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
@@ -29,10 +29,10 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
private void EnsureAuthHeader() private void EnsureAuthHeader()
{ {
if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken); _http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else }
_http.DefaultRequestHeaders.Authorization = null;
} }
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default) public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
+3 -3
View File
@@ -34,10 +34,10 @@ public class ClientBrowserClient : IClientBrowserClient
private void EnsureAuthHeader() private void EnsureAuthHeader()
{ {
if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken); _http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else }
_http.DefaultRequestHeaders.Authorization = null;
} }
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync( public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
@@ -32,22 +32,21 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후) // TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken)) if (string.IsNullOrEmpty(accessToken))
{ {
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken"); accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken)) if (!string.IsNullOrEmpty(accessToken))
{ {
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken"); var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry"); var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (long.TryParse(ticksStr, out var ticks)) if (long.TryParse(ticksStr, out var ticks))
{ {
_tokenStore.AccessToken = storedToken; _tokenStore.AccessToken = accessToken;
_tokenStore.RefreshToken = refreshToken; _tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks; _tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
} }
} }
} }
if (string.IsNullOrEmpty(_tokenStore.AccessToken)) if (string.IsNullOrEmpty(accessToken))
{ {
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
} }
+3 -3
View File
@@ -28,10 +28,10 @@ public class FaqBrowserClient : IFaqBrowserClient
private void EnsureAuthHeader() private void EnsureAuthHeader()
{ {
if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken); _http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else }
_http.DefaultRequestHeaders.Authorization = null;
} }
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default) public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
+3 -3
View File
@@ -33,10 +33,10 @@ public class InquiryBrowserClient : IInquiryBrowserClient
private void EnsureAuthHeader() private void EnsureAuthHeader()
{ {
if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken); _http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else }
_http.DefaultRequestHeaders.Authorization = null;
} }
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync( public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
@@ -0,0 +1,72 @@
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() private void EnsureAuthHeader()
{ {
if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _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) public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
@@ -44,7 +44,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{ {
EnsureAuthHeader(); EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>( var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct); $"tax-filing/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
return result?.Data ?? []; return result?.Data ?? [];
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -60,7 +60,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{ {
EnsureAuthHeader(); EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>( var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"taxfiling/client/{clientId}", cancellationToken: ct); $"tax-filing/client/{clientId}", cancellationToken: ct);
return result?.Data ?? []; return result?.Data ?? [];
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -76,7 +76,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{ {
EnsureAuthHeader(); EnsureAuthHeader();
return await _http.GetFromJsonAsync<TaxFiling>( return await _http.GetFromJsonAsync<TaxFiling>(
$"taxfiling/{id}", cancellationToken: ct); $"tax-filing/{id}", cancellationToken: ct);
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
@@ -90,7 +90,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try try
{ {
EnsureAuthHeader(); EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct); var response = await _http.PostAsJsonAsync("tax-filing", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return null; return null;
@@ -111,7 +111,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try try
{ {
EnsureAuthHeader(); EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct); var response = await _http.PutAsJsonAsync($"tax-filing/{id}", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return null; return null;
@@ -132,7 +132,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try try
{ {
EnsureAuthHeader(); EnsureAuthHeader();
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct); var response = await _http.DeleteAsync($"tax-filing/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -13,7 +13,6 @@ public interface ITelegramNotificationService
Task SendInfoAsync(string title, string message, CancellationToken ct = default); Task SendInfoAsync(string title, string message, CancellationToken ct = default);
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default); Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
Task SendSystemNotificationAsync(string message, CancellationToken ct = default); Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default);
} }
public class TelegramNotificationService : ITelegramNotificationService public class TelegramNotificationService : ITelegramNotificationService
@@ -97,10 +96,4 @@ public class TelegramNotificationService : ITelegramNotificationService
var text = $"<b>️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>"; var text = $"<b>️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
await SendMessageAsync(text, ct); await SendMessageAsync(text, ct);
} }
public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default)
{
var text = $"<b>📊 {reportTitle}</b>\n\n{reportContent}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
await SendToChat(_systemChatId, text, ct);
}
} }
@@ -48,7 +48,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>(); var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildDailyReportAsync(date, ct); var report = await reportService.BuildDailyReportAsync(date, ct);
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct); await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
_lastDailyReportDate = date; _lastDailyReportDate = date;
logger.LogInformation("Daily telegram report sent for {Date}", date); logger.LogInformation("Daily telegram report sent for {Date}", date);
} }
@@ -63,7 +63,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>(); var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct); var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct); await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
_lastWeeklyReportWeekStart = weekStart; _lastWeeklyReportWeekStart = weekStart;
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart); logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
} }
+11 -14
View File
@@ -10,12 +10,12 @@ using System.Text.Json;
/// </summary> /// </summary>
public class TokenRefreshHandler : DelegatingHandler public class TokenRefreshHandler : DelegatingHandler
{ {
private readonly IServiceProvider _serviceProvider; private readonly ITokenStore _tokenStore;
private readonly ILogger<TokenRefreshHandler> _logger; private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger) public TokenRefreshHandler(ITokenStore tokenStore, ILogger<TokenRefreshHandler> logger)
{ {
_serviceProvider = serviceProvider; _tokenStore = tokenStore;
_logger = logger; _logger = logger;
} }
@@ -23,13 +23,10 @@ public class TokenRefreshHandler : DelegatingHandler
HttpRequestMessage request, HttpRequestMessage request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
// 요청에 access token 추가 // 요청에 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); var response = await base.SendAsync(request, cancellationToken);
@@ -37,15 +34,15 @@ public class TokenRefreshHandler : DelegatingHandler
// 401 응답이면 토큰 갱신 시도 // 401 응답이면 토큰 갱신 시도
if (response.StatusCode == HttpStatusCode.Unauthorized) 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) if (newTokenPair != null)
{ {
// TokenStore에 토큰 저장 // TokenStore에 토큰 저장
tokenStore.AccessToken = newTokenPair.AccessToken; _tokenStore.AccessToken = newTokenPair.AccessToken;
tokenStore.RefreshToken = newTokenPair.RefreshToken; _tokenStore.RefreshToken = newTokenPair.RefreshToken;
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks; _tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
// 새 토큰으로 재요청 // 새 토큰으로 재요청
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken); request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
@@ -54,7 +51,7 @@ public class TokenRefreshHandler : DelegatingHandler
else else
{ {
_logger.LogWarning("토큰 갱신 실패 - 로그아웃"); _logger.LogWarning("토큰 갱신 실패 - 로그아웃");
tokenStore.Clear(); _tokenStore.Clear();
} }
} }
} }
-31
View File
@@ -1,31 +0,0 @@
<?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,8 +6,6 @@ const password = process.env.E2E_ADMIN_PASSWORD;
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, ''); const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
test.describe('admin CRM pages', () => { test.describe('admin CRM pages', () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.'); test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
await loginThroughAdminUi(page, baseUrl, username, password); await loginThroughAdminUi(page, baseUrl, username, password);
@@ -123,61 +121,4 @@ test.describe('admin CRM pages', () => {
expect(consoleErrors, 'no console errors during CRM navigation').toEqual([]); 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 });
});
}); });
+1 -24
View File
@@ -24,14 +24,7 @@ export async function getAdminToken(
} }
export async function installAdminToken(page: Page, token: string) { export async function installAdminToken(page: Page, token: string) {
await page.addInitScript(value => { await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
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( export async function loginThroughAdminUi(
@@ -58,22 +51,6 @@ export async function navigateInBlazor(page: Page, targetUrl: string) {
window.location.href = url; window.location.href = url;
}, targetUrl); }, 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( export async function findInquiryByName(