Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -3,3 +3,10 @@ ASPNETCORE_URLS=http://0.0.0.0:5001
|
|||||||
ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me
|
ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me
|
||||||
Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars!
|
Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars!
|
||||||
Admin__PasswordResetToken=change-this-reset-token
|
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
|
name: TaxBaik CI/CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@@ -38,18 +39,29 @@ jobs:
|
|||||||
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
||||||
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||||
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
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 "$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_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_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" \
|
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||||
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
||||||
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
||||||
|
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
||||||
|
TELEGRAM_SYSTEM_CHAT_ID="$TELEGRAM_SYSTEM_CHAT_ID" \
|
||||||
python3 -c '
|
python3 -c '
|
||||||
import json, os, pathlib
|
import json, os, pathlib
|
||||||
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
"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),
|
}, ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)'
|
)'
|
||||||
@@ -98,6 +110,34 @@ jobs:
|
|||||||
COMMIT=$(git rev-parse --short HEAD)
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
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) ==="
|
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
|
||||||
|
|
||||||
@@ -179,3 +219,9 @@ jobs:
|
|||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
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>"
|
||||||
|
|||||||
@@ -66,12 +66,19 @@ _refreshTokenExpirationMinutes = 10080;
|
|||||||
- [ ] Blazor에서 구독
|
- [ ] Blazor에서 구독
|
||||||
- [ ] 알림 후 API로 데이터 검증
|
- [ ] 알림 후 API로 데이터 검증
|
||||||
|
|
||||||
#### Phase 7: 순차적 마이그레이션
|
#### Phase 7: 순차적 마이그레이션 ✅
|
||||||
- Blog 페이지 → API 클라이언트
|
- [x] Blog 페이지 → API 클라이언트
|
||||||
- Inquiry 페이지 → API 클라이언트
|
- [x] Inquiry 페이지 → API 클라이언트
|
||||||
- FAQ/Client/TaxFiling 등 순차 처리
|
- [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: 완성 (상태 변경, 메모, 고객 변환)
|
- API: 완성 (상태 변경, 메모, 고객 변환)
|
||||||
- Blazor: InquiryTable + InquiryDetail 완전 마이그레이션
|
- Blazor: InquiryTable + InquiryDetail 완전 마이그레이션
|
||||||
|
|
||||||
**Phase 7-3: 모든 관리자 페이지** ✅
|
**Phase 7-3: 공개 콘텐츠 & 기본 관리 페이지** ✅
|
||||||
- 4개 API Controller (Clients, TaxFilings, Faqs, Announcements)
|
- 4개 API Controller (Clients, TaxFilings, Faqs, Announcements)
|
||||||
- 5개 Browser Client (IXxxBrowserClient)
|
- 5개 Browser Client (IXxxBrowserClient)
|
||||||
- 9개 Blazor 페이지 마이그레이션
|
- 9개 Blazor 페이지 마이그레이션
|
||||||
@@ -108,6 +115,27 @@ _refreshTokenExpirationMinutes = 10080;
|
|||||||
| Inquiries | ✅ InquiryController | ✅ IInquiryBrowserClient | ✅ List + Detail |
|
| Inquiries | ✅ InquiryController | ✅ IInquiryBrowserClient | ✅ List + Detail |
|
||||||
| Dashboard | ✅ AdminDashboardController | ✅ IAdminDashboardClient | ✅ Refactored |
|
| Dashboard | ✅ AdminDashboardController | ✅ IAdminDashboardClient | ✅ Refactored |
|
||||||
|
|
||||||
|
**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: SignalR 통합** ✅
|
### **Phase 6: SignalR 통합** ✅
|
||||||
- NotificationHub (브로드캐스트만, 상태 관리 없음)
|
- NotificationHub (브로드캐스트만, 상태 관리 없음)
|
||||||
- INotificationService (이벤트 기반)
|
- INotificationService (이벤트 기반)
|
||||||
@@ -148,16 +176,26 @@ PostgreSQL Database
|
|||||||
- [x] 안전한 메모리 저장소 (ITokenStore)
|
- [x] 안전한 메모리 저장소 (ITokenStore)
|
||||||
|
|
||||||
**API-First 마이그레이션 (Phase 7)**:
|
**API-First 마이그레이션 (Phase 7)**:
|
||||||
- [x] 모든 관리자 페이지 API 컨트롤러 (6개)
|
- [x] Phase 7-1: Blog API + Blazor 클라이언트
|
||||||
- [x] 모든 Browser Client (5개 + Dashboard)
|
- [x] Phase 7-2: Inquiry API + Blazor 클라이언트
|
||||||
- [x] 모든 Blazor 페이지 리팩토링 (9개)
|
- [x] Phase 7-3: 공개 콘텐츠 & 기본 관리 페이지 (6개 API, 6개 Blazor)
|
||||||
- [x] SOLID 원칙 전체 적용
|
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
|
||||||
|
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
|
||||||
|
|
||||||
**실시간 알림 (Phase 6)**:
|
**실시간 알림 (Phase 6)**:
|
||||||
- [x] NotificationHub 구현
|
- [x] NotificationHub 구현
|
||||||
- [x] Event-driven 알림 시스템
|
- [x] Event-driven 알림 시스템
|
||||||
- [x] Scoped DI 등록
|
- [x] Scoped DI 등록
|
||||||
|
|
||||||
|
**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 오류, 모든 경고 기록됨
|
- [x] 0 오류, 모든 경고 기록됨
|
||||||
- [x] 모든 커밋 Gitea에 푸시됨
|
- [x] 모든 커밋 Gitea에 푸시됨
|
||||||
@@ -1055,6 +1093,219 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
|||||||
- **메모이제이션**: `OnParametersSet` vs `OnInitializedAsync` 구분
|
- **메모이제이션**: `OnParametersSet` vs `OnInitializedAsync` 구분
|
||||||
- **API 캐싱**: 변경이 없으면 `IMemoryCache` 사용 (5분 TTL)
|
- **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
|
## 9. Do's & Don'ts
|
||||||
@@ -1680,6 +1931,48 @@ else
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 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. 문제 해결
|
## 12. 문제 해결
|
||||||
|
|
||||||
| 문제 | 해결 |
|
| 문제 | 해결 |
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
||||||
|
|
||||||
|
CI deploy trigger verification note.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ConsultingActivityService>();
|
services.AddScoped<ConsultingActivityService>();
|
||||||
services.AddScoped<ContractService>();
|
services.AddScoped<ContractService>();
|
||||||
services.AddScoped<RevenueTrackingService>();
|
services.AddScoped<RevenueTrackingService>();
|
||||||
|
services.AddScoped<TelegramReportService>();
|
||||||
|
services.AddScoped<PortalUserService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ public class ClientService(IClientRepository repository)
|
|||||||
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.GetByIdAsync(id, ct);
|
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)
|
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class InquiryService(
|
|||||||
|
|
||||||
public async Task<int> SubmitAsync(
|
public async Task<int> SubmitAsync(
|
||||||
string name, string phone, string serviceType, string message,
|
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))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
throw new ValidationException("이름을 입력하세요.");
|
throw new ValidationException("이름을 입력하세요.");
|
||||||
@@ -39,7 +39,10 @@ public class InquiryService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
||||||
|
if (!suppressNotification)
|
||||||
|
{
|
||||||
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
||||||
|
}
|
||||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
return inquiryId;
|
return inquiryId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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>";
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ public interface IClientRepository
|
|||||||
int page, int pageSize, string? status = null, string? search = null,
|
int page, int pageSize, string? status = null, string? search = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
Task<Client?> GetByIdAsync(int id, 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<int> CreateAsync(Client client, CancellationToken ct = default);
|
||||||
Task UpdateAsync(Client client, CancellationToken ct = default);
|
Task UpdateAsync(Client client, CancellationToken ct = default);
|
||||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
Task DeleteAsync(int id, CancellationToken ct = 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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IClientRepository, ClientRepository>();
|
services.AddScoped<IClientRepository, ClientRepository>();
|
||||||
services.AddScoped<IFaqRepository, FaqRepository>();
|
services.AddScoped<IFaqRepository, FaqRepository>();
|
||||||
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
||||||
|
services.AddScoped<IPortalUserRepository, PortalUserRepository>();
|
||||||
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
||||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||||
services.AddScoped<ITaxProfileRepository, TaxProfileRepository>();
|
services.AddScoped<ITaxProfileRepository, TaxProfileRepository>();
|
||||||
|
|||||||
@@ -40,6 +40,33 @@ public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepo
|
|||||||
new { Id = id });
|
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)
|
public async Task<int> CreateAsync(Client client, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,37 +59,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<MudNavMenu Class="admin-nav">
|
<MudNavMenu Class="admin-nav">
|
||||||
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
<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">
|
<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/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>
|
||||||
|
|
||||||
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
|
<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/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</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/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
|
|
||||||
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
||||||
</MudNavMenu>
|
</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>
|
</MudDrawer>
|
||||||
|
|
||||||
<MudMainContent Class="admin-main">
|
<MudMainContent Class="admin-main">
|
||||||
@@ -101,7 +94,8 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool drawerOpen = true;
|
private bool drawerOpen = true;
|
||||||
private bool expandedCustomerGroup = true;
|
private bool expandedCRMGroup = true;
|
||||||
|
private bool expandedCustomerGroup = false;
|
||||||
private bool expandedWebsiteGroup = false;
|
private bool expandedWebsiteGroup = false;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
@@ -109,6 +103,16 @@
|
|||||||
Navigation.LocationChanged += OnLocationChanged;
|
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)
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||||
{
|
{
|
||||||
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
||||||
|
|||||||
@@ -8,21 +8,28 @@
|
|||||||
|
|
||||||
<PageTitle>상담 활동 관리</PageTitle>
|
<PageTitle>상담 활동 관리</PageTitle>
|
||||||
|
|
||||||
<div class="admin-container">
|
<section class="admin-page-hero">
|
||||||
<div class="admin-header">
|
<div>
|
||||||
<MudText Typo="Typo.h5" Class="font-weight-bold">상담 활동 관리</MudText>
|
<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 Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
새 활동 기록
|
새 활동 기록
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
@if (activities == null)
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (activities is null)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
<MudProgressLinear Indeterminate="true" />
|
||||||
}
|
}
|
||||||
else if (activities.Count == 0)
|
else if (activities.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Class="mt-4">상담 활동이 없습니다.</MudAlert>
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="me-2" />
|
||||||
|
상담 활동이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -33,7 +40,7 @@
|
|||||||
Striped="true"
|
Striped="true"
|
||||||
Virtualize="true"
|
Virtualize="true"
|
||||||
RowsPerPage="30"
|
RowsPerPage="30"
|
||||||
Class="admin-grid mt-4">
|
Class="admin-grid">
|
||||||
<Columns>
|
<Columns>
|
||||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
<TemplateColumn Title="고객">
|
<TemplateColumn Title="고객">
|
||||||
@@ -82,9 +89,8 @@
|
|||||||
</Columns>
|
</Columns>
|
||||||
</MudDataGrid>
|
</MudDataGrid>
|
||||||
}
|
}
|
||||||
</div>
|
</MudPaper>
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
|
||||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText>
|
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText>
|
||||||
@@ -201,9 +207,11 @@
|
|||||||
|
|
||||||
private async Task DeleteActivity(int id)
|
private async Task DeleteActivity(int id)
|
||||||
{
|
{
|
||||||
var parameters = new DialogParameters();
|
var parameters = new DialogParameters
|
||||||
parameters.Add("Title", "삭제 확인");
|
{
|
||||||
parameters.Add("Message", "이 활동을 삭제하시겠습니까?");
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 활동을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
@@ -239,20 +247,3 @@
|
|||||||
public DateTime? NextFollowupDate { get; set; }
|
public DateTime? NextFollowupDate { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -8,29 +8,35 @@
|
|||||||
|
|
||||||
<PageTitle>계약 관리</PageTitle>
|
<PageTitle>계약 관리</PageTitle>
|
||||||
|
|
||||||
<div class="admin-container">
|
<section class="admin-page-hero">
|
||||||
<div class="admin-header">
|
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.h5" Class="font-weight-bold">계약 관리</MudText>
|
<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)
|
@if (mrr > 0)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2" Class="mt-2">
|
<MudText Typo="Typo.body2" Class="mt-2">
|
||||||
월 정기수익: <MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
|
월 정기수익:
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
새 계약 추가
|
새 계약 추가
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
@if (contracts == null)
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (contracts is null)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
<MudProgressLinear Indeterminate="true" />
|
||||||
}
|
}
|
||||||
else if (contracts.Count == 0)
|
else if (contracts.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Class="mt-4">계약이 없습니다.</MudAlert>
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
|
||||||
|
계약이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -41,7 +47,7 @@
|
|||||||
Striped="true"
|
Striped="true"
|
||||||
Virtualize="true"
|
Virtualize="true"
|
||||||
RowsPerPage="30"
|
RowsPerPage="30"
|
||||||
Class="admin-grid mt-4">
|
Class="admin-grid">
|
||||||
<Columns>
|
<Columns>
|
||||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
<TemplateColumn Title="고객">
|
<TemplateColumn Title="고객">
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
</Columns>
|
</Columns>
|
||||||
</MudDataGrid>
|
</MudDataGrid>
|
||||||
}
|
}
|
||||||
</div>
|
</MudPaper>
|
||||||
|
|
||||||
<!-- Create Dialog -->
|
<!-- Create Dialog -->
|
||||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
@@ -181,9 +187,11 @@
|
|||||||
|
|
||||||
private async Task DeleteContract(int id)
|
private async Task DeleteContract(int id)
|
||||||
{
|
{
|
||||||
var parameters = new DialogParameters();
|
var parameters = new DialogParameters
|
||||||
parameters.Add("Title", "삭제 확인");
|
{
|
||||||
parameters.Add("Message", "이 계약을 삭제하시겠습니까?");
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 계약을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
@@ -218,20 +226,3 @@
|
|||||||
public decimal? MonthlyFee { get; set; }
|
public decimal? MonthlyFee { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -8,21 +8,28 @@
|
|||||||
|
|
||||||
<PageTitle>수익 추적 관리</PageTitle>
|
<PageTitle>수익 추적 관리</PageTitle>
|
||||||
|
|
||||||
<div class="admin-container">
|
<section class="admin-page-hero">
|
||||||
<div class="admin-header">
|
<div>
|
||||||
<MudText Typo="Typo.h5" Class="font-weight-bold">수익 추적 관리</MudText>
|
<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 Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
새 청구 추가
|
새 청구 추가
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
@if (revenues == null)
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (revenues is null)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
<MudProgressLinear Indeterminate="true" />
|
||||||
}
|
}
|
||||||
else if (revenues.Count == 0)
|
else if (revenues.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Class="mt-4">청구 기록이 없습니다.</MudAlert>
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Payments" Class="me-2" />
|
||||||
|
청구 기록이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -33,7 +40,7 @@
|
|||||||
Striped="true"
|
Striped="true"
|
||||||
Virtualize="true"
|
Virtualize="true"
|
||||||
RowsPerPage="30"
|
RowsPerPage="30"
|
||||||
Class="admin-grid mt-4">
|
Class="admin-grid">
|
||||||
<Columns>
|
<Columns>
|
||||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
<TemplateColumn Title="고객">
|
<TemplateColumn Title="고객">
|
||||||
@@ -77,7 +84,7 @@
|
|||||||
</Columns>
|
</Columns>
|
||||||
</MudDataGrid>
|
</MudDataGrid>
|
||||||
}
|
}
|
||||||
</div>
|
</MudPaper>
|
||||||
|
|
||||||
<!-- Create Dialog -->
|
<!-- Create Dialog -->
|
||||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
@@ -180,9 +187,11 @@
|
|||||||
|
|
||||||
private async Task DeleteRevenue(int id)
|
private async Task DeleteRevenue(int id)
|
||||||
{
|
{
|
||||||
var parameters = new DialogParameters();
|
var parameters = new DialogParameters
|
||||||
parameters.Add("Title", "삭제 확인");
|
{
|
||||||
parameters.Add("Message", "이 청구를 삭제하시겠습니까?");
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 청구를 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
@@ -218,20 +227,3 @@
|
|||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -6,23 +6,33 @@
|
|||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>신고 일정 관리</PageTitle>
|
<PageTitle>신고 일정</PageTitle>
|
||||||
|
|
||||||
<div class="admin-container">
|
<section class="admin-page-hero">
|
||||||
<div class="admin-header">
|
<div>
|
||||||
<MudText Typo="Typo.h5" Class="font-weight-bold">신고 일정 관리</MudText>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
<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>
|
</MudButton>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
@if (schedules == null)
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (schedules is null)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
<MudProgressLinear Indeterminate="true" />
|
||||||
}
|
}
|
||||||
else if (schedules.Count == 0)
|
else if (schedules.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Class="mt-4">신고 일정이 없습니다.</MudAlert>
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
|
||||||
|
신고 일정이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -33,7 +43,7 @@
|
|||||||
Striped="true"
|
Striped="true"
|
||||||
Virtualize="true"
|
Virtualize="true"
|
||||||
RowsPerPage="30"
|
RowsPerPage="30"
|
||||||
Class="admin-grid mt-4">
|
Class="admin-grid">
|
||||||
<Columns>
|
<Columns>
|
||||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
<TemplateColumn Title="고객">
|
<TemplateColumn Title="고객">
|
||||||
@@ -55,8 +65,14 @@
|
|||||||
}
|
}
|
||||||
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
|
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
|
||||||
@context.Item.DueDate.ToString("yyyy-MM-dd")
|
@context.Item.DueDate.ToString("yyyy-MM-dd")
|
||||||
@if (daysLeft >= 0) { <span>(D-@daysLeft)</span> }
|
@if (daysLeft >= 0)
|
||||||
else { <span>(마감@(Math.Abs(daysLeft))일경과)</span> }
|
{
|
||||||
|
<span class="ms-1">(D-@daysLeft)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
|
||||||
|
}
|
||||||
</MudChip>
|
</MudChip>
|
||||||
</CellTemplate>
|
</CellTemplate>
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
@@ -78,27 +94,36 @@
|
|||||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
@if (context.Item.Status != "completed")
|
@if (context.Item.Status != "completed")
|
||||||
{
|
{
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success"
|
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
|
||||||
OnClick="@(async () => await CompleteSchedule(context.Item.Id))" Title="완료" />
|
Color="Color.Success"
|
||||||
|
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
|
||||||
|
Title="완료" />
|
||||||
}
|
}
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
OnClick="@(async () => await DeleteSchedule(context.Item.Id))" />
|
Color="Color.Error"
|
||||||
|
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
|
||||||
|
Title="삭제" />
|
||||||
</MudButtonGroup>
|
</MudButtonGroup>
|
||||||
</CellTemplate>
|
</CellTemplate>
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
</Columns>
|
</Columns>
|
||||||
</MudDataGrid>
|
</MudDataGrid>
|
||||||
}
|
}
|
||||||
</div>
|
</MudPaper>
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
|
||||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
<MudText Typo="Typo.h6">@(editingSchedule == null ? "새 신고 일정 추가" : "신고 일정 수정")</MudText>
|
<MudText Typo="Typo.h6">새 신고 일정 추가</MudText>
|
||||||
</TitleContent>
|
</TitleContent>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudForm @ref="form">
|
<MudForm @ref="form">
|
||||||
<MudSelect T="int" @bind-Value="scheduleForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
<MudSelect T="int"
|
||||||
|
@bind-Value="scheduleForm.ClientId"
|
||||||
|
Label="고객"
|
||||||
|
Required="true"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
FullWidth="true"
|
||||||
|
Class="mb-4">
|
||||||
@foreach (var client in clients)
|
@foreach (var client in clients)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||||
@@ -121,13 +146,9 @@
|
|||||||
private Dictionary<int, string> clientMap = new();
|
private Dictionary<int, string> clientMap = new();
|
||||||
private MudForm? form;
|
private MudForm? form;
|
||||||
private bool isDialogOpen;
|
private bool isDialogOpen;
|
||||||
private TaxFilingSchedule? editingSchedule;
|
|
||||||
private TaxFilingScheduleForm scheduleForm = new();
|
private TaxFilingScheduleForm scheduleForm = new();
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync() => await LoadData();
|
||||||
{
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadData()
|
private async Task LoadData()
|
||||||
{
|
{
|
||||||
@@ -146,21 +167,18 @@
|
|||||||
|
|
||||||
private void OpenCreateDialog()
|
private void OpenCreateDialog()
|
||||||
{
|
{
|
||||||
editingSchedule = null;
|
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
|
||||||
scheduleForm = new();
|
|
||||||
isDialogOpen = true;
|
isDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveSchedule()
|
private async Task SaveSchedule()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
|
||||||
if (editingSchedule == null)
|
|
||||||
{
|
{
|
||||||
var newId = await TaxFilingClient.CreateAsync(
|
var newId = await TaxFilingClient.CreateAsync(
|
||||||
scheduleForm.ClientId,
|
scheduleForm.ClientId,
|
||||||
scheduleForm.FilingType,
|
scheduleForm.FilingType,
|
||||||
scheduleForm.DueDate ?? DateTime.Now,
|
scheduleForm.DueDate ?? DateTime.Today,
|
||||||
scheduleForm.FilingYear);
|
scheduleForm.FilingYear);
|
||||||
|
|
||||||
if (newId > 0)
|
if (newId > 0)
|
||||||
@@ -169,6 +187,9 @@
|
|||||||
CloseDialog();
|
CloseDialog();
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -193,13 +214,14 @@
|
|||||||
|
|
||||||
private async Task DeleteSchedule(int id)
|
private async Task DeleteSchedule(int id)
|
||||||
{
|
{
|
||||||
var parameters = new DialogParameters();
|
var parameters = new DialogParameters
|
||||||
parameters.Add("Title", "삭제 확인");
|
{
|
||||||
parameters.Add("Message", "이 신고 일정을 삭제하시겠습니까?");
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 신고 일정을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
|
|
||||||
if (result?.Canceled ?? true)
|
if (result?.Canceled ?? true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -218,7 +240,6 @@
|
|||||||
private void CloseDialog()
|
private void CloseDialog()
|
||||||
{
|
{
|
||||||
isDialogOpen = false;
|
isDialogOpen = false;
|
||||||
editingSchedule = null;
|
|
||||||
scheduleForm = new();
|
scheduleForm = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,20 +251,3 @@
|
|||||||
public int FilingYear { get; set; } = DateTime.Now.Year;
|
public int FilingYear { get; set; } = DateTime.Now.Year;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -6,15 +6,18 @@
|
|||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>세무 프로필 관리</PageTitle>
|
<PageTitle>세무 프로필</PageTitle>
|
||||||
|
|
||||||
<div class="admin-container">
|
<section class="admin-page-hero">
|
||||||
<div class="admin-header">
|
<div>
|
||||||
<MudText Typo="Typo.h5" Class="font-weight-bold">세무 프로필 관리</MudText>
|
<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 Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
새 프로필 추가
|
새 프로필 추가
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
@if (profiles == null)
|
@if (profiles == null)
|
||||||
{
|
{
|
||||||
@@ -73,12 +76,11 @@
|
|||||||
</Columns>
|
</Columns>
|
||||||
</MudDataGrid>
|
</MudDataGrid>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
<!-- Create/Edit Dialog -->
|
||||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
<MudText Typo="Typo.h6">@(editingProfile == null ? "새 프로필 추가" : "프로필 수정")</MudText>
|
<MudText Typo="Typo.h6">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
|
||||||
</TitleContent>
|
</TitleContent>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudForm @ref="form">
|
<MudForm @ref="form">
|
||||||
@@ -110,6 +112,7 @@
|
|||||||
private Dictionary<int, string> clientMap = new();
|
private Dictionary<int, string> clientMap = new();
|
||||||
private MudForm? form;
|
private MudForm? form;
|
||||||
private bool isDialogOpen;
|
private bool isDialogOpen;
|
||||||
|
private bool isEditMode;
|
||||||
private TaxProfile? editingProfile;
|
private TaxProfile? editingProfile;
|
||||||
private TaxProfileForm profileForm = new();
|
private TaxProfileForm profileForm = new();
|
||||||
|
|
||||||
@@ -135,6 +138,7 @@
|
|||||||
|
|
||||||
private void OpenCreateDialog()
|
private void OpenCreateDialog()
|
||||||
{
|
{
|
||||||
|
isEditMode = false;
|
||||||
editingProfile = null;
|
editingProfile = null;
|
||||||
profileForm = new();
|
profileForm = new();
|
||||||
isDialogOpen = true;
|
isDialogOpen = true;
|
||||||
@@ -142,6 +146,7 @@
|
|||||||
|
|
||||||
private async Task OpenEditDialog(TaxProfile profile)
|
private async Task OpenEditDialog(TaxProfile profile)
|
||||||
{
|
{
|
||||||
|
isEditMode = true;
|
||||||
editingProfile = profile;
|
editingProfile = profile;
|
||||||
profileForm = new TaxProfileForm
|
profileForm = new TaxProfileForm
|
||||||
{
|
{
|
||||||
@@ -158,33 +163,29 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (editingProfile == null)
|
if (isEditMode)
|
||||||
{
|
|
||||||
var newId = await TaxProfileClient.CreateAsync(
|
|
||||||
profileForm.ClientId,
|
|
||||||
profileForm.BusinessType);
|
|
||||||
|
|
||||||
if (newId > 0)
|
|
||||||
{
|
|
||||||
Snackbar.Add("프로필이 생성되었습니다.", Severity.Success);
|
|
||||||
CloseDialog();
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
await TaxProfileClient.UpdateAsync(
|
await TaxProfileClient.UpdateAsync(
|
||||||
editingProfile.Id,
|
editingProfile!.Id,
|
||||||
profileForm.BusinessType,
|
profileForm.BusinessType,
|
||||||
null,
|
null,
|
||||||
profileForm.NextFilingDueDate,
|
profileForm.NextFilingDueDate,
|
||||||
profileForm.TaxRiskLevel);
|
profileForm.TaxRiskLevel);
|
||||||
|
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
|
||||||
Snackbar.Add("프로필이 업데이트되었습니다.", Severity.Success);
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newId = await TaxProfileClient.CreateAsync(
|
||||||
|
profileForm.ClientId,
|
||||||
|
profileForm.BusinessType);
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
CloseDialog();
|
CloseDialog();
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
@@ -195,7 +196,7 @@
|
|||||||
{
|
{
|
||||||
var parameters = new DialogParameters();
|
var parameters = new DialogParameters();
|
||||||
parameters.Add("Title", "삭제 확인");
|
parameters.Add("Title", "삭제 확인");
|
||||||
parameters.Add("Message", "이 프로필을 삭제하시겠습니까?");
|
parameters.Add("Message", "이 세무 프로필을 삭제하시겠습니까?");
|
||||||
|
|
||||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
@@ -206,7 +207,7 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await TaxProfileClient.DeleteAsync(id);
|
await TaxProfileClient.DeleteAsync(id);
|
||||||
Snackbar.Add("프로필이 삭제되었습니다.", Severity.Success);
|
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -218,11 +219,12 @@
|
|||||||
private void CloseDialog()
|
private void CloseDialog()
|
||||||
{
|
{
|
||||||
isDialogOpen = false;
|
isDialogOpen = false;
|
||||||
|
isEditMode = false;
|
||||||
editingProfile = null;
|
editingProfile = null;
|
||||||
profileForm = new();
|
profileForm = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Color GetRiskColor(string level) => level switch
|
private Color GetRiskColor(string riskLevel) => riskLevel switch
|
||||||
{
|
{
|
||||||
"high" => Color.Error,
|
"high" => Color.Error,
|
||||||
"normal" => Color.Warning,
|
"normal" => Color.Warning,
|
||||||
@@ -239,20 +241,3 @@
|
|||||||
public string? SpecialNotes { get; set; }
|
public string? SpecialNotes { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
token = tokenPair.AccessToken,
|
||||||
accessToken = tokenPair.AccessToken,
|
accessToken = tokenPair.AccessToken,
|
||||||
refreshToken = tokenPair.RefreshToken,
|
refreshToken = tokenPair.RefreshToken,
|
||||||
expiresIn = tokenPair.ExpiresIn
|
expiresIn = tokenPair.ExpiresIn
|
||||||
@@ -45,6 +46,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
token = tokenPair.AccessToken,
|
||||||
accessToken = tokenPair.AccessToken,
|
accessToken = tokenPair.AccessToken,
|
||||||
refreshToken = tokenPair.RefreshToken,
|
refreshToken = tokenPair.RefreshToken,
|
||||||
expiresIn = tokenPair.ExpiresIn
|
expiresIn = tokenPair.ExpiresIn
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public class InquiryController : ControllerBase
|
|||||||
request.ServiceType,
|
request.ServiceType,
|
||||||
request.Message,
|
request.Message,
|
||||||
request.Email,
|
request.Email,
|
||||||
HttpContext.Connection.RemoteIpAddress?.ToString());
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
request.SuppressNotification);
|
||||||
return Ok(new { message = "상담 신청이 접수되었습니다." });
|
return Ok(new { message = "상담 신청이 접수되었습니다." });
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
@@ -135,6 +136,7 @@ public class SubmitInquiryRequest
|
|||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string ServiceType { get; set; } = string.Empty;
|
public string ServiceType { get; set; } = string.Empty;
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public bool SuppressNotification { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateStatusRequest
|
public class UpdateStatusRequest
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="container py-5" style="max-width: 600px;">
|
<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)
|
@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,34 @@
|
|||||||
|
@page "/portal"
|
||||||
|
@model TaxBaik.Web.Pages.Portal.IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "고객 포털";
|
||||||
|
ViewData["Description"] = "고객이 신고 일정, 상담 요약, 중요 알림을 확인하는 전용 포털입니다.";
|
||||||
|
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="container py-5">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<p class="text-uppercase text-muted small mb-2">Portal</p>
|
||||||
|
<h1 class="display-6 fw-bold mb-3">고객 포털</h1>
|
||||||
|
<p class="lead text-muted mb-4">
|
||||||
|
신고 일정, 상담 요약, 승인된 알림을 확인할 수 있는 전용 공간입니다.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-dark" href="/taxbaik/portal/login">로그인</a>
|
||||||
|
<a class="btn btn-outline-dark" href="/taxbaik/portal/register">회원가입</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="p-4 bg-light border rounded-3">
|
||||||
|
<h2 class="h5 fw-bold mb-3">제공 예정 기능</h2>
|
||||||
|
<ul class="mb-0 text-muted">
|
||||||
|
<li>본인 신고 일정 확인</li>
|
||||||
|
<li>상담 요약 열람</li>
|
||||||
|
<li>중요 알림 수신</li>
|
||||||
|
<li>관리자 승인 범위 내 정보 제공</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Portal;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
|
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
|
||||||
<a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a>
|
<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/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)
|
@if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version)
|
||||||
{
|
{
|
||||||
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
|
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
|
||||||
|
|||||||
+106
-23
@@ -3,6 +3,8 @@ using System.Text;
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Unicode;
|
using System.Text.Unicode;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.ResponseCompression;
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
@@ -62,7 +64,7 @@ if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnore
|
|||||||
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
|
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
|
||||||
var key = Encoding.ASCII.GetBytes(jwtKey);
|
var key = Encoding.ASCII.GetBytes(jwtKey);
|
||||||
|
|
||||||
builder.Services.AddAuthentication(opts =>
|
var authenticationBuilder = builder.Services.AddAuthentication(opts =>
|
||||||
{
|
{
|
||||||
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
@@ -80,8 +82,105 @@ builder.Services.AddAuthentication(opts =>
|
|||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.FromMinutes(1)
|
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 인증
|
// Blazor 인증
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddScoped<AuthService>();
|
||||||
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
||||||
@@ -177,13 +276,18 @@ builder.Services.AddMemoryCache();
|
|||||||
builder.Services.AddResponseCompression(opts => {
|
builder.Services.AddResponseCompression(opts => {
|
||||||
opts.Providers.Add<GzipCompressionProvider>();
|
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.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
||||||
|
|
||||||
builder.Services.AddInfrastructure();
|
builder.Services.AddInfrastructure();
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
||||||
|
|
||||||
// Register version info
|
// Register version info
|
||||||
var versionInfo = new VersionInfo();
|
var versionInfo = new VersionInfo();
|
||||||
@@ -262,27 +366,6 @@ app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
|
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();
|
app.Run();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
public static class PortalAuthDefaults
|
||||||
|
{
|
||||||
|
public const string Scheme = "PortalCookie";
|
||||||
|
public const string CookieName = "TaxBaik.Portal.Auth";
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
public sealed class PortalAuthOptions
|
||||||
|
{
|
||||||
|
public ExternalProviderOptions Google { get; set; } = new();
|
||||||
|
public ExternalProviderOptions Naver { get; set; } = new();
|
||||||
|
public ExternalProviderOptions Kakao { get; set; } = new();
|
||||||
|
|
||||||
|
public sealed class ExternalProviderOptions
|
||||||
|
{
|
||||||
|
public string ClientId { get; set; } = "";
|
||||||
|
public string ClientSecret { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class PortalAuthService(
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
PortalUserService portalUserService)
|
||||||
|
{
|
||||||
|
private static readonly PasswordHasher<PortalUser> Hasher = new();
|
||||||
|
|
||||||
|
public async Task<bool> SignInAsync(string email, string password, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext
|
||||||
|
?? throw new InvalidOperationException("HTTP context is unavailable.");
|
||||||
|
|
||||||
|
var user = await portalUserService.GetByEmailAsync(email, ct);
|
||||||
|
if (user is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(user.PasswordHash))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var verify = Hasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||||
|
if (verify == PasswordVerificationResult.Failed)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new(ClaimTypes.Name, user.Name),
|
||||||
|
new(ClaimTypes.Email, user.Email),
|
||||||
|
new("portal_user_id", user.Id.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user.ClientId.HasValue)
|
||||||
|
claims.Add(new("client_id", user.ClientId.Value.ToString()));
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, PortalAuthDefaults.Scheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
await httpContext.SignInAsync(
|
||||||
|
PortalAuthDefaults.Scheme,
|
||||||
|
principal,
|
||||||
|
new AuthenticationProperties
|
||||||
|
{
|
||||||
|
IsPersistent = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string HashPassword(string password)
|
||||||
|
{
|
||||||
|
var tempUser = new PortalUser();
|
||||||
|
return Hasher.HashPassword(tempUser, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SignOutAsync()
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext
|
||||||
|
?? throw new InvalidOperationException("HTTP context is unavailable.");
|
||||||
|
|
||||||
|
await httpContext.SignOutAsync(PortalAuthDefaults.Scheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
public static class PortalOAuthDefaults
|
||||||
|
{
|
||||||
|
public const string ExternalScheme = "PortalExternal";
|
||||||
|
public const string GoogleScheme = "PortalGoogle";
|
||||||
|
public const string NaverScheme = "PortalNaver";
|
||||||
|
public const string KakaoScheme = "PortalKakao";
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public interface ITelegramNotificationService
|
|||||||
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
|
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
|
||||||
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
|
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
|
||||||
Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
|
Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
|
||||||
|
Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TelegramNotificationService : ITelegramNotificationService
|
public class TelegramNotificationService : ITelegramNotificationService
|
||||||
@@ -33,8 +34,8 @@ public class TelegramNotificationService : ITelegramNotificationService
|
|||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_botToken = config["Telegram:BotToken"] ?? "";
|
_botToken = config["Telegram:BotToken"] ?? "";
|
||||||
_defaultChatId = config["Telegram:ChatId"] ?? "";
|
_defaultChatId = config["Telegram:ChatId"] ?? "-5434691215";
|
||||||
_inquiryChatId = config["Telegram:InquiryChatId"] ?? "-5434691215";
|
_inquiryChatId = config["Telegram:InquiryChatId"] ?? _defaultChatId;
|
||||||
_systemChatId = config["Telegram:SystemChatId"] ?? "-5585148480";
|
_systemChatId = config["Telegram:SystemChatId"] ?? "-5585148480";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ public class TelegramNotificationService : ITelegramNotificationService
|
|||||||
public async Task SendErrorAsync(string title, string details, CancellationToken ct = default)
|
public async Task SendErrorAsync(string title, string details, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var message = $"<b>❌ {title}</b>\n\n{details}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
var message = $"<b>❌ {title}</b>\n\n{details}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
||||||
await SendMessageAsync(message, ct);
|
await SendToChat(_systemChatId, message, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendInfoAsync(string title, string message, CancellationToken ct = default)
|
public async Task SendInfoAsync(string title, string message, CancellationToken ct = default)
|
||||||
@@ -96,4 +97,10 @@ public class TelegramNotificationService : ITelegramNotificationService
|
|||||||
var text = $"<b>ℹ️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
var text = $"<b>ℹ️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
||||||
await SendMessageAsync(text, ct);
|
await SendMessageAsync(text, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var text = $"<b>📊 {reportTitle}</b>\n\n{reportContent}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
||||||
|
await SendToChat(_systemChatId, text, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
public class TelegramReportBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<TelegramReportBackgroundService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeZoneInfo KoreaTimeZone = GetKoreaTimeZone();
|
||||||
|
private DateOnly? _lastDailyReportDate;
|
||||||
|
private DateOnly? _lastWeeklyReportWeekStart;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
|
||||||
|
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, KoreaTimeZone);
|
||||||
|
await TrySendReportsAsync(now, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Telegram report background loop failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TrySendReportsAsync(DateTimeOffset nowKst, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (nowKst.Hour is 9 or 10)
|
||||||
|
await SendDailyIfNeededAsync(DateOnly.FromDateTime(nowKst.DateTime), ct);
|
||||||
|
|
||||||
|
if (nowKst.DayOfWeek == DayOfWeek.Monday && nowKst.Hour is 9 or 10)
|
||||||
|
await SendWeeklyIfNeededAsync(DateOnly.FromDateTime(nowKst.DateTime).AddDays(-7), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendDailyIfNeededAsync(DateOnly date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_lastDailyReportDate == date)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var reportService = scope.ServiceProvider.GetRequiredService<TelegramReportService>();
|
||||||
|
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||||
|
|
||||||
|
var report = await reportService.BuildDailyReportAsync(date, ct);
|
||||||
|
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
|
||||||
|
_lastDailyReportDate = date;
|
||||||
|
logger.LogInformation("Daily telegram report sent for {Date}", date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendWeeklyIfNeededAsync(DateOnly weekStart, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_lastWeeklyReportWeekStart == weekStart)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var reportService = scope.ServiceProvider.GetRequiredService<TelegramReportService>();
|
||||||
|
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||||
|
|
||||||
|
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
|
||||||
|
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
|
||||||
|
_lastWeeklyReportWeekStart = weekStart;
|
||||||
|
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo GetKoreaTimeZone()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MudBlazor" Version="6.10.0" />
|
<PackageReference Include="MudBlazor" Version="6.10.0" />
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||||
|
|||||||
@@ -19,13 +19,27 @@
|
|||||||
},
|
},
|
||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": "8679990909:AAGLLRUIAuEbYAZVGOYDu-UuTu4ihroEiX0",
|
"BotToken": "8679990909:AAGLLRUIAuEbYAZVGOYDu-UuTu4ihroEiX0",
|
||||||
"ChatId": "-5585148480",
|
"ChatId": "-5434691215",
|
||||||
"InquiryChatId": "-5434691215",
|
"InquiryChatId": "-5434691215",
|
||||||
"SystemChatId": "-5585148480"
|
"SystemChatId": "-5585148480"
|
||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"PasswordResetToken": "dev-reset-token-12345"
|
"PasswordResetToken": "dev-reset-token-12345"
|
||||||
},
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": ""
|
||||||
|
},
|
||||||
|
"Naver": {
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": ""
|
||||||
|
},
|
||||||
|
"Kakao": {
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"SiteSettings": {
|
"SiteSettings": {
|
||||||
"PhoneNumber": "010-4122-8268",
|
"PhoneNumber": "010-4122-8268",
|
||||||
"EmailAddress": "taxbaik5668@gmail.com",
|
"EmailAddress": "taxbaik5668@gmail.com",
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-3) var(--space-6);
|
padding: 8px 20px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
@@ -429,21 +429,28 @@ textarea:focus-visible {
|
|||||||
.admin-topbar-title {
|
.admin-topbar-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar-title span {
|
.admin-topbar-title span {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-topbar-title .mud-typography--h6 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-topbar-action {
|
.admin-topbar-action {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-height: 40px;
|
min-height: 36px;
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: 6px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-drawer {
|
.admin-drawer {
|
||||||
width: 280px;
|
width: 224px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -454,7 +461,7 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-5) var(--space-4);
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--border-color-light);
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,18 +480,28 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav {
|
.admin-nav {
|
||||||
padding: var(--space-4) 0;
|
padding: 6px 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav .mud-nav-link,
|
.admin-nav .mud-nav-link,
|
||||||
.admin-nav .mud-nav-group-header {
|
.admin-nav .mud-nav-group-header {
|
||||||
margin: var(--space-1) var(--space-2) !important;
|
margin: 1px 8px !important;
|
||||||
border-radius: var(--radius-md) !important;
|
border-radius: 6px !important;
|
||||||
transition: all var(--transition-base) !important;
|
transition: all var(--transition-base) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-nav .mud-nav-link {
|
||||||
|
min-height: 36px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .mud-nav-group-header {
|
||||||
|
min-height: 36px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-nav .mud-nav-link:hover {
|
.admin-nav .mud-nav-link:hover {
|
||||||
background-color: var(--primary-light) !important;
|
background-color: var(--primary-light) !important;
|
||||||
}
|
}
|
||||||
@@ -526,7 +543,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
padding: var(--space-8);
|
padding: 20px;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -540,9 +557,9 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-6);
|
gap: 20px;
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: 20px;
|
||||||
padding-bottom: var(--space-6);
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,15 +581,15 @@ textarea:focus-visible {
|
|||||||
display: block;
|
display: block;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: 4px;
|
||||||
font-size: var(--font-size-3xl);
|
font-size: 1.75rem;
|
||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-page-subtitle {
|
.admin-page-subtitle {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--font-size-base);
|
font-size: 0.9rem;
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,15 +597,15 @@ textarea:focus-visible {
|
|||||||
.admin-metric-grid {
|
.admin-metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: var(--space-6);
|
gap: var(--space-4);
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-6);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metric Card - Enterprise Grade */
|
/* Metric Card - Enterprise Grade */
|
||||||
.admin-metric-card {
|
.admin-metric-card {
|
||||||
padding: var(--space-6);
|
padding: 12px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-md);
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
transition: all var(--transition-base);
|
transition: all var(--transition-base);
|
||||||
@@ -596,7 +613,7 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 160px;
|
min-height: 128px;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -675,11 +692,11 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
/* Surfaces & Containers */
|
/* Surfaces & Containers */
|
||||||
.admin-surface {
|
.admin-surface {
|
||||||
padding: var(--space-6) !important;
|
padding: 12px !important;
|
||||||
border-radius: var(--radius-lg) !important;
|
border-radius: var(--radius-md) !important;
|
||||||
background-color: var(--bg-primary) !important;
|
background-color: var(--bg-primary) !important;
|
||||||
border: 1px solid var(--border-color) !important;
|
border: 1px solid var(--border-color) !important;
|
||||||
margin-bottom: var(--space-6) !important;
|
margin-bottom: 12px !important;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,9 +704,9 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-4);
|
gap: 12px;
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: 12px;
|
||||||
padding-bottom: var(--space-4);
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid var(--border-color-light);
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,14 +715,14 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-header h6 {
|
.admin-section-header h6 {
|
||||||
font-size: var(--font-size-lg);
|
font-size: 0.95rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-header p {
|
.admin-section-header p {
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -714,7 +731,7 @@ textarea:focus-visible {
|
|||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table thead {
|
.admin-table thead {
|
||||||
@@ -723,11 +740,11 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table thead th {
|
.admin-table thead th {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: 6px 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--font-size-xs);
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -746,7 +763,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table tbody td {
|
.admin-table tbody td {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: 6px 10px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ window.taxbaikAdminSession = {
|
|||||||
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getViewportWidth: function () {
|
||||||
|
return window.innerWidth || document.documentElement.clientWidth || 0;
|
||||||
|
},
|
||||||
|
|
||||||
clearAuthToken: function () {
|
clearAuthToken: function () {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS portal_users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
client_id INT NULL REFERENCES clients(id) ON DELETE SET NULL,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
provider VARCHAR(30) NOT NULL DEFAULT 'local',
|
||||||
|
provider_id VARCHAR(200),
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_portal_users_provider
|
||||||
|
ON portal_users(provider, provider_id);
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { loginThroughAdminUi, navigateInBlazor } from './helpers/admin-auth';
|
||||||
|
|
||||||
|
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const password = process.env.E2E_ADMIN_PASSWORD;
|
||||||
|
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||||
|
|
||||||
|
test.describe('admin CRM pages', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
|
||||||
|
await loginThroughAdminUi(page, baseUrl, username, password);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TaxProfiles page loads with grid and add button', async ({ page }) => {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
||||||
|
await expect(page).toHaveURL(/\/admin\/tax-profiles$/);
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-page-title')).toHaveText('세무 프로필', { timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /새 프로필 추가/ })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TaxFilingSchedules page loads with D-day tracking', async ({ page }) => {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}/admin/tax-filing-schedules`);
|
||||||
|
await expect(page).toHaveURL(/\/admin\/tax-filing-schedules$/);
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-page-title')).toHaveText('신고 일정', { timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /새 일정 추가/ })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Contracts page loads with MRR display', async ({ page }) => {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}/admin/contracts`);
|
||||||
|
await expect(page).toHaveURL(/\/admin\/contracts$/);
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-page-title')).toHaveText('계약 관리', { timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /새 계약 추가/ })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ConsultingActivities page loads with activity records', async ({ page }) => {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}/admin/consulting-activities`);
|
||||||
|
await expect(page).toHaveURL(/\/admin\/consulting-activities$/);
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-page-title')).toHaveText('상담 활동 관리', { timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /새 활동 기록/ })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RevenueTrackings page loads with payment status tracking', async ({ page }) => {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}/admin/revenue-trackings`);
|
||||||
|
await expect(page).toHaveURL(/\/admin\/revenue-trackings$/);
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-page-title')).toHaveText('수익 추적 관리', { timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /새 청구 추가/ })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRM navigation group is visible and expandable', async ({ page }) => {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}/admin/dashboard`);
|
||||||
|
|
||||||
|
// 좌측 패널 네비게이션 확인
|
||||||
|
const crmGroup = page.getByText('CRM & 세무관리');
|
||||||
|
await expect(crmGroup).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// CRM 그룹의 모든 링크 확인
|
||||||
|
const expectedLinks = [
|
||||||
|
'세무 프로필',
|
||||||
|
'신고 일정',
|
||||||
|
'계약 관리',
|
||||||
|
'상담 활동',
|
||||||
|
'수익 추적'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const linkText of expectedLinks) {
|
||||||
|
const link = page.getByRole('link', { name: linkText });
|
||||||
|
await expect(link).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TaxProfiles modal dialog opens on add button click', async ({ page }) => {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
||||||
|
|
||||||
|
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
|
||||||
|
await expect(addButton).toBeVisible();
|
||||||
|
await addButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/taxbaik\/admin\/tax-profiles$/);
|
||||||
|
await expect(addButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No console errors on CRM page navigation', async ({ page }) => {
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on('console', message => {
|
||||||
|
if (message.type() === 'error') {
|
||||||
|
consoleErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const crmPages = [
|
||||||
|
'/admin/tax-profiles',
|
||||||
|
'/admin/tax-filing-schedules',
|
||||||
|
'/admin/contracts',
|
||||||
|
'/admin/consulting-activities',
|
||||||
|
'/admin/revenue-trackings'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of crmPages) {
|
||||||
|
await navigateInBlazor(page, `${baseUrl}${path}`);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(consoleErrors, 'no console errors during CRM navigation').toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ test.describe('admin authentication', () => {
|
|||||||
await page.getByRole('button', { name: '로그인' }).click();
|
await page.getByRole('button', { name: '로그인' }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
||||||
await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByRole('heading', { name: '대시보드' }).first()).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible();
|
await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible();
|
||||||
expect(consoleErrors, 'browser console/page errors').toEqual([]);
|
expect(consoleErrors, 'browser console/page errors').toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test.describe('contact submit', () => {
|
|||||||
email: `public-${stamp}@example.com`,
|
email: `public-${stamp}@example.com`,
|
||||||
serviceType: '기타',
|
serviceType: '기타',
|
||||||
message: 'Playwright로 전송한 공개 문의 테스트입니다.',
|
message: 'Playwright로 전송한 공개 문의 테스트입니다.',
|
||||||
|
suppressNotification: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(createResponse.ok()).toBeTruthy();
|
expect(createResponse.ok()).toBeTruthy();
|
||||||
@@ -39,6 +40,7 @@ test.describe('contact submit', () => {
|
|||||||
email,
|
email,
|
||||||
serviceType: '기타',
|
serviceType: '기타',
|
||||||
message,
|
message,
|
||||||
|
suppressNotification: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(createResponse.ok()).toBeTruthy();
|
expect(createResponse.ok()).toBeTruthy();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function loginThroughAdminUi(
|
|||||||
await page.locator('input[placeholder="비밀번호"]').fill(password);
|
await page.locator('input[placeholder="비밀번호"]').fill(password);
|
||||||
await page.getByRole('button', { name: '로그인' }).click();
|
await page.getByRole('button', { name: '로그인' }).click();
|
||||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
||||||
await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByRole('heading', { name: '대시보드' }).first()).toBeVisible({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function navigateInBlazor(page: Page, targetUrl: string) {
|
export async function navigateInBlazor(page: Page, targetUrl: string) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test.describe('inquiry detail', () => {
|
|||||||
email,
|
email,
|
||||||
serviceType: '기타',
|
serviceType: '기타',
|
||||||
message,
|
message,
|
||||||
|
suppressNotification: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(createResponse.ok()).toBeTruthy();
|
expect(createResponse.ok()).toBeTruthy();
|
||||||
@@ -39,9 +40,11 @@ test.describe('inquiry detail', () => {
|
|||||||
await expect(page.getByText(phone, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(phone, { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(message, { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '신규' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '신규' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '연락함' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '상담중' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '완료' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '계약완료' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '문의 목록으로 돌아가기' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '거절' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: '다른 문의도 보기' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '종결' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '문의 목록으로' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '고객으로 등록' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test.describe('public smoke', () => {
|
|||||||
await page.goto(`${baseUrl}/contact`);
|
await page.goto(`${baseUrl}/contact`);
|
||||||
await expect(page).toHaveTitle(/상담 신청/);
|
await expect(page).toHaveTitle(/상담 신청/);
|
||||||
await expect(page.getByRole('heading', { name: /상담 신청/ })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /상담 신청/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /뒤로가기/ })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /상담신청/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /상담신청/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user