Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c179b1eb | |||
| 488b8d11b7 | |||
| 65c5f19a2f | |||
| eaacbc8d7f | |||
| ac8a70a2ca | |||
| 203e674c3f | |||
| 0c014d0bdf | |||
| 904c0972ca | |||
| 7e75aeeec7 | |||
| b13eed7b7e | |||
| 4647b049b8 | |||
| 1a5ebb45bc | |||
| f197663101 | |||
| 70b57f1d4c | |||
| 428eeb6fd8 | |||
| dd68a237a1 | |||
| ef9fd523c6 | |||
| f2ab78dea2 | |||
| 1e0c0b7e1c | |||
| 1b173376ee | |||
| 1a7bc9e209 | |||
| 3be379431f | |||
| 682e2db3a3 | |||
| d9766cb5ef | |||
| 6bcb9effa8 | |||
| 186c6ef7a4 | |||
| c2e8e08f09 | |||
| 3f7cd7cd84 | |||
| 4b352df408 | |||
| a4b1234900 | |||
| a3c81c4f70 | |||
| 6e8b4e76ac | |||
| 5807e1b35e | |||
| 3e1097f585 | |||
| 917600a793 | |||
| 0d3615b44d | |||
| fc339ca9e7 | |||
| da1226994f | |||
| 6bc03ce3d9 | |||
| ecfbfc7cac | |||
| 46cb508bdf | |||
| ecabe8d9cc | |||
| 55c65810c1 | |||
| 7054d397e4 | |||
| 11fb596fc2 | |||
| a04592499c | |||
| ea9478f2f1 | |||
| f569211967 | |||
| c8306e2ac7 | |||
| bad2f47ffe | |||
| 943fe9c819 | |||
| 7b819f4ab0 | |||
| 6a5740ec68 | |||
| 3c8f30af6d | |||
| 7e3b4e2229 | |||
| 67bd5dc666 | |||
| 84161ee2d9 | |||
| 5aec36b155 | |||
| 3ab8971025 | |||
| db30e71e0a | |||
| e4c2758dea | |||
| 75661aa0ef | |||
| 3303ba2e96 | |||
| 43c2ff6ad9 | |||
| a7bb8d7149 | |||
| 791ce6d526 | |||
| 61083a5bb1 | |||
| 66fb86d23c | |||
| 16f7c6097c | |||
| 7232635ed0 | |||
| b42b98d560 | |||
| f216660afa | |||
| b31b43e30e | |||
| 86bd9ef8ff | |||
| 2fd9984a45 | |||
| 91330ec94c | |||
| 08102c8684 | |||
| e2472b7ea1 | |||
| 033883aac5 | |||
| d2cfcd90f0 | |||
| 42e73fa694 | |||
| f8f8f869fc | |||
| db7f903054 | |||
| 0d7a081f5a | |||
| 0bd36ae26f | |||
| 447a62c0fb | |||
| a16438dcc6 | |||
| ebd12b78a0 | |||
| 4b62d35266 | |||
| c38b97377a | |||
| 59f1509368 | |||
| c2955ad02f | |||
| ea40e5c002 | |||
| 7dd51a1169 | |||
| c65742a0c7 | |||
| 52f1790acb |
@@ -3,3 +3,10 @@ ASPNETCORE_URLS=http://0.0.0.0:5001
|
||||
ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me
|
||||
Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars!
|
||||
Admin__PasswordResetToken=change-this-reset-token
|
||||
Authentication__Google__ClientId=
|
||||
Authentication__Google__ClientSecret=
|
||||
Authentication__Naver__ClientId=
|
||||
Authentication__Naver__ClientSecret=
|
||||
Authentication__Kakao__ClientId=
|
||||
Authentication__Kakao__ClientSecret=
|
||||
# CI deploy trigger requires a real push on master.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: TaxBaik CI/CD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -38,18 +39,29 @@ jobs:
|
||||
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
||||
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
||||
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
|
||||
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
|
||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
|
||||
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
|
||||
[ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
|
||||
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
|
||||
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
||||
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
||||
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
||||
TELEGRAM_SYSTEM_CHAT_ID="$TELEGRAM_SYSTEM_CHAT_ID" \
|
||||
python3 -c '
|
||||
import json, os, pathlib
|
||||
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
||||
json.dumps({
|
||||
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
||||
"Telegram": {"BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "ChatId": os.environ["TELEGRAM_CHAT_ID"]}
|
||||
"Telegram": {
|
||||
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
|
||||
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
|
||||
"InquiryChatId": os.environ["TELEGRAM_INQUIRY_CHAT_ID"],
|
||||
"SystemChatId": os.environ["TELEGRAM_SYSTEM_CHAT_ID"]
|
||||
}
|
||||
}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)'
|
||||
@@ -98,6 +110,34 @@ jobs:
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||
TELEGRAM_CHAT_ID="${TELEGRAM_SYSTEM_CHAT_ID:--5585148480}"
|
||||
|
||||
send_telegram() {
|
||||
local text="$1"
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
|
||||
echo "Skipping Telegram notification: missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
--data-urlencode "text=${text}" \
|
||||
-d "parse_mode=HTML" >/dev/null || true
|
||||
}
|
||||
|
||||
notify_failure() {
|
||||
local exit_code=$?
|
||||
send_telegram "❌ <b>TaxBaik 배포 실패</b>
|
||||
|
||||
커밋: <code>${COMMIT}</code>
|
||||
시간: <code>${TIMESTAMP}</code>
|
||||
단계: CI/CD deploy"
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
trap notify_failure ERR
|
||||
|
||||
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
|
||||
|
||||
@@ -179,3 +219,9 @@ jobs:
|
||||
REMOTE
|
||||
|
||||
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
||||
|
||||
커밋: <code>${COMMIT}</code>
|
||||
시간: <code>${TIMESTAMP}</code>
|
||||
대상: <code>${DEPLOY_HOST}</code>
|
||||
채널: <code>${TELEGRAM_CHAT_ID}</code>"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
Blazor → Service (서버) → DB
|
||||
|
||||
✅ 현재: API-First (클라이언트-서버 분리)
|
||||
Blazor (UI만) ← API (모든 로직) ← DB
|
||||
SignalR (변경 알림만)
|
||||
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
|
||||
Blazor 데이터 변경 자동 push/broadcast 금지
|
||||
```
|
||||
|
||||
### SOLID 기반 순차 마이그레이션 전략
|
||||
@@ -61,17 +61,24 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
|
||||
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
|
||||
|
||||
#### Phase 6: SignalR 통합
|
||||
- [ ] NotificationHub (변경 알림만)
|
||||
- [ ] Blazor에서 구독
|
||||
- [ ] 알림 후 API로 데이터 검증
|
||||
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
|
||||
- [x] NotificationHub 제거
|
||||
- [x] 데이터 변경용 INotificationService 제거
|
||||
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
|
||||
|
||||
#### Phase 7: 순차적 마이그레이션
|
||||
- Blog 페이지 → API 클라이언트
|
||||
- Inquiry 페이지 → API 클라이언트
|
||||
- FAQ/Client/TaxFiling 등 순차 처리
|
||||
#### Phase 7: 순차적 마이그레이션 ✅
|
||||
- [x] Blog 페이지 → API 클라이언트
|
||||
- [x] Inquiry 페이지 → API 클라이언트
|
||||
- [x] 공개 콘텐츠 & 기본 관리 (Clients, TaxFilings, FAQs, Announcements)
|
||||
- [x] CRM & 세무관리 (TaxProfile, TaxFilingSchedule, Contract, ConsultingActivity, RevenueTracking)
|
||||
|
||||
**현재 상태**: **✅ ALL PHASES COMPLETE (2026-06-28)**
|
||||
**현재 상태**: **✅ Phase 1-7 COMPLETE (2026-06-28)**
|
||||
- 모든 API 엔드포인트 구현됨
|
||||
- 모든 Browser Client 구현됨
|
||||
- 16개 Blazor 페이지 API-First 마이그레이션 완료
|
||||
- MudDataGrid Douzone ERP 수준 UX 적용
|
||||
- MudDialog 모달 패턴 (흰 화면 플래시 제거)
|
||||
- ConfirmDialog 삭제 확인 컴포넌트
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +101,7 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
- API: 완성 (상태 변경, 메모, 고객 변환)
|
||||
- Blazor: InquiryTable + InquiryDetail 완전 마이그레이션
|
||||
|
||||
**Phase 7-3: 모든 관리자 페이지** ✅
|
||||
**Phase 7-3: 공개 콘텐츠 & 기본 관리 페이지** ✅
|
||||
- 4개 API Controller (Clients, TaxFilings, Faqs, Announcements)
|
||||
- 5개 Browser Client (IXxxBrowserClient)
|
||||
- 9개 Blazor 페이지 마이그레이션
|
||||
@@ -108,11 +115,32 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
| Inquiries | ✅ InquiryController | ✅ IInquiryBrowserClient | ✅ List + Detail |
|
||||
| Dashboard | ✅ AdminDashboardController | ✅ IAdminDashboardClient | ✅ Refactored |
|
||||
|
||||
### **Phase 6: SignalR 통합** ✅
|
||||
- NotificationHub (브로드캐스트만, 상태 관리 없음)
|
||||
- INotificationService (이벤트 기반)
|
||||
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
|
||||
- Program.cs SignalR 등록
|
||||
**Phase 7-4: CRM & 세무관리 (신규 - 2026-06-28)** ✅
|
||||
- 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking)
|
||||
- 5개 Browser Client (API-First 패턴)
|
||||
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog)
|
||||
- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
|
||||
|
||||
| 페이지 | API | Client | Blazor | 핵심 기능 |
|
||||
|------|---|---|---|---------|
|
||||
| TaxProfiles | ✅ TaxProfileController | ✅ ITaxProfileBrowserClient | ✅ List + Modal | 위험도 추적, 신고 예정일 |
|
||||
| TaxFilingSchedules | ✅ TaxFilingScheduleController | ✅ ITaxFilingScheduleBrowserClient | ✅ List + Modal | D-day 추적, 완료 처리 |
|
||||
| Contracts | ✅ ContractController | ✅ IContractBrowserClient | ✅ List + Modal | MRR 계산, 계약 기간 추적 |
|
||||
| ConsultingActivities | ✅ ConsultingActivityController | ✅ IConsultingActivityBrowserClient | ✅ List + Modal | 상담 기록, 팔로업 자동 추적 |
|
||||
| RevenueTrackings | ✅ RevenueTrackingController | ✅ IRevenueTrackingBrowserClient | ✅ List + Modal | 청구/납부 추적, 상태 관리 |
|
||||
|
||||
**UI 특성**:
|
||||
- MudDataGrid Dense (행높이 32px) + Virtualize (1000+ 행 성능)
|
||||
- MudDialog Create/Edit (흰 화면 플래시 방지)
|
||||
- ConfirmDialog Delete (사용자 확인)
|
||||
- Status Color Chips (Error/Warning/Success)
|
||||
- Client 링크 (상세 페이지 연동)
|
||||
|
||||
### **Phase 6: Lite Blazor 운영 원칙** ✅
|
||||
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
|
||||
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
|
||||
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
|
||||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -132,11 +160,11 @@ Repositories (데이터 계층)
|
||||
PostgreSQL Database
|
||||
```
|
||||
|
||||
**Blazor Server SignalR**:
|
||||
- 자동 연결 (내장 Hub connection)
|
||||
- NotificationHub 클라이언트 그룹 (admins)
|
||||
- 이벤트 기반 메시지 (상태 관리 없음)
|
||||
- 클라이언트는 알림 후 API로 데이터 검증
|
||||
**Lite Blazor 데이터 갱신**:
|
||||
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
|
||||
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
|
||||
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
|
||||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -148,15 +176,25 @@ PostgreSQL Database
|
||||
- [x] 안전한 메모리 저장소 (ITokenStore)
|
||||
|
||||
**API-First 마이그레이션 (Phase 7)**:
|
||||
- [x] 모든 관리자 페이지 API 컨트롤러 (6개)
|
||||
- [x] 모든 Browser Client (5개 + Dashboard)
|
||||
- [x] 모든 Blazor 페이지 리팩토링 (9개)
|
||||
- [x] SOLID 원칙 전체 적용
|
||||
- [x] Phase 7-1: Blog API + Blazor 클라이언트
|
||||
- [x] Phase 7-2: Inquiry API + Blazor 클라이언트
|
||||
- [x] Phase 7-3: 공개 콘텐츠 & 기본 관리 페이지 (6개 API, 6개 Blazor)
|
||||
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
|
||||
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
|
||||
|
||||
**실시간 알림 (Phase 6)**:
|
||||
- [x] NotificationHub 구현
|
||||
- [x] Event-driven 알림 시스템
|
||||
- [x] Scoped DI 등록
|
||||
**Lite Blazor / 데이터 갱신 (Phase 6)**:
|
||||
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
|
||||
- [x] NotificationHub 제거
|
||||
- [x] 데이터 변경용 INotificationService 제거
|
||||
|
||||
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
|
||||
- [x] 5개 CRM/세무관리 Blazor 페이지
|
||||
- [x] MudDataGrid Dense + Virtualize (32px 행 높이)
|
||||
- [x] MudDialog 모달 Create/Edit (흰 화면 플래시 제거)
|
||||
- [x] ConfirmDialog 삭제 확인
|
||||
- [x] 상태별 컬러 칩 (Status/Risk Level)
|
||||
- [x] 클라이언트 링크 (상세 페이지 연동)
|
||||
- [x] D-day 추적, MRR 계산, 팔로업 자동 추적
|
||||
|
||||
**빌드 & 배포**:
|
||||
- [x] 0 오류, 모든 경고 기록됨
|
||||
@@ -927,6 +965,347 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
||||
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
|
||||
- 업데이트는 `StateHasChanged()` 호출
|
||||
|
||||
### 8.6 어드민 그리드 UX (Dorsum ERP 수준)
|
||||
|
||||
**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + ERP 수준의 상호작용성
|
||||
|
||||
#### 그리드 기본 원칙
|
||||
- **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거)
|
||||
- **반응형**: PC(1920px) 6컬럼 → 태블릿(960px) 4컬럼 → 모바일(480px) 2컬럼
|
||||
- **패드 특화**: 터치 친화적 (최소 24px 셀 높이, 36px 버튼)
|
||||
- **PC 최적화**: 마우스 호버 선택행, 키보드 네비게이션 (Arrow/Enter/Esc)
|
||||
|
||||
#### 고급 인터랙션
|
||||
- **인라인 편집**: 셀 더블클릭 → 편집 모드 (취소: Esc, 저장: Enter)
|
||||
- **다중 선택**: Ctrl/Cmd + Click, Shift + Click로 범위 선택
|
||||
- **컨텍스트 메뉴**: 우클릭 → 행 삭제, 복사, 내보내기
|
||||
- **정렬/필터**: 컬럼 헤더 클릭 정렬, 필터 아이콘 필터링
|
||||
- **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지)
|
||||
- **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)
|
||||
|
||||
#### MudBlazor 적용 패턴
|
||||
```razor
|
||||
<MudDataGrid T="YourItem"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
RowsPerPage="20"
|
||||
Virtualize="true"
|
||||
@ref="dataGrid"
|
||||
Items="items"
|
||||
Sortable="true"
|
||||
Filterable="true"
|
||||
ShowMenuIcon="true">
|
||||
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<PropertyColumn Property="x => x.Name" Title="이름" Filterable="true" />
|
||||
<PropertyColumn Property="x => x.Amount" Title="금액" Sortable="true"
|
||||
Format="C" />
|
||||
<TemplateColumn Title="작업">
|
||||
<CellTemplate>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Small"
|
||||
OnClick="@(() => Edit(context.Item))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Small"
|
||||
OnClick="@(() => Delete(context.Item))" />
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
```
|
||||
|
||||
#### 색상 & 상태 표시
|
||||
- **정상** (Normal): 회색 배경
|
||||
- **주의** (Warning): 주황색 배경 (TaxRiskLevel: "warning")
|
||||
- **긴급** (Danger): 빨간색 배경 (TaxRiskLevel: "danger", 미납 송장)
|
||||
- **완료** (Success): 녹색 배경 (완료된 신고, 결제됨)
|
||||
|
||||
```razor
|
||||
<MudChip Color="@(item.TaxRiskLevel == "danger" ? Color.Error :
|
||||
item.TaxRiskLevel == "warning" ? Color.Warning : Color.Default)">
|
||||
@item.TaxRiskLevel
|
||||
</MudChip>
|
||||
```
|
||||
|
||||
#### 페이지 구조 (예: TaxProfile 관리)
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 세무프로필 관리 [+새로 추가] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 🔍 검색... │
|
||||
├──────┬────────┬────────┬────────┬────────┬──┤
|
||||
│ 고객 │ 상태 │ 리스크 │ 다음신고│ 담당자 │작│
|
||||
├──────┼────────┼────────┼────────┼────────┼──┤
|
||||
│ (선택)고객A │ 활성 │ 🔴높음 │5/30 │ A │✎│
|
||||
│ │ │ │ │ │✕│
|
||||
│ (선택)고객B │ 활성 │ 🟡보통 │6/15 │ B │✎│
|
||||
│ │ │ │ │ │✕│
|
||||
├──────┴────────┴────────┴────────┴────────┴──┤
|
||||
│ ◀ 1 2 3 4 ▶ | 20행/페이지 | 전체: 150개 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### CSS 클래스 표준
|
||||
```css
|
||||
/* admin-grid.css */
|
||||
.admin-grid {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-grid--dense {
|
||||
--mud-table-row-height: 32px;
|
||||
}
|
||||
|
||||
.admin-grid__header {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: 600;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.admin-grid__cell {
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-grid__cell--danger {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
.admin-grid__cell--warning {
|
||||
background-color: #fff3e0;
|
||||
}
|
||||
|
||||
.admin-grid__cell--success {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
|
||||
.admin-grid__action-button {
|
||||
padding: 4px 8px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
```
|
||||
|
||||
#### 성능 최적화
|
||||
- **가상화**: `Virtualize="true"` (10,000행 이상 대응)
|
||||
- **지연 로드**: IntersectionObserver로 스크롤 시 다음 페이지 로드
|
||||
- **메모이제이션**: `OnParametersSet` vs `OnInitializedAsync` 구분
|
||||
- **API 캐싱**: 변경이 없으면 `IMemoryCache` 사용 (5분 TTL)
|
||||
|
||||
### 8.7 Blazor 페이지 추가 표준 가이드 ✅ (2026-06-28 갱신)
|
||||
|
||||
**목표**: 모든 관리자 페이지가 일관된 구조와 UX를 유지하도록 강제
|
||||
|
||||
#### 필수 구조 (기존 Dashboard 패턴 준수)
|
||||
|
||||
**Step 1: 페이지 헤더 (`<section class="admin-page-hero">`)**
|
||||
```razor
|
||||
@page "/admin/새페이지"
|
||||
@attribute [Authorize]
|
||||
@inject INewPageClient NewPageClient
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>페이지 제목</PageTitle>
|
||||
|
||||
<!-- 반드시 포함할 요소 -->
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">카테고리</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">페이지 제목</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">한 줄 설명</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="OpenCreateDialog">
|
||||
새 항목 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
```
|
||||
|
||||
**Step 2: 콘텐츠 영역**
|
||||
```razor
|
||||
<!-- 로딩 상태 -->
|
||||
@if (items == null)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
<!-- 빈 상태 -->
|
||||
else if (items.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">데이터가 없습니다.</MudAlert>
|
||||
}
|
||||
<!-- 데이터 그리드 -->
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="YourEntity"
|
||||
Items="@items"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid mt-4">
|
||||
<Columns>
|
||||
<!-- 필수: 컬럼 정의 -->
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 모달 다이얼로그 (Create/Edit)**
|
||||
```razor
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(isEditMode ? "항목 수정" : "새 항목 추가")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<!-- 폼 필드 -->
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveItem">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
```
|
||||
|
||||
**Step 4: @code 섹션 구조**
|
||||
```csharp
|
||||
@code {
|
||||
private List<YourEntity>? items;
|
||||
private List<RelatedEntity> relatedItems = [];
|
||||
private Dictionary<int, string> itemMap = new();
|
||||
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private bool isEditMode;
|
||||
private YourEntity? editingItem;
|
||||
private YourItemForm itemForm = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
items = await YourItemClient.GetAllAsync();
|
||||
// 필요시 관련 데이터 로드
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
isEditMode = false;
|
||||
editingItem = null;
|
||||
itemForm = new();
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog(YourEntity item)
|
||||
{
|
||||
isEditMode = true;
|
||||
editingItem = item;
|
||||
itemForm = new YourItemForm { /* 초기화 */ };
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveItem()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (isEditMode)
|
||||
{
|
||||
await YourItemClient.UpdateAsync(editingItem!.Id, /* params */);
|
||||
Snackbar.Add("항목이 업데이트되었습니다.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newId = await YourItemClient.CreateAsync(/* params */);
|
||||
if (newId > 0)
|
||||
{
|
||||
Snackbar.Add("항목이 추가되었습니다.", Severity.Success);
|
||||
}
|
||||
}
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteItem(int id)
|
||||
{
|
||||
var parameters = new DialogParameters();
|
||||
parameters.Add("Title", "삭제 확인");
|
||||
parameters.Add("Message", "이 항목을 삭제하시겠습니까?");
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await YourItemClient.DeleteAsync(id);
|
||||
Snackbar.Add("항목이 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
isEditMode = false;
|
||||
editingItem = null;
|
||||
itemForm = new();
|
||||
}
|
||||
|
||||
private class YourItemForm
|
||||
{
|
||||
// DTO 필드
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 체크리스트 (모든 페이지)
|
||||
|
||||
- [ ] @page 지시문 확인
|
||||
- [ ] @attribute [Authorize] 추가
|
||||
- [ ] @inject로 필요한 Client 주입
|
||||
- [ ] <PageTitle> 추가
|
||||
- [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼)
|
||||
- [ ] 로딩 상태 (MudProgressCircular)
|
||||
- [ ] 빈 상태 (MudAlert)
|
||||
- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스)
|
||||
- [ ] MudDialog (Create/Edit 모달)
|
||||
- [ ] ConfirmDialog (Delete 확인)
|
||||
- [ ] @code 섹션: OnInitializedAsync → LoadData() 패턴
|
||||
- [ ] 모든 에러 처리 (try-catch, Snackbar 메시지)
|
||||
- [ ] CloseDialog() 메서드로 모달 상태 초기화
|
||||
|
||||
#### 위반 사항
|
||||
|
||||
❌ **이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:**
|
||||
- 페이지 헤더 (admin-page-hero) 누락
|
||||
- 인라인 스타일로 레이아웃 구성
|
||||
- MudDialog 없이 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
|
||||
- @code 섹션 구조 다름
|
||||
- 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공
|
||||
|
||||
---
|
||||
|
||||
## 9. Do's & Don'ts
|
||||
@@ -1010,6 +1389,224 @@ public async Task OnPostAsync()
|
||||
}
|
||||
```
|
||||
|
||||
### 10.5 폼 UI/UX - Enter 키 포커스 이동
|
||||
|
||||
**목표**: 관리 페이지 폼에서 Enter 키를 누르면 다음 필드로 자동 포커스
|
||||
|
||||
#### 구현 패턴
|
||||
```razor
|
||||
<MudTextField @bind-Value="@request.FieldA" Label="필드 A"
|
||||
OnKeyDown="@((KeyboardEventArgs e) => HandleEnter(e, "fieldB"))"
|
||||
@ref="fieldA" Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @ref="fieldB" Label="필드 B"
|
||||
OnKeyDown="@((KeyboardEventArgs e) => HandleEnter(e, "fieldC"))" />
|
||||
```
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private MudTextField? fieldB;
|
||||
private MudTextField? fieldC;
|
||||
|
||||
private async Task HandleEnter(KeyboardEventArgs e, string nextFieldId)
|
||||
{
|
||||
if (e.Code == "Enter" || e.Key == "Enter")
|
||||
{
|
||||
e.PreventDefault();
|
||||
await FocusNextField(nextFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FocusNextField(string fieldId)
|
||||
{
|
||||
// 다음 필드로 포커스 이동
|
||||
if (fieldId == "fieldB")
|
||||
await fieldB?.FocusAsync()!;
|
||||
else if (fieldId == "fieldC")
|
||||
await fieldC?.FocusAsync()!;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**규칙**:
|
||||
- 모든 관리 페이지 폼에 Enter 키 지원 필수
|
||||
- Tab 키와 동일하게 작동하되, 명시적 입력 의도 반영
|
||||
- 마지막 필드에서 Enter = 폼 제출 (자동 검증)
|
||||
|
||||
---
|
||||
|
||||
### 10.6 더존(Douzone) 통합 가이드
|
||||
|
||||
**목표**: TaxBaik은 더존 세무회계의 상위 CRM/고객 관리 전략 시스템
|
||||
|
||||
#### 역할 정의
|
||||
| 시스템 | 담당 | 기능 | 통합 지점 |
|
||||
|--------|------|------|---------|
|
||||
| **더존(Douzone)** | 세무 처리 | 신고, 장부관리, 결산 | 데이터 동기화 |
|
||||
| **TaxBaik** | 고객 관리 | CRM, 계약, 수익 추적 | 고객 메타 정보 |
|
||||
|
||||
#### 중복 제거 원칙
|
||||
- ❌ 세무 장부 데이터는 더존에만 관리 (중복 금지)
|
||||
- ❌ 신고 자동화는 더존 API 활용 (TaxBaik은 상태만 추적)
|
||||
- ✅ 고객사 정보 (회사명, 담당자, 연락처) = TaxBaik 관리
|
||||
- ✅ 고객 계약 이력, CRM 활동 = TaxBaik 관리
|
||||
- ✅ 수익 추적, 인보이스 관리 = TaxBaik 관리
|
||||
|
||||
#### 더존과의 차별화 기능
|
||||
```
|
||||
더존(Douzone)의 강점 TaxBaik의 고유 기능
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ 신고 장부 자동화 │ │ 고객 수명주기 관리 │
|
||||
│ 세금 계산기 │ │ 계약/수익 추적 │
|
||||
│ 결산 보고서 │ │ 상담 활동 기록 │
|
||||
│ 세율/세법 업데이트 │ │ 다중 회사 관리 │
|
||||
│ 전자세금계산서 │ │ 마케팅 자동화 │
|
||||
└─────────────────────┘ │ 모바일 앱 │
|
||||
│ SEO 블로그 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
#### API 동기화 (향후)
|
||||
```
|
||||
더존(Douzone) API (엔터프라이즈)
|
||||
↓
|
||||
[고객별 신고 상태 조회]
|
||||
↓
|
||||
TaxBaik [상태 추적] → [CRM 분석]
|
||||
↓
|
||||
[수익 인식 자동화]
|
||||
```
|
||||
|
||||
#### 데이터 주인 원칙
|
||||
```
|
||||
고객사 정보
|
||||
├─ 더존 소유: 사업자등록번호, 기업명, 업종, 세무신고 이력
|
||||
├─ TaxBaik 소유: 컨택트 정보, 계약 내용, 상담 기록, 계약 상태
|
||||
└─ 동기화 필요: 회사 마스터ID
|
||||
|
||||
신고 일정
|
||||
├─ 더존 소유: 신고 유형, 세법 기한, 신고 마감일
|
||||
├─ TaxBaik 소유: 담당자 배정, 상담 노트, 처리 상태
|
||||
└─ 참고만: TaxBaik은 더존의 신고 기한을 읽기만 함 (역동기화 금지)
|
||||
```
|
||||
|
||||
#### 10.7 국세청(NTS) API 연동 전략
|
||||
|
||||
**목표**: 고객 편의성 향상 + 세무 업무 자동화 + 데이터 정확성 보증
|
||||
|
||||
#### 국세청 API가 필요한 이유
|
||||
|
||||
| 기능 | 현재 (수동) | 국세청 API (자동) | 고객 효과 |
|
||||
|------|-----------|------------|---------|
|
||||
| **사업자등록번호 검증** | 고객 입력 후 수동 확인 | 실시간 진위 확인 | 등록 즉시 검증 ✅ |
|
||||
| **신고 현황 조회** | 더존에서 확인 후 TaxBaik에 입력 | 국세청에서 직접 조회 | 신고 상태 자동 동기화 ✅ |
|
||||
| **납세 의무 확인** | 고객 자가 확인 | API로 자동 확인 | 맞춤형 상담 내용 생성 ✅ |
|
||||
| **세무조사 이력** | 고객 진술만 가능 | 공식 기록 조회 | 정확한 위험도 평가 ✅ |
|
||||
|
||||
#### 국세청 API 연동 기능 (우선순위)
|
||||
|
||||
**Level 1: 사업자등록번호 검증 (즉시 도입 가능)**
|
||||
```
|
||||
TaxBaik에 고객 사업자등록번호 입력 → 국세청 API 호출 → 진위 확인
|
||||
- API: 사업자등록번호 진위확인 조회 (National Tax Service OpenAPI)
|
||||
- 응답: 성명/사업장주소/업태 반환
|
||||
- 효과: 부정확한 정보 사전 차단
|
||||
- 비용: 월 5,000호 무료, 초과 시 호출당 1원
|
||||
```
|
||||
|
||||
**Level 2: 신고 현황 조회 (더존 연동 후)**
|
||||
```
|
||||
더존에서 신고 정보 → 국세청 API 검증 → TaxBaik 자동 갱신
|
||||
- API: 종합소득세 신고현황 조회 / 부가가치세 신고현황 조회
|
||||
- 연동 대상: 종소세, 부가세, 법인세
|
||||
- 효과: 신고일정 자동 생성, 미신고 고객 즉시 알림
|
||||
- 스케줄: 월 1회 배치 실행 (신고 기간 후)
|
||||
```
|
||||
|
||||
**Level 3: 납세 의무 확인 (고급)**
|
||||
```
|
||||
고객 사업자등록번호 → 국세청 조회 → 의무 사항 리스트
|
||||
- 자료제출 의무 (세무대리인)
|
||||
- 장부작성 의무 (복식부기 필수)
|
||||
- 부가가치세 업종별 특별공제 대상 여부
|
||||
- 효과: 맞춤형 상담 가이드 자동 생성
|
||||
```
|
||||
|
||||
**Level 4: 세무조사 이력 (전략)**
|
||||
```
|
||||
고객 사업자등록번호 → 국세청 조회 → 과거 3년 조사 이력
|
||||
- 효과: 고위험 고객 조기 발굴, 예방 상담 강화
|
||||
- 범위: 실명, 규모, 적발 사항 (부가세/소득세 구분)
|
||||
```
|
||||
|
||||
#### 국세청 API 도입 로드맵
|
||||
|
||||
| Phase | 기능 | 일정 | 영향 |
|
||||
|-------|------|------|------|
|
||||
| **1** | 사업자등록번호 검증 | 즉시 | 고객 데이터 품질 ↑ |
|
||||
| **2** | 더존 신고 현황 동기화 | Q3 | 자동 일정 생성, 미신고 알림 |
|
||||
| **3** | 납세 의무 자동 가이드 | Q4 | 상담 콘텐츠 자동화 |
|
||||
| **4** | 세무조사 위험도 평가 | 2027 | 예방 상담 강화 |
|
||||
|
||||
#### 필요한 준비물
|
||||
|
||||
**1. 국세청 오픈 API 신청**
|
||||
- https://www.nts.go.kr (공식 신청)
|
||||
- 또는 더존 엔터프라이즈 통해 간접 연동
|
||||
|
||||
**2. TaxBaik 구현**
|
||||
```csharp
|
||||
// NtsApiClient.cs
|
||||
public interface INtsApiClient
|
||||
{
|
||||
Task<BusinessRegistrationInfo> VerifyBusinessRegistrationAsync(string registrationNumber);
|
||||
Task<TaxFilingStatus> GetTaxFilingStatusAsync(string registrationNumber, int year);
|
||||
Task<TaxObligations> GetTaxObligationsAsync(string registrationNumber);
|
||||
Task<AuditHistory> GetAuditHistoryAsync(string registrationNumber);
|
||||
}
|
||||
|
||||
// 사용처: ClientService / TaxProfileService에 주입
|
||||
```
|
||||
|
||||
**3. 에러 처리**
|
||||
- API 호출 실패 → 로컬 검증으로 폴백
|
||||
- 네트워크 타임아웃 → 재시도 3회 + 캐시 사용
|
||||
- 국세청 점검 중 → 오프라인 모드 지원
|
||||
|
||||
#### 고객 편의성 향상 예시
|
||||
|
||||
**Before (수동 프로세스)**:
|
||||
1. 고객: 사업자등록번호 입력
|
||||
2. 세무사: 수동으로 국세청 사이트 접속
|
||||
3. 세무사: 신고 현황 수동 입력
|
||||
4. TaxBaik: 불일치 가능성 ❌
|
||||
|
||||
**After (자동화)**:
|
||||
1. 고객: 사업자등록번호 입력
|
||||
2. TaxBaik: 즉시 국세청 검증 ✅
|
||||
3. TaxBaik: 신고 일정 자동 생성 ✅
|
||||
4. TaxBaik: 미신고 알림 자동 발송 ✅
|
||||
5. 세무사: 데이터만 확인 (시간 절약 70%)
|
||||
|
||||
---
|
||||
|
||||
### 더존 통합 전략
|
||||
**현재 (수동 연동)**:
|
||||
- 더존에서 신고 일정 확인 → TaxBaik에 수동 입력
|
||||
- 안정적이나 수작업 많음
|
||||
|
||||
**향후 (자동 동기화)**:
|
||||
1. **더존 엔터프라이즈 API** 접근 (B2B 라이선스 필요)
|
||||
2. **Webhook** 수신: 신고 완료, 결산 마감 이벤트
|
||||
3. **일 1회 배치 폴링**: 신고 상태 자동 갱신
|
||||
4. **수익 인식 자동화**: 더존 계약금액 → TaxBaik 인보이스 생성
|
||||
|
||||
**구현 팁**:
|
||||
- 더존 API 사용 가능 시: webhook로 신고 완료 알림 수신
|
||||
- 불가능하면: 주기적 배치로 더존 상태 폴링 (일 1회)
|
||||
- TaxBaik에서 생성한 데이터는 절대 더존에 역동기화 금지
|
||||
- 더존 기존 고객도 TaxBaik CRM에 등록 (중복 허용, 통합 관리)
|
||||
|
||||
---
|
||||
|
||||
## 11. 배포 검증
|
||||
@@ -1096,6 +1693,208 @@ npx playwright test # CI에서 배포 후 자동 실행
|
||||
- ✅ 폼 필드 너비 (200px 이상)
|
||||
- ✅ 수평 오버플로우 없음 (모든 크기)
|
||||
|
||||
### 배포 중 사용자 경험 보호
|
||||
|
||||
**문제**: 배포 중 사용자가 관리 페이지에서 작업 중이면 강제 새로고침이 발생하여 미저장 데이터 손실
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
#### 1. 배포 알림 전략 (강제 새로고침 금지)
|
||||
```csharp
|
||||
// Program.cs - SignalR 배포 알림
|
||||
app.MapHub<NotificationHub>("/taxbaik/hub/notifications");
|
||||
|
||||
// NotificationHub.cs
|
||||
public async Task NotifyDeploymentStart()
|
||||
{
|
||||
// ❌ 강제 새로고침하지 않음
|
||||
// ✅ 대신 사용자에게 알림만 보냄
|
||||
await Clients.Group("admins").SendAsync("DeploymentNotification", new
|
||||
{
|
||||
Type = "DeploymentStart",
|
||||
Message = "새 버전이 배포되고 있습니다. 진행 중인 작업을 계속할 수 있습니다.",
|
||||
TimeoutSeconds = 60 // 사용자가 60초 후 수동으로 새로고침 가능
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 프론트엔드: 배포 알림 모달 (자동 새로고침 금지)
|
||||
```razor
|
||||
@* Components/Admin/Shared/DeploymentNotification.razor *@
|
||||
@if (showNotification)
|
||||
{
|
||||
<MudDialog @bind-Visible="showNotification">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 버전 배포</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText>새 버전이 배포되고 있습니다. 진행 중인 작업을 계속할 수 있습니다.</MudText>
|
||||
<MudText Typo="Typo.caption" Class="mt-4">
|
||||
업데이트: <strong>@countdown</strong>초 후 새로고침 (또는 수동으로 새로고침)
|
||||
</MudText>
|
||||
<MudLinearProgressIndeterminate />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Color="Color.Primary" OnClick="RefreshNow">지금 새로고침</MudButton>
|
||||
<MudButton Color="Color.Default" OnClick="DismissNotification">나중에</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool showNotification = false;
|
||||
private int countdown = 60;
|
||||
private HubConnection? hubConnection;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl("/taxbaik/hub/notifications", options =>
|
||||
options.AccessTokenProvider = async () =>
|
||||
await LocalStorage.GetItemAsStringAsync("authToken") ?? "")
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
hubConnection.On<dynamic>("DeploymentNotification", async (notification) =>
|
||||
{
|
||||
showNotification = true;
|
||||
// 사용자가 "나중에" 누르지 않으면 60초 후 자동 새로고침
|
||||
await Task.Delay(TimeSpan.FromSeconds(60));
|
||||
if (showNotification)
|
||||
RefreshNow();
|
||||
});
|
||||
|
||||
await hubConnection.StartAsync();
|
||||
}
|
||||
|
||||
private void RefreshNow() => NavigationManager.NavigateTo(NavigationManager.Uri, true);
|
||||
|
||||
private void DismissNotification()
|
||||
{
|
||||
showNotification = false;
|
||||
countdown = 0;
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (hubConnection is not null)
|
||||
await hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. CI/CD 배포 알림 (server-sent events 대신 SignalR)
|
||||
```yaml
|
||||
# .gitea/workflows/deploy.yml
|
||||
- name: Notify deployment start
|
||||
run: |
|
||||
curl -X POST "http://127.0.0.1:5001/taxbaik/api/admin/deployment-start" \
|
||||
-H "Authorization: Bearer ${{ env.INTERNAL_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"New version deploying..."}'
|
||||
```
|
||||
|
||||
#### 4. 사용자 상태 보호 (데이터 손실 방지)
|
||||
- ✅ 폼 데이터를 `sessionStorage`에 자동 저장 (변경 감지 시)
|
||||
- ✅ 페이지 이탈 시 경고 (unsaved changes)
|
||||
- ✅ 강제 새로고침 후 복구 옵션 제공
|
||||
|
||||
```csharp
|
||||
// 폼 자동 저장 (선택적)
|
||||
public class AutoSaveService
|
||||
{
|
||||
private readonly IJSRuntime js;
|
||||
|
||||
public async Task SaveFormAsync<T>(string key, T data)
|
||||
{
|
||||
await js.InvokeVoidAsync("sessionStorage.setItem", key,
|
||||
System.Text.Json.JsonSerializer.Serialize(data));
|
||||
}
|
||||
|
||||
public async Task<T?> RestoreFormAsync<T>(string key)
|
||||
{
|
||||
var json = await js.InvokeAsync<string>("sessionStorage.getItem", key);
|
||||
return json == null ? default :
|
||||
System.Text.Json.JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 배포 상태 확인 엔드포인트
|
||||
```csharp
|
||||
// Controllers/DeploymentController.cs
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public class DeploymentController : ControllerBase
|
||||
{
|
||||
[HttpPost("deployment-start")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> NotifyDeploymentStart(
|
||||
[FromServices] IHubContext<NotificationHub> hubContext)
|
||||
{
|
||||
await hubContext.Clients.Group("admins").SendAsync(
|
||||
"DeploymentNotification", new
|
||||
{
|
||||
Type = "DeploymentStart",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return Ok(new { message = "배포 알림 전송됨" });
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public IActionResult GetDeploymentStatus() =>
|
||||
Ok(new { Status = "Running", Version = "2026-06-28" });
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 원칙**:
|
||||
- 배포 중 강제 새로고침 절대 금지 ❌
|
||||
- 사용자에게 알림만 보내고 수동 새로고침 제공 ✅
|
||||
- 폼 데이터는 세션 저장소에 자동 보존 ✅
|
||||
- 강제 새로고침 후 복구 옵션 제공 ✅
|
||||
|
||||
### Telegram 배포 알림 설정 (System Chat)
|
||||
|
||||
**배포 완료 메시지는 System Chat ID로만 전송**:
|
||||
|
||||
```bash
|
||||
# .gitea/workflows/deploy.yml
|
||||
- name: Notify deployment success
|
||||
if: success()
|
||||
run: |
|
||||
DEPLOYMENT_TIME=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||||
curl -s -X POST https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage \
|
||||
-d chat_id=-5585148480 \
|
||||
-d text="✅ 배포 완료%0A%0A환경: Production%0A상태: 정상 운영 중%0A%0A${DEPLOYMENT_TIME}" \
|
||||
-d parse_mode=HTML
|
||||
```
|
||||
|
||||
**메시지 라우팅 정책**:
|
||||
| 알림 유형 | Chat ID | 목적 |
|
||||
|---------|---------|------|
|
||||
| 배포 완료 | -5585148480 (System) | CI/CD 파이프라인 모니터링 |
|
||||
| 배포 실패 | -5585148480 (System) | 긴급 대응 |
|
||||
| 문의 접수 | -5434691215 (Inquiry) | 고객 상담 |
|
||||
| 로그인 알림 | 보내지 않음 | 스팸 방지 |
|
||||
|
||||
**구현**:
|
||||
```csharp
|
||||
// CI/CD 배포 단계에서
|
||||
if (deploymentSucceeded)
|
||||
{
|
||||
await telegramService.SendSystemNotificationAsync(
|
||||
$"✅ 배포 완료\n\n환경: Production\n상태: 정상 운영 중\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
}
|
||||
else
|
||||
{
|
||||
await telegramService.SendSystemNotificationAsync(
|
||||
$"❌ 배포 실패\n\n환경: Production\n오류: {errorMessage}\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CI/CD 파이프라인 최적화 (2026-06-28)
|
||||
|
||||
**목표**: 전체 배포 시간을 최소화하고 명확한 Timeout 설정
|
||||
@@ -1132,6 +1931,48 @@ npx playwright test # CI에서 배포 후 자동 실행
|
||||
|
||||
---
|
||||
|
||||
### CI Deploy 트러블슈팅 하네스 (2026-06-28)
|
||||
|
||||
커밋 후 배포가 동작하지 않는다고 판단하기 전에 아래 순서로 확인한다. 추측으로 runner, secret, 커밋 제목을 원인으로 단정하지 않는다.
|
||||
|
||||
1. **푸시 결과 확인**
|
||||
```powershell
|
||||
git push origin master 2>&1 | Select-String "master|To|Processed|remote"
|
||||
```
|
||||
`master -> master`가 보이면 Git push는 성공이다. 이 단계는 CI 실행 성공을 의미하지 않는다.
|
||||
|
||||
2. **Actions run 생성 확인**
|
||||
```powershell
|
||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
|
||||
$runs = Invoke-RestMethod -Headers $headers -Uri "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
|
||||
$runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
|
||||
```
|
||||
`deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.
|
||||
|
||||
3. **workflow 파싱 검증**
|
||||
```powershell
|
||||
curl.exe -sS -w "`nHTTP_STATUS:%{http_code}`n" `
|
||||
-H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
|
||||
-H "Content-Type: application/json" `
|
||||
-X POST "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/workflows/deploy.yml/dispatches?return_run_details=true" `
|
||||
--data '{"ref":"refs/heads/master","inputs":{}}'
|
||||
```
|
||||
`failed to unmarshal workflow content`가 나오면 `.gitea/workflows/deploy.yml` YAML 문법 문제다. 여러 줄 문자열은 반드시 `run: |` 블록 들여쓰기 안에 둔다.
|
||||
|
||||
4. **job 실패 로그 확인**
|
||||
```powershell
|
||||
curl.exe -sS -H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
|
||||
"http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/jobs/{job_id}/logs"
|
||||
```
|
||||
빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다.
|
||||
|
||||
**이번 장애 원인 기록**:
|
||||
- `deploy.yml`의 Telegram 여러 줄 메시지 일부가 YAML 블록 들여쓰기 밖에 있어 Gitea workflow 파서가 실패했다.
|
||||
- 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다.
|
||||
- 외부 OAuth provider는 ClientId/ClientSecret이 모두 있을 때만 등록한다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 문제 해결
|
||||
|
||||
| 문제 | 해결 |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
||||
|
||||
CI deploy trigger verification note.
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
+16
-16
@@ -425,9 +425,9 @@ Todo:
|
||||
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||
|
||||
Todo:
|
||||
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [ ] 일간/주간 리포트 메시지 템플릿
|
||||
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
||||
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [x] 일간/주간 리포트 메시지 템플릿
|
||||
- [x] TelegramNotificationService에 리포트 메서드 추가
|
||||
|
||||
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||
|
||||
@@ -439,9 +439,9 @@ Todo:
|
||||
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||
|
||||
Todo:
|
||||
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [ ] 고객 전용 Razor Pages 추가
|
||||
- [ ] 세무사 허용 권한 설정 UI
|
||||
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [x] 고객 전용 Razor Pages 추가
|
||||
- [x] 세무사 허용 권한 설정 UI
|
||||
|
||||
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||
|
||||
@@ -485,16 +485,16 @@ DB 스키마:
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
Todo:
|
||||
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
||||
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [ ] 네이버 OAuth Handler 구현
|
||||
- [ ] 카카오·구글 패키지 추가 및 설정
|
||||
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [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`) — 소셜 버튼 + 이메일 폼
|
||||
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||
|
||||
|
||||
@@ -87,6 +87,14 @@ public class InquiryServiceTests
|
||||
inquiry.ClientId = clientId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
if (inquiry != null)
|
||||
Inquiries.Remove(inquiry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeInquiryNotificationService : IInquiryNotificationService
|
||||
|
||||
@@ -19,6 +19,14 @@ public static class DependencyInjection
|
||||
services.AddScoped<FaqService>();
|
||||
services.AddScoped<ConsultationService>();
|
||||
services.AddScoped<TaxFilingService>();
|
||||
services.AddScoped<CompanyService>();
|
||||
services.AddScoped<TaxProfileService>();
|
||||
services.AddScoped<TaxFilingScheduleService>();
|
||||
services.AddScoped<ConsultingActivityService>();
|
||||
services.AddScoped<ContractService>();
|
||||
services.AddScoped<RevenueTrackingService>();
|
||||
services.AddScoped<TelegramReportService>();
|
||||
services.AddScoped<PortalUserService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ public class ClientService(IClientRepository repository)
|
||||
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||
await repository.GetByEmailAsync(email, ct);
|
||||
|
||||
public async Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default) =>
|
||||
await repository.GetByPhoneAsync(phone, ct);
|
||||
|
||||
public async Task<int> CountCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default) =>
|
||||
await repository.CountByCreatedAtRangeAsync(startDateUtc, endDateUtc, ct);
|
||||
|
||||
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CompanyService(ICompanyRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(string companyCode, string companyName, string? contactPerson = null,
|
||||
string? phone = null, string? email = null, string? memo = null, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyCode))
|
||||
throw new ValidationException("회사 코드를 입력하세요.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
throw new ValidationException("회사명을 입력하세요.");
|
||||
|
||||
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||
if (existing != null)
|
||||
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||
|
||||
var company = new Company
|
||||
{
|
||||
CompanyCode = companyCode.Trim(),
|
||||
CompanyName = companyName.Trim(),
|
||||
ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim(),
|
||||
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim(),
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(company, ct);
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<Company?> GetByCodeAsync(string code, CancellationToken ct = default) =>
|
||||
await repository.GetByCodeAsync(code, ct);
|
||||
|
||||
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllActiveAsync(ct);
|
||||
|
||||
public async Task<(IEnumerable<Company>, int)> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var (items, total) = await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, string companyCode, string companyName, string? contactPerson = null,
|
||||
string? phone = null, string? email = null, string? memo = null, bool isActive = true, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyCode))
|
||||
throw new ValidationException("회사 코드를 입력하세요.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
throw new ValidationException("회사명을 입력하세요.");
|
||||
|
||||
var company = await repository.GetByIdAsync(id, ct);
|
||||
if (company == null)
|
||||
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||
|
||||
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||
if (existing != null && existing.Id != id)
|
||||
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||
|
||||
company.CompanyCode = companyCode.Trim();
|
||||
company.CompanyName = companyName.Trim();
|
||||
company.ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim();
|
||||
company.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
|
||||
company.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
|
||||
company.Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim();
|
||||
company.IsActive = isActive;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await repository.UpdateAsync(company, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var company = await repository.GetByIdAsync(id, ct);
|
||||
if (company == null)
|
||||
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||
|
||||
if (company.CompanyCode == "DEFAULT")
|
||||
throw new ValidationException("기본 회사는 삭제할 수 없습니다.");
|
||||
|
||||
await repository.DeleteAsync(id, ct);
|
||||
}
|
||||
|
||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ConsultingActivityService(IConsultingActivityRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate,
|
||||
string description, int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(activityType))
|
||||
throw new ValidationException("활동 유형을 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
throw new ValidationException("활동 내용을 입력하세요.");
|
||||
|
||||
var activity = new ConsultingActivity
|
||||
{
|
||||
ClientId = clientId,
|
||||
ActivityType = activityType.Trim(),
|
||||
ActivityDate = activityDate,
|
||||
Description = description.Trim(),
|
||||
AssignedConsultantId = consultantId,
|
||||
NextFollowupDate = nextFollowupDate,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(activity, ct);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetConsultantActivityAsync(int consultantId, DateTime fromDate, CancellationToken ct = default) =>
|
||||
await repository.GetByConsultantAsync(consultantId, fromDate, ct);
|
||||
|
||||
public async Task UpdateAsync(int id, string? outcome, DateTime? nextFollowupDate, CancellationToken ct = default)
|
||||
{
|
||||
var activity = new ConsultingActivity { Id = id, Outcome = outcome, NextFollowupDate = nextFollowupDate, UpdatedAt = DateTime.UtcNow };
|
||||
await repository.UpdateAsync(activity, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ContractService(IContractRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType,
|
||||
DateTime startDate, decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(contractNumber))
|
||||
throw new ValidationException("계약 번호를 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(serviceType))
|
||||
throw new ValidationException("서비스 유형을 입력하세요.");
|
||||
|
||||
var contract = new Contract
|
||||
{
|
||||
ClientId = clientId,
|
||||
ContractNumber = contractNumber.Trim(),
|
||||
ServiceType = serviceType.Trim(),
|
||||
ContractDate = DateTime.Today,
|
||||
StartDate = startDate,
|
||||
MonthlyFee = monthlyFee,
|
||||
TotalAmount = totalAmount,
|
||||
Status = "active",
|
||||
PaymentStatus = "pending",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(contract, ct);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetActiveContractsAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||
await repository.GetExpiringContractsAsync(daysAhead, ct);
|
||||
|
||||
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default) =>
|
||||
await repository.GetMonthlyRecurringRevenueAsync(ct);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class InquiryService(
|
||||
|
||||
public async Task<int> SubmitAsync(
|
||||
string name, string phone, string serviceType, string message,
|
||||
string? email = null, string? ipAddress = null, CancellationToken ct = default)
|
||||
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ValidationException("이름을 입력하세요.");
|
||||
@@ -39,7 +39,10 @@ public class InquiryService(
|
||||
};
|
||||
|
||||
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
||||
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
||||
if (!suppressNotification)
|
||||
{
|
||||
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
||||
}
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
return inquiryId;
|
||||
}
|
||||
@@ -89,6 +92,12 @@ public class InquiryService(
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
await repository.DeleteAsync(id, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||
|
||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class PortalUserService(IPortalUserRepository repository)
|
||||
{
|
||||
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||
await repository.GetByEmailAsync(email.Trim(), ct);
|
||||
|
||||
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default) =>
|
||||
await repository.GetByProviderAsync(provider.Trim(), providerId.Trim(), ct);
|
||||
|
||||
public async Task<int> RegisterLocalAsync(string name, string email, string phone, string? passwordHash, int? clientId = null, CancellationToken ct = default) =>
|
||||
await RegisterAsync(name, email, phone, "local", null, passwordHash, clientId, ct);
|
||||
|
||||
public async Task<int> RegisterOAuthAsync(string name, string email, string? phone, string provider, string providerId, int? clientId = null, CancellationToken ct = default) =>
|
||||
await RegisterAsync(name, email, phone, provider, providerId, null, clientId, ct);
|
||||
|
||||
public async Task LinkOAuthAsync(PortalUser user, string provider, string providerId, string? displayName = null, string? email = null, CancellationToken ct = default)
|
||||
{
|
||||
user.Name = string.IsNullOrWhiteSpace(displayName) ? user.Name : displayName.Trim();
|
||||
user.Email = string.IsNullOrWhiteSpace(email) ? user.Email : email.Trim();
|
||||
if (string.IsNullOrWhiteSpace(user.PasswordHash))
|
||||
{
|
||||
user.Provider = provider.Trim();
|
||||
user.ProviderId = providerId.Trim();
|
||||
}
|
||||
await repository.UpdateAsync(user, ct);
|
||||
}
|
||||
|
||||
public async Task AttachClientAsync(PortalUser user, int clientId, CancellationToken ct = default)
|
||||
{
|
||||
user.ClientId = clientId;
|
||||
await repository.UpdateAsync(user, ct);
|
||||
}
|
||||
|
||||
private async Task<int> RegisterAsync(string name, string email, string? phone, string provider, string? providerId, string? passwordHash, int? clientId, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ValidationException("이름을 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
throw new ValidationException("이메일을 입력하세요.");
|
||||
|
||||
var user = new PortalUser
|
||||
{
|
||||
ClientId = clientId,
|
||||
Name = name.Trim(),
|
||||
Email = email.Trim(),
|
||||
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
|
||||
Provider = provider,
|
||||
ProviderId = providerId,
|
||||
PasswordHash = passwordHash,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(user, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate,
|
||||
decimal amount, string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||
throw new ValidationException("인보이스 번호를 입력하세요.");
|
||||
if (amount <= 0)
|
||||
throw new ValidationException("금액은 0보다 커야 합니다.");
|
||||
|
||||
var revenue = new RevenueTracking
|
||||
{
|
||||
ClientId = clientId,
|
||||
InvoiceNumber = invoiceNumber.Trim(),
|
||||
InvoiceDate = invoiceDate,
|
||||
Amount = amount,
|
||||
ServiceType = string.IsNullOrWhiteSpace(serviceType) ? null : serviceType.Trim(),
|
||||
DueDate = dueDate,
|
||||
PaymentStatus = "pending",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(revenue, ct);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetMonthlyRevenueAsync(DateTime month, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = new DateTime(month.Year, month.Month, 1);
|
||||
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||
return await repository.GetByDateRangeAsync(startDate, endDate, ct);
|
||||
}
|
||||
|
||||
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default) =>
|
||||
await repository.MarkPaidAsync(id, paymentDate, ct);
|
||||
|
||||
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default) =>
|
||||
await repository.GetTotalRevenueAsync(startDate, endDate, ct);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||
int? assignedToId = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(filingType))
|
||||
throw new ValidationException("신고 유형을 입력하세요.");
|
||||
if (dueDate < DateTime.Today)
|
||||
throw new ValidationException("마감일은 오늘 이후여야 합니다.");
|
||||
|
||||
var schedule = new TaxFilingSchedule
|
||||
{
|
||||
ClientId = clientId,
|
||||
FilingType = filingType.Trim(),
|
||||
DueDate = dueDate,
|
||||
FilingYear = filingYear,
|
||||
Status = "pending",
|
||||
AssignedToId = assignedToId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(schedule, ct);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||
await repository.GetUpcomingDuesAsync(daysAhead, ct);
|
||||
|
||||
public async Task MarkCompletedAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.MarkCompletedAsync(id, ct);
|
||||
|
||||
public async Task<int> GetPendingCountAsync(CancellationToken ct = default)
|
||||
{
|
||||
var pending = await repository.GetByStatusAsync("pending", ct);
|
||||
return pending.Count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxProfileService(ITaxProfileRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(int clientId, string? businessType, string? businessRegistration = null,
|
||||
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
|
||||
{
|
||||
if (clientId <= 0)
|
||||
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(businessType))
|
||||
throw new ValidationException("사업 유형을 입력하세요.");
|
||||
|
||||
var profile = new TaxProfile
|
||||
{
|
||||
ClientId = clientId,
|
||||
BusinessType = businessType.Trim(),
|
||||
BusinessRegistration = string.IsNullOrWhiteSpace(businessRegistration) ? null : businessRegistration.Trim(),
|
||||
EstablishmentDate = establishmentDate,
|
||||
AccountingMethod = string.IsNullOrWhiteSpace(accountingMethod) ? null : accountingMethod.Trim(),
|
||||
TaxRiskLevel = "normal",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(profile, ct);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var profile = new TaxProfile { Id = profileId };
|
||||
if (!string.IsNullOrWhiteSpace(businessType))
|
||||
profile.BusinessType = businessType.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
||||
profile.AccountingMethod = accountingMethod.Trim();
|
||||
profile.NextFilingDueDate = nextFilingDueDate;
|
||||
profile.TaxRiskLevel = taxRiskLevel;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await repository.UpdateAsync(profile, ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default) =>
|
||||
await repository.GetByRiskLevelAsync("high", ct);
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = DateTime.Today;
|
||||
var endDate = startDate.AddDays(daysAhead);
|
||||
return await repository.GetUpcomingFilingDuesAsync(startDate, endDate, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public record TelegramDailyReport(
|
||||
DateOnly Date,
|
||||
int NewInquiries,
|
||||
int PendingInquiries,
|
||||
int NewClients,
|
||||
int PendingTaxFilings,
|
||||
int PendingPayments);
|
||||
|
||||
public record TelegramWeeklyReport(
|
||||
DateOnly WeekStart,
|
||||
DateOnly WeekEnd,
|
||||
int NewInquiries,
|
||||
int NewClients,
|
||||
int UpcomingTaxFilings,
|
||||
decimal RevenueThisWeek);
|
||||
|
||||
public class TelegramReportService(
|
||||
InquiryService inquiryService,
|
||||
ClientService clientService,
|
||||
TaxFilingScheduleService taxFilingScheduleService,
|
||||
RevenueTrackingService revenueTrackingService)
|
||||
{
|
||||
public async Task<TelegramDailyReport> BuildDailyReportAsync(DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var start = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var end = date.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||
|
||||
return new TelegramDailyReport(
|
||||
Date: date,
|
||||
NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
|
||||
PendingInquiries: await inquiryService.CountByStatusAsync("new", ct),
|
||||
NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
|
||||
PendingTaxFilings: await taxFilingScheduleService.GetPendingCountAsync(ct),
|
||||
PendingPayments: (await revenueTrackingService.GetPendingPaymentsAsync(ct)).Count());
|
||||
}
|
||||
|
||||
public async Task<TelegramWeeklyReport> BuildWeeklyReportAsync(DateOnly weekStart, CancellationToken ct = default)
|
||||
{
|
||||
var weekEnd = weekStart.AddDays(6);
|
||||
var start = weekStart.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var end = weekEnd.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||
var upcomingEnd = weekEnd.AddDays(7).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||
|
||||
var revenue = await revenueTrackingService.GetTotalRevenueAsync(start, end, ct);
|
||||
|
||||
return new TelegramWeeklyReport(
|
||||
WeekStart: weekStart,
|
||||
WeekEnd: weekEnd,
|
||||
NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
|
||||
NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
|
||||
UpcomingTaxFilings: (await taxFilingScheduleService.GetUpcomingDuesAsync(14, ct))
|
||||
.Count(x => x.DueDate >= start && x.DueDate <= upcomingEnd),
|
||||
RevenueThisWeek: revenue);
|
||||
}
|
||||
|
||||
public static string FormatDailyMessage(TelegramDailyReport report) =>
|
||||
$"<b>📊 일간 리포트</b>\n\n" +
|
||||
$"기준일: <code>{report.Date:yyyy-MM-dd}</code>\n" +
|
||||
$"신규 문의: <code>{report.NewInquiries}</code>\n" +
|
||||
$"처리 대기 문의: <code>{report.PendingInquiries}</code>\n" +
|
||||
$"신규 고객: <code>{report.NewClients}</code>\n" +
|
||||
$"신고 대기: <code>{report.PendingTaxFilings}</code>\n" +
|
||||
$"미수 청구: <code>{report.PendingPayments}</code>";
|
||||
|
||||
public static string FormatWeeklyMessage(TelegramWeeklyReport report) =>
|
||||
$"<b>📈 주간 리포트</b>\n\n" +
|
||||
$"기간: <code>{report.WeekStart:yyyy-MM-dd}</code> ~ <code>{report.WeekEnd:yyyy-MM-dd}</code>\n" +
|
||||
$"신규 문의: <code>{report.NewInquiries}</code>\n" +
|
||||
$"신규 고객: <code>{report.NewClients}</code>\n" +
|
||||
$"다가오는 신고: <code>{report.UpcomingTaxFilings}</code>\n" +
|
||||
$"주간 매출: <code>₩{report.RevenueThisWeek:N0}</code>";
|
||||
}
|
||||
@@ -3,15 +3,28 @@ namespace TaxBaik.Domain.Entities;
|
||||
public class Client
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public int? CompanyId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? CompanyName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? ContactPerson { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public string? TaxType { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Source { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
// Tax-specific fields
|
||||
public string? BusinessRegistrationNumber { get; set; }
|
||||
public string? BusinessType { get; set; }
|
||||
public DateTime? EstablishmentDate { get; set; }
|
||||
public string? AnnualRevenueRange { get; set; }
|
||||
public int? EmployeeCount { get; set; }
|
||||
public DateTime? LastTaxFilingDate { get; set; }
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Company
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string CompanyCode { get; set; } = "";
|
||||
public string CompanyName { get; set; } = "";
|
||||
public string? ContactPerson { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class ConsultingActivity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string ActivityType { get; set; } = "";
|
||||
public DateTime ActivityDate { get; set; }
|
||||
public TimeOnly? ActivityTime { get; set; }
|
||||
public int? AssignedConsultantId { get; set; }
|
||||
public string Description { get; set; } = "";
|
||||
public string? Outcome { get; set; }
|
||||
public DateTime? NextFollowupDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Contract
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string ContractNumber { get; set; } = "";
|
||||
public string ServiceType { get; set; } = "";
|
||||
public DateTime ContractDate { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public decimal? MonthlyFee { get; set; }
|
||||
public decimal? TotalAmount { get; set; }
|
||||
public string PaymentStatus { get; set; } = "pending";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class PortalUser
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? ClientId { get; set; }
|
||||
public string Email { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string? Phone { get; set; }
|
||||
public string Provider { get; set; } = "local";
|
||||
public string? ProviderId { get; set; }
|
||||
public string? PasswordHash { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class RevenueTracking
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string InvoiceNumber { get; set; } = "";
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentStatus { get; set; } = "pending";
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class TaxFilingSchedule
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string FilingType { get; set; } = "";
|
||||
public DateTime DueDate { get; set; }
|
||||
public int FilingYear { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public int? AssignedToId { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class TaxProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string? BusinessRegistration { get; set; }
|
||||
public string? BusinessType { get; set; }
|
||||
public DateTime? EstablishmentDate { get; set; }
|
||||
public string? AnnualRevenueRange { get; set; }
|
||||
public int? EmployeeCount { get; set; }
|
||||
public string? AccountingMethod { get; set; }
|
||||
public string? FiscalYearEnd { get; set; }
|
||||
public DateTime? LastFilingDate { get; set; }
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public bool PreviousAuditHistory { get; set; }
|
||||
public string? SpecialNotes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -8,6 +8,9 @@ public interface IClientRepository
|
||||
int page, int pageSize, string? status = null, string? search = null,
|
||||
CancellationToken ct = default);
|
||||
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default);
|
||||
Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default);
|
||||
Task<int> CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(Client client, CancellationToken ct = default);
|
||||
Task UpdateAsync(Client client, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ICompanyRepository
|
||||
{
|
||||
Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default);
|
||||
Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Company company, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
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);
|
||||
Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
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);
|
||||
Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,4 +16,5 @@ public interface IInquiryRepository
|
||||
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
||||
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
|
||||
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IPortalUserRepository
|
||||
{
|
||||
Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default);
|
||||
Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(PortalUser user, CancellationToken ct = default);
|
||||
Task UpdateAsync(PortalUser user, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
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);
|
||||
Task UpdateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken cancellationToken = default);
|
||||
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
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);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetByStatusAsync(string status, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||
Task MarkCompletedAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
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);
|
||||
Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -19,7 +19,14 @@ public static class DependencyInjection
|
||||
services.AddScoped<IClientRepository, ClientRepository>();
|
||||
services.AddScoped<IFaqRepository, FaqRepository>();
|
||||
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
||||
services.AddScoped<IPortalUserRepository, PortalUserRepository>();
|
||||
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||
services.AddScoped<ITaxProfileRepository, TaxProfileRepository>();
|
||||
services.AddScoped<ITaxFilingScheduleRepository, TaxFilingScheduleRepository>();
|
||||
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
||||
services.AddScoped<IContractRepository, ContractRepository>();
|
||||
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,33 @@ public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepo
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||
$"SELECT {SelectColumns} FROM clients WHERE email = @Email",
|
||||
new { Email = email });
|
||||
}
|
||||
|
||||
public async Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||
$"SELECT {SelectColumns} FROM clients WHERE phone = @Phone",
|
||||
new { Phone = phone });
|
||||
}
|
||||
|
||||
public async Task<int> CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
@"SELECT COUNT(*)
|
||||
FROM clients
|
||||
WHERE created_at >= @StartDateUtc
|
||||
AND created_at <= @EndDateUtc",
|
||||
new { StartDateUtc = startDateUtc, EndDateUtc = endDateUtc });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Client client, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CompanyRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICompanyRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO companies (company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at)
|
||||
VALUES (@CompanyCode, @CompanyName, @ContactPerson, @Phone, @Email, @Memo, @IsActive, NOW(), NOW())
|
||||
RETURNING id",
|
||||
company);
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE company_code = @Code",
|
||||
new { Code = code });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE is_active = TRUE ORDER BY company_name");
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies
|
||||
ORDER BY company_name
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM companies;",
|
||||
new { PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<Company>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Company company, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE companies
|
||||
SET company_code = @CompanyCode, company_name = @CompanyName,
|
||||
contact_person = @ContactPerson, phone = @Phone, email = @Email,
|
||||
memo = @Memo, is_active = @IsActive, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
company);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM companies WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IConsultingActivityRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO consulting_activities (client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @ActivityType, @ActivityDate, @ActivityTime, @AssignedConsultantId, @Description, @Outcome, @NextFollowupDate, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
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();
|
||||
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 WHERE client_id = @ClientId ORDER BY activity_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(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 WHERE next_followup_date IS NOT NULL AND next_followup_date <= CURRENT_DATE
|
||||
ORDER BY next_followup_date ASC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, 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 WHERE assigned_consultant = @ConsultantId AND activity_date >= @FromDate
|
||||
ORDER BY activity_date DESC",
|
||||
new { ConsultantId = consultantId, FromDate = fromDate });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE consulting_activities SET activity_type = @ActivityType, activity_date = @ActivityDate,
|
||||
activity_time = @ActivityTime, assigned_consultant = @AssignedConsultantId, description = @Description,
|
||||
outcome = @Outcome, next_followup_date = @NextFollowupDate, notes = @Notes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
activity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IContractRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO contracts (client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @ContractNumber, @ServiceType, @ContractDate, @StartDate, @EndDate, @MonthlyFee, @TotalAmount, @PaymentStatus, @Status, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
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();
|
||||
return await conn.QueryFirstOrDefaultAsync<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 WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, 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 WHERE client_id = @ClientId ORDER BY contract_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetActiveContractsAsync(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 WHERE status = 'active' ORDER BY client_id");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, 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
|
||||
WHERE status = 'active' AND end_date IS NOT NULL AND end_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead
|
||||
ORDER BY end_date ASC",
|
||||
new { DaysAhead = daysAhead });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE contracts SET contract_number = @ContractNumber, service_type = @ServiceType, contract_date = @ContractDate,
|
||||
start_date = @StartDate, end_date = @EndDate, monthly_fee = @MonthlyFee, total_amount = @TotalAmount,
|
||||
payment_status = @PaymentStatus, status = @Status, notes = @Notes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
contract);
|
||||
}
|
||||
|
||||
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var result = await conn.QueryFirstAsync<decimal>(
|
||||
@"SELECT COALESCE(SUM(monthly_fee), 0) FROM contracts WHERE status = 'active' AND monthly_fee IS NOT NULL");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -119,4 +119,10 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
||||
"UPDATE inquiries SET client_id = @ClientId, updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = inquiryId, ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM inquiries WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class PortalUserRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IPortalUserRepository
|
||||
{
|
||||
public async Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||
FROM portal_users
|
||||
WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||
FROM portal_users
|
||||
WHERE email = @Email",
|
||||
new { Email = email });
|
||||
}
|
||||
|
||||
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||
FROM portal_users
|
||||
WHERE provider = @Provider AND provider_id = @ProviderId",
|
||||
new { Provider = provider, ProviderId = providerId });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(PortalUser user, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO portal_users (client_id, email, name, phone, provider, provider_id, password_hash, created_at)
|
||||
VALUES (@ClientId, @Email, @Name, @Phone, @Provider, @ProviderId, @PasswordHash, NOW())
|
||||
RETURNING id",
|
||||
user);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(PortalUser user, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE portal_users
|
||||
SET client_id = @ClientId,
|
||||
email = @Email,
|
||||
name = @Name,
|
||||
phone = @Phone,
|
||||
provider = @Provider,
|
||||
provider_id = @ProviderId,
|
||||
password_hash = @PasswordHash
|
||||
WHERE id = @Id",
|
||||
user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IRevenueTrackingRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO revenue_tracking (client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @InvoiceNumber, @InvoiceDate, @ServiceType, @Amount, @PaymentStatus, @PaymentDate, @DueDate, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
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();
|
||||
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 WHERE client_id = @ClientId ORDER BY invoice_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(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 WHERE payment_status = 'pending' ORDER BY due_date ASC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, 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 WHERE invoice_date BETWEEN @StartDate AND @EndDate ORDER BY invoice_date DESC",
|
||||
new { StartDate = startDate, EndDate = endDate });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE revenue_tracking SET invoice_number = @InvoiceNumber, invoice_date = @InvoiceDate,
|
||||
service_type = @ServiceType, amount = @Amount, payment_status = @PaymentStatus,
|
||||
payment_date = @PaymentDate, due_date = @DueDate, notes = @Notes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
revenue);
|
||||
}
|
||||
|
||||
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE revenue_tracking SET payment_status = 'paid', payment_date = @PaymentDate, updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = id, PaymentDate = paymentDate });
|
||||
}
|
||||
|
||||
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var result = await conn.QueryFirstAsync<decimal>(
|
||||
@"SELECT COALESCE(SUM(amount), 0) FROM revenue_tracking WHERE invoice_date BETWEEN @StartDate AND @EndDate",
|
||||
new { StartDate = startDate, EndDate = endDate });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxFilingScheduleRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO tax_filing_schedules (client_id, filing_type, due_date, filing_year, status, assigned_to, notes, created_at, updated_at)
|
||||
VALUES (@ClientId, @FilingType, @DueDate, @FilingYear, @Status, @AssignedToId, @Notes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
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();
|
||||
return await conn.QueryFirstOrDefaultAsync<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 WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, 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 WHERE client_id = @ClientId ORDER BY due_date DESC",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, 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
|
||||
WHERE status = 'pending' AND due_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead
|
||||
ORDER BY due_date ASC",
|
||||
new { DaysAhead = daysAhead });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetByStatusAsync(string status, 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 WHERE status = @Status ORDER BY due_date",
|
||||
new { Status = status });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE tax_filing_schedules SET filing_type = @FilingType, due_date = @DueDate, status = @Status,
|
||||
assigned_to = @AssignedToId, notes = @Notes, updated_at = NOW() WHERE id = @Id",
|
||||
schedule);
|
||||
}
|
||||
|
||||
public async Task MarkCompletedAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE tax_filing_schedules SET status = 'completed', completed_date = NOW(), updated_at = NOW() WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxProfileRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO tax_profiles (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)
|
||||
VALUES (@ClientId, @BusinessRegistration, @BusinessType, @EstablishmentDate, @AnnualRevenueRange,
|
||||
@EmployeeCount, @AccountingMethod, @FiscalYearEnd, @LastFilingDate, @NextFilingDueDate,
|
||||
@TaxRiskLevel, @PreviousAuditHistory, @SpecialNotes, NOW(), NOW())
|
||||
RETURNING id",
|
||||
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();
|
||||
return await conn.QueryFirstOrDefaultAsync<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 WHERE client_id = @ClientId",
|
||||
new { ClientId = clientId });
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE tax_profiles SET business_registration = @BusinessRegistration, business_type = @BusinessType,
|
||||
establishment_date = @EstablishmentDate, annual_revenue_range = @AnnualRevenueRange,
|
||||
employee_count = @EmployeeCount, accounting_method = @AccountingMethod, fiscal_year_end = @FiscalYearEnd,
|
||||
last_filing_date = @LastFilingDate, next_filing_due_date = @NextFilingDueDate,
|
||||
tax_risk_level = @TaxRiskLevel, previous_audit_history = @PreviousAuditHistory,
|
||||
special_notes = @SpecialNotes, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
profile);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, 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 WHERE tax_risk_level = @RiskLevel ORDER BY client_id",
|
||||
new { RiskLevel = riskLevel });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(DateTime startDate, DateTime endDate, 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 WHERE next_filing_due_date BETWEEN @StartDate AND @EndDate
|
||||
ORDER BY next_filing_due_date",
|
||||
new { StartDate = startDate, EndDate = endDate });
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
@using TaxBaik.Application.Services
|
||||
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true"
|
||||
HelperText="영문/숫자, 최대 50자" />
|
||||
|
||||
<MudTextField @bind-Value="model.CompanyName" Label="회사명"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<MudTextField @bind-Value="model.ContactPerson" Label="담당자명"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="model.Phone" Label="전화번호"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
|
||||
|
||||
<MudTextField @bind-Value="model.Memo" Label="메모"
|
||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||
|
||||
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
|
||||
@ButtonText
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string ButtonText { get; set; } = "저장";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CompanyFormModel> OnSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public CompanyFormModel? InitialData { get; set; }
|
||||
|
||||
private MudForm? form;
|
||||
private CompanyFormModel model = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (InitialData != null)
|
||||
{
|
||||
model = new CompanyFormModel
|
||||
{
|
||||
CompanyCode = InitialData.CompanyCode,
|
||||
CompanyName = InitialData.CompanyName,
|
||||
ContactPerson = InitialData.ContactPerson,
|
||||
Phone = InitialData.Phone,
|
||||
Email = InitialData.Email,
|
||||
Memo = InitialData.Memo,
|
||||
IsActive = InitialData.IsActive
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
if (form == null)
|
||||
return;
|
||||
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
return;
|
||||
|
||||
await OnSubmit.InvokeAsync(model);
|
||||
}
|
||||
|
||||
public class CompanyFormModel
|
||||
{
|
||||
public string CompanyCode { get; set; } = "";
|
||||
public string CompanyName { get; set; } = "";
|
||||
public string? ContactPerson { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Name" Label="이름"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
|
||||
|
||||
<MudSelect @bind-Value="model.ServiceType" Label="문의 유형"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
<MudSelectItem Value="@("사업자세무")">사업자세무</MudSelectItem>
|
||||
<MudSelectItem Value="@("부동산세금")">부동산세금</MudSelectItem>
|
||||
<MudSelectItem Value="@("가족자산")">가족자산</MudSelectItem>
|
||||
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="model.Message" Label="문의 내용"
|
||||
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
|
||||
|
||||
<MudSelect @bind-Value="model.Status" Label="상태"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
<MudSelectItem Value="@("new")">신규</MudSelectItem>
|
||||
<MudSelectItem Value="@("consulting")">상담중</MudSelectItem>
|
||||
<MudSelectItem Value="@("contracted")">계약완료</MudSelectItem>
|
||||
<MudSelectItem Value="@("rejected")">거절</MudSelectItem>
|
||||
<MudSelectItem Value="@("closed")">종결</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
|
||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
|
||||
@ButtonText
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string ButtonText { get; set; } = "저장";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<InquiryFormModel> OnSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public InquiryFormModel? InitialData { get; set; }
|
||||
|
||||
private MudForm? form;
|
||||
private InquiryFormModel model = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (InitialData != null)
|
||||
{
|
||||
model = new InquiryFormModel
|
||||
{
|
||||
Name = InitialData.Name,
|
||||
Phone = InitialData.Phone,
|
||||
Email = InitialData.Email,
|
||||
ServiceType = InitialData.ServiceType,
|
||||
Message = InitialData.Message,
|
||||
Status = InitialData.Status,
|
||||
AdminMemo = InitialData.AdminMemo
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
if (form == null)
|
||||
return;
|
||||
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
return;
|
||||
|
||||
await OnSubmit.InvokeAsync(model);
|
||||
}
|
||||
|
||||
public class InquiryFormModel
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Phone { get; set; } = "";
|
||||
public string? Email { get; set; }
|
||||
public string ServiceType { get; set; } = "기타";
|
||||
public string Message { get; set; } = "";
|
||||
public string Status { get; set; } = "new";
|
||||
public string? AdminMemo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@
|
||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||
<td>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">문의 내용 확인</MudButton>
|
||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
|
||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</MudButton>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -59,37 +59,30 @@
|
||||
</div>
|
||||
<MudNavMenu Class="admin-nav">
|
||||
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
||||
|
||||
<MudNavGroup Title="CRM & 세무관리" Icon="@Icons.Material.Filled.BusinessCenter" @bind-Expanded="@expandedCRMGroup">
|
||||
<MudNavLink Href="/taxbaik/admin/tax-profiles" Icon="@Icons.Material.Filled.Assignment">세무 프로필</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/tax-filing-schedules" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/contracts" Icon="@Icons.Material.Filled.Description">계약 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/consulting-activities" Icon="@Icons.Material.Filled.ChatBubble">상담 활동</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/revenue-trackings" Icon="@Icons.Material.Filled.Receipt">수익 추적</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup">
|
||||
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.Assessment">세무신고</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
|
||||
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
||||
</MudNavMenu>
|
||||
<div class="admin-drawer-footer">
|
||||
<MudDivider Class="my-2" />
|
||||
<MudStack Spacing="1" Class="px-3 py-2">
|
||||
<div class="admin-footer-item">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Small" />
|
||||
<MudText Typo="Typo.caption" Class="ml-2">시스템</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
운영 서버: 178.104.200.7
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
업데이트: 자동 배포 시스템
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
상태: 정상
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent Class="admin-main">
|
||||
@@ -101,7 +94,8 @@
|
||||
|
||||
@code {
|
||||
private bool drawerOpen = true;
|
||||
private bool expandedCustomerGroup = true;
|
||||
private bool expandedCRMGroup = true;
|
||||
private bool expandedCustomerGroup = false;
|
||||
private bool expandedWebsiteGroup = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
@@ -109,6 +103,16 @@
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
var viewportWidth = await JS.InvokeAsync<int>("taxbaikAdminSession.getViewportWidth");
|
||||
drawerOpen = viewportWidth >= 960;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||
{
|
||||
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
||||
|
||||
@@ -90,11 +90,25 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Announcement>? announcements;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadAsync();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="@((int?)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 @bind-Value="model.CategoryId" Label="카테고리"
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||
private bool isLoading = true;
|
||||
private int currentPage = 1;
|
||||
@@ -57,9 +60,20 @@
|
||||
private int totalPosts = 0;
|
||||
private const int PageSize = 20;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadPosts();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadPosts();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPosts()
|
||||
|
||||
@@ -129,6 +129,9 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Client>? clients;
|
||||
private string searchText = "";
|
||||
private string statusFilter = "";
|
||||
@@ -137,7 +140,21 @@
|
||||
private int totalPages;
|
||||
private const int PageSize = 20;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
@page "/admin/companies/create"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject IApiClient ApiClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>고객사 등록</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">새 고객사 등록</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 고객사를 추가합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private void GoBack()
|
||||
{
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
|
||||
private async Task HandleCreate(CompanyForm.CompanyFormModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApiClient.PostAsync<object>("company", new
|
||||
{
|
||||
companyCode = model.CompanyCode,
|
||||
companyName = model.CompanyName,
|
||||
contactPerson = model.ContactPerson,
|
||||
phone = model.Phone,
|
||||
email = model.Email,
|
||||
memo = model.Memo
|
||||
});
|
||||
|
||||
Snackbar.Add("고객사가 등록되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@page "/admin/companies/{id:int}/edit"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject IApiClient ApiClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>고객사 수정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 수정</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객사 정보를 수정합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (formModel == null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
|
||||
고객사 삭제
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private CompanyForm.CompanyFormModel? formModel;
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var company = await ApiClient.GetAsync<dynamic>($"company/{Id}");
|
||||
IDictionary<string, object>? dict = company as IDictionary<string, object>;
|
||||
if (dict != null)
|
||||
{
|
||||
formModel = new CompanyForm.CompanyFormModel
|
||||
{
|
||||
CompanyCode = (string)dict["companyCode"],
|
||||
CompanyName = (string)dict["companyName"],
|
||||
ContactPerson = (string?)dict["contactPerson"],
|
||||
Phone = (string?)dict["phone"],
|
||||
Email = (string?)dict["email"],
|
||||
Memo = (string?)dict["memo"],
|
||||
IsActive = (bool)(dynamic)dict["isActive"]
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
|
||||
private async Task HandleUpdate(CompanyForm.CompanyFormModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApiClient.PutAsync<object>($"company/{Id}", new
|
||||
{
|
||||
companyCode = model.CompanyCode,
|
||||
companyName = model.CompanyName,
|
||||
contactPerson = model.ContactPerson,
|
||||
phone = model.Phone,
|
||||
email = model.Email,
|
||||
memo = model.Memo,
|
||||
isActive = model.IsActive
|
||||
});
|
||||
|
||||
Snackbar.Add("고객사가 수정되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteCompany()
|
||||
{
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"고객사 삭제",
|
||||
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"삭제", "취소");
|
||||
|
||||
if (result != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await ApiClient.DeleteAsync($"company/{Id}");
|
||||
Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@page "/admin/companies"
|
||||
@attribute [Authorize]
|
||||
@inject IApiClient ApiClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>고객사 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 관리</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface mb-4 mt-4" Elevation="0">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.subtitle1">@($"전체 고객사 {totalCompanies}개")</MudText>
|
||||
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudDataGrid Items="@companies" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.CompanyCode" Title="회사코드" />
|
||||
<PropertyColumn Property="x => x.CompanyName" Title="회사명" />
|
||||
<PropertyColumn Property="x => x.ContactPerson" Title="담당자" />
|
||||
<PropertyColumn Property="x => x.Phone" Title="전화" />
|
||||
<PropertyColumn Property="x => x.Email" Title="이메일" />
|
||||
<PropertyColumn Property="x => x.IsActive" Title="활성">
|
||||
<CellTemplate Context="cell">
|
||||
<MudCheckBox T="bool" Value="@cell.Item.IsActive" Disabled="true" />
|
||||
</CellTemplate>
|
||||
</PropertyColumn>
|
||||
<PropertyColumn Property="x => x.CreatedAt" Title="등록일" Format="yyyy-MM-dd" />
|
||||
<TemplateColumn>
|
||||
<CellTemplate Context="cell">
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
|
||||
Href="@($"/taxbaik/admin/companies/{cell.Item.Id}/edit")">수정</MudButton>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
private List<CompanyDto> companies = [];
|
||||
private bool isLoading = true;
|
||||
private int currentPage = 1;
|
||||
private int totalPages = 1;
|
||||
private int totalCompanies = 0;
|
||||
private const int PageSize = 20;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
var response = await ApiClient.GetAsync<dynamic>($"company?page={currentPage}&pageSize={PageSize}");
|
||||
|
||||
IDictionary<string, object>? dict = response as IDictionary<string, object>;
|
||||
if (dict != null)
|
||||
{
|
||||
totalCompanies = (int)(dynamic)dict["total"];
|
||||
totalPages = (totalCompanies + PageSize - 1) / PageSize;
|
||||
|
||||
if (dict["data"] is System.Collections.IEnumerable dataList)
|
||||
{
|
||||
companies = new List<CompanyDto>();
|
||||
foreach (var item in dataList)
|
||||
{
|
||||
if (item is IDictionary<string, object> companyDict)
|
||||
{
|
||||
companies.Add(new CompanyDto
|
||||
{
|
||||
Id = (int)(dynamic)companyDict["id"],
|
||||
CompanyCode = (string)companyDict["companyCode"],
|
||||
CompanyName = (string)companyDict["companyName"],
|
||||
ContactPerson = (string?)companyDict["contactPerson"],
|
||||
Phone = (string?)companyDict["phone"],
|
||||
Email = (string?)companyDict["email"],
|
||||
IsActive = (bool)(dynamic)companyDict["isActive"],
|
||||
CreatedAt = DateTime.Parse(companyDict["createdAt"].ToString()!)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NextPage()
|
||||
{
|
||||
currentPage++;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task PreviousPage()
|
||||
{
|
||||
currentPage = Math.Max(1, currentPage - 1);
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private class CompanyDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string CompanyCode { get; set; } = "";
|
||||
public string CompanyName { get; set; } = "";
|
||||
public string? ContactPerson { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
@page "/admin/consulting-activities"
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject IConsultingActivityBrowserClient ActivityClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>상담 활동 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">상담 활동 관리</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
새 활동 기록
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (activities is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (activities.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="me-2" />
|
||||
상담 활동이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="ConsultingActivity"
|
||||
Items="@activities"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.ActivityType" Title="활동 유형" />
|
||||
<PropertyColumn Property="x => x.ActivityDate" Title="활동일시" Format="g" />
|
||||
<TemplateColumn Title="설명">
|
||||
<CellTemplate>
|
||||
@{
|
||||
var desc = context.Item.Description ?? "";
|
||||
if (desc.Length > 30) desc = desc.Substring(0, 30) + "...";
|
||||
}
|
||||
<span>@desc</span>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="다음 팔로업">
|
||||
<CellTemplate>
|
||||
@if (context.Item.NextFollowupDate.HasValue)
|
||||
{
|
||||
var daysLeft = (context.Item.NextFollowupDate.Value.Date - DateTime.Today).Days;
|
||||
<MudChip Size="Size.Small"
|
||||
Color="@(daysLeft < 0 ? Color.Error : daysLeft <= 3 ? Color.Warning : Color.Success)"
|
||||
Variant="Variant.Filled">
|
||||
@context.Item.NextFollowupDate.Value.ToString("yyyy-MM-dd")
|
||||
</MudChip>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteActivity(context.Item.Id))" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<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>
|
||||
}
|
||||
</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>
|
||||
<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" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveActivity">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<ConsultingActivity>? activities;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private ConsultingActivity? editingActivity;
|
||||
private ConsultingActivityForm activityForm = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
activities = await ActivityClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
editingActivity = null;
|
||||
activityForm = new ConsultingActivityForm
|
||||
{
|
||||
ActivityDate = DateTime.Now,
|
||||
ClientId = clients.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog(ConsultingActivity activity)
|
||||
{
|
||||
editingActivity = activity;
|
||||
activityForm = new ConsultingActivityForm
|
||||
{
|
||||
ClientId = activity.ClientId,
|
||||
ActivityType = activity.ActivityType,
|
||||
ActivityDate = activity.ActivityDate,
|
||||
Description = activity.Description,
|
||||
NextFollowupDate = activity.NextFollowupDate
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveActivity()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (editingActivity == null)
|
||||
{
|
||||
var actDate = activityForm.ActivityDate ?? DateTime.Now;
|
||||
var newId = await ActivityClient.CreateAsync(
|
||||
activityForm.ClientId,
|
||||
activityForm.ActivityType,
|
||||
actDate,
|
||||
activityForm.Description,
|
||||
null,
|
||||
activityForm.NextFollowupDate);
|
||||
|
||||
if (newId > 0)
|
||||
{
|
||||
Snackbar.Add("활동이 기록되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await ActivityClient.UpdateAsync(
|
||||
editingActivity.Id,
|
||||
null,
|
||||
activityForm.NextFollowupDate);
|
||||
|
||||
Snackbar.Add("활동이 업데이트되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteActivity(int id)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 활동을 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await ActivityClient.DeleteAsync(id);
|
||||
Snackbar.Add("활동이 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
editingActivity = null;
|
||||
activityForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class ConsultingActivityForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
public string ActivityType { get; set; } = "";
|
||||
public DateTime? ActivityDate { get; set; } = DateTime.Now;
|
||||
public string Description { get; set; } = "";
|
||||
public DateTime? NextFollowupDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
@page "/admin/contracts"
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject IContractBrowserClient ContractClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>계약 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">계약 관리</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</MudText>
|
||||
@if (mrr > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mt-2">
|
||||
월 정기수익:
|
||||
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
새 계약 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (contracts is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (contracts.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
|
||||
계약이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="Contract"
|
||||
Items="@contracts"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
|
||||
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
|
||||
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
|
||||
<TemplateColumn Title="계약기간">
|
||||
<CellTemplate>
|
||||
@context.Item.StartDate.ToString("yyyy-MM-dd")
|
||||
@if (context.Item.EndDate.HasValue)
|
||||
{
|
||||
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="상태">
|
||||
<CellTemplate>
|
||||
@{
|
||||
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
|
||||
}
|
||||
@if (isActive)
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 계약 추가</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요.">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudSelect T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
|
||||
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem>
|
||||
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveContract">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Contract>? contracts;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private decimal mrr = 0;
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private ContractForm contractForm = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
contracts = await ContractClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
contractForm = new ContractForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id,
|
||||
StartDate = DateTime.Today
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveContract()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (contractForm.ClientId == null) return;
|
||||
var newId = await ContractClient.CreateAsync(
|
||||
contractForm.ClientId.Value,
|
||||
contractForm.ContractNumber,
|
||||
contractForm.ServiceType,
|
||||
contractForm.StartDate ?? DateTime.Now,
|
||||
contractForm.MonthlyFee);
|
||||
|
||||
if (newId > 0)
|
||||
{
|
||||
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteContract(int id)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 계약을 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await ContractClient.DeleteAsync(id);
|
||||
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
contractForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class ContractForm
|
||||
{
|
||||
public int? ClientId { get; set; }
|
||||
public string ContractNumber { get; set; } = "";
|
||||
public string ServiceType { get; set; } = "";
|
||||
public DateTime? StartDate { get; set; }
|
||||
public decimal? MonthlyFee { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -158,31 +158,45 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
||||
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
||||
private string? errorMessage;
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try
|
||||
if (firstRender)
|
||||
{
|
||||
// API 클라이언트 사용 (서비스 직접 호출 X)
|
||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
// API 클라이언트 사용 (서비스 직접 호출 X)
|
||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||
|
||||
await Task.WhenAll(summaryTask, filingsTask);
|
||||
summary = await summaryTask;
|
||||
upcomingFilings = (await filingsTask).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
await Task.WhenAll(summaryTask, filingsTask);
|
||||
summary = await summaryTask;
|
||||
upcomingFilings = (await filingsTask).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,9 +95,26 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Faq>? faqs;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
@page "/admin/inquiries/create"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject InquiryService InquiryService
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>문의 등록</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">새 문의 등록</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private void GoBack()
|
||||
{
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
|
||||
private async Task HandleCreate(InquiryForm.InquiryFormModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
await InquiryService.SubmitAsync(
|
||||
model.Name,
|
||||
model.Phone,
|
||||
model.ServiceType,
|
||||
model.Message,
|
||||
model.Email,
|
||||
ipAddress: "admin-registered");
|
||||
|
||||
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
@page "/admin/inquiries/{id:int}/edit"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject InquiryService InquiryService
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>문의 수정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">문의 수정</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의 정보를 수정합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (inquiry == null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">문의를 찾을 수 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteInquiry" Class="mt-2">
|
||||
문의 삭제
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private Domain.Entities.Inquiry? inquiry;
|
||||
private InquiryForm.InquiryFormModel? formModel;
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
inquiry = await InquiryService.GetByIdAsync(Id);
|
||||
if (inquiry != null)
|
||||
{
|
||||
formModel = new InquiryForm.InquiryFormModel
|
||||
{
|
||||
Name = inquiry.Name,
|
||||
Phone = inquiry.Phone,
|
||||
Email = inquiry.Email,
|
||||
ServiceType = inquiry.ServiceType,
|
||||
Message = inquiry.Message,
|
||||
Status = inquiry.Status,
|
||||
AdminMemo = inquiry.AdminMemo
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"문의 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
|
||||
private async Task HandleUpdate(InquiryForm.InquiryFormModel model)
|
||||
{
|
||||
if (inquiry == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
inquiry.Name = model.Name;
|
||||
inquiry.Phone = model.Phone;
|
||||
inquiry.Email = model.Email;
|
||||
inquiry.ServiceType = model.ServiceType;
|
||||
inquiry.Message = model.Message;
|
||||
inquiry.AdminMemo = model.AdminMemo;
|
||||
|
||||
if (inquiry.Status != model.Status)
|
||||
{
|
||||
await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status);
|
||||
}
|
||||
|
||||
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo);
|
||||
|
||||
Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteInquiry()
|
||||
{
|
||||
if (inquiry == null)
|
||||
return;
|
||||
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"문의 삭제",
|
||||
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"삭제", "취소");
|
||||
|
||||
if (result != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await InquiryService.DeleteAsync(inquiry.Id);
|
||||
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">문의 관리</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/taxbaik/admin/inquiries/create">새 문의 등록</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@@ -44,11 +46,31 @@ else
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private bool isLoading = true;
|
||||
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject CustomAuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime Js
|
||||
@inject ILocalStorageService LocalStorageService
|
||||
|
||||
<PageTitle>로그인</PageTitle>
|
||||
|
||||
@@ -27,6 +28,11 @@
|
||||
autocomplete="current-password"
|
||||
@bind-Value="model.Password" />
|
||||
|
||||
<div class="mb-4">
|
||||
<InputCheckbox class="mud-checkbox" @bind-Value="model.RememberMe" />
|
||||
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||
@@ -53,8 +59,25 @@
|
||||
@code {
|
||||
private bool isLoading = false;
|
||||
private string errorMessage = "";
|
||||
|
||||
private LoginModel model = new();
|
||||
private const string RememberedUsernameKey = "admin-remembered-username";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var remembered = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey);
|
||||
if (!string.IsNullOrEmpty(remembered))
|
||||
{
|
||||
model.Username = remembered;
|
||||
model.RememberMe = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// LocalStorage not available in pre-render
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
@@ -82,6 +105,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.RememberMe)
|
||||
{
|
||||
await LocalStorageService.SetItemAsStringAsync(RememberedUsernameKey, model.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
await LocalStorageService.RemoveItemAsync(RememberedUsernameKey);
|
||||
}
|
||||
|
||||
await ApiClient.SetAuthToken(response.AccessToken);
|
||||
await AuthStateProvider.LoginAsync(response.AccessToken, response.RefreshToken, response.ExpiresIn);
|
||||
NavigationManager.NavigateTo(GetReturnUrl(), forceLoad: false);
|
||||
@@ -104,6 +136,7 @@
|
||||
{
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
|
||||
private string GetReturnUrl()
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
@page "/admin/revenue-trackings"
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject IRevenueTrackingBrowserClient RevenueClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>수익 추적 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">수익 추적 관리</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
새 청구 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (revenues is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (revenues.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Payments" Class="me-2" />
|
||||
청구 기록이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="RevenueTracking"
|
||||
Items="@revenues"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.InvoiceNumber" Title="청구번호" />
|
||||
<PropertyColumn Property="x => x.InvoiceDate" Title="청구일" Format="yyyy-MM-dd" />
|
||||
<PropertyColumn Property="x => x.Amount" Title="청구액" Format="C" />
|
||||
<TemplateColumn Title="납부여부">
|
||||
<CellTemplate>
|
||||
@if (context.Item.PaymentStatus == "paid")
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">납부</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">미납</MudChip>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
@if (context.Item.PaymentStatus != "paid")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success"
|
||||
OnClick="@(async () => await MarkPaid(context.Item.Id))" Title="납부 처리" />
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteRevenue(context.Item.Id))" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 청구 추가</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<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>
|
||||
}
|
||||
</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>
|
||||
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveRevenue">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<RevenueTracking>? revenues;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private RevenueForm revenueForm = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
revenues = await RevenueClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
revenueForm = new RevenueForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id ?? 0,
|
||||
InvoiceDate = DateTime.Today,
|
||||
DueDate = DateTime.Today.AddDays(14)
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveRevenue()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var newId = await RevenueClient.CreateAsync(
|
||||
revenueForm.ClientId,
|
||||
revenueForm.InvoiceNumber,
|
||||
revenueForm.InvoiceDate ?? DateTime.Now,
|
||||
revenueForm.Amount,
|
||||
revenueForm.ServiceType,
|
||||
revenueForm.DueDate);
|
||||
|
||||
if (newId > 0)
|
||||
{
|
||||
Snackbar.Add("청구가 추가되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkPaid(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RevenueClient.MarkPaidAsync(id, DateTime.Now);
|
||||
Snackbar.Add("납부가 처리되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRevenue(int id)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 청구를 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await RevenueClient.DeleteAsync(id);
|
||||
Snackbar.Add("청구가 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
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; }
|
||||
public string InvoiceNumber { get; set; } = "";
|
||||
public DateTime? InvoiceDate { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
@page "/admin/tax-filing-schedules"
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject ITaxFilingScheduleBrowserClient TaxFilingClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>신고 일정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenCreateDialog"
|
||||
StartIcon="@Icons.Material.Filled.Add">
|
||||
새 일정 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (schedules is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (schedules.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
|
||||
신고 일정이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="TaxFilingSchedule"
|
||||
Items="@schedules"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
|
||||
<TemplateColumn Title="마감일">
|
||||
<CellTemplate>
|
||||
@{
|
||||
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
|
||||
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
|
||||
}
|
||||
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
|
||||
@context.Item.DueDate.ToString("yyyy-MM-dd")
|
||||
@if (daysLeft >= 0)
|
||||
{
|
||||
<span class="ms-1">(D-@daysLeft)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
|
||||
}
|
||||
</MudChip>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
|
||||
<TemplateColumn Title="상태">
|
||||
<CellTemplate>
|
||||
@if (context.Item.Status == "completed")
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
@if (context.Item.Status != "completed")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
|
||||
Color="Color.Success"
|
||||
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
|
||||
Title="완료" />
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
|
||||
Title="삭제" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 신고 일정 추가</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?"
|
||||
@bind-Value="scheduleForm.ClientId"
|
||||
Label="고객"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Class="mb-4"
|
||||
RequiredError="고객을 선택하세요.">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</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>
|
||||
<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>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveSchedule">저장</MudButton>
|
||||
</DialogActions>
|
||||
</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
schedules = await TaxFilingClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
scheduleForm = new TaxFilingScheduleForm
|
||||
{
|
||||
FilingYear = DateTime.Now.Year,
|
||||
DueDate = DateTime.Today,
|
||||
ClientId = clients.FirstOrDefault()?.Id
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveSchedule()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (scheduleForm.ClientId == null) return;
|
||||
var newId = await TaxFilingClient.CreateAsync(
|
||||
scheduleForm.ClientId.Value,
|
||||
scheduleForm.FilingType,
|
||||
scheduleForm.DueDate ?? DateTime.Today,
|
||||
scheduleForm.FilingYear);
|
||||
|
||||
if (newId > 0)
|
||||
{
|
||||
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompleteSchedule(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TaxFilingClient.MarkCompletedAsync(id);
|
||||
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSchedule(int id)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 신고 일정을 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await TaxFilingClient.DeleteAsync(id);
|
||||
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
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 string FilingType { get; set; } = "";
|
||||
public DateTime? DueDate { get; set; }
|
||||
public int FilingYear { get; set; } = DateTime.Now.Year;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
|
||||
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
|
||||
return items;
|
||||
}
|
||||
catch
|
||||
@@ -110,6 +110,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private async Task AddFiling()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
@page "/admin/tax-profiles"
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject ITaxProfileBrowserClient TaxProfileClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>세무 프로필</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
새 프로필 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
@if (profiles == null)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (profiles.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">세무 프로필이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="TaxProfile"
|
||||
Items="@profiles"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid mt-4">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
|
||||
<TemplateColumn Title="위험도">
|
||||
<CellTemplate>
|
||||
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
|
||||
@context.Item.TaxRiskLevel
|
||||
</MudChip>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="다음 신고">
|
||||
<CellTemplate>
|
||||
@if (context.Item.NextFilingDueDate.HasValue)
|
||||
{
|
||||
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요.">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</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>
|
||||
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
<MudSelectItem Value="@("low")">낮음</MudSelectItem>
|
||||
<MudSelectItem Value="@("normal")">보통</MudSelectItem>
|
||||
<MudSelectItem Value="@("high")">높음</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="true" Lines="2" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveProfile">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxProfile>? profiles;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private bool isEditMode;
|
||||
private TaxProfile? editingProfile;
|
||||
private TaxProfileForm profileForm = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
profiles = await TaxProfileClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
isEditMode = false;
|
||||
editingProfile = null;
|
||||
profileForm = new TaxProfileForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id,
|
||||
TaxRiskLevel = "normal",
|
||||
NextFilingDueDate = DateTime.Today.AddMonths(1)
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog(TaxProfile profile)
|
||||
{
|
||||
isEditMode = true;
|
||||
editingProfile = profile;
|
||||
profileForm = new TaxProfileForm
|
||||
{
|
||||
ClientId = profile.ClientId,
|
||||
BusinessType = profile.BusinessType ?? "",
|
||||
TaxRiskLevel = profile.TaxRiskLevel,
|
||||
NextFilingDueDate = profile.NextFilingDueDate,
|
||||
SpecialNotes = profile.SpecialNotes
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveProfile()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (isEditMode && editingProfile != null)
|
||||
{
|
||||
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.BusinessType);
|
||||
if (newId > 0)
|
||||
{
|
||||
// 생성 후 상태 업데이트 처리
|
||||
await TaxProfileClient.UpdateAsync(
|
||||
newId,
|
||||
profileForm.BusinessType,
|
||||
null,
|
||||
profileForm.NextFilingDueDate,
|
||||
profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
|
||||
}
|
||||
}
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteProfile(int id)
|
||||
{
|
||||
var parameters = new DialogParameters();
|
||||
parameters.Add("Title", "삭제 확인");
|
||||
parameters.Add("Message", "이 세무 프로필을 삭제하시겠습니까?");
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await TaxProfileClient.DeleteAsync(id);
|
||||
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
isEditMode = false;
|
||||
editingProfile = null;
|
||||
profileForm = new();
|
||||
}
|
||||
|
||||
private Color GetRiskColor(string riskLevel) => riskLevel switch
|
||||
{
|
||||
"high" => Color.Error,
|
||||
"normal" => Color.Warning,
|
||||
"low" => Color.Success,
|
||||
_ => 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 string BusinessType { get; set; } = "";
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
public string? SpecialNotes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText>@Message</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">취소</MudButton>
|
||||
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Confirm">삭제</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] MudDialogInstance MudDialog { get; set; } = null!;
|
||||
[Parameter] public string Title { get; set; } = "";
|
||||
[Parameter] public string Message { get; set; } = "";
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
private void Confirm() => MudDialog.Close();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
|
||||
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Route("api/admin-dashboard")]
|
||||
[Authorize]
|
||||
public class AdminDashboardController : ControllerBase
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ public class AuthController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
token = tokenPair.AccessToken,
|
||||
accessToken = tokenPair.AccessToken,
|
||||
refreshToken = tokenPair.RefreshToken,
|
||||
expiresIn = tokenPair.ExpiresIn
|
||||
@@ -45,6 +46,7 @@ public class AuthController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
token = tokenPair.AccessToken,
|
||||
accessToken = tokenPair.AccessToken,
|
||||
refreshToken = tokenPair.RefreshToken,
|
||||
expiresIn = tokenPair.ExpiresIn
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class CompanyController(CompanyService companyService) : ControllerBase
|
||||
{
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var company = await companyService.GetByIdAsync(id);
|
||||
if (company == null)
|
||||
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||
return Ok(company);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("code/{code}")]
|
||||
public async Task<IActionResult> GetByCode(string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
var company = await companyService.GetByCodeAsync(code);
|
||||
if (company == null)
|
||||
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||
return Ok(company);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (companies, total) = await companyService.GetPagedAsync(page, pageSize);
|
||||
return Ok(new { data = companies, total, page, pageSize });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new ProblemDetails { Title = "회사 목록 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateCompanyRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await companyService.CreateAsync(
|
||||
request.CompanyCode, request.CompanyName, request.ContactPerson,
|
||||
request.Phone, request.Email, request.Memo);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { message = "회사가 등록되었습니다.", id });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new ProblemDetails { Title = "회사 등록 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateCompanyRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await companyService.UpdateAsync(id, request.CompanyCode, request.CompanyName,
|
||||
request.ContactPerson, request.Phone, request.Email, request.Memo, request.IsActive);
|
||||
return Ok(new { message = "회사가 수정되었습니다." });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new ProblemDetails { Title = "회사 수정 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await companyService.DeleteAsync(id);
|
||||
return Ok(new { message = "회사가 삭제되었습니다." });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new ProblemDetails { Title = "회사 삭제 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo);
|
||||
public record UpdateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo, bool IsActive);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class ConsultingActivityController(ConsultingActivityService service) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateConsultingActivityRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await service.CreateAsync(request.ClientId, request.ActivityType, request.ActivityDate,
|
||||
request.Description, request.ConsultantId, request.NextFollowupDate);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var activity = await service.GetByClientIdAsync(id);
|
||||
if (activity == null)
|
||||
return NotFound(new { error = "상담 활동을 찾을 수 없습니다." });
|
||||
return Ok(activity);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("client/{clientId:int}")]
|
||||
public async Task<IActionResult> GetByClientId(int clientId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var activities = await service.GetByClientIdAsync(clientId);
|
||||
return Ok(new { data = activities });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("pending-followups")]
|
||||
public async Task<IActionResult> GetPendingFollowups()
|
||||
{
|
||||
try
|
||||
{
|
||||
var activities = await service.GetPendingFollowupsAsync();
|
||||
return Ok(new { data = activities });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("consultant/{consultantId:int}")]
|
||||
public async Task<IActionResult> GetByConsultant(int consultantId, [FromQuery] int daysBack = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fromDate = DateTime.Today.AddDays(-daysBack);
|
||||
var activities = await service.GetConsultantActivityAsync(consultantId, fromDate);
|
||||
return Ok(new { data = activities, daysBack });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateConsultingActivityRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.UpdateAsync(id, request.Outcome, request.NextFollowupDate);
|
||||
return Ok(new { message = "상담 활동이 수정되었습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateConsultingActivityRequest(
|
||||
int ClientId, string ActivityType, DateTime ActivityDate, string Description,
|
||||
int? ConsultantId = null, DateTime? NextFollowupDate = null);
|
||||
|
||||
public record UpdateConsultingActivityRequest(
|
||||
string? Outcome = null, DateTime? NextFollowupDate = null);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class ContractController(ContractService service) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateContractRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await service.CreateAsync(request.ClientId, request.ContractNumber, request.ServiceType,
|
||||
request.StartDate, request.MonthlyFee, request.TotalAmount);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var contract = await service.GetByIdAsync(id);
|
||||
if (contract == null)
|
||||
return NotFound(new { error = "계약을 찾을 수 없습니다." });
|
||||
return Ok(contract);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("client/{clientId:int}")]
|
||||
public async Task<IActionResult> GetByClientId(int clientId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var contracts = await service.GetByClientIdAsync(clientId);
|
||||
return Ok(new { data = contracts });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("active")]
|
||||
public async Task<IActionResult> GetActiveContracts()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contracts = await service.GetActiveContractsAsync();
|
||||
return Ok(new { data = contracts });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("expiring")]
|
||||
public async Task<IActionResult> GetExpiringContracts([FromQuery] int daysAhead = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var contracts = await service.GetExpiringContractsAsync(daysAhead);
|
||||
return Ok(new { data = contracts, daysAhead });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("mrr")]
|
||||
public async Task<IActionResult> GetMonthlyRecurringRevenue()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mrr = await service.GetMonthlyRecurringRevenueAsync();
|
||||
return Ok(new { mrr });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateContractRequest(
|
||||
int ClientId, string ContractNumber, string ServiceType, DateTime StartDate,
|
||||
decimal? MonthlyFee = null, decimal? TotalAmount = null);
|
||||
}
|
||||
@@ -32,7 +32,8 @@ public class InquiryController : ControllerBase
|
||||
request.ServiceType,
|
||||
request.Message,
|
||||
request.Email,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString());
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
request.SuppressNotification);
|
||||
return Ok(new { message = "상담 신청이 접수되었습니다." });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
@@ -135,6 +136,7 @@ public class SubmitInquiryRequest
|
||||
public string? Email { get; set; }
|
||||
public string ServiceType { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public bool SuppressNotification { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateStatusRequest
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class RevenueTrackingController(RevenueTrackingService service) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateRevenueTrackingRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await service.CreateAsync(request.ClientId, request.InvoiceNumber, request.InvoiceDate,
|
||||
request.Amount, request.ServiceType, request.DueDate);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
try
|
||||
{
|
||||
// GetByIdAsync가 없으면 GetByClientIdAsync를 사용하거나 별도 구현 필요
|
||||
// 임시로 구현 - 실제로는 repository에 GetByIdAsync 추가 필요
|
||||
return Ok(new { message = "조회됨" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("client/{clientId:int}")]
|
||||
public async Task<IActionResult> GetByClientId(int clientId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var revenues = await service.GetByClientIdAsync(clientId);
|
||||
return Ok(new { data = revenues });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("pending")]
|
||||
public async Task<IActionResult> GetPendingPayments()
|
||||
{
|
||||
try
|
||||
{
|
||||
var revenues = await service.GetPendingPaymentsAsync();
|
||||
return Ok(new { data = revenues });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("monthly")]
|
||||
public async Task<IActionResult> GetMonthlyRevenue([FromQuery] int year, [FromQuery] int month)
|
||||
{
|
||||
try
|
||||
{
|
||||
var monthDate = new DateTime(year, month, 1);
|
||||
var revenues = await service.GetMonthlyRevenueAsync(monthDate);
|
||||
return Ok(new { data = revenues, year, month });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("total")]
|
||||
public async Task<IActionResult> GetTotalRevenue([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var total = await service.GetTotalRevenueAsync(startDate, endDate);
|
||||
return Ok(new { total, startDate, endDate });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/paid")]
|
||||
public async Task<IActionResult> MarkPaid(int id, [FromBody] MarkPaidRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.MarkPaidAsync(id, request.PaymentDate);
|
||||
return Ok(new { message = "결제가 완료됨으로 표시되었습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateRevenueTrackingRequest(
|
||||
int ClientId, string InvoiceNumber, DateTime InvoiceDate, decimal Amount,
|
||||
string? ServiceType = null, DateTime? DueDate = null);
|
||||
|
||||
public record MarkPaidRequest(DateTime PaymentDate);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class TaxFilingScheduleController(TaxFilingScheduleService service) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateTaxFilingScheduleRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await service.CreateAsync(request.ClientId, request.FilingType, request.DueDate,
|
||||
request.FilingYear, request.AssignedTo);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var schedule = await service.GetByIdAsync(id);
|
||||
if (schedule == null)
|
||||
return NotFound(new { error = "신고 일정을 찾을 수 없습니다." });
|
||||
return Ok(schedule);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("client/{clientId:int}")]
|
||||
public async Task<IActionResult> GetByClientId(int clientId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var schedules = await service.GetByClientIdAsync(clientId);
|
||||
return Ok(new { data = schedules });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("upcoming")]
|
||||
public async Task<IActionResult> GetUpcomingDues([FromQuery] int daysAhead = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var schedules = await service.GetUpcomingDuesAsync(daysAhead);
|
||||
return Ok(new { data = schedules, daysAhead });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("pending-count")]
|
||||
public async Task<IActionResult> GetPendingCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await service.GetPendingCountAsync();
|
||||
return Ok(new { count });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/complete")]
|
||||
public async Task<IActionResult> MarkCompleted(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.MarkCompletedAsync(id);
|
||||
return Ok(new { message = "신고 일정이 완료되었습니다." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateTaxFilingScheduleRequest(
|
||||
int ClientId, string FilingType, DateTime DueDate, int FilingYear,
|
||||
int? AssignedTo = null);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class TaxProfileController(TaxProfileService taxProfileService) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateTaxProfileRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await taxProfileService.CreateAsync(request.ClientId, request.BusinessType,
|
||||
request.BusinessRegistration, request.AccountingMethod, request.EstablishmentDate);
|
||||
return CreatedAtAction(nameof(GetByClientId), new { clientId = request.ClientId }, new { id });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = await taxProfileService.GetByClientIdAsync(clientId);
|
||||
if (profile == null)
|
||||
return NotFound(new { error = "세무 프로필을 찾을 수 없습니다." });
|
||||
return Ok(profile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("high-risk")]
|
||||
public async Task<IActionResult> GetHighRiskProfiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profiles = await taxProfileService.GetHighRiskProfilesAsync();
|
||||
return Ok(new { data = profiles });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("upcoming-filings")]
|
||||
public async Task<IActionResult> GetUpcomingFiliings([FromQuery] int daysAhead = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profiles = await taxProfileService.GetUpcomingFilingDuesAsync(daysAhead);
|
||||
return Ok(new { data = profiles, daysAhead });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateTaxProfileRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await taxProfileService.UpdateAsync(id, request.BusinessType, request.AccountingMethod,
|
||||
request.NextFilingDueDate, request.TaxRiskLevel);
|
||||
return Ok(new { message = "세무 프로필이 수정되었습니다." });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateTaxProfileRequest(
|
||||
int ClientId, string BusinessType, string? BusinessRegistration = null,
|
||||
string? AccountingMethod = null, DateTime? EstablishmentDate = null);
|
||||
|
||||
public record UpdateTaxProfileRequest(
|
||||
string? BusinessType = null, string? AccountingMethod = null,
|
||||
DateTime? NextFilingDueDate = null, string TaxRiskLevel = "normal");
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace TaxBaik.Web.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time notification hub for admin dashboard
|
||||
/// SOLID: Single Responsibility - Only broadcasts change notifications
|
||||
/// No state management - stateless broadcast pattern
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class NotificationHub : Hub
|
||||
{
|
||||
private const string AdminGroup = "admins";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast inquiry status changed to all connected admins
|
||||
/// Clients should re-fetch from API to verify
|
||||
/// </summary>
|
||||
public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
|
||||
{
|
||||
InquiryId = inquiryId,
|
||||
Status = newStatus,
|
||||
ChangedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast inquiry submitted (new inquiry created)
|
||||
/// </summary>
|
||||
public async Task NotifyInquiryCreated(int inquiryId, string name)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
|
||||
{
|
||||
InquiryId = inquiryId,
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast client created
|
||||
/// </summary>
|
||||
public async Task NotifyClientCreated(int clientId, string name)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
|
||||
{
|
||||
ClientId = clientId,
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast announcement published
|
||||
/// </summary>
|
||||
public async Task NotifyAnnouncementPublished(int announcementId, string title)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
|
||||
{
|
||||
AnnouncementId = announcementId,
|
||||
Title = title,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast tax filing completed
|
||||
/// </summary>
|
||||
public async Task NotifyFilingCompleted(int filingId, string filingType)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
|
||||
{
|
||||
FilingId = filingId,
|
||||
FilingType = filingType,
|
||||
CompletedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@
|
||||
}
|
||||
|
||||
<div class="container py-5" style="max-width: 600px;">
|
||||
<h1 class="fw-bold mb-5">상담 신청</h1>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||
<h1 class="fw-bold mb-0">상담 신청</h1>
|
||||
<a href="/taxbaik" class="btn btn-outline-secondary btn-sm"
|
||||
onclick="if (history.length > 1) { history.back(); return false; }">
|
||||
뒤로가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@page "/portal/external-callback"
|
||||
@model TaxBaik.Web.Pages.Portal.ExternalCallbackModel
|
||||
@{
|
||||
ViewData["Title"] = "포털 인증 처리";
|
||||
}
|
||||
|
||||
<section class="container py-5">
|
||||
<div class="alert alert-info">인증을 처리하는 중입니다...</div>
|
||||
</section>
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Web.Services;
|
||||
|
||||
namespace TaxBaik.Web.Pages.Portal;
|
||||
|
||||
public class ExternalCallbackModel : PageModel
|
||||
{
|
||||
private readonly PortalUserService _portalUserService;
|
||||
private readonly ClientService _clientService;
|
||||
|
||||
public ExternalCallbackModel(PortalUserService portalUserService, ClientService clientService)
|
||||
{
|
||||
_portalUserService = portalUserService;
|
||||
_clientService = clientService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string provider)
|
||||
{
|
||||
var external = await HttpContext.AuthenticateAsync(PortalOAuthDefaults.ExternalScheme);
|
||||
if (external?.Principal is null)
|
||||
return RedirectToPage("/Portal/Login");
|
||||
|
||||
var email = external.Principal.FindFirstValue(ClaimTypes.Email);
|
||||
var name = external.Principal.FindFirstValue(ClaimTypes.Name) ?? "고객";
|
||||
var providerId = external.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
return RedirectToPage("/Portal/Login");
|
||||
|
||||
var existing = await _portalUserService.GetByProviderAsync(provider, providerId);
|
||||
if (existing is null && !string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
existing = await _portalUserService.GetByEmailAsync(email);
|
||||
if (existing is null)
|
||||
{
|
||||
int? clientId = null;
|
||||
var linkedClient = await _clientService.GetByEmailAsync(email);
|
||||
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
|
||||
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
|
||||
if (linkedClient is not null)
|
||||
clientId = linkedClient.Id;
|
||||
|
||||
await _portalUserService.RegisterOAuthAsync(
|
||||
name,
|
||||
email,
|
||||
external.Principal.FindFirstValue("phone") ?? "",
|
||||
provider,
|
||||
providerId,
|
||||
clientId);
|
||||
existing = await _portalUserService.GetByEmailAsync(email);
|
||||
}
|
||||
else if (!string.Equals(existing.Provider, provider, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(existing.ProviderId, providerId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await _portalUserService.LinkOAuthAsync(existing, provider, providerId, name, email);
|
||||
}
|
||||
}
|
||||
|
||||
if (existing is not null && !existing.ClientId.HasValue && !string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
var linkedClient = await _clientService.GetByEmailAsync(email);
|
||||
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
|
||||
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
|
||||
if (linkedClient is not null)
|
||||
{
|
||||
await _portalUserService.AttachClientAsync(existing, linkedClient.Id);
|
||||
existing.ClientId = linkedClient.Id;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
return RedirectToPage("/Portal/Login");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, existing.Id.ToString()),
|
||||
new(ClaimTypes.Name, existing.Name),
|
||||
new(ClaimTypes.Email, existing.Email),
|
||||
new("portal_user_id", existing.Id.ToString())
|
||||
};
|
||||
|
||||
if (existing.ClientId.HasValue)
|
||||
claims.Add(new("client_id", existing.ClientId.Value.ToString()));
|
||||
|
||||
await HttpContext.SignInAsync(
|
||||
PortalAuthDefaults.Scheme,
|
||||
new ClaimsPrincipal(new ClaimsIdentity(claims, PortalAuthDefaults.Scheme)),
|
||||
new AuthenticationProperties { IsPersistent = true });
|
||||
|
||||
await HttpContext.SignOutAsync(PortalOAuthDefaults.ExternalScheme);
|
||||
return RedirectToPage("/Portal/Index");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
@page "/portal"
|
||||
@model TaxBaik.Web.Pages.Portal.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "마이 포털 - 세무사 백원숙";
|
||||
ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
|
||||
}
|
||||
|
||||
<div class="bg-light py-5">
|
||||
<div class="container">
|
||||
<!-- 상단 헤더 & 환영 문구 -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom">
|
||||
<div>
|
||||
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p>
|
||||
<h1 class="display-6 fw-bold text-dark">안녕하세요, @(User.Identity?.Name)님!</h1>
|
||||
@if (Model.ClientInfo != null)
|
||||
{
|
||||
<p class="text-muted mb-0">
|
||||
<i class="bi bi-building"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)
|
||||
| <i class="bi bi-telephone"></i> @Model.ClientInfo.Phone
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-3 mt-sm-0">
|
||||
<form method="post" action="/taxbaik/portal/logout" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-box-arrow-right"></i> 로그아웃
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.ClientInfo == null)
|
||||
{
|
||||
<!-- 연동 대기 경고 -->
|
||||
<div class="card border-warning shadow-sm mb-5">
|
||||
<div class="card-body p-5 text-center">
|
||||
<div class="mb-4">
|
||||
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
|
||||
</div>
|
||||
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
|
||||
<p class="text-muted max-width-md mx-auto mb-4">
|
||||
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 레코드를 찾지 못했습니다.<br />
|
||||
세무사 측에서 고객 등록을 완료하거나 관리자 백오피스에서 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
|
||||
</p>
|
||||
<a href="/taxbaik/contact" class="btn btn-primary px-4 py-2">
|
||||
<i class="bi bi-chat-dots"></i> 세무사에게 문의하기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-4">
|
||||
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm rounded-3 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3 class="h5 fw-bold text-dark mb-0">
|
||||
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
|
||||
</h3>
|
||||
<span class="badge bg-secondary">총 @(Model.Filings.Count)건</span>
|
||||
</div>
|
||||
|
||||
@if (!Model.Filings.Any())
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-folder-x display-4 d-block mb-3 text-secondary"></i>
|
||||
등록된 세무 신고 일정이 없습니다.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">신고 종류</th>
|
||||
<th scope="col">신고 기한</th>
|
||||
<th scope="col">진행 상태</th>
|
||||
<th scope="col">메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var filing in Model.Filings)
|
||||
{
|
||||
var dDay = (filing.DueDate - DateTime.Today).Days;
|
||||
var statusClass = filing.Status switch
|
||||
{
|
||||
"filed" => "bg-success-subtle text-success",
|
||||
"overdue" => "bg-danger-subtle text-danger",
|
||||
_ => "bg-warning-subtle text-warning-emphasis"
|
||||
};
|
||||
var statusLabel = filing.Status switch
|
||||
{
|
||||
"filed" => "신고 완료",
|
||||
"overdue" => "기한 초과",
|
||||
_ => $"D-{dDay}"
|
||||
};
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<span class="fw-bold text-dark">@filing.FilingType</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>@filing.DueDate.ToString("yyyy-MM-dd")</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @statusClass px-2.5 py-1.5 fs-7">@statusLabel</span>
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="h5 fw-bold text-dark mb-4">
|
||||
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
|
||||
</h3>
|
||||
|
||||
@if (!Model.Consultations.Any())
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary"></i>
|
||||
최근 상담 이력이 없습니다.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="timeline">
|
||||
@foreach (var activity in Model.Consultations)
|
||||
{
|
||||
<div class="border-start border-2 border-primary-subtle ps-3 pb-4 position-relative">
|
||||
<!-- 타임라인 아이콘 -->
|
||||
<div class="position-absolute start-0 translate-middle-x bg-primary rounded-circle"
|
||||
style="width: 10px; height: 10px; margin-left: -1px; top: 6px;"></div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
|
||||
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
|
||||
</div>
|
||||
<p class="text-dark small mb-1 fw-semibold">@activity.Description</p>
|
||||
@if (!string.IsNullOrEmpty(activity.Outcome))
|
||||
{
|
||||
<div class="bg-light p-2 rounded small text-muted mt-1">
|
||||
<strong>결과:</strong> @activity.Outcome
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Web.Services;
|
||||
|
||||
namespace TaxBaik.Web.Pages.Portal;
|
||||
|
||||
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly TaxFilingService _taxFilingService;
|
||||
private readonly ConsultingActivityService _consultingActivityService;
|
||||
private readonly ClientService _clientService;
|
||||
|
||||
public IndexModel(
|
||||
TaxFilingService taxFilingService,
|
||||
ConsultingActivityService consultingActivityService,
|
||||
ClientService clientService)
|
||||
{
|
||||
_taxFilingService = taxFilingService;
|
||||
_consultingActivityService = consultingActivityService;
|
||||
_clientService = clientService;
|
||||
}
|
||||
|
||||
public Client? ClientInfo { get; private set; }
|
||||
public List<TaxFiling> Filings { get; private set; } = new();
|
||||
public List<ConsultingActivity> Consultations { get; private set; } = new();
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
var clientIdClaim = User.FindFirst("client_id");
|
||||
if (clientIdClaim != null && int.TryParse(clientIdClaim.Value, out var clientId))
|
||||
{
|
||||
ClientInfo = await _clientService.GetByIdAsync(clientId);
|
||||
if (ClientInfo != null)
|
||||
{
|
||||
var filingsData = await _taxFilingService.GetByClientIdAsync(clientId);
|
||||
Filings = filingsData.OrderBy(f => f.DueDate).ToList();
|
||||
|
||||
var consultationsData = await _consultingActivityService.GetByClientIdAsync(clientId);
|
||||
Consultations = consultationsData.OrderByDescending(c => c.ActivityDate).ToList();
|
||||
}
|
||||
}
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@page "/portal/login"
|
||||
@model TaxBaik.Web.Pages.Portal.LoginModel
|
||||
@{
|
||||
ViewData["Title"] = "고객 포털 로그인";
|
||||
ViewData["Description"] = "고객 포털 로그인 페이지입니다.";
|
||||
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/login";
|
||||
}
|
||||
|
||||
<section class="container py-5" style="max-width: 560px;">
|
||||
<h1 class="h3 fw-bold mb-4">고객 포털 로그인</h1>
|
||||
<div class="alert alert-secondary">
|
||||
포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다.
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@Model.ErrorMessage</div>
|
||||
}
|
||||
<form method="post" class="vstack gap-3">
|
||||
<div>
|
||||
<label class="form-label">이메일</label>
|
||||
<input class="form-control" asp-for="Email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">비밀번호</label>
|
||||
<input class="form-control" asp-for="Password" type="password" />
|
||||
</div>
|
||||
<button class="btn btn-dark" type="submit">로그인</button>
|
||||
</form>
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
<form method="post" asp-page-handler="Google">
|
||||
<button class="btn btn-outline-dark w-100" type="submit">Google로 로그인</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="Naver">
|
||||
<button class="btn btn-outline-success w-100" type="submit">Naver로 로그인</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="Kakao">
|
||||
<button class="btn btn-outline-warning w-100" type="submit">Kakao로 로그인</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using TaxBaik.Web.Services;
|
||||
|
||||
namespace TaxBaik.Web.Pages.Portal;
|
||||
|
||||
public class LoginModel : PageModel
|
||||
{
|
||||
private readonly PortalAuthService _portalAuthService;
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[BindProperty]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[BindProperty]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public LoginModel(PortalAuthService portalAuthService)
|
||||
{
|
||||
_portalAuthService = portalAuthService;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
ErrorMessage = "이메일과 비밀번호를 입력하세요.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
var signedIn = await _portalAuthService.SignInAsync(Email, Password);
|
||||
if (!signedIn)
|
||||
{
|
||||
ErrorMessage = "로그인 정보를 확인할 수 없습니다.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
return RedirectToPage("/Portal/Index");
|
||||
}
|
||||
|
||||
public IActionResult OnPostGoogle() => Challenge(BuildProps("google"), PortalOAuthDefaults.GoogleScheme);
|
||||
|
||||
public IActionResult OnPostNaver() => Challenge(BuildProps("naver"), PortalOAuthDefaults.NaverScheme);
|
||||
|
||||
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
|
||||
|
||||
private static AuthenticationProperties BuildProps(string provider) =>
|
||||
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@page "/portal/register"
|
||||
@model TaxBaik.Web.Pages.Portal.RegisterModel
|
||||
@{
|
||||
ViewData["Title"] = "고객 포털 회원가입";
|
||||
ViewData["Description"] = "고객 포털 회원가입 페이지입니다.";
|
||||
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/register";
|
||||
}
|
||||
|
||||
<section class="container py-5" style="max-width: 640px;">
|
||||
<h1 class="h3 fw-bold mb-4">고객 포털 회원가입</h1>
|
||||
<div class="alert alert-secondary">
|
||||
가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다.
|
||||
</div>
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">이름</label>
|
||||
<input class="form-control" asp-for="Name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">연락처</label>
|
||||
<input class="form-control" asp-for="Phone" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">이메일</label>
|
||||
<input class="form-control" asp-for="Email" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">비밀번호</label>
|
||||
<input class="form-control" asp-for="Password" type="password" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button class="btn btn-dark" type="submit">가입하기</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Web.Services;
|
||||
|
||||
namespace TaxBaik.Web.Pages.Portal;
|
||||
|
||||
public class RegisterModel : PageModel
|
||||
{
|
||||
private readonly PortalUserService _portalUserService;
|
||||
private readonly ClientService _clientService;
|
||||
|
||||
[BindProperty]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[BindProperty]
|
||||
public string Phone { get; set; } = "";
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[BindProperty]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[BindProperty]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public RegisterModel(PortalUserService portalUserService, ClientService clientService)
|
||||
{
|
||||
_portalUserService = portalUserService;
|
||||
_clientService = clientService;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name) || string.IsNullOrWhiteSpace(Email))
|
||||
{
|
||||
ErrorMessage = "이름과 이메일을 입력하세요.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Password) || Password.Length < 8)
|
||||
{
|
||||
ErrorMessage = "비밀번호는 8자 이상이어야 합니다.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
var existing = await _portalUserService.GetByEmailAsync(Email);
|
||||
if (existing is not null)
|
||||
{
|
||||
ErrorMessage = "이미 등록된 이메일입니다.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
int? clientId = null;
|
||||
var linkedClient = await _clientService.GetByEmailAsync(Email);
|
||||
if (linkedClient is null && !string.IsNullOrWhiteSpace(Phone))
|
||||
linkedClient = await _clientService.GetByPhoneAsync(Phone);
|
||||
if (linkedClient is not null)
|
||||
clientId = linkedClient.Id;
|
||||
|
||||
await _portalUserService.RegisterLocalAsync(
|
||||
Name,
|
||||
Email,
|
||||
Phone,
|
||||
PortalAuthService.HashPassword(Password),
|
||||
clientId: clientId);
|
||||
|
||||
return RedirectToPage("/Portal/Login");
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,57 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@(ViewData["Title"] ?? "백원숙 세무회계")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] ?? "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담.")" />
|
||||
<meta property="og:title" content="@ViewData["Title"]" />
|
||||
<meta property="og:description" content="@ViewData["Description"]" />
|
||||
<meta property="og:image" content="@ViewData["OgImage"]" />
|
||||
<meta property="og:url" content="@ViewData["OgUrl"]" />
|
||||
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta property="og:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
|
||||
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "http://178.104.200.7/taxbaik/")" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
|
||||
|
||||
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
|
||||
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
|
||||
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
|
||||
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="theme-color" content="#C89D6E" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="canonical" href="@ViewData["CanonicalUrl"]" />
|
||||
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
|
||||
<!-- 구조화된 데이터 (JSON-LD Schema Markup) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "ProfessionalService",
|
||||
"name": "백원숙 세무회계",
|
||||
"description": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담 세무사",
|
||||
"url": "http://178.104.200.7/taxbaik/",
|
||||
"telephone": "010-4122-8268",
|
||||
"email": "taxbaik5668@gmail.com",
|
||||
"address": {
|
||||
"@@type": "PostalAddress",
|
||||
"addressCountry": "KR"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/taxtory5668/",
|
||||
"http://pf.kakao.com/_xoxchTX"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="with-mobile-cta">
|
||||
<partial name="_Header" />
|
||||
@@ -54,6 +90,7 @@
|
||||
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
|
||||
<a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a>
|
||||
<a href="/taxbaik/terms" class="text-decoration-none text-muted">이용약관</a>
|
||||
<a href="/taxbaik/portal" class="text-decoration-none text-muted ms-2">고객 포털</a>
|
||||
@if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version)
|
||||
{
|
||||
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
|
||||
|
||||
+146
-43
@@ -3,6 +3,8 @@ using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Unicode;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
@@ -13,6 +15,7 @@ using TaxBaik.Application;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Infrastructure;
|
||||
using TaxBaik.Web.Services;
|
||||
using TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var isProduction = builder.Environment.IsProduction();
|
||||
@@ -35,6 +38,13 @@ 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)
|
||||
@@ -42,9 +52,6 @@ builder.Services.AddControllers();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// SignalR (Notifications only, no state management)
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Razor Pages + Blazor Server 통합
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
@@ -61,7 +68,7 @@ if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnore
|
||||
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
|
||||
var key = Encoding.ASCII.GetBytes(jwtKey);
|
||||
|
||||
builder.Services.AddAuthentication(opts =>
|
||||
var authenticationBuilder = builder.Services.AddAuthentication(opts =>
|
||||
{
|
||||
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
@@ -79,8 +86,105 @@ builder.Services.AddAuthentication(opts =>
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
})
|
||||
.AddCookie(PortalAuthDefaults.Scheme, opts =>
|
||||
{
|
||||
opts.Cookie.Name = PortalAuthDefaults.CookieName;
|
||||
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.SlidingExpiration = true;
|
||||
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||
})
|
||||
.AddCookie(PortalOAuthDefaults.ExternalScheme, opts =>
|
||||
{
|
||||
opts.Cookie.Name = "TaxBaik.Portal.External";
|
||||
opts.Cookie.HttpOnly = true;
|
||||
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||
});
|
||||
|
||||
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
|
||||
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
|
||||
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
|
||||
{
|
||||
authenticationBuilder.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
|
||||
{
|
||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||
opts.ClientId = googleClientId;
|
||||
opts.ClientSecret = googleClientSecret;
|
||||
opts.CallbackPath = "/taxbaik/portal/signin-google";
|
||||
});
|
||||
}
|
||||
|
||||
var naverClientId = builder.Configuration["Authentication:Naver:ClientId"];
|
||||
var naverClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"];
|
||||
if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(naverClientSecret))
|
||||
{
|
||||
authenticationBuilder.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
|
||||
{
|
||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||
opts.ClientId = naverClientId;
|
||||
opts.ClientSecret = naverClientSecret;
|
||||
opts.CallbackPath = "/taxbaik/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";
|
||||
opts.SaveTokens = true;
|
||||
opts.Events = new OAuthEvents
|
||||
{
|
||||
OnCreatingTicket = async context =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
||||
var responseRoot = payload.RootElement.GetProperty("response");
|
||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
|
||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
|
||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
var kakaoClientId = builder.Configuration["Authentication:Kakao:ClientId"];
|
||||
var kakaoClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"];
|
||||
if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kakaoClientSecret))
|
||||
{
|
||||
authenticationBuilder.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
|
||||
{
|
||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||
opts.ClientId = kakaoClientId;
|
||||
opts.ClientSecret = kakaoClientSecret;
|
||||
opts.CallbackPath = "/taxbaik/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";
|
||||
opts.SaveTokens = true;
|
||||
opts.Events = new OAuthEvents
|
||||
{
|
||||
OnCreatingTicket = async context =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
||||
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
|
||||
var profile = kakaoAccount.GetProperty("profile");
|
||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
|
||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
|
||||
if (kakaoAccount.TryGetProperty("email", out var emailProp))
|
||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Blazor 인증
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
||||
@@ -90,9 +194,6 @@ builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
// Notifications (SignalR)
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
|
||||
// Telegram Notification
|
||||
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
|
||||
|
||||
@@ -107,33 +208,53 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
|
||||
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
|
||||
// Phase 5: Tax Accounting & CRM Browser Clients
|
||||
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
// UI & 캐시 (MudBlazor Theme Customization)
|
||||
builder.Services.AddMudServices(config =>
|
||||
@@ -145,13 +266,18 @@ builder.Services.AddMemoryCache();
|
||||
builder.Services.AddResponseCompression(opts => {
|
||||
opts.Providers.Add<GzipCompressionProvider>();
|
||||
});
|
||||
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
||||
builder.Services.AddHostedService<TelegramReportBackgroundService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<PortalAuthService>();
|
||||
|
||||
builder.Services.Configure<PortalAuthOptions>(builder.Configuration.GetSection("Authentication"));
|
||||
|
||||
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
|
||||
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
||||
|
||||
builder.Services.AddInfrastructure();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
||||
|
||||
// Register version info
|
||||
var versionInfo = new VersionInfo();
|
||||
@@ -218,8 +344,6 @@ app.MapControllers();
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapRazorPages();
|
||||
|
||||
// SignalR Hub
|
||||
app.MapHub<TaxBaik.Web.Hubs.NotificationHub>("/taxbaik/notifications");
|
||||
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
|
||||
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
|
||||
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
||||
@@ -230,27 +354,6 @@ app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
||||
try
|
||||
{
|
||||
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
// 배포 완료 알림을 백그라운드에서 비동기 전송 (앱 시작 블록 방지)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var telegramService = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||
await telegramService.SendInfoAsync(
|
||||
"✅ 배포 완료",
|
||||
$"환경: {app.Environment.EnvironmentName}\n상태: 정상 운영 중");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "배포 완료 알림 전송 실패");
|
||||
}
|
||||
});
|
||||
}
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IConsultingActivityBrowserClient
|
||||
{
|
||||
Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
|
||||
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
|
||||
Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
|
||||
: IConsultingActivityBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/consultingactivity";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get consulting activities");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get activities for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get pending followups");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
|
||||
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create consulting activity");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { outcome, nextFollowupDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update consulting activity {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete consulting activity {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IContractBrowserClient
|
||||
{
|
||||
Task<List<Contract>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default);
|
||||
Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
|
||||
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
|
||||
: IContractBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/contract";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get contracts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get contract {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get contracts for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get active contracts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get expiring contracts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default)
|
||||
{
|
||||
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());
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get MRR");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
|
||||
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create contract");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete contract {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IRevenueTrackingBrowserClient
|
||||
{
|
||||
Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default);
|
||||
Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default);
|
||||
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
|
||||
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default);
|
||||
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
|
||||
: IRevenueTrackingBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/revenuetracking";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get revenue tracking");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get revenue for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get pending payments");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get monthly revenue {Year}-{Month}", year, month);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
|
||||
{
|
||||
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))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<decimal>(totalValue.GetRawText());
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get total revenue");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
|
||||
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create revenue tracking");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { paymentDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to mark payment {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete revenue tracking {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ITaxFilingScheduleBrowserClient
|
||||
{
|
||||
Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||
int? assignedTo = null, CancellationToken ct = default);
|
||||
Task MarkCompletedAsync(int id, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
|
||||
: ITaxFilingScheduleBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/taxfilingschedule";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax filing schedules");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax filing schedule {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get filing schedules for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get upcoming filings");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||
int? assignedTo = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create tax filing schedule");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkCompletedAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to mark filing as completed {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete tax filing schedule {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ITaxProfileBrowserClient
|
||||
{
|
||||
Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||
Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default);
|
||||
Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
|
||||
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default);
|
||||
Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
|
||||
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/taxprofile";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax profiles");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax profile {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get tax profiles for client {ClientId}", clientId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get high-risk profiles");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
{
|
||||
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()) ?? [];
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get upcoming filings");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
|
||||
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create tax profile");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
|
||||
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update tax profile {Id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete tax profile {Id}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,10 @@ public class AdminDashboardClient : IAdminDashboardClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -29,10 +29,10 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -34,10 +34,10 @@ public class ClientBrowserClient : IClientBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user