Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0dc05c58c |
@@ -8,8 +8,8 @@
|
||||
Blazor → Service (서버) → DB
|
||||
|
||||
✅ 현재: API-First (클라이언트-서버 분리)
|
||||
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
|
||||
Blazor 데이터 변경 자동 push/broadcast 금지
|
||||
Blazor (UI만) ← API (모든 로직) ← DB
|
||||
SignalR (변경 알림만)
|
||||
```
|
||||
|
||||
### SOLID 기반 순차 마이그레이션 전략
|
||||
@@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
|
||||
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
|
||||
|
||||
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
|
||||
- [x] NotificationHub 제거
|
||||
- [x] 데이터 변경용 INotificationService 제거
|
||||
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
|
||||
#### Phase 6: SignalR 통합
|
||||
- [ ] NotificationHub (변경 알림만)
|
||||
- [ ] Blazor에서 구독
|
||||
- [ ] 알림 후 API로 데이터 검증
|
||||
|
||||
#### Phase 7: 순차적 마이그레이션 ✅
|
||||
- [x] Blog 페이지 → API 클라이언트
|
||||
@@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
- Status Color Chips (Error/Warning/Success)
|
||||
- Client 링크 (상세 페이지 연동)
|
||||
|
||||
### **Phase 6: Lite Blazor 운영 원칙** ✅
|
||||
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
|
||||
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
|
||||
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
|
||||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
|
||||
### **Phase 6: SignalR 통합** ✅
|
||||
- NotificationHub (브로드캐스트만, 상태 관리 없음)
|
||||
- INotificationService (이벤트 기반)
|
||||
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
|
||||
- Program.cs SignalR 등록
|
||||
|
||||
---
|
||||
|
||||
@@ -160,11 +160,11 @@ Repositories (데이터 계층)
|
||||
PostgreSQL Database
|
||||
```
|
||||
|
||||
**Lite Blazor 데이터 갱신**:
|
||||
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
|
||||
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
|
||||
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
|
||||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
|
||||
**Blazor Server SignalR**:
|
||||
- 자동 연결 (내장 Hub connection)
|
||||
- NotificationHub 클라이언트 그룹 (admins)
|
||||
- 이벤트 기반 메시지 (상태 관리 없음)
|
||||
- 클라이언트는 알림 후 API로 데이터 검증
|
||||
|
||||
---
|
||||
|
||||
@@ -182,10 +182,10 @@ PostgreSQL Database
|
||||
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
|
||||
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
|
||||
|
||||
**Lite Blazor / 데이터 갱신 (Phase 6)**:
|
||||
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
|
||||
- [x] NotificationHub 제거
|
||||
- [x] 데이터 변경용 INotificationService 제거
|
||||
**실시간 알림 (Phase 6)**:
|
||||
- [x] NotificationHub 구현
|
||||
- [x] Event-driven 알림 시스템
|
||||
- [x] Scoped DI 등록
|
||||
|
||||
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
|
||||
- [x] 5개 CRM/세무관리 Blazor 페이지
|
||||
|
||||
+16
-16
@@ -425,9 +425,9 @@ Todo:
|
||||
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||
|
||||
Todo:
|
||||
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [x] 일간/주간 리포트 메시지 템플릿
|
||||
- [x] TelegramNotificationService에 리포트 메서드 추가
|
||||
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [ ] 일간/주간 리포트 메시지 템플릿
|
||||
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
||||
|
||||
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||
|
||||
@@ -439,9 +439,9 @@ Todo:
|
||||
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||
|
||||
Todo:
|
||||
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [x] 고객 전용 Razor Pages 추가
|
||||
- [x] 세무사 허용 권한 설정 UI
|
||||
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [ ] 고객 전용 Razor Pages 추가
|
||||
- [ ] 세무사 허용 권한 설정 UI
|
||||
|
||||
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||
|
||||
@@ -485,16 +485,16 @@ DB 스키마:
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
Todo:
|
||||
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
|
||||
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [x] 네이버 OAuth Handler 구현
|
||||
- [x] 카카오·구글 패키지 추가 및 설정
|
||||
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
||||
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [ ] 네이버 OAuth Handler 구현
|
||||
- [ ] 카카오·구글 패키지 추가 및 설정
|
||||
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetPendingFollowupsAsync(ct);
|
||||
|
||||
|
||||
@@ -36,9 +36,6 @@ public class ContractService(IContractRepository repository)
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetPendingPaymentsAsync(ct);
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ public class TaxProfileService(ITaxProfileRepository repository)
|
||||
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
||||
public interface IConsultingActivityRepository
|
||||
{
|
||||
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
||||
public interface IContractRepository
|
||||
{
|
||||
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
||||
public interface IRevenueTrackingRepository
|
||||
{
|
||||
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
||||
public interface ITaxFilingScheduleRepository
|
||||
{
|
||||
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
||||
public interface ITaxProfileRepository
|
||||
{
|
||||
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -16,14 +16,6 @@ public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory
|
||||
activity);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<ConsultingActivity>(
|
||||
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||
FROM consulting_activities ORDER BY activity_date DESC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -16,14 +16,6 @@ public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
contract);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Contract>(
|
||||
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||
FROM contracts ORDER BY contract_date DESC");
|
||||
}
|
||||
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -16,14 +16,6 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
|
||||
revenue);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<RevenueTracking>(
|
||||
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||
FROM revenue_tracking ORDER BY invoice_date DESC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -16,14 +16,6 @@ public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory)
|
||||
schedule);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||
FROM tax_filing_schedules ORDER BY due_date DESC");
|
||||
}
|
||||
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -20,16 +20,6 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
|
||||
profile);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles ORDER BY id DESC");
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||
|
||||
@@ -90,25 +90,11 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Announcement>? announcements;
|
||||
|
||||
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 LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
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">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ else
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
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">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||
private bool isLoading = true;
|
||||
private int currentPage = 1;
|
||||
@@ -60,20 +57,9 @@
|
||||
private int totalPosts = 0;
|
||||
private const int PageSize = 20;
|
||||
|
||||
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 LoadPosts();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
private async Task LoadPosts()
|
||||
|
||||
@@ -129,9 +129,6 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Client>? clients;
|
||||
private string searchText = "";
|
||||
private string statusFilter = "";
|
||||
@@ -140,21 +137,7 @@
|
||||
private int totalPages;
|
||||
private const int PageSize = 20;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
protected override async Task OnInitializedAsync() => await 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">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
|
||||
<MudSelectItem Value="@("방문 상담")">방문 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("전화 상담")">전화 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조사 대응 미팅")">세무조사 대응 미팅</MudSelectItem>
|
||||
<MudSelectItem Value="@("카카오톡 상담")">카카오톡 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("이메일 자료 접수")">이메일 자료 접수</MudSelectItem>
|
||||
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="activityForm.ActivityType" 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" />
|
||||
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
@@ -123,9 +116,6 @@
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<ConsultingActivity>? activities;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
@@ -134,20 +124,9 @@
|
||||
private ConsultingActivity? editingActivity;
|
||||
private ConsultingActivityForm activityForm = new();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -155,9 +134,9 @@
|
||||
try
|
||||
{
|
||||
activities = await ActivityClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -168,11 +147,7 @@
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
editingActivity = null;
|
||||
activityForm = new ConsultingActivityForm
|
||||
{
|
||||
ActivityDate = DateTime.Now,
|
||||
ClientId = clients.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -192,16 +167,6 @@
|
||||
|
||||
private async Task SaveActivity()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (editingActivity == null)
|
||||
@@ -273,12 +238,6 @@
|
||||
activityForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class ConsultingActivityForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
|
||||
@@ -107,21 +107,14 @@
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<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">
|
||||
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem>
|
||||
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="contractForm.ServiceType" 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" />
|
||||
</MudForm>
|
||||
@@ -133,9 +126,6 @@
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Contract>? contracts;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
@@ -144,20 +134,9 @@
|
||||
private bool isDialogOpen;
|
||||
private ContractForm contractForm = new();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -165,9 +144,9 @@
|
||||
try
|
||||
{
|
||||
contracts = await ContractClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -178,31 +157,16 @@
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
contractForm = new ContractForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id,
|
||||
StartDate = DateTime.Today
|
||||
};
|
||||
contractForm = new();
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveContract()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (contractForm.ClientId == null) return;
|
||||
var newId = await ContractClient.CreateAsync(
|
||||
contractForm.ClientId.Value,
|
||||
contractForm.ClientId,
|
||||
contractForm.ContractNumber,
|
||||
contractForm.ServiceType,
|
||||
contractForm.StartDate ?? DateTime.Now,
|
||||
@@ -253,15 +217,9 @@
|
||||
contractForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class ContractForm
|
||||
{
|
||||
public int? ClientId { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string ContractNumber { get; set; } = "";
|
||||
public string ServiceType { get; set; } = "";
|
||||
public DateTime? StartDate { get; set; }
|
||||
|
||||
@@ -158,45 +158,31 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
||||
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
||||
private string? errorMessage;
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (firstRender)
|
||||
try
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
// API 클라이언트 사용 (서비스 직접 호출 X)
|
||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||
// API 클라이언트 사용 (서비스 직접 호출 X)
|
||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||
|
||||
await Task.WhenAll(summaryTask, filingsTask);
|
||||
summary = await summaryTask;
|
||||
upcomingFilings = (await filingsTask).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
await Task.WhenAll(summaryTask, filingsTask);
|
||||
summary = await summaryTask;
|
||||
upcomingFilings = (await filingsTask).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,26 +95,9 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Faq>? faqs;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
|
||||
@@ -46,31 +46,11 @@ else
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private bool isLoading = true;
|
||||
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
|
||||
{
|
||||
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">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem>
|
||||
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem>
|
||||
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
@@ -119,9 +113,6 @@
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<RevenueTracking>? revenues;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
@@ -129,20 +120,9 @@
|
||||
private bool isDialogOpen;
|
||||
private RevenueForm revenueForm = new();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -150,9 +130,9 @@
|
||||
try
|
||||
{
|
||||
revenues = await RevenueClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -162,27 +142,12 @@
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
revenueForm = new RevenueForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id ?? 0,
|
||||
InvoiceDate = DateTime.Today,
|
||||
DueDate = DateTime.Today.AddDays(14)
|
||||
};
|
||||
revenueForm = new();
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveRevenue()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var newId = await RevenueClient.CreateAsync(
|
||||
@@ -252,12 +217,6 @@
|
||||
revenueForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class RevenueForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
|
||||
@@ -117,29 +117,19 @@
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?"
|
||||
<MudSelect T="int"
|
||||
@bind-Value="scheduleForm.ClientId"
|
||||
Label="고객"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Class="mb-4"
|
||||
RequiredError="고객을 선택하세요.">
|
||||
Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
|
||||
<MudSelectItem Value="@("종합소득세")">종합소득세</MudSelectItem>
|
||||
<MudSelectItem Value="@("부가가치세")">부가가치세</MudSelectItem>
|
||||
<MudSelectItem Value="@("법인세")">법인세</MudSelectItem>
|
||||
<MudSelectItem Value="@("원천세")">원천세</MudSelectItem>
|
||||
<MudSelectItem Value="@("종합부동산세")">종합부동산세</MudSelectItem>
|
||||
<MudSelectItem Value="@("양도소득세")">양도소득세</MudSelectItem>
|
||||
<MudSelectItem Value="@("상속·증여세")">상속·증여세</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정")">세무조정</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="scheduleForm.FilingType" 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" />
|
||||
</MudForm>
|
||||
@@ -151,40 +141,23 @@
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxFilingSchedule>? schedules;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private TaxFilingScheduleForm scheduleForm = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadData();
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
schedules = await TaxFilingClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -194,32 +167,16 @@
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
scheduleForm = new TaxFilingScheduleForm
|
||||
{
|
||||
FilingYear = DateTime.Now.Year,
|
||||
DueDate = DateTime.Today,
|
||||
ClientId = clients.FirstOrDefault()?.Id
|
||||
};
|
||||
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveSchedule()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (scheduleForm.ClientId == null) return;
|
||||
var newId = await TaxFilingClient.CreateAsync(
|
||||
scheduleForm.ClientId.Value,
|
||||
scheduleForm.ClientId,
|
||||
scheduleForm.FilingType,
|
||||
scheduleForm.DueDate ?? DateTime.Today,
|
||||
scheduleForm.FilingYear);
|
||||
@@ -286,15 +243,9 @@
|
||||
scheduleForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class TaxFilingScheduleForm
|
||||
{
|
||||
public int? ClientId { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string FilingType { get; set; } = "";
|
||||
public DateTime? DueDate { get; set; }
|
||||
public int FilingYear { get; set; } = DateTime.Now.Year;
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
|
||||
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
|
||||
return items;
|
||||
}
|
||||
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()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -84,23 +84,13 @@ else
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
|
||||
<MudSelectItem Value="@("일반제조업")">일반제조업</MudSelectItem>
|
||||
<MudSelectItem Value="@("도소매업")">도소매업</MudSelectItem>
|
||||
<MudSelectItem Value="@("서비스업")">서비스업</MudSelectItem>
|
||||
<MudSelectItem Value="@("정보통신업")">정보통신업</MudSelectItem>
|
||||
<MudSelectItem Value="@("부동산업")">부동산업</MudSelectItem>
|
||||
<MudSelectItem Value="@("건설업")">건설업</MudSelectItem>
|
||||
<MudSelectItem Value="@("음식점업")">음식점업</MudSelectItem>
|
||||
<MudSelectItem Value="@("프리랜서")">프리랜서</MudSelectItem>
|
||||
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="profileForm.BusinessType" 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="@("normal")">보통</MudSelectItem>
|
||||
@@ -117,9 +107,6 @@ else
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxProfile>? profiles;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
@@ -129,20 +116,9 @@ else
|
||||
private TaxProfile? editingProfile;
|
||||
private TaxProfileForm profileForm = new();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -150,9 +126,9 @@ else
|
||||
try
|
||||
{
|
||||
profiles = await TaxProfileClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -164,12 +140,7 @@ else
|
||||
{
|
||||
isEditMode = false;
|
||||
editingProfile = null;
|
||||
profileForm = new TaxProfileForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id,
|
||||
TaxRiskLevel = "normal",
|
||||
NextFilingDueDate = DateTime.Today.AddMonths(1)
|
||||
};
|
||||
profileForm = new();
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -190,43 +161,25 @@ else
|
||||
|
||||
private async Task SaveProfile()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (isEditMode && editingProfile != null)
|
||||
if (isEditMode)
|
||||
{
|
||||
await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType,
|
||||
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success);
|
||||
await TaxProfileClient.UpdateAsync(
|
||||
editingProfile!.Id,
|
||||
profileForm.BusinessType,
|
||||
null,
|
||||
profileForm.NextFilingDueDate,
|
||||
profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!profileForm.ClientId.HasValue)
|
||||
{
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
var newId = await TaxProfileClient.CreateAsync(
|
||||
profileForm.ClientId.Value,
|
||||
profileForm.ClientId,
|
||||
profileForm.BusinessType);
|
||||
if (newId > 0)
|
||||
{
|
||||
// 생성 후 상태 업데이트 처리
|
||||
await TaxProfileClient.UpdateAsync(
|
||||
newId,
|
||||
profileForm.BusinessType,
|
||||
null,
|
||||
profileForm.NextFilingDueDate,
|
||||
profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
|
||||
}
|
||||
}
|
||||
@@ -279,15 +232,9 @@ else
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class TaxProfileForm
|
||||
{
|
||||
public int? ClientId { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string BusinessType { get; set; } = "";
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
|
||||
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin-dashboard")]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
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}")]
|
||||
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}")]
|
||||
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}")]
|
||||
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}")]
|
||||
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}")]
|
||||
public async Task<IActionResult> GetByClientId(int clientId)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -52,5 +52,5 @@ public class LoginModel : PageModel
|
||||
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
|
||||
|
||||
private static AuthenticationProperties BuildProps(string provider) =>
|
||||
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
|
||||
new() { RedirectUri = $"/portal/external-callback?provider={provider}" };
|
||||
}
|
||||
|
||||
@@ -3,57 +3,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta property="og:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
|
||||
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "http://178.104.200.7/taxbaik/")" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
|
||||
|
||||
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
|
||||
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
|
||||
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
|
||||
|
||||
<title>@(ViewData["Title"] ?? "백원숙 세무회계")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] ?? "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담.")" />
|
||||
<meta property="og:title" content="@ViewData["Title"]" />
|
||||
<meta property="og:description" content="@ViewData["Description"]" />
|
||||
<meta property="og:image" content="@ViewData["OgImage"]" />
|
||||
<meta property="og:url" content="@ViewData["OgUrl"]" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="theme-color" content="#C89D6E" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "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 rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
|
||||
<!-- 구조화된 데이터 (JSON-LD Schema Markup) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "ProfessionalService",
|
||||
"name": "백원숙 세무회계",
|
||||
"description": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담 세무사",
|
||||
"url": "http://178.104.200.7/taxbaik/",
|
||||
"telephone": "010-4122-8268",
|
||||
"email": "taxbaik5668@gmail.com",
|
||||
"address": {
|
||||
"@@type": "PostalAddress",
|
||||
"addressCountry": "KR"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/taxtory5668/",
|
||||
"http://pf.kakao.com/_xoxchTX"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="with-mobile-cta">
|
||||
<partial name="_Header" />
|
||||
|
||||
+35
-23
@@ -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}")
|
||||
.Enrich.FromLogContext()
|
||||
.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)
|
||||
@@ -52,6 +45,9 @@ builder.Services.AddControllers();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// SignalR (Notifications only, no state management)
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Razor Pages + Blazor Server 통합
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
@@ -93,8 +89,8 @@ var authenticationBuilder = builder.Services.AddAuthentication(opts =>
|
||||
opts.Cookie.HttpOnly = true;
|
||||
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||
opts.LoginPath = "/taxbaik/portal/login";
|
||||
opts.AccessDeniedPath = "/taxbaik/portal/login";
|
||||
opts.LoginPath = "/portal/login";
|
||||
opts.AccessDeniedPath = "/portal/login";
|
||||
opts.SlidingExpiration = true;
|
||||
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||
})
|
||||
@@ -115,7 +111,7 @@ if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(goo
|
||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||
opts.ClientId = googleClientId;
|
||||
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.ClientId = naverClientId;
|
||||
opts.ClientSecret = naverClientSecret;
|
||||
opts.CallbackPath = "/taxbaik/portal/signin-naver";
|
||||
opts.CallbackPath = "/portal/signin-naver";
|
||||
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
|
||||
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
|
||||
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.ClientId = kakaoClientId;
|
||||
opts.ClientSecret = kakaoClientSecret;
|
||||
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
|
||||
opts.CallbackPath = "/portal/signin-kakao";
|
||||
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
|
||||
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
|
||||
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
|
||||
@@ -194,6 +190,9 @@ builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
// Notifications (SignalR)
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
|
||||
// Telegram Notification
|
||||
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
|
||||
|
||||
@@ -208,53 +207,64 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
|
||||
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
// Phase 5: Tax Accounting & CRM Browser Clients
|
||||
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
// UI & 캐시 (MudBlazor Theme Customization)
|
||||
builder.Services.AddMudServices(config =>
|
||||
@@ -344,6 +354,8 @@ app.MapControllers();
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapRazorPages();
|
||||
|
||||
// SignalR Hub
|
||||
app.MapHub<TaxBaik.Web.Hubs.NotificationHub>("/taxbaik/notifications");
|
||||
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
|
||||
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
|
||||
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
||||
|
||||
@@ -14,24 +14,15 @@ public interface IConsultingActivityBrowserClient
|
||||
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
|
||||
{
|
||||
private const string BaseUrl = "/api/consultingactivity";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -45,7 +36,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -59,7 +49,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
|
||||
@@ -77,7 +66,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -95,7 +83,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { outcome, nextFollowupDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -110,7 +97,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -16,24 +16,15 @@ public interface IContractBrowserClient
|
||||
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
|
||||
{
|
||||
private const string BaseUrl = "/api/contract";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -47,7 +38,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -61,7 +51,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -75,7 +64,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||
@@ -92,7 +80,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||
@@ -109,7 +96,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
|
||||
if (response.TryGetProperty("mrr", out var mrrValue))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
|
||||
@@ -127,7 +113,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -145,7 +130,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -16,24 +16,15 @@ public interface IRevenueTrackingBrowserClient
|
||||
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
|
||||
{
|
||||
private const string BaseUrl = "/api/revenuetracking";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -47,7 +38,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -61,7 +51,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||
@@ -78,7 +67,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||
@@ -95,7 +83,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>(
|
||||
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
|
||||
if (response.TryGetProperty("total", out var totalValue))
|
||||
@@ -114,7 +101,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -132,7 +118,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { paymentDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -147,7 +132,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -15,24 +15,15 @@ public interface ITaxFilingScheduleBrowserClient
|
||||
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
|
||||
{
|
||||
private const string BaseUrl = "/api/taxfilingschedule";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -46,7 +37,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -60,7 +50,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -74,7 +63,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
|
||||
@@ -92,7 +80,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -110,7 +97,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
@@ -124,7 +110,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -17,23 +17,14 @@ public interface ITaxProfileBrowserClient
|
||||
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 void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -47,7 +38,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -61,7 +51,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -75,7 +64,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||
@@ -92,7 +80,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||
@@ -110,7 +97,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -129,7 +115,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -144,7 +129,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ public class AdminDashboardClient : IAdminDashboardClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -29,10 +29,10 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -34,10 +34,10 @@ public class ClientBrowserClient : IClientBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
|
||||
@@ -32,22 +32,21 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
|
||||
if (!string.IsNullOrEmpty(storedToken))
|
||||
accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
|
||||
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
|
||||
if (long.TryParse(ticksStr, out var ticks))
|
||||
{
|
||||
_tokenStore.AccessToken = storedToken;
|
||||
_tokenStore.AccessToken = accessToken;
|
||||
_tokenStore.RefreshToken = refreshToken;
|
||||
_tokenStore.TokenExpiryTicks = ticks;
|
||||
accessToken = storedToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ public class FaqBrowserClient : IFaqBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -33,10 +33,10 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
@@ -44,7 +44,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
|
||||
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
|
||||
$"tax-filing/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
@@ -60,7 +60,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
|
||||
$"taxfiling/client/{clientId}", cancellationToken: ct);
|
||||
$"tax-filing/client/{clientId}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
@@ -76,7 +76,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<TaxFiling>(
|
||||
$"taxfiling/{id}", cancellationToken: ct);
|
||||
$"tax-filing/{id}", cancellationToken: ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -90,7 +90,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
|
||||
var response = await _http.PostAsJsonAsync("tax-filing", filing, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
@@ -111,7 +111,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
try
|
||||
{
|
||||
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)
|
||||
return null;
|
||||
|
||||
@@ -132,7 +132,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
|
||||
var response = await _http.DeleteAsync($"tax-filing/{id}", cancellationToken: ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
|
||||
@@ -13,7 +13,6 @@ public interface ITelegramNotificationService
|
||||
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
|
||||
Task SendInquiryNotificationAsync(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
|
||||
@@ -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>";
|
||||
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 report = await reportService.BuildDailyReportAsync(date, ct);
|
||||
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
|
||||
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
|
||||
_lastDailyReportDate = date;
|
||||
logger.LogInformation("Daily telegram report sent for {Date}", date);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public class TelegramReportBackgroundService(
|
||||
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||
|
||||
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
|
||||
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
|
||||
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
|
||||
_lastWeeklyReportWeekStart = weekStart;
|
||||
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ using System.Text.Json;
|
||||
/// </summary>
|
||||
public class TokenRefreshHandler : DelegatingHandler
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -23,13 +23,10 @@ public class TokenRefreshHandler : DelegatingHandler
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
|
||||
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
|
||||
|
||||
// 요청에 access token 추가
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
request.Headers.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
@@ -37,15 +34,15 @@ public class TokenRefreshHandler : DelegatingHandler
|
||||
// 401 응답이면 토큰 갱신 시도
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.RefreshToken))
|
||||
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken))
|
||||
{
|
||||
var newTokenPair = await RefreshTokenAsync(tokenStore.RefreshToken, request, cancellationToken);
|
||||
var newTokenPair = await RefreshTokenAsync(_tokenStore.RefreshToken, request, cancellationToken);
|
||||
if (newTokenPair != null)
|
||||
{
|
||||
// TokenStore에 토큰 저장
|
||||
tokenStore.AccessToken = newTokenPair.AccessToken;
|
||||
tokenStore.RefreshToken = newTokenPair.RefreshToken;
|
||||
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
|
||||
_tokenStore.AccessToken = newTokenPair.AccessToken;
|
||||
_tokenStore.RefreshToken = newTokenPair.RefreshToken;
|
||||
_tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
|
||||
|
||||
// 새 토큰으로 재요청
|
||||
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
|
||||
@@ -54,7 +51,7 @@ public class TokenRefreshHandler : DelegatingHandler
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
|
||||
tokenStore.Clear();
|
||||
_tokenStore.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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(/\/$/, '');
|
||||
|
||||
test.describe('admin CRM pages', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
|
||||
await loginThroughAdminUi(page, baseUrl, username, password);
|
||||
@@ -123,61 +121,4 @@ test.describe('admin CRM pages', () => {
|
||||
|
||||
expect(consoleErrors, 'no console errors during CRM navigation').toEqual([]);
|
||||
});
|
||||
|
||||
test('TaxProfiles form displays valid business type combo choices', async ({ page }) => {
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
|
||||
|
||||
// JS 네이티브 클릭으로 강제 격발하여 오프셋 씹힘 소멸
|
||||
await addButton.evaluate(el => (el as HTMLButtonElement).click());
|
||||
|
||||
// 대화상자(MudDialog) 자체의 노출 대기
|
||||
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// mud-select 컨테이너 자체 클릭 (이벤트 핸들러 직접 격발)
|
||||
const select = page.locator('.mud-select').filter({ hasText: '사업 유형' }).first();
|
||||
await select.evaluate(el => (el as HTMLDivElement).click());
|
||||
|
||||
// 활성화된 팝오버(.mud-popover-open) 내에서 텍스트 노출 검증
|
||||
const popover = page.locator('.mud-popover-open');
|
||||
await expect(popover.getByText('일반제조업')).toBeVisible({ timeout: 5000 });
|
||||
await expect(popover.getByText('도소매업')).toBeVisible({ timeout: 5000 });
|
||||
await expect(popover.getByText('서비스업')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('TaxFilingSchedules form displays filing type combo choices', async ({ page }) => {
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-filing-schedules`);
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 일정 추가/ });
|
||||
await addButton.evaluate(el => (el as HTMLButtonElement).click());
|
||||
|
||||
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const select = page.locator('.mud-select').filter({ hasText: '신고 유형' }).first();
|
||||
await select.evaluate(el => (el as HTMLDivElement).click());
|
||||
|
||||
const popover = page.locator('.mud-popover-open');
|
||||
await expect(popover.getByText('종합소득세')).toBeVisible({ timeout: 5000 });
|
||||
await expect(popover.getByText('부가가치세')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Contracts form displays service type combo choices', async ({ page }) => {
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/contracts`);
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 계약 추가/ });
|
||||
await addButton.evaluate(el => (el as HTMLButtonElement).click());
|
||||
|
||||
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const select = page.locator('.mud-select').filter({ hasText: '서비스 유형' }).first();
|
||||
await select.evaluate(el => (el as HTMLDivElement).click());
|
||||
|
||||
const popover = page.locator('.mud-popover-open');
|
||||
await expect(popover.getByText('개인 기장대리')).toBeVisible({ timeout: 5000 });
|
||||
await expect(popover.getByText('법인 기장대리')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,14 +24,7 @@ export async function getAdminToken(
|
||||
}
|
||||
|
||||
export async function installAdminToken(page: Page, token: string) {
|
||||
await page.addInitScript(value => {
|
||||
localStorage.setItem('accessToken', value);
|
||||
localStorage.setItem('refreshToken', 'ci-test-refresh-token');
|
||||
// Calculate C# Ticks for 1 hour from now: (JS_ms * 10000) + 621355968000000000
|
||||
const expiryMs = Date.now() + 3600 * 1000;
|
||||
const ticks = (expiryMs * 10000) + 621355968000000000;
|
||||
localStorage.setItem('tokenExpiry', ticks.toString());
|
||||
}, token);
|
||||
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
|
||||
}
|
||||
|
||||
export async function loginThroughAdminUi(
|
||||
@@ -58,22 +51,6 @@ export async function navigateInBlazor(page: Page, targetUrl: string) {
|
||||
|
||||
window.location.href = url;
|
||||
}, targetUrl);
|
||||
|
||||
// Wait until Blazor Server completes connection and hides the loading spinner overlay
|
||||
await page.locator('#blazor-loading').waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {});
|
||||
|
||||
// Give the SPA router a brief window to unmount the previous page and mount the loading spinner
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Also wait for MudBlazor's dynamic loading spinners to disappear (ensuring the grid is interactive)
|
||||
const spinner = page.locator('.mud-progress-circular, .mud-progress-linear-bar');
|
||||
try {
|
||||
if (await spinner.count() > 0) {
|
||||
await spinner.first().waitFor({ state: 'hidden', timeout: 10000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Suppress timeout if the spinner was already gone or never showed up
|
||||
}
|
||||
}
|
||||
|
||||
export async function findInquiryByName(
|
||||
|
||||
Reference in New Issue
Block a user