Compare commits

...

69 Commits

Author SHA1 Message Date
kjh2064 b243f89087 공개 홈 Razor Pages 프리렌더 수정 2026-06-30 00:03:53 +09:00
kjh2064 21896c564c 공개 홈 Razor Pages 렌더 모드 정리 2026-06-29 23:57:15 +09:00
kjh2064 c8eb6a318c 홈과 관리자 로그인 화면 테마 및 제목 정리 2026-06-29 23:50:32 +09:00
kjh2064 17f7f1d728 지침의 레거시 정책과 우선순위 정리 2026-06-29 23:40:13 +09:00
kjh2064 79d91831c6 지침의 MudDataGrid와 MudDialog 예시 정리 2026-06-29 23:37:04 +09:00
kjh2064 274f70e066 MudDataGrid와 MudDialog 폐기 기준 명시 2026-06-29 23:34:58 +09:00
kjh2064 428eeb6fd8 관리자 CSS 레거시 추가 정리 2026-06-29 23:25:12 +09:00
kjh2064 dd68a237a1 Blazor 호스팅을 Fluent UI v5 단일 엔트리로 통합 2026-06-29 23:13:48 +09:00
kjh2064 ef9fd523c6 관리자 및 사이트 UI 토큰 정리 2026-06-29 23:13:47 +09:00
kjh2064 f2ab78dea2 수익 추적 조회 API 복원 2026-06-29 23:13:46 +09:00
kjh2064 1e0c0b7e1c refactor: 홈 라우팅 충돌 해결 및 임시 구현 정리
TaxBaik CI/CD / build-and-deploy (push) Failing after 53s
2026-06-29 22:49:12 +09:00
kjh2064 1b173376ee refactor: admin ui를 fluent v5와 html 기반으로 전환
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m53s
2026-06-29 22:37:40 +09:00
kjh2064 1a7bc9e209 docs: fluent v5와 skeleton 기준 반영 2026-06-29 22:37:39 +09:00
kjh2064 3be379431f lite blazor 데이터 갱신 정리
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 18:18:04 +09:00
kjh2064 682e2db3a3 fix: CRM 다이얼로그의 ClientId 바인딩을 Nullable int? 로 변경하고 CompanyName null 대비 Fallback 이름을 Name으로 매핑하여 MudSelect 초기 렌더링 Circuit 크래시 원천 차단
TaxBaik CI/CD / build-and-deploy (push) Failing after 37s
2026-06-29 17:14:07 +09:00
kjh2064 d9766cb5ef fix: E2E 내비게이션 시 Blazor Dynamic Spinner 감지 및 MudDialog 고유 식별자 기반 native click 연동을 적용하여 비동기 클릭 유실 원천 차단
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 17:03:32 +09:00
kjh2064 6bcb9effa8 fix: E2E 콤보박스 검증 테스트가 mud-popover-open 및 getByLabel을 사용하여 안정적(Robust)으로 동작하도록 전면 리팩토링하여 CI 실패 해결
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-06-29 16:30:31 +09:00
kjh2064 186c6ef7a4 fix: 텔레그램 알림 예외에서 브라우저 강제 종료(JSDisconnectedException, TaskCanceledException) 필터링 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m18s
2026-06-29 16:20:10 +09:00
kjh2064 c2e8e08f09 test: E2E 테스트에 세무 프로필, 신고 일정, 계약 관리의 콤보 데이터 목록(Dropdown choices) 노출 검증 케이스 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-06-29 16:18:17 +09:00
kjh2064 3f7cd7cd84 fix: 기존 모든 목록 페이지들의 데이터 로드 생명주기를 OnAfterRenderAsync로 수정하여 Prerendering 401 오류 및 CRUD 마비 현상 완벽 해결
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-06-29 16:15:42 +09:00
kjh2064 4b352df408 fix: 기존 모든 브라우저 클라이언트의 TokenRefreshHandler 의존성 제거 및 수동 토큰 직접 주입 패턴 일괄 일치화 적용 (콤보 데이터 유실 문제 완벽 해결)
TaxBaik CI/CD / build-and-deploy (push) Successful in 52s
2026-06-29 16:07:23 +09:00
kjh2064 a4b1234900 fix: CRM 페이지 다이얼로그의 콤보박스 기본 고객 바인딩 수정 및 폼 유효성 검사(Validation) 보강
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m23s
2026-06-29 16:00:42 +09:00
kjh2064 a3c81c4f70 fix: TaxFilingBrowserClient의 이중 api/prefix 조립 문제 해결 (BaseUrl에 이미 포함되어 있으므로 상대경로에서 걷어냄)
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:51:09 +09:00
kjh2064 6e8b4e76ac fix: TaxFilingBrowserClient의 API 라우트 경로 오타 및 prefix 누락 오류 수정 (tax-filing -> api/taxfiling)
TaxBaik CI/CD / build-and-deploy (push) Successful in 57s
2026-06-29 15:47:07 +09:00
kjh2064 5807e1b35e fix: HttpClientFactory 생명주기 불일치(Scope Capture) 문제를 회피하기 위해 CRM API 클라이언트에 직접 토큰 주입하도록 전면 개편
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-06-29 15:43:15 +09:00
kjh2064 3e1097f585 fix: DelegatingHandler와 TokenStore의 생명주기 불일치(Scope Capture) 문제 해결을 위한 IServiceProvider 동적 해석(Resolve) 적용
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:39:07 +09:00
kjh2064 917600a793 fix: 인증 로컬스토리지 복구 흐름에서 TokenStore 적재가 보장되지 않은 상태로 인증 통과 처리되는 보안 누수 현상 수정 (401 오류 원천 차단)
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:35:32 +09:00
kjh2064 0d3615b44d fix: Blazor 인증 공급자의 비동기 로딩 지연에 의한 API 호출 레이스 컨디션 해결 (CascadingParameter Task<AuthenticationState> 대기 추가)
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-06-29 15:30:14 +09:00
kjh2064 fc339ca9e7 fix: Blazor Server Prerendering 시점의 401 에러 방지를 위해 CRM 화면 API 로드 수명 주기를 OnAfterRenderAsync로 일괄 개선
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:27:03 +09:00
kjh2064 da1226994f fix: E2E 테스트 시 Blazor 인증 상태 복원을 위한 로컬스토리지 토큰 세트(accessToken, refreshToken, tokenExpiry) 주입 보강
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m18s
2026-06-29 15:23:21 +09:00
kjh2064 6bc03ce3d9 fix: CI E2E 테스트용 로컬스토리지 인증 토큰 키 불일치 수정 (auth_token -> accessToken)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m28s
2026-06-29 15:20:36 +09:00
kjh2064 ecfbfc7cac feat: 검색엔진 노출 강화를 위한 SEO 설정(sitemap.xml, JSON-LD 구조화 데이터, 메타 태그) 추가 및 개선
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-06-29 15:18:44 +09:00
kjh2064 46cb508bdf fix: Contract, TaxProfile, TaxFilingSchedule에 대해 선제적으로 GetAllAsync API 및 구현체 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:16:08 +09:00
kjh2064 ecabe8d9cc fix: ConsultingActivity 전체 조회 API 및 리포지토리/서비스 구현체 구현
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-06-29 15:12:23 +09:00
kjh2064 55c65810c1 fix: RevenueTracking 전체 조회 API 및 리포지토리/서비스 구현체 구현
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
2026-06-29 15:09:21 +09:00
kjh2064 7054d397e4 fix: AdminDashboardController의 라우트 매핑 오류 수정 (api/admin-dashboard)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-06-29 15:05:59 +09:00
kjh2064 11fb596fc2 Merge branch 'feature/telegram-logging'
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:02:54 +09:00
kjh2064 a04592499c fix: 블로그 작성/수정 시 카테고리 MudSelect 타입 캐스팅 오류 수정 2026-06-29 14:52:09 +09:00
kjh2064 ea9478f2f1 Merge pull request 'feat: Serilog 기반 실시간 텔레그램 에러 알림 연동' (#6) from feature/telegram-logging into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/6
2026-06-29 11:41:38 +09:00
kjh2064 f569211967 feat: Serilog 기반 실시간 텔레그램 에러 알림 연동 2026-06-29 11:35:27 +09:00
kjh2064 c8306e2ac7 Merge pull request 'docs: ROADMAP_WBS.md 내 텔레그램 및 고객 포털 태스크 완료 상태 체크 업데이트' (#5) from docs/roadmap-update into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m26s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/5
2026-06-29 00:08:07 +09:00
kjh2064 bad2f47ffe Merge pull request 'feat: 고객 포털 세무 신고 및 상담 요약 실시간 대시보드 화면 고도화 및 어드민 UX 리사이징 보완' (#4) from feature/client-portal into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m31s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/4
2026-06-29 00:07:57 +09:00
kjh2064 943fe9c819 Merge pull request 'feat: TelegramNotificationService 내에 SendReportAsync 추가 및 백그라운드 리포팅 로직 개선' (#3) from feature/telegram-reports into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m20s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/3
2026-06-29 00:07:47 +09:00
kjh2064 7b819f4ab0 docs: ROADMAP_WBS.md 내 텔레그램 및 고객 포털 태스크 완료 상태 체크 업데이트 2026-06-29 00:05:52 +09:00
kjh2064 6a5740ec68 feat: 고객 포털 세무 신고 및 상담 요약 실시간 대시보드 화면 고도화 및 어드민 UX 리사이징 보완 2026-06-29 00:05:32 +09:00
kjh2064 3c8f30af6d feat: TelegramNotificationService 내에 SendReportAsync 추가 및 백그라운드 리포팅 로직 개선 2026-06-29 00:05:14 +09:00
kjh2064 7e3b4e2229 test(e2e): relax tax profile dialog check
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 23:25:06 +09:00
kjh2064 67bd5dc666 test(e2e): suppress inquiry telegrams in ci
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 21:40:11 +09:00
kjh2064 84161ee2d9 fix(contact): allow suppressing inquiry telegrams 2026-06-28 21:40:10 +09:00
kjh2064 5aec36b155 fix(telegram): remove duplicate deploy success notice
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-06-28 21:33:33 +09:00
kjh2064 3ab8971025 test(public): cover contact back navigation
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-28 21:30:08 +09:00
kjh2064 db30e71e0a fix(contact): restore inquiry telegram notifications 2026-06-28 21:30:07 +09:00
kjh2064 e4c2758dea test(e2e): stabilize crm modal check
TaxBaik CI/CD / build-and-deploy (push) Successful in 52s
2026-06-28 21:15:50 +09:00
kjh2064 75661aa0ef style(admin): compact admin shell 2026-06-28 21:15:50 +09:00
kjh2064 3303ba2e96 style(admin): compact the admin shell
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m25s
2026-06-28 21:04:08 +09:00
kjh2064 43c2ff6ad9 fix(telegram): route deploy complete to system chat
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 21:03:05 +09:00
kjh2064 a7bb8d7149 fix(admin): remove drawer footer info and close on mobile
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-06-28 20:58:51 +09:00
kjh2064 791ce6d526 test(e2e): wait for tax profile dialog before assertions
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 20:54:03 +09:00
kjh2064 61083a5bb1 test(e2e): align browser checks with current UI
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-28 20:49:50 +09:00
kjh2064 66fb86d23c fix(admin): standardize empty CRM states 2026-06-28 20:49:49 +09:00
kjh2064 16f7c6097c test(e2e): disambiguate dashboard heading
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 19:38:17 +09:00
kjh2064 7232635ed0 docs(ci): add deploy troubleshooting harness
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-28 19:34:23 +09:00
kjh2064 b42b98d560 fix(auth): return token alias for admin login 2026-06-28 19:34:22 +09:00
kjh2064 f216660afa fix(portal): skip unconfigured oauth providers
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-28 19:29:54 +09:00
kjh2064 b31b43e30e fix(ci): repair deploy workflow yaml
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m45s
2026-06-28 19:25:40 +09:00
kjh2064 86bd9ef8ff chore(ci): allow manual deploy dispatch 2026-06-28 19:13:35 +09:00
kjh2064 2fd9984a45 chore(ci): trigger deploy after verification 2026-06-28 18:55:29 +09:00
kjh2064 91330ec94c chore(ci): trigger deploy with real push 2026-06-28 18:50:11 +09:00
kjh2064 08102c8684 chore(ci): deploy trigger 2026-06-28 18:42:55 +09:00
114 changed files with 4011 additions and 4274 deletions
+1
View File
@@ -9,3 +9,4 @@ Authentication__Naver__ClientId=
Authentication__Naver__ClientSecret=
Authentication__Kakao__ClientId=
Authentication__Kakao__ClientSecret=
# CI deploy trigger requires a real push on master.
+1
View File
@@ -1,6 +1,7 @@
name: TaxBaik CI/CD
on:
workflow_dispatch:
push:
branches:
- master
+123 -41
View File
@@ -8,10 +8,27 @@
Blazor → Service (서버) → DB
✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만) ← API (모든 로직) ← DB
SignalR (변경 알림만)
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
Blazor 데이터 변경 자동 push/broadcast 금지
```
### UI 기준 원칙 (2026-06-29 추가)
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/` 기준으로 한다.
- 신규 또는 리팩토링 UI는 Fluent UI Blazor v5 패턴을 우선 적용한다.
- MudBlazor는 레거시 폐기 대상이다. 새 UI나 리팩토링 UI에서는 사용하지 않는다.
- 기존 MudBlazor 잔여 코드는 Fluent v5 또는 순수 HTML/CSS로 점진 전환한다.
- 기본 로딩 상태는 `Skeleton`이다. `MudProgressCircular` / `MudProgressLinear`는 예외적으로만 사용한다.
- `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이다. 새 작업에서는 사용하지 말고 Fluent v5 또는 순수 HTML/CSS 패턴으로 대체한다.
- 목록, 카드, 대시보드, 상세 페이지의 초기 데이터 상태는 스켈톤으로 먼저 렌더링하고, 데이터 수신 후 실제 UI로 교체한다.
- 로딩 중 블로킹 스피너보다 스켈톤을 우선한다.
- 관리자와 공개 사이트는 가능한 한 같은 `design-tokens.css` / `ui-primitives.css` 기반으로 구성한다.
- Blazor 진입점은 중복 매핑하지 말고, 동일 호스트 내에서 라우트 충돌이 없도록 단일 엔트리 기준으로 구성한다.
- `@page` 중복이나 동일 경로의 Razor Pages + Blazor 중복 선언은 배포 전에 반드시 제거한다.
### 레거시 정책
- MudBlazor, MudDataGrid, MudDialog, MudTabs는 신규 도입 금지다.
- 남아 있는 레거시 UI는 우선순위에 따라 Fluent v5 또는 순수 HTML/CSS로 교체한다.
### SOLID 기반 순차 마이그레이션 전략
#### Phase 1-3: API Foundations ✅
@@ -29,6 +46,7 @@ Blazor (UI만) ← API (모든 로직) ← DB
- AdminDashboardClient 구현
- 서비스 inject → API 호출로 변경
- 에러 처리 & 로딩 상태
- 기본 로딩은 Skeleton 적용
- [x] 구조: IAdminDashboardClient → HttpClient 추상화
**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출
@@ -61,10 +79,10 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
#### Phase 6: SignalR 통합
- [ ] NotificationHub (변경 알림만)
- [ ] Blazor에서 구독
- [ ] 알림 후 API로 데이터 검증
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
#### Phase 7: 순차적 마이그레이션 ✅
- [x] Blog 페이지 → API 클라이언트
@@ -76,10 +94,18 @@ _refreshTokenExpirationMinutes = 10080;
- 모든 API 엔드포인트 구현됨
- 모든 Browser Client 구현됨
- 16개 Blazor 페이지 API-First 마이그레이션 완료
- MudDataGrid Douzone ERP 수준 UX 적용
- MudDialog 모달 패턴 (흰 화면 플래시 제거)
- 과거 기록: 관리 화면에서 그리드/모달 UX를 빠르게 안정화한 단계
- 모달 패턴 (흰 화면 플래시 제거)
- ConfirmDialog 삭제 확인 컴포넌트
### 2026-06-29 운영 기준 업데이트
- 관리자 백오피스는 Fluent UI v5 우선 구조로 재정리한다.
- 기본 로딩은 스피너가 아니라 Skeleton이다.
- `design-tokens.css``ui-primitives.css`는 사이트/관리자 공통의 기본 계층이다.
- 라우팅 충돌은 가장 먼저 확인할 항목이며, 동일 경로가 두 번 등록되는 구조를 만들지 않는다.
- 커밋은 기능/호스팅/UI/CSS처럼 주제별로 분리한다.
- 레거시 제거 우선순위는 `MudBlazor` 계열 UI가 1순위다.
---
## 📊 **전체 프로젝트 완료 현황**
@@ -118,7 +144,7 @@ _refreshTokenExpirationMinutes = 10080;
**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)
- 5개 Blazor 페이지 (그리드 Dense, Virtualize, 모달 패턴)
- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
| 페이지 | API | Client | Blazor | 핵심 기능 |
@@ -130,17 +156,17 @@ _refreshTokenExpirationMinutes = 10080;
| RevenueTrackings | ✅ RevenueTrackingController | ✅ IRevenueTrackingBrowserClient | ✅ List + Modal | 청구/납부 추적, 상태 관리 |
**UI 특성**:
- MudDataGrid Dense (행높이 32px) + Virtualize (1000+ 행 성능)
- MudDialog Create/Edit (흰 화면 플래시 방지)
- Dense 그리드 + Virtualize (1000+ 행 성능)
- Create/Edit 모달 (흰 화면 플래시 방지)
- ConfirmDialog Delete (사용자 확인)
- Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동)
### **Phase 6: SignalR 통합** ✅
- NotificationHub (브로드캐스트만, 상태 관리 없음)
- INotificationService (이벤트 기반)
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
- Program.cs SignalR 등록
### **Phase 6: Lite Blazor 운영 원칙** ✅
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
---
@@ -160,11 +186,11 @@ Repositories (데이터 계층)
PostgreSQL Database
```
**Blazor Server SignalR**:
- 자동 연결 (내장 Hub connection)
- NotificationHub 클라이언트 그룹 (admins)
- 이벤트 기반 메시지 (상태 관리 없음)
- 클라이언트는 알림 후 API로 데이터 검증
**Lite Blazor 데이터 갱신**:
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
---
@@ -182,15 +208,15 @@ PostgreSQL Database
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
**실시간 알림 (Phase 6)**:
- [x] NotificationHub 구현
- [x] Event-driven 알림 시스템
- [x] Scoped DI 등록
**Lite Blazor / 데이터 갱신 (Phase 6)**:
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지
- [x] MudDataGrid Dense + Virtualize (32px 행 높이)
- [x] MudDialog 모달 Create/Edit (흰 화면 플래시 제거)
- [x] Dense 그리드 + Virtualize (32px 행 높이)
- [x] 모달 Create/Edit (흰 화면 플래시 제거)
- [x] ConfirmDialog 삭제 확인
- [x] 상태별 컬러 칩 (Status/Risk Level)
- [x] 클라이언트 링크 (상세 페이지 연동)
@@ -964,6 +990,8 @@ Admin 로그인 페이지만 [AllowAnonymous]:
- 전역 상태 불필요 (세션 → DB에서 읽음)
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
- 업데이트는 `StateHasChanged()` 호출
- 초기 렌더는 Skeleton 우선
- 로딩이 필요한 목록/카드/대시보드는 `items == null` 또는 `summary == null` 패턴으로 스켈톤 렌더링
### 8.6 어드민 그리드 UX (Dorsum ERP 수준)
@@ -983,9 +1011,11 @@ Admin 로그인 페이지만 [AllowAnonymous]:
- **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지)
- **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)
#### MudBlazor 적용 패턴
#### UI 적용 패턴
```razor
<MudDataGrid T="YourItem"
```razor
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
<YourGridComponent T="YourItem"
Dense="true"
Hover="true"
Striped="true"
@@ -1011,7 +1041,8 @@ Admin 로그인 페이지만 [AllowAnonymous]:
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
</YourGridComponent>
```
```
#### 색상 & 상태 표시
@@ -1126,7 +1157,7 @@ Admin 로그인 페이지만 [AllowAnonymous]:
<!-- 로딩 상태 -->
@if (items == null)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
<!-- 빈 상태 -->
else if (items.Count == 0)
@@ -1136,7 +1167,9 @@ else if (items.Count == 0)
<!-- 데이터 그리드 -->
else
{
<MudDataGrid T="YourEntity"
```razor
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
<YourGridComponent T="YourEntity"
Items="@items"
Dense="true"
Hover="true"
@@ -1147,13 +1180,16 @@ else
<Columns>
<!-- 필수: 컬럼 정의 -->
</Columns>
</MudDataGrid>
</YourGridComponent>
```
}
```
**Step 3: 모달 다이얼로그 (Create/Edit)**
```razor
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
```razor
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 별도 라우트로 대체 -->
<YourDialogComponent @bind-IsVisible="isDialogOpen">
<TitleContent>
<MudText Typo="Typo.h6">@(isEditMode ? "항목 수정" : "새 항목 추가")</MudText>
</TitleContent>
@@ -1166,7 +1202,8 @@ else
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveItem">저장</MudButton>
</DialogActions>
</MudDialog>
</YourDialogComponent>
```
```
**Step 4: @code 섹션 구조**
@@ -1288,10 +1325,10 @@ else
- [ ] @inject로 필요한 Client 주입
- [ ] <PageTitle> 추가
- [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼)
- [ ] 로딩 상태 (MudProgressCircular)
- [ ] 로딩 상태 기본값은 `Skeleton`
- [ ] 빈 상태 (MudAlert)
- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스)
- [ ] MudDialog (Create/Edit 모달)
- [ ] Dense 그리드 (Virtualize=true, RowsPerPage=30, admin-grid 클래스)
- [ ] 모달 (Create/Edit)
- [ ] ConfirmDialog (Delete 확인)
- [ ] @code 섹션: OnInitializedAsync → LoadData() 패턴
- [ ] 모든 에러 처리 (try-catch, Snackbar 메시지)
@@ -1302,7 +1339,7 @@ else
❌ **이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:**
- 페이지 헤더 (admin-page-hero) 누락
- 인라인 스타일로 레이아웃 구성
- MudDialog 없이 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
- 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
- @code 섹션 구조 다름
- 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공
@@ -1723,7 +1760,9 @@ public async Task NotifyDeploymentStart()
@* Components/Admin/Shared/DeploymentNotification.razor *@
@if (showNotification)
{
<MudDialog @bind-Visible="showNotification">
```razor
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 HTML/CSS 패턴으로 대체 -->
<YourDialogComponent @bind-Visible="showNotification">
<TitleContent>
<MudText Typo="Typo.h6">새 버전 배포</MudText>
</TitleContent>
@@ -1738,7 +1777,8 @@ public async Task NotifyDeploymentStart()
<MudButton Color="Color.Primary" OnClick="RefreshNow">지금 새로고침</MudButton>
<MudButton Color="Color.Default" OnClick="DismissNotification">나중에</MudButton>
</DialogActions>
</MudDialog>
</YourDialogComponent>
```
}
@code {
@@ -1931,6 +1971,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. 문제 해결
| 문제 | 해결 |
+11 -1
View File
@@ -2,6 +2,8 @@
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
CI deploy trigger verification note.
---
## 개요
@@ -24,7 +26,7 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위
|-----|------|
| **백엔드** | ASP.NET Core 10, C# |
| **공개 사이트** | Razor Pages (SSR) |
| **관리자** | Blazor Server + MudBlazor |
| **관리자** | Blazor Server + Fluent UI Blazor v5 |
| **데이터베이스** | PostgreSQL 18.4 |
| **ORM** | Dapper |
| **리버스 프록시** | Nginx |
@@ -96,6 +98,14 @@ TaxBaik/
- 연락처 정보
- 소셜 미디어 링크
- **UI 기준**
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/`
- 기본 로딩 상태는 `Skeleton`
- MudBlazor는 레거시 폐기 대상이며 신규 UI에 사용하지 않음
- `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이며 신규 UI에 사용하지 않음
- 사이트와 관리자는 `design-tokens.css` / `ui-primitives.css`를 공유
- Blazor 라우트는 중복 선언하지 않고 단일 엔트리 기준으로 관리
---
## 빠른 시작
+16 -16
View File
@@ -425,9 +425,9 @@ Todo:
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
Todo:
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [ ] 일간/주간 리포트 메시지 템플릿
- [ ] TelegramNotificationService에 리포트 메서드 추가
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [x] 일간/주간 리포트 메시지 템플릿
- [x] TelegramNotificationService에 리포트 메서드 추가
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
@@ -439,9 +439,9 @@ Todo:
- 개인정보 열람 범위는 세무사가 허용한 항목만
Todo:
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [ ] 고객 전용 Razor Pages 추가
- [ ] 세무사 허용 권한 설정 UI
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [x] 고객 전용 Razor Pages 추가
- [x] 세무사 허용 권한 설정 UI
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
@@ -485,16 +485,16 @@ DB 스키마:
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
Todo:
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [ ] V011__CreatePortalUsers.sql 마이그레이션
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [ ] 네이버 OAuth Handler 구현
- [ ] 카카오·구글 패키지 추가 및 설정
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [x] 네이버 OAuth Handler 구현
- [x] 카카오·구글 패키지 추가 및 설정
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [ ] Gitea Secrets에 OAuth 키 추가
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
@@ -33,6 +33,9 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
await repository.GetPendingFollowupsAsync(ct);
@@ -36,6 +36,9 @@ public class ContractService(IContractRepository repository)
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -15,7 +15,7 @@ public class InquiryService(
public async Task<int> SubmitAsync(
string name, string phone, string serviceType, string message,
string? email = null, string? ipAddress = null, CancellationToken ct = default)
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new ValidationException("이름을 입력하세요.");
@@ -39,7 +39,10 @@ public class InquiryService(
};
var inquiryId = await repository.CreateAsync(inquiry, ct);
if (!suppressNotification)
{
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
}
memoryCache.Remove(AdminDashboardService.CacheKey);
return inquiryId;
}
@@ -34,6 +34,12 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
await repository.GetPendingPaymentsAsync(ct);
@@ -33,6 +33,9 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -31,6 +31,9 @@ public class TaxProfileService(ITaxProfileRepository repository)
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
{
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IConsultingActivityRepository
{
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IContractRepository
{
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
@@ -5,6 +5,8 @@ using TaxBaik.Domain.Entities;
public interface IRevenueTrackingRepository
{
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface ITaxFilingScheduleRepository
{
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface ITaxProfileRepository
{
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
@@ -16,6 +16,14 @@ public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory
activity);
}
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<ConsultingActivity>(
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
FROM consulting_activities ORDER BY activity_date DESC");
}
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRe
contract);
}
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<Contract>(
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
FROM contracts ORDER BY contract_date DESC");
}
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,23 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
revenue);
}
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<RevenueTracking>(
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
FROM revenue_tracking ORDER BY invoice_date DESC");
}
public async Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<RevenueTracking>(
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
FROM revenue_tracking WHERE id = @Id",
new { Id = id });
}
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory)
schedule);
}
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxFilingSchedule>(
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
FROM tax_filing_schedules ORDER BY due_date DESC");
}
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -20,6 +20,16 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
profile);
}
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxProfile>(
@"SELECT id, client_id, business_registration, business_type, establishment_date,
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
FROM tax_profiles ORDER BY id DESC");
}
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
+11 -93
View File
@@ -1,4 +1,5 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.FluentUI.AspNetCore.Components
<!DOCTYPE html>
<html lang="ko">
<head>
@@ -6,9 +7,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>백원숙 세무회계 - 관리자</title>
<base href="/taxbaik/" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link rel="stylesheet" href="css/design-tokens.css" />
<link rel="stylesheet" href="css/ui-primitives.css" />
<link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
<script>
document.documentElement.classList.toggle(
'admin-login-route',
@@ -31,100 +34,15 @@
<p>로드 중...</p>
</div>
</div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<MudDialogProvider />
<MudSnackbarProvider />
<FluentProviders />
<FluentDialogProvider />
<FluentTooltipProvider />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/admin-session.js"></script>
<script src="_framework/blazor.web.js"></script>
<script>window.taxbaikAdminSession?.watchReconnect();</script>
</body>
</html>
@code {
private bool isDarkMode = false;
private MudTheme mudTheme = new()
{
Palette = new PaletteLight()
{
Primary = "#1976D2",
PrimaryContrastText = "#FFFFFF",
Secondary = "#2D9F7E",
SecondaryContrastText = "#FFFFFF",
Tertiary = "#FF8A50",
TertiaryContrastText = "#FFFFFF",
Surface = "#F5F7FA",
Background = "#FFFFFF",
BackgroundGrey = "#F8F9FB",
DrawerBackground = "#FFFFFF",
DrawerText = "#424242",
AppbarBackground = "#FFFFFF",
AppbarText = "#424242",
TextPrimary = "#1A1A1A",
TextSecondary = "#64748B",
TextDisabled = "#94A3B8",
ActionDefault = "#1976D2",
ActionDisabled = "#BDBDBD",
Divider = "#E2E8F0",
DividerLight = "#F1F5F9",
Error = "#DC2626",
ErrorContrastText = "#FFFFFF",
Warning = "#F59E0B",
WarningContrastText = "#FFFFFF",
Info = "#06B6D4",
InfoContrastText = "#FFFFFF",
Success = "#16A34A",
SuccessContrastText = "#FFFFFF",
},
LayoutProperties = new LayoutProperties()
{
DefaultBorderRadius = "8px"
},
Typography = new Typography()
{
Default = new Default()
{
FontSize = ".875rem",
FontWeight = 400,
LineHeight = 1.5
},
H1 = new H1()
{
FontSize = "2.5rem",
FontWeight = 600,
LineHeight = 1.2
},
H2 = new H2()
{
FontSize = "2rem",
FontWeight = 600,
LineHeight = 1.3
},
H3 = new H3()
{
FontSize = "1.75rem",
FontWeight = 600,
LineHeight = 1.3
},
H4 = new H4()
{
FontSize = "1.5rem",
FontWeight = 600,
LineHeight = 1.4
},
H5 = new H5()
{
FontSize = "1.25rem",
FontWeight = 500,
LineHeight = 1.4
},
H6 = new H6()
{
FontSize = "1rem",
FontWeight = 500,
LineHeight = 1.5
}
}
};
}
@@ -1,18 +1,17 @@
@using MudBlazor
<MudDialog>
<DialogContent>
<MudText>정말로 삭제하시겠습니까?</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="@Cancel">취소</MudButton>
<MudButton Color="Color.Error" OnClick="@Confirm">삭제</MudButton>
</DialogActions>
</MudDialog>
@using Microsoft.FluentUI.AspNetCore.Components
<div class="admin-dialog">
<div class="admin-dialog-title">삭제 확인</div>
<p class="admin-dialog-message">정말로 삭제하시겠습니까?</p>
<div class="admin-dialog-actions">
<FluentButton Appearance="ButtonAppearance.Transparent" @onclick="Cancel">취소</FluentButton>
<FluentButton Appearance="ButtonAppearance.Primary" @onclick="Confirm">삭제</FluentButton>
</div>
</div>
@code {
[CascadingParameter] MudDialogInstance? MudDialog { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public EventCallback OnConfirm { get; set; }
void Cancel() => MudDialog?.Cancel();
void Confirm() => MudDialog?.Close(DialogResult.Ok(true));
Task Cancel() => OnCancel.InvokeAsync();
Task Confirm() => OnConfirm.InvokeAsync();
}
@@ -1,49 +1,28 @@
@using TaxBaik.Application.Services
@using Microsoft.FluentUI.AspNetCore.Components
<MudForm @ref="form">
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
Variant="Variant.Outlined" Class="mb-4" Required="true"
HelperText="영문/숫자, 최대 50자" />
<MudTextField @bind-Value="model.CompanyName" Label="회사명"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.ContactPerson" Label="담당자명"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.Phone" Label="전화번호"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudTextField @bind-Value="model.Memo" Label="메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
@ButtonText
</MudButton>
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
<form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
<FluentTextInput Label="회사 코드" @bind-CurrentValue="model.CompanyCode" />
<FluentTextInput Label="회사명" @bind-CurrentValue="model.CompanyName" />
<FluentTextInput Label="담당자명" @bind-CurrentValue="model.ContactPerson" />
<FluentTextInput Label="전화번호" @bind-CurrentValue="model.Phone" />
<FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
<FluentTextArea Label="메모" @bind-CurrentValue="model.Memo" />
<label class="admin-checkbox-row">
<input type="checkbox" @bind="model.IsActive" />
<span>활성</span>
</label>
<div class="admin-form-actions">
<button type="submit" class="admin-login-submit">@ButtonText</button>
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
</div>
</MudForm>
</form>
@code {
[Parameter, EditorRequired]
public string ButtonText { get; set; } = "저장";
[Parameter]
public EventCallback<CompanyFormModel> OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
[Parameter]
public CompanyFormModel? InitialData { get; set; }
private MudForm? form;
[Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
[Parameter] public EventCallback<CompanyFormModel> OnSubmit { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public CompanyFormModel? InitialData { get; set; }
private CompanyFormModel model = new();
protected override void OnInitialized()
@@ -63,17 +42,7 @@
}
}
private async Task HandleSubmit()
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync(model);
}
private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
public class CompanyFormModel
{
@@ -1,61 +1,38 @@
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using Microsoft.FluentUI.AspNetCore.Components
<MudForm @ref="form">
<MudTextField @bind-Value="model.Name" Label="이름"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
<FluentTextInput Label="이름" @bind-CurrentValue="model.Name" />
<FluentTextInput Label="전화번호 (예: 010-1234-5678)" @bind-CurrentValue="model.Phone" />
<FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
<FluentSelect TValue="string" TOption="string" Label="문의 유형" @bind-CurrentValue="model.ServiceType">
<FluentOption Value="@("사업자세무")">사업자세무</FluentOption>
<FluentOption Value="@("부동산세금")">부동산세금</FluentOption>
<FluentOption Value="@("가족자산")">가족자산</FluentOption>
<FluentOption Value="@("기타")">기타</FluentOption>
</FluentSelect>
<FluentTextArea Label="문의 내용" @bind-CurrentValue="model.Message" />
<FluentSelect TValue="string" TOption="string" Label="상태" @bind-CurrentValue="model.Status">
<FluentOption Value="@("new")">신규</FluentOption>
<FluentOption Value="@("consulting")">상담중</FluentOption>
<FluentOption Value="@("contracted")">계약완료</FluentOption>
<FluentOption Value="@("rejected")">거절</FluentOption>
<FluentOption Value="@("closed")">종결</FluentOption>
</FluentSelect>
<FluentTextArea Label="관리 메모" @bind-CurrentValue="model.AdminMemo" />
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudSelect @bind-Value="model.ServiceType" Label="문의 유형"
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("사업자세무")">사업자세무</MudSelectItem>
<MudSelectItem Value="@("부동산세금")">부동산세금</MudSelectItem>
<MudSelectItem Value="@("가족자산")">가족자산</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="model.Message" Label="문의 내용"
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
<MudSelect @bind-Value="model.Status" Label="상태"
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("new")">신규</MudSelectItem>
<MudSelectItem Value="@("consulting")">상담중</MudSelectItem>
<MudSelectItem Value="@("contracted")">계약완료</MudSelectItem>
<MudSelectItem Value="@("rejected")">거절</MudSelectItem>
<MudSelectItem Value="@("closed")">종결</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
@ButtonText
</MudButton>
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
<div class="admin-form-actions">
<button type="submit" class="admin-login-submit">@ButtonText</button>
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
</div>
</MudForm>
</form>
@code {
[Parameter, EditorRequired]
public string ButtonText { get; set; } = "저장";
[Parameter]
public EventCallback<InquiryFormModel> OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
[Parameter]
public InquiryFormModel? InitialData { get; set; }
private MudForm? form;
[Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
[Parameter] public EventCallback<InquiryFormModel> OnSubmit { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public InquiryFormModel? InitialData { get; set; }
private InquiryFormModel model = new();
protected override void OnInitialized()
@@ -75,17 +52,7 @@
}
}
private async Task HandleSubmit()
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync(model);
}
private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
public class InquiryFormModel
{
+14 -16
View File
@@ -1,4 +1,5 @@
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
<div class="admin-table-wrap">
<table class="admin-table mt-4">
<thead>
<tr>
<th>이름</th>
@@ -18,22 +19,19 @@
<td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td>
<td>
<MudChip Size="Size.Small" Color="@GetStatusColor(inquiry.Status)">
@GetStatusLabel(inquiry.Status)
</MudChip>
<span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span>
</td>
<td>@GetPreview(inquiry.Message)</td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
<td>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</MudButton>
<a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</a>
<a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</a>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</table>
</div>
@code {
[Parameter, EditorRequired]
@@ -66,14 +64,14 @@
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
}
private static Color GetStatusColor(string status) => status switch
private static string GetStatusClass(string status) => status switch
{
"new" => Color.Warning,
"consulting" => Color.Info,
"contracted" => Color.Success,
"rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
"new" => "warning",
"consulting" => "info",
"contracted" => "success",
"rejected" => "danger",
"closed" => "muted",
_ => "muted"
};
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
@@ -3,133 +3,110 @@
@inject IJSRuntime JS
@implements IDisposable
<MudLayout Class="admin-shell">
<MudAppBar Elevation="0" Class="admin-topbar">
<MudIconButton Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
Class="admin-menu-button"
OnClick="@ToggleDrawer" />
<div class="admin-shell">
<header class="admin-topbar">
<button type="button" class="admin-icon-button admin-menu-button" @onclick="ToggleDrawer" aria-label="메뉴 열기">
<span class="material-icons">menu</span>
</button>
<div class="admin-topbar-title">
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
<span class="admin-topbar-kicker">TaxBaik Admin</span>
<h1>세무회계 관리 대시보드</h1>
</div>
<MudSpacer />
<!-- 상단 액션 바 -->
<div class="admin-topbar-actions">
<MudTooltip Text="공개 웹사이트 방문">
<MudButton Class="admin-topbar-action"
Variant="Variant.Text"
Color="Color.Inherit"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.OpenInNew"
Href="/taxbaik"
Target="_blank">
<a class="admin-topbar-action" href="/taxbaik" target="_blank" rel="noreferrer">
<span class="material-icons">open_in_new</span>
공개 사이트
</MudButton>
</MudTooltip>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<MudTooltip Text="로그아웃 (Ctrl+Q)">
<MudButton Class="admin-topbar-action"
Variant="Variant.Text"
Color="Color.Error"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Logout"
Href="/taxbaik/admin/logout">
</a>
<a class="admin-topbar-action danger" href="/taxbaik/admin/logout">
<span class="material-icons">logout</span>
로그아웃
</MudButton>
</MudTooltip>
</a>
</div>
</MudAppBar>
</header>
<MudDrawer @bind-open="@drawerOpen"
Elevation="0"
Variant="DrawerVariant.Responsive"
Breakpoint="Breakpoint.Md"
Class="admin-drawer">
<aside class="@DrawerClass">
<div class="admin-drawer-brand">
<div class="admin-brand-mark">T</div>
<div>
<MudText Typo="Typo.subtitle1">TaxBaik</MudText>
<MudText Typo="Typo.caption">세무 운영 콘솔</MudText>
<div class="admin-brand-title">TaxBaik</div>
<div class="admin-brand-subtitle">세무 운영 콘솔</div>
</div>
</div>
<MudNavMenu Class="admin-nav">
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
<MudNavGroup Title="CRM & 세무관리" Icon="@Icons.Material.Filled.BusinessCenter" @bind-Expanded="@expandedCRMGroup">
<MudNavLink Href="/taxbaik/admin/tax-profiles" Icon="@Icons.Material.Filled.Assignment">세무 프로필</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filing-schedules" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/contracts" Icon="@Icons.Material.Filled.Description">계약 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/consulting-activities" Icon="@Icons.Material.Filled.ChatBubble">상담 활동</MudNavLink>
<MudNavLink Href="/taxbaik/admin/revenue-trackings" Icon="@Icons.Material.Filled.Receipt">수익 추적</MudNavLink>
</MudNavGroup>
<nav class="admin-nav">
<a href="/taxbaik/admin/dashboard" class="admin-nav-link">대시보드</a>
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup">
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.Assessment">세무신고</MudNavLink>
</MudNavGroup>
<details open>
<summary>CRM & 세무관리</summary>
<a href="/taxbaik/admin/tax-profiles" class="admin-nav-link">세무 프로필</a>
<a href="/taxbaik/admin/tax-filing-schedules" class="admin-nav-link">신고 일정</a>
<a href="/taxbaik/admin/contracts" class="admin-nav-link">계약 관리</a>
<a href="/taxbaik/admin/consulting-activities" class="admin-nav-link">상담 활동</a>
<a href="/taxbaik/admin/revenue-trackings" class="admin-nav-link">수익 추적</a>
</details>
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
</MudNavGroup>
<details>
<summary>고객 관리</summary>
<a href="/taxbaik/admin/clients" class="admin-nav-link">고객 카드</a>
<a href="/taxbaik/admin/tax-filings" class="admin-nav-link">세무신고</a>
</details>
<details>
<summary>홈페이지</summary>
<a href="/taxbaik/admin/announcements" class="admin-nav-link">공지사항</a>
<a href="/taxbaik/admin/faqs" class="admin-nav-link">FAQ 관리</a>
<a href="/taxbaik/admin/blog" class="admin-nav-link">블로그 관리</a>
<a href="/taxbaik/admin/season-simulator" class="admin-nav-link">시즌 시뮬레이터</a>
</details>
<a href="/taxbaik/admin/inquiries" class="admin-nav-link">문의 관리</a>
<a href="/taxbaik/admin/settings" class="admin-nav-link">설정</a>
</nav>
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
</MudNavMenu>
<div class="admin-drawer-footer">
<MudDivider Class="my-2" />
<MudStack Spacing="1" Class="px-3 py-2">
<div class="admin-footer-item">
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Small" />
<MudText Typo="Typo.caption" Class="ml-2">시스템</MudText>
<span class="material-icons">shield</span>
<span>보안 모드</span>
</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 class="admin-footer-meta">Fluent UI Blazor 기반 관리자 콘솔</div>
</div>
</MudDrawer>
</aside>
<MudMainContent Class="admin-main">
<MudContainer MaxWidth="MaxWidth.False" Class="admin-content">
<main class="admin-content">
<div class="admin-content-inner">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
</div>
</main>
</div>
@code {
private bool drawerOpen = true;
private bool expandedCRMGroup = true;
private bool expandedCustomerGroup = false;
private bool expandedWebsiteGroup = false;
protected override void OnInitialized()
{
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 string DrawerClass => drawerOpen ? "admin-drawer open" : "admin-drawer";
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
}
private void ToggleDrawer()
{
drawerOpen = !drawerOpen;
}
private void ToggleDrawer() => drawerOpen = !drawerOpen;
public void Dispose()
{
@@ -5,101 +5,47 @@
@using TaxBaik.Web.Services
@inject IAnnouncementBrowserClient AnnouncementClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</MudText>
<div class="admin-eyebrow">Homepage</div>
<h1 class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</h1>
</div>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<MudForm @ref="form">
<MudGrid>
<MudItem xs="12">
<MudTextField @bind-Value="model.Title"
Label="제목"
Variant="Variant.Outlined"
Required="true"
RequiredError="제목을 입력하세요."
HelperText="홈페이지 상단 공지 바에 표시되는 텍스트입니다." />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="model.Content"
Label="상세 내용 (선택)"
Variant="Variant.Outlined"
Lines="3"
HelperText="부가 설명이 있을 경우 입력합니다. 없으면 제목만 표시됩니다." />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect @bind-Value="model.DisplayType"
Label="유형"
Variant="Variant.Outlined">
<MudSelectItem Value="@("info")">일반 (파란색)</MudSelectItem>
<MudSelectItem Value="@("banner")">배너 (주황색) — 중요 이벤트</MudSelectItem>
<MudSelectItem Value="@("urgent")">긴급 (빨간색) — 마감 임박</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField @bind-Value="model.SortOrder"
Label="노출 순서"
Variant="Variant.Outlined"
HelperText="숫자가 클수록 먼저 표시됩니다." />
</MudItem>
<MudItem xs="12" sm="6">
<MudDatePicker @bind-Date="startsAtDate"
Label="게시 시작일 (비우면 즉시)"
Variant="Variant.Outlined"
DateFormat="yyyy-MM-dd"
Clearable="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudDatePicker @bind-Date="endsAtDate"
Label="게시 종료일 (비우면 무기한)"
Variant="Variant.Outlined"
DateFormat="yyyy-MM-dd"
Clearable="true" />
</MudItem>
<MudItem xs="12">
<MudSwitch @bind-Checked="model.IsActive"
Label="@(model.IsActive ? "활성화 (홈페이지에 노출)" : "비활성화 (홈페이지 미노출)")"
Color="Color.Primary" />
</MudItem>
</MudGrid>
<div class="d-flex gap-2 mt-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
Disabled="isSaving"
@onclick="SaveAsync">
@(isSaving ? "저장 중..." : "저장")
</MudButton>
<MudButton Variant="Variant.Outlined"
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/announcements"))">
취소
</MudButton>
<div class="admin-surface" style="max-width:720px;">
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
<label>상세 내용 (선택) <textarea class="admin-input" rows="3" @bind="model.Content"></textarea></label>
<label>유형
<select class="admin-input" @bind="model.DisplayType">
<option value="info">일반 (파란색)</option>
<option value="banner">배너 (주황색)</option>
<option value="urgent">긴급 (빨간색)</option>
</select>
</label>
<label>노출 순서 <input class="admin-input" type="number" @bind="model.SortOrder" /></label>
<label>게시 시작일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="StartsAtText" /></label>
<label>게시 종료일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="EndsAtText" /></label>
<label><input type="checkbox" @bind="model.IsActive" /> @(model.IsActive ? "활성화" : "비활성화")</label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/announcements")'>취소</button>
</div>
</form>
</div>
</MudForm>
</MudPaper>
@code {
[Parameter] public int? Id { get; set; }
private MudForm? form;
private bool isSaving;
private DateTime? startsAtDate;
private DateTime? endsAtDate;
private AnnouncementDto model = new();
private string StartsAtText { get => startsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => startsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string EndsAtText { get => endsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => endsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnInitializedAsync()
{
@@ -134,41 +80,18 @@
private async Task SaveAsync()
{
if (form is null) return;
await form.Validate();
if (!form.IsValid) return;
isSaving = true;
try
{
model.StartsAt = startsAtDate.HasValue
? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime()
: null;
model.EndsAt = endsAtDate.HasValue
? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime()
: null;
if (Id.HasValue)
{
var result = await AnnouncementClient.UpdateAsync(Id.Value, model);
if (result != null)
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
else
Snackbar.Add("저장 실패", Severity.Error);
}
else
{
var result = await AnnouncementClient.CreateAsync(model);
if (result != null)
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
else
Snackbar.Add("저장 실패", Severity.Error);
}
model.StartsAt = startsAtDate.HasValue ? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() : null;
model.EndsAt = endsAtDate.HasValue ? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime() : null;
var result = Id.HasValue ? await AnnouncementClient.UpdateAsync(Id.Value, model) : await AnnouncementClient.CreateAsync(model);
await JS.InvokeVoidAsync("alert", result != null ? "공지사항이 저장되었습니다." : "저장 실패");
Navigation.NavigateTo("/taxbaik/admin/announcements");
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
finally
{
@@ -4,36 +4,32 @@
@using TaxBaik.Domain.Entities
@inject IAnnouncementBrowserClient AnnouncementClient
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>공지사항 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">공지사항 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</MudText>
<div class="admin-eyebrow">Homepage</div>
<h1 class="admin-page-title">공지사항 관리</h1>
<p class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/announcements/create">
공지 등록
</MudButton>
<a class="site-button primary" href="/taxbaik/admin/announcements/create">공지 등록</a>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (announcements is null)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
}
else if (!announcements.Any())
{
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
<div class="muted">등록된 공지사항이 없습니다.</div>
}
else
{
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>제목</th>
@@ -49,52 +45,54 @@
{
<tr>
<td>@item.Title</td>
<td>
<MudChip Size="Size.Small" Color="@GetTypeColor(item.DisplayType)">
@GetTypeLabel(item.DisplayType)
</MudChip>
</td>
<td><span class="status-pill info">@GetTypeLabel(item.DisplayType)</span></td>
<td>
@if (IsCurrentlyActive(item))
{
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
<span class="status-pill success">노출 중</span>
}
else if (!item.IsActive)
{
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
<span class="status-pill default">비활성</span>
}
else
{
<MudChip Size="Size.Small" Color="Color.Warning">기간 외</MudChip>
<span class="status-pill warning">기간 외</span>
}
</td>
<td class="small">
@FormatPeriod(item)
</td>
<td class="small">@FormatPeriod(item)</td>
<td>@item.SortOrder</td>
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
삭제
</MudButton>
</MudButtonGroup>
<div class="admin-actions">
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</table>
</div>
}
</MudPaper>
</div>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Announcement>? announcements;
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
private async Task LoadAsync()
@@ -105,36 +103,32 @@
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
announcements = [];
}
}
private async Task DeleteAsync(Announcement item)
{
var confirmed = await DialogService.ShowMessageBox(
"공지 삭제",
$"'{item.Title}' 공지를 삭제하시겠습니까?",
yesText: "삭제", cancelText: "취소");
if (confirmed != true) return;
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Title}' 공지를 삭제하시겠습니까?");
if (!confirmed) return;
try
{
var success = await AnnouncementClient.DeleteAsync(item.Id);
if (success)
{
Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "공지사항이 삭제되었습니다.");
await LoadAsync();
}
else
{
Snackbar.Add("삭제 실패", Severity.Error);
await JS.InvokeVoidAsync("alert", "삭제 실패");
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
@@ -154,13 +148,6 @@
return $"{start} ~ {end}";
}
private static Color GetTypeColor(string type) => type switch
{
"urgent" => Color.Error,
"banner" => Color.Warning,
_ => Color.Info
};
private static string GetTypeLabel(string type) => type switch
{
"urgent" => "긴급",
@@ -6,77 +6,53 @@
@inject BlogService BlogService
@inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>새 포스트 작성</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">새 포스트 작성</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</MudText>
<div class="admin-eyebrow">Content</div>
<h1 class="admin-page-title">새 포스트 작성</h1>
<p class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</p>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
<div class="admin-surface mt-4">
<form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
<label>카테고리
<select class="admin-input" @bind="CategoryIdText">
<option value="">선택하세요</option>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
<option value="@category.Id.ToString()">@category.Name</option>
}
</MudSelect>
<MudTextField @bind-Value="model.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
</select>
</label>
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
<label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
<label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
<label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
<label><input type="checkbox" @bind="model.IsPublished" /> 즉시 발행</label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">저장</button>
</div>
</form>
</div>
</MudForm>
</MudPaper>
@code {
private MudForm? form;
private List<Domain.Entities.Category> categories = [];
private CreatePostModel model = new();
private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
protected override async Task OnInitializedAsync()
{
categories = (await CategoryRepository.GetAllAsync()).ToList();
}
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/blog");
}
private async Task SavePost()
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
try
{
await BlogService.CreateAsync(new CreateBlogPostDto
@@ -90,12 +66,12 @@
IsPublished = model.IsPublished
});
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
await JS.InvokeVoidAsync("alert", ex.Message);
}
}
@@ -6,76 +6,60 @@
@inject BlogService BlogService
@inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
<PageTitle>포스트 수정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">포스트 수정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">블로그 포스트를 수정합니다.</MudText>
<div class="admin-eyebrow">Content</div>
<h1 class="admin-page-title">포스트 수정</h1>
<p class="admin-page-subtitle">블로그 포스트를 수정합니다.</p>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
</section>
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
<div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
}
else if (post == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
<div class="admin-surface mt-4">포스트를 찾을 수 없습니다.</div>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
<div class="admin-surface mt-4">
<form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
<label>카테고리
<select class="admin-input" @bind="CategoryIdText">
<option value="">선택하세요</option>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
<option value="@category.Id.ToString()">@category.Name</option>
}
</MudSelect>
<MudTextField @bind-Value="model.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error"
@onclick="DeletePost">삭제</MudButton>
</select>
</label>
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
<label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
<label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
<label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
<label><input type="checkbox" @bind="model.IsPublished" /> 발행</label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">저장</button>
<button type="button" class="site-button secondary" @onclick="DeletePost">삭제</button>
</div>
</form>
</div>
</MudForm>
</MudPaper>
}
@code {
[Parameter]
public int Id { get; set; }
private MudForm? form;
[Parameter] public int Id { get; set; }
private Domain.Entities.BlogPost? post;
private List<Domain.Entities.Category> categories = [];
private EditPostModel model = new();
private bool isLoading = true;
private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
protected override async Task OnInitializedAsync()
{
@@ -90,7 +74,7 @@ else
}
catch (Exception ex)
{
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"포스트 로드 실패: {ex.Message}");
}
finally
{
@@ -109,20 +93,9 @@ else
model.IsPublished = post.IsPublished;
}
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/blog");
}
private async Task SavePost()
{
if (form == null || post == null)
return;
await form.Validate();
if (!form.IsValid)
return;
if (post == null) return;
try
{
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
@@ -135,44 +108,23 @@ else
SeoDescription = model.SeoDescription,
IsPublished = model.IsPublished
});
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", ex.Message);
}
}
private async Task DeletePost()
{
if (post == null)
return;
var result = await DialogService.ShowMessageBox(
"포스트 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
return;
try
{
if (post == null) return;
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
await BlogService.DeleteAsync(post.Id);
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
}
}
private class EditPostModel
{
@@ -1,55 +1,72 @@
@page "/admin/blog"
@attribute [Authorize]
@inject IApiClient ApiClient
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>블로그 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">블로그 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</MudText>
<div class="admin-eyebrow">Content</div>
<h1 class="admin-page-title">블로그 관리</h1>
<p class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote"
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
</section>
<MudPaper Class="admin-surface mb-4" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
</MudStack>
</MudPaper>
<div class="admin-surface mb-4">
<div class="admin-summary-bar">
<span>전체 포스트: @($"{totalPosts}개")</span>
<span>페이지 @currentPage / @totalPages</span>
</div>
</div>
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행">
<CellTemplate Context="cell">
<MudCheckBox T="bool" Value="@cell.Item.IsPublished"
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ViewCount" Title="조회수" />
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
<TemplateColumn>
<CellTemplate Context="cell">
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
<div class="admin-surface">
@if (isLoading)
{
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
else
{
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>제목</th>
<th>발행</th>
<th>조회수</th>
<th>작성일</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var post in posts)
{
<tr>
<td>@post.Title</td>
<td><label><input type="checkbox" checked="@post.IsPublished" @onchange="@(async e => await TogglePublish(post, (bool)e.Value!))" /> 발행</label></td>
<td>@post.ViewCount</td>
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
<td>
<div class="admin-row-actions">
<a class="site-button secondary" href="@($"/taxbaik/admin/blog/{post.Id}/edit")">수정</a>
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeletePost(post.Id))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
</MudStack>
<div class="admin-pagination">
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
</div>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
private bool isLoading = true;
private int currentPage = 1;
@@ -57,10 +74,20 @@
private int totalPosts = 0;
private const int PageSize = 20;
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadPosts();
StateHasChanged();
}
}
}
private string NavTo(string url) => url;
private async Task LoadPosts()
{
@@ -78,58 +105,33 @@
totalPosts = 0;
totalPages = 1;
}
finally
{
isLoading = false;
}
private async Task PreviousPage()
{
if (currentPage <= 1)
return;
currentPage--;
await LoadPosts();
}
private async Task NextPage()
{
if (currentPage >= totalPages)
return;
currentPage++;
await LoadPosts();
}
private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadPosts(); } }
private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadPosts(); } }
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
{
var previous = post.IsPublished;
post.IsPublished = isPublished;
var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new
{
post.Title,
post.Content,
post.CategoryId,
post.Tags,
post.SeoTitle,
post.SeoDescription,
post.ThumbnailUrl,
IsPublished = isPublished,
post.AuthorId
});
var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new { post.Title, post.Content, post.CategoryId, post.Tags, post.SeoTitle, post.SeoDescription, post.ThumbnailUrl, IsPublished = isPublished, post.AuthorId });
if (result == null)
{
post.IsPublished = previous;
Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "발행 상태 변경에 실패했습니다.");
return;
}
Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "발행 상태가 변경되었습니다.");
}
private async Task DeletePost(int postId)
{
await ApiClient.DeleteAsync($"blog/{postId}");
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
await LoadPosts();
}
@@ -4,185 +4,123 @@
@inject ClientService ClientService
@inject ConsultationService ConsultationService
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>고객 상세</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Client Details</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객 상세</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</MudText>
<div class="admin-eyebrow">Client Details</div>
<h1 class="admin-page-title">고객 상세</h1>
<p class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</p>
</div>
</section>
@if (client == null)
{
<MudText>고객을 찾을 수 없습니다.</MudText>
return;
<div class="admin-surface mt-4">고객을 찾을 수 없습니다.</div>
}
else
{
<div class="admin-page-actions">
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{ClientId}/edit")">수정</a>
</div>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ArrowBack"
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/clients"))">
목록으로
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning"
StartIcon="@Icons.Material.Filled.Edit"
Href="@($"/taxbaik/admin/clients/{ClientId}/edit")">
수정
</MudButton>
</MudStack>
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">고객 정보</MudText>
<MudGrid Spacing="2">
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
<MudText>@client.Name</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">상호</MudText>
<MudText>@(client.CompanyName ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
<MudText>@(client.Phone ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
<MudText>@(client.Email ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">서비스</MudText>
<MudText>@(client.ServiceType ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">사업자 유형</MudText>
<MudText>@(client.TaxType ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">유입 경로</MudText>
<MudText>@(client.Source ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">등록일</MudText>
<MudText>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</MudText>
</MudItem>
<div class="admin-detail-grid">
<section class="admin-surface">
<h3 class="admin-section-title">고객 정보</h3>
<div class="admin-kv-grid">
<div><span>이름</span><strong>@client.Name</strong></div>
<div><span>상호</span><strong>@(client.CompanyName ?? "-")</strong></div>
<div><span>연락처</span><strong>@(client.Phone ?? "-")</strong></div>
<div><span>이메일</span><strong>@(client.Email ?? "-")</strong></div>
<div><span>서비스</span><strong>@(client.ServiceType ?? "-")</strong></div>
<div><span>사업자 유형</span><strong>@(client.TaxType ?? "-")</strong></div>
<div><span>유입 경로</span><strong>@(client.Source ?? "-")</strong></div>
<div><span>등록일</span><strong>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</strong></div>
@if (!string.IsNullOrWhiteSpace(client.Memo))
{
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">메모</MudText>
<MudText Style="white-space: pre-wrap;">@client.Memo</MudText>
</MudItem>
<div class="span-2"><span>메모</span><strong style="white-space: pre-wrap;">@client.Memo</strong></div>
}
</MudGrid>
</MudPaper>
</MudItem>
</div>
</section>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
<MudText Typo="Typo.h6">상담 이력</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Size="Size.Small"
OnClick="OpenAddConsultation">
+ 상담 추가
</MudButton>
</MudStack>
<section class="admin-surface">
<div class="admin-section-header compact">
<div>
<h3 class="admin-section-title">상담 이력</h3>
</div>
<button type="button" class="site-button primary" @onclick="OpenAddConsultation">+ 상담 추가</button>
</div>
@if (showAddForm)
{
<MudPaper Class="pa-3 mb-3" Outlined="true">
<MudGrid Spacing="2">
<MudItem xs="12" sm="6">
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
<form class="admin-dialog-card mb-4" @onsubmit="AddConsultation" @onsubmit:preventDefault="true">
<label>상담일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="ConsultationDateText" /></label>
<label>서비스 분야
<select class="admin-input" @bind="newServiceType">
<option value="">선택하세요</option>
@foreach (var t in ClientService.ServiceTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
<option value="@t">@t</option>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
Lines="3" Variant="Variant.Outlined" Required="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newResult" Label="결과">
<MudSelectItem Value="@("")">-</MudSelectItem>
</select>
</label>
<label>상담 내용 * <textarea class="admin-input" rows="3" @bind="newSummary"></textarea></label>
<label>결과
<select class="admin-input" @bind="newResult">
<option value="">-</option>
@foreach (var r in ConsultationService.Results)
{
<MudSelectItem Value="@r">@r</MudSelectItem>
<option value="@r">@r</option>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
Format="N0" />
</MudItem>
</MudGrid>
<MudStack Row="true" Class="mt-2" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddConsultation">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
</MudStack>
</MudPaper>
</select>
</label>
<label>수임료 (원) <input class="admin-input" type="text" placeholder="100000" @bind="FeeText" /></label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">저장</button>
<button type="button" class="site-button secondary" @onclick='() => showAddForm = false'>취소</button>
</div>
</form>
}
@if (consultations.Count == 0)
{
<MudText Color="Color.Secondary">상담 이력이 없습니다.</MudText>
<p class="muted">상담 이력이 없습니다.</p>
}
else
{
<MudList T="string" Dense="true">
<div class="admin-activity-list">
@foreach (var c in consultations)
{
<MudListItem>
<MudPaper Class="pa-3" Outlined="true" Style="width:100%">
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween">
<article class="admin-activity-card">
<div class="admin-activity-head">
<div>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@c.ConsultationDate.ToString("yyyy-MM-dd")
@if (!string.IsNullOrEmpty(c.ServiceType)) { <text> · @c.ServiceType</text> }
</MudText>
<MudText Style="white-space: pre-wrap;" Class="mt-1">@c.Summary</MudText>
<span class="muted">@c.ConsultationDate.ToString("yyyy-MM-dd") @(string.IsNullOrEmpty(c.ServiceType) ? "" : $"· {c.ServiceType}")</span>
</div>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteConsultation(c.Id))">✕</button>
</div>
<p style="white-space: pre-wrap;">@c.Summary</p>
@if (!string.IsNullOrEmpty(c.Result))
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="mt-1">@c.Result</MudChip>
<span class="status-pill info">@c.Result</span>
}
@if (c.Fee.HasValue)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
수임료: @c.Fee.Value.ToString("N0")원
</MudText>
<div class="muted">수임료: @c.Fee.Value.ToString("N0")원</div>
}
</article>
}
</div>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteConsultation(c.Id))" />
</MudStack>
</MudPaper>
</MudListItem>
}
</MudList>
</section>
</div>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
[Parameter]
public int ClientId { get; set; }
[Parameter] public int ClientId { get; set; }
private Domain.Entities.Client? client;
private List<Domain.Entities.Consultation> consultations = [];
private bool showAddForm;
private DateTime? newDate = DateTime.Today;
private string newServiceType = "";
@@ -190,10 +128,10 @@
private string newResult = "";
private decimal? newFee;
protected override async Task OnInitializedAsync()
{
await LoadAll();
}
private string ConsultationDateText { get => newDate?.ToString("yyyy-MM-dd") ?? ""; set => newDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string FeeText { get => newFee?.ToString() ?? ""; set => newFee = decimal.TryParse(value, out var d) ? d : null; }
protected override async Task OnInitializedAsync() => await LoadAll();
private async Task LoadAll()
{
@@ -215,6 +153,12 @@
{
try
{
if (string.IsNullOrWhiteSpace(newSummary))
{
await JS.InvokeVoidAsync("alert", "상담 내용을 입력하세요.");
return;
}
var c = new Domain.Entities.Consultation
{
ClientId = ClientId,
@@ -224,21 +168,23 @@
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
Fee = newFee
};
await ConsultationService.CreateAsync(c);
showAddForm = false;
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "상담이 추가되었습니다.");
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
await JS.InvokeVoidAsync("alert", ex.Message);
}
}
private async Task DeleteConsultation(int id)
{
if (!await JS.InvokeAsync<bool>("confirm", "이 상담을 삭제하시겠습니까?")) return;
await ConsultationService.DeleteAsync(id);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
Snackbar.Add("삭제되었습니다.", Severity.Info);
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
}
}
@@ -6,117 +6,74 @@
@using TaxBaik.Domain.Entities
@inject IClientBrowserClient ClientClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText>
<div class="admin-eyebrow">CRM</div>
<h1 class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</h1>
</div>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients"
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
</section>
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
<div class="admin-surface" style="max-width:720px;">
@if (isLoading)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
}
else
{
<MudForm @ref="form" @bind-IsValid="isValid">
<MudGrid Spacing="3">
@* 기본 정보 *@
<MudItem xs="12">
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">기본 정보</MudText>
<MudDivider />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true"
RequiredError="고객명을 입력하세요." />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="dto.Phone" Label="연락처"
Placeholder="010-0000-0000" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" />
</MudItem>
@* 세무 정보 *@
<MudItem xs="12" Class="mt-2">
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">세무 정보</MudText>
<MudDivider />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true">
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
<label>고객명 * <input class="admin-input" @bind="dto.Name" /></label>
<label>회사명 <input class="admin-input" @bind="dto.CompanyName" /></label>
<label>연락처 <input class="admin-input" @bind="dto.Phone" /></label>
<label>이메일 <input class="admin-input" type="email" @bind="dto.Email" /></label>
<label>서비스 유형
<select class="admin-input" @bind="dto.ServiceType">
<option value="">선택하세요</option>
@foreach (var t in ClientService.ServiceTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
<option value="@t">@t</option>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true">
</select>
</label>
<label>세금 유형
<select class="admin-input" @bind="dto.TaxType">
<option value="">선택하세요</option>
@foreach (var t in ClientService.TaxTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
<option value="@t">@t</option>
}
</MudSelect>
</MudItem>
@* 관리 정보 *@
<MudItem xs="12" Class="mt-2">
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">관리 정보</MudText>
<MudDivider />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true">
<MudSelectItem Value="@("active")">활성</MudSelectItem>
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true">
</select>
</label>
<label>상태
<select class="admin-input" @bind="dto.Status">
<option value="active">활성</option>
<option value="inactive">비활성</option>
</select>
</label>
<label>유입 경로
<select class="admin-input" @bind="dto.Source">
<option value="">선택하세요</option>
@foreach (var s in ClientService.Sources)
{
<MudSelectItem Value="@s">@s</MudSelectItem>
<option value="@s">@s</option>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="dto.Memo" Label="메모"
Lines="4" AutoGrow="true"
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
</MudItem>
@* 저장 버튼 *@
<MudItem xs="12" Class="d-flex gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="@SaveAsync" Disabled="@isSaving">
@(isSaving ? "저장 중..." : "저장")
</MudButton>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients">
취소
</MudButton>
</MudItem>
</MudGrid>
</MudForm>
</select>
</label>
<label>메모 <textarea class="admin-input" rows="4" @bind="dto.Memo"></textarea></label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>취소</button>
</div>
</form>
}
</MudPaper>
</div>
@code {
[Parameter] public int? Id { get; set; }
private MudForm form = null!;
private CreateClientDto dto = new() { Status = "active" };
private bool isValid;
private bool isLoading = true;
private bool isSaving;
@@ -129,7 +86,7 @@
var client = await ClientClient.GetByIdAsync(Id.Value);
if (client is null)
{
Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "고객을 찾을 수 없습니다.");
Navigation.NavigateTo("/taxbaik/admin/clients");
return;
}
@@ -148,7 +105,7 @@
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
Navigation.NavigateTo("/taxbaik/admin/clients");
return;
}
@@ -158,33 +115,29 @@
private async Task SaveAsync()
{
await form.Validate();
if (!isValid) return;
isSaving = true;
try
{
if (string.IsNullOrWhiteSpace(dto.Name))
{
await JS.InvokeVoidAsync("alert", "고객명을 입력하세요.");
return;
}
if (Id.HasValue)
{
var result = await ClientClient.UpdateAsync(Id.Value, dto);
if (result != null)
Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success);
else
Snackbar.Add("수정에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", result != null ? "고객 정보가 수정되었습니다." : "수정에 실패했습니다.");
}
else
{
var result = await ClientClient.CreateAsync(dto);
if (result != null)
Snackbar.Add("고객이 등록되었습니다.", Severity.Success);
else
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", result != null ? "고객이 등록되었습니다." : "등록에 실패했습니다.");
}
Navigation.NavigateTo("/taxbaik/admin/clients");
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
finally
{
@@ -4,63 +4,44 @@
@using TaxBaik.Domain.Entities
@inject IClientBrowserClient ClientClient
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>고객 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText>
<div class="admin-eyebrow">CRM</div>
<h1 class="admin-page-title">고객 관리</h1>
<p class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PersonAdd"
Href="/taxbaik/admin/clients/create">
고객 등록
</MudButton>
<button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients/create")'>고객 등록</button>
</section>
@* 검색/필터 바 *@
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
<MudGrid>
<MudItem xs="12" md="5">
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect @bind-Value="statusFilter" Label="상태" T="string">
<MudSelectItem Value="@("")">전체</MudSelectItem>
<MudSelectItem Value="@("active")">활성</MudSelectItem>
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
</MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Text" OnClick="@ResetAsync" FullWidth="true">초기화</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
<div class="admin-surface mb-3 pa-3">
<div class="admin-filter-grid">
<input class="admin-input" placeholder="검색 (이름·연락처·회사명)" @bind="searchText" @onkeyup="OnSearchKeyUp" />
<select class="admin-input" @bind="statusFilter">
<option value="">전체</option>
<option value="active">활성</option>
<option value="inactive">비활성</option>
</select>
<button type="button" class="site-button secondary" @onclick="SearchAsync">검색</button>
<button type="button" class="site-button secondary" @onclick="ResetAsync">초기화</button>
</div>
</div>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (clients is null)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
else if (!clients.Any())
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
</div>
<div class="muted mt-4">등록된 고객이 없습니다.</div>
}
else
{
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>이름</th>
@@ -81,54 +62,36 @@
<td><strong>@c.Name</strong></td>
<td>@(c.CompanyName ?? "—")</td>
<td>@(c.Phone ?? "—")</td>
<td>
@if (!string.IsNullOrEmpty(c.ServiceType))
{
<MudChip Size="Size.Small" Color="Color.Primary">@c.ServiceType</MudChip>
}
</td>
<td>@(c.ServiceType ?? "—")</td>
<td>@(c.TaxType ?? "—")</td>
<td>
@if (c.Status == "active")
{
<MudChip Size="Size.Small" Color="Color.Success">활성</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
}
</td>
<td>@(c.Status == "active" ? "활성" : "비활성")</td>
<td>@(c.Source ?? "—")</td>
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(c))">
삭제
</MudButton>
</MudButtonGroup>
<div class="admin-row-actions">
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(c))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
@* 페이징 *@
</table>
</div>
@if (totalPages > 1)
{
<div class="d-flex justify-center pa-3">
<MudPagination BoundaryCount="1" MiddleCount="3"
Count="@totalPages" Selected="@currentPage"
SelectedChanged="@OnPageChanged" />
<div class="admin-pagination">
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1)" @onclick="PreviousPage">이전</button>
<span>@currentPage / @totalPages</span>
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages)" @onclick="NextPage">다음</button>
</div>
}
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText>
<div class="admin-table-footer">총 @(totalCount)명</div>
}
</MudPaper>
</div>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Client>? clients;
private string searchText = "";
private string statusFilter = "";
@@ -137,81 +100,56 @@
private int totalPages;
private const int PageSize = 20;
protected override async Task OnInitializedAsync() => await LoadAsync();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
private async Task LoadAsync()
{
try
{
var (items, total) = await ClientClient.GetPagedAsync(
currentPage, PageSize,
string.IsNullOrEmpty(statusFilter) ? null : statusFilter,
string.IsNullOrEmpty(searchText) ? null : searchText);
var (items, total) = await ClientClient.GetPagedAsync(currentPage, PageSize, string.IsNullOrEmpty(statusFilter) ? null : statusFilter, string.IsNullOrEmpty(searchText) ? null : searchText);
clients = items.ToList();
totalCount = total;
totalPages = (int)Math.Ceiling((double)total / PageSize);
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
clients = [];
totalCount = 0;
totalPages = 0;
}
}
private async Task SearchAsync()
{
currentPage = 1;
await LoadAsync();
}
private async Task ResetAsync()
{
searchText = "";
statusFilter = "";
currentPage = 1;
await LoadAsync();
}
private async Task OnPageChanged(int page)
{
currentPage = page;
await LoadAsync();
}
private async Task OnSearchKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter") await SearchAsync();
}
private async Task SearchAsync() { currentPage = 1; await LoadAsync(); }
private async Task ResetAsync() { searchText = ""; statusFilter = ""; currentPage = 1; await LoadAsync(); }
private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadAsync(); } }
private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadAsync(); } }
private async Task OnSearchKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") await SearchAsync(); }
private async Task DeleteAsync(Client client)
{
var confirmed = await DialogService.ShowMessageBox(
"고객 삭제",
$"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.",
yesText: "삭제", cancelText: "취소");
if (confirmed != true) return;
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.");
if (!confirmed) return;
try
{
var success = await ClientClient.DeleteAsync(client.Id);
if (success)
{
Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", $"{client.Name} 고객이 삭제되었습니다.");
await LoadAsync();
}
else
{
Snackbar.Add("삭제에 실패했습니다.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
}
await LoadAsync();
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
}
@@ -3,22 +3,22 @@
@using TaxBaik.Web.Components.Admin.Forms
@inject IApiClient ApiClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>고객사 등록</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">새 고객사 등록</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 고객사를 추가합니다.</MudText>
<div class="admin-eyebrow">Settings</div>
<h1 class="admin-page-title">새 고객사 등록</h1>
<p class="admin-page-subtitle">새로운 고객사를 추가합니다.</p>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<div class="admin-surface mt-4">
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
</MudPaper>
</div>
@code {
private void GoBack()
@@ -40,12 +40,12 @@
memo = model.Memo
});
Snackbar.Add("고객사가 등록되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "고객사가 등록되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/companies");
}
catch (Exception ex)
{
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
}
}
}
@@ -3,39 +3,37 @@
@using TaxBaik.Web.Components.Admin.Forms
@inject IApiClient ApiClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
<PageTitle>고객사 수정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 수정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객사 정보를 수정합니다.</MudText>
<div class="admin-eyebrow">Settings</div>
<h1 class="admin-page-title">고객사 수정</h1>
<p class="admin-page-subtitle">고객사 정보를 수정합니다.</p>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section>
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
<div class="admin-surface mt-4">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
}
else if (formModel == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
<div class="admin-surface mt-4">고객사를 찾을 수 없습니다.</div>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<div class="admin-surface mt-4">
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
<MudDivider Class="my-4" />
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
고객사 삭제
</MudButton>
</MudPaper>
<div class="mt-4">
<button type="button" class="site-button secondary danger" @onclick="DeleteCompany">고객사 삭제</button>
</div>
</div>
}
@code {
@@ -67,7 +65,7 @@ else
}
catch (Exception ex)
{
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
}
finally
{
@@ -95,34 +93,29 @@ else
isActive = model.IsActive
});
Snackbar.Add("고객사가 수정되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "고객사가 수정되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/companies");
}
catch (Exception ex)
{
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
}
}
private async Task DeleteCompany()
{
var result = await DialogService.ShowMessageBox(
"고객사 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."))
return;
try
{
await ApiClient.DeleteAsync($"company/{Id}");
Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "고객사가 삭제되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/companies");
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
}
}
}
@@ -1,53 +1,71 @@
@page "/admin/companies"
@attribute [Authorize]
@inject IApiClient ApiClient
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>고객사 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</MudText>
<div class="admin-eyebrow">Settings</div>
<h1 class="admin-page-title">고객사 관리</h1>
<p class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/companies/create")'>새 고객사 등록</button>
</section>
<MudPaper Class="admin-surface mb-4 mt-4" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1">@($"전체 고객사 {totalCompanies}개")</MudText>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
</MudStack>
</MudPaper>
<div class="admin-surface mb-4 mt-4">
<div class="admin-summary-bar">
<span>@($"전체 고객사 {totalCompanies}개")</span>
<span>페이지 @currentPage / @totalPages</span>
</div>
</div>
<MudDataGrid Items="@companies" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.CompanyCode" Title="회사코드" />
<PropertyColumn Property="x => x.CompanyName" Title="회사명" />
<PropertyColumn Property="x => x.ContactPerson" Title="담당자" />
<PropertyColumn Property="x => x.Phone" Title="전화" />
<PropertyColumn Property="x => x.Email" Title="이메일" />
<PropertyColumn Property="x => x.IsActive" Title="활성">
<CellTemplate Context="cell">
<MudCheckBox T="bool" Value="@cell.Item.IsActive" Disabled="true" />
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.CreatedAt" Title="등록일" Format="yyyy-MM-dd" />
<TemplateColumn>
<CellTemplate Context="cell">
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
Href="@($"/taxbaik/admin/companies/{cell.Item.Id}/edit")">수정</MudButton>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
<div class="admin-surface">
@if (isLoading)
{
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
else
{
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>회사코드</th>
<th>회사명</th>
<th>담당자</th>
<th>전화</th>
<th>이메일</th>
<th>활성</th>
<th>등록일</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in companies)
{
<tr>
<td>@item.CompanyCode</td>
<td>@item.CompanyName</td>
<td>@(item.ContactPerson ?? "—")</td>
<td>@(item.Phone ?? "—")</td>
<td>@(item.Email ?? "—")</td>
<td>@(item.IsActive ? "활성" : "비활성")</td>
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
<td><a class="site-button secondary" href="@($"/taxbaik/admin/companies/{item.Id}/edit")">수정</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
</MudStack>
<div class="admin-pagination">
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
</div>
@code {
private List<CompanyDto> companies = [];
@@ -100,7 +118,7 @@
}
catch (Exception ex)
{
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
}
finally
{
@@ -131,4 +149,6 @@
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}
private string NavTo(string url) => url;
}
@@ -2,131 +2,124 @@
@using TaxBaik.Web.Services.AdminClients
@inject IConsultingActivityBrowserClient ActivityClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
@attribute [Authorize]
<PageTitle>상담 활동 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">상담 활동 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</MudText>
<div class="admin-eyebrow">CRM & 세무관리</div>
<h1 class="admin-page-title">상담 활동 관리</h1>
<p class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 활동 기록
</MudButton>
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 활동 기록</button>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (activities is null)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
else if (activities.Count == 0)
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.Timeline" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">상담 활동이 없습니다.</MudText>
</div>
<div class="muted">상담 활동이 없습니다.</div>
}
else
{
<MudDataGrid T="ConsultingActivity"
Items="@activities"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>고객</th>
<th>활동 유형</th>
<th>활동일시</th>
<th>설명</th>
<th>다음 팔로업</th>
<th>작업</th>
</tr>
</thead>
<tbody>
@foreach (var item in activities)
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
<tr>
<td>@item.Id</td>
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
<td>@item.ActivityType</td>
<td>@item.ActivityDate.ToString("g")</td>
<td>@Truncate(item.Description)</td>
<td>@(item.NextFollowupDate?.ToString("yyyy-MM-dd") ?? "—")</td>
<td>
<div class="admin-row-actions">
<button type="button" class="admin-icon-button" @onclick="@(async () => await OpenEditDialog(item))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteActivity(item.Id))">✕</button>
</div>
</td>
</tr>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ActivityType" Title="활동 유형" />
<PropertyColumn Property="x => x.ActivityDate" Title="활동일시" Format="g" />
<TemplateColumn Title="설명">
<CellTemplate>
@{
var desc = context.Item.Description ?? "";
if (desc.Length > 30) desc = desc.Substring(0, 30) + "...";
</tbody>
</table>
</div>
}
<span>@desc</span>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="다음 팔로업">
<CellTemplate>
@if (context.Item.NextFollowupDate.HasValue)
{
var daysLeft = (context.Item.NextFollowupDate.Value.Date - DateTime.Today).Days;
<MudChip Size="Size.Small"
Color="@(daysLeft < 0 ? Color.Error : daysLeft <= 3 ? Color.Warning : Color.Success)"
Variant="Variant.Filled">
@context.Item.NextFollowupDate.Value.ToString("yyyy-MM-dd")
</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
OnClick="@(async () => await DeleteActivity(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
</div>
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<dialog class="admin-dialog" open="@isDialogOpen">
<form class="admin-dialog-card" @onsubmit="SaveActivity" @onsubmit:preventDefault="true">
<h3>@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</h3>
<label>고객
<select class="admin-input" @bind="ClientIdText">
<option value="">선택하세요</option>
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
}
</MudSelect>
<MudTextField T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveActivity">저장</MudButton>
</DialogActions>
</MudDialog>
</select>
</label>
<label>활동 유형
<select class="admin-input" @bind="activityForm.ActivityType">
<option value="">선택하세요</option>
<option value="방문 상담">방문 상담</option>
<option value="전화 상담">전화 상담</option>
<option value="세무조사 대응 미팅">세무조사 대응 미팅</option>
<option value="카카오톡 상담">카카오톡 상담</option>
<option value="이메일 자료 접수">이메일 자료 접수</option>
<option value="기타">기타</option>
</select>
</label>
<label>활동일 <input class="admin-input" type="text" placeholder="2026-06-29 14:00" @bind="ActivityDateText" /></label>
<label>설명 <textarea class="admin-input" rows="4" @bind="activityForm.Description"></textarea></label>
<label>다음 팔로업일 <input class="admin-input" type="text" placeholder="2026-07-10" @bind="NextFollowupText" /></label>
<div class="admin-dialog-actions">
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
<button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<ConsultingActivity>? activities;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen;
private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new();
protected override async Task OnInitializedAsync()
private string ClientIdText { get => activityForm.ClientId > 0 ? activityForm.ClientId.ToString() : ""; set => activityForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string ActivityDateText { get => activityForm.ActivityDate?.ToString("yyyy-MM-dd HH:mm") ?? ""; set => activityForm.ActivityDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string NextFollowupText { get => activityForm.NextFollowupDate?.ToString("yyyy-MM-dd") ?? ""; set => activityForm.NextFollowupDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
private async Task LoadData()
@@ -134,20 +127,20 @@
try
{
activities = await ActivityClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
}
}
private void OpenCreateDialog()
{
editingActivity = null;
activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
activityForm = new ConsultingActivityForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, ActivityDate = DateTime.Now };
isDialogOpen = true;
}
@@ -163,87 +156,60 @@
NextFollowupDate = activity.NextFollowupDate
};
isDialogOpen = true;
await Task.CompletedTask;
}
private async Task SaveActivity()
{
if (activityForm.ClientId <= 0 || string.IsNullOrWhiteSpace(activityForm.ActivityType) || string.IsNullOrWhiteSpace(activityForm.Description))
{
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
return;
}
try
{
if (editingActivity == null)
{
var actDate = activityForm.ActivityDate ?? DateTime.Now;
var newId = await ActivityClient.CreateAsync(
activityForm.ClientId,
activityForm.ActivityType,
actDate,
activityForm.Description,
null,
activityForm.NextFollowupDate);
var newId = await ActivityClient.CreateAsync(activityForm.ClientId, activityForm.ActivityType, activityForm.ActivityDate ?? DateTime.Now, activityForm.Description, null, activityForm.NextFollowupDate);
if (newId > 0)
{
Snackbar.Add("활동이 기록되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "활동이 기록되었습니다.");
CloseDialog();
await LoadData();
}
}
else
{
await ActivityClient.UpdateAsync(
editingActivity.Id,
null,
activityForm.NextFollowupDate);
Snackbar.Add("활동이 업데이트되었습니다.", Severity.Success);
await ActivityClient.UpdateAsync(editingActivity.Id, null, activityForm.NextFollowupDate);
await JS.InvokeVoidAsync("alert", "활동이 업데이트되었습니다.");
CloseDialog();
await LoadData();
}
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
}
private async Task DeleteActivity(int id)
{
var parameters = new DialogParameters
{
{ "Title", "삭제 확인" },
{ "Message", "이 활동을 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
if (!await JS.InvokeAsync<bool>("confirm", "이 활동을 삭제하시겠습니까?")) return;
try
{
await ActivityClient.DeleteAsync(id);
Snackbar.Add("활동이 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "활동이 삭제되었습니다.");
await LoadData();
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
}
}
private void CloseDialog()
{
isDialogOpen = false;
editingActivity = null;
activityForm = new();
}
private class ConsultingActivityForm
{
public int ClientId { get; set; }
public string ActivityType { get; set; } = "";
public DateTime? ActivityDate { get; set; } = DateTime.Now;
public string Description { get; set; } = "";
public DateTime? NextFollowupDate { get; set; }
}
private void CloseDialog() { isDialogOpen = false; editingActivity = null; activityForm = new(); }
private static string Truncate(string? text) => string.IsNullOrWhiteSpace(text) ? "—" : text.Length > 30 ? text[..30] + "..." : text;
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
private sealed class ConsultingActivityForm { public int ClientId { get; set; } public string ActivityType { get; set; } = ""; public DateTime? ActivityDate { get; set; } = DateTime.Now; public string Description { get; set; } = ""; public DateTime? NextFollowupDate { get; set; } }
}
+103 -141
View File
@@ -2,141 +2,125 @@
@using TaxBaik.Web.Services.AdminClients
@inject IContractBrowserClient ContractClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
@attribute [Authorize]
<PageTitle>계약 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">계약 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</MudText>
<div class="admin-eyebrow">CRM & 세무관리</div>
<h1 class="admin-page-title">계약 관리</h1>
<p class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</p>
@if (mrr > 0)
{
<MudText Typo="Typo.body2" Class="mt-2">
월 정기수익:
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
</MudText>
<p class="admin-page-subtitle mt-2">월 정기수익: <strong>₩@mrr.ToString("N0")</strong></p>
}
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 계약 추가
</MudButton>
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 계약 추가</button>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (contracts is null)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
}
else if (contracts.Count == 0)
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.Description" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">계약이 없습니다.</MudText>
<div class="muted">계약이 없습니다.</div>
}
else
{
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>고객</th>
<th>계약번호</th>
<th>서비스 유형</th>
<th>월 수수료</th>
<th>계약기간</th>
<th>상태</th>
<th>작업</th>
</tr>
</thead>
<tbody>
@foreach (var item in contracts)
{
var isActive = !item.EndDate.HasValue || item.EndDate.Value >= DateTime.Today;
<tr>
<td>@item.Id</td>
<td>@(clientMap.TryGetValue(item.ClientId, out var clientName) ? clientName : "")</td>
<td>@item.ContractNumber</td>
<td>@item.ServiceType</td>
<td>@(item.MonthlyFee?.ToString("C") ?? "—")</td>
<td>@item.StartDate@if (item.EndDate.HasValue){<span>~ @item.EndDate.Value</span>}</td>
<td><span class="status-pill @(isActive ? "success" : "muted")">@(isActive ? "활성" : "만료")</span></td>
<td><button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteContract(item.Id))">✕</button></td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<MudDataGrid T="Contract"
Items="@contracts"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
<TemplateColumn Title="계약기간">
<CellTemplate>
@context.Item.StartDate.ToString("yyyy-MM-dd")
@if (context.Item.EndDate.HasValue)
{
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="상태">
<CellTemplate>
@{
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
}
@if (isActive)
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
</div>
<!-- Create Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">새 계약 추가</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<dialog class="admin-dialog" open="@isDialogOpen">
<form class="admin-dialog-card" @onsubmit="SaveContract" @onsubmit:preventDefault="true">
<h3>새 계약 추가</h3>
<label>고객
<select class="admin-input" @bind="ClientIdText">
<option value="">선택하세요</option>
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
}
</MudSelect>
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveContract">저장</MudButton>
</DialogActions>
</MudDialog>
</select>
</label>
<label>계약번호 <input class="admin-input" @bind="contractForm.ContractNumber" /></label>
<label>서비스 유형
<select class="admin-input" @bind="contractForm.ServiceType">
<option value="개인 기장대리">개인 기장대리</option>
<option value="법인 기장대리">법인 기장대리</option>
<option value="세무조정 대행">세무조정 대행</option>
<option value="양도세 신고대리">양도세 신고대리</option>
<option value="상속·증여 자문">상속·증여 자문</option>
<option value="세무조사 대응">세무조사 대응</option>
</select>
</label>
<label>계약 시작일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="StartDateText" /></label>
<label>월 수수료 <input class="admin-input" type="text" placeholder="100000" @bind="MonthlyFeeText" /></label>
<div class="admin-dialog-actions">
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
<button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Contract>? contracts;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private decimal mrr = 0;
private MudForm? form;
private bool isDialogOpen;
private ContractForm contractForm = new();
private string ClientIdText { get => contractForm.ClientId > 0 ? contractForm.ClientId.ToString() : ""; set => contractForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string StartDateText { get => contractForm.StartDate?.ToString("yyyy-MM-dd") ?? ""; set => contractForm.StartDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string MonthlyFeeText { get => contractForm.MonthlyFee?.ToString() ?? ""; set => contractForm.MonthlyFee = decimal.TryParse(value, out var amount) ? amount : null; }
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
private async Task LoadData()
@@ -144,20 +128,20 @@
try
{
contracts = await ContractClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
}
catch (Exception ex)
{
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
}
}
private void OpenCreateDialog()
{
contractForm = new();
contractForm = new ContractForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, StartDate = DateTime.Today };
isDialogOpen = true;
}
@@ -165,64 +149,42 @@
{
try
{
var newId = await ContractClient.CreateAsync(
contractForm.ClientId,
contractForm.ContractNumber,
contractForm.ServiceType,
contractForm.StartDate ?? DateTime.Now,
contractForm.MonthlyFee);
if (contractForm.ClientId <= 0)
{
await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
return;
}
var newId = await ContractClient.CreateAsync(contractForm.ClientId, contractForm.ContractNumber, contractForm.ServiceType, contractForm.StartDate ?? DateTime.Today, contractForm.MonthlyFee);
if (newId > 0)
{
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "계약이 추가되었습니다.");
CloseDialog();
await LoadData();
}
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
}
private async Task DeleteContract(int id)
{
var parameters = new DialogParameters
{
{ "Title", "삭제 확인" },
{ "Message", "이 계약을 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
if (!await JS.InvokeAsync<bool>("confirm", "이 계약을 삭제하시겠습니까?")) return;
try
{
await ContractClient.DeleteAsync(id);
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "계약이 삭제되었습니다.");
await LoadData();
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
}
}
private void CloseDialog()
{
isDialogOpen = false;
contractForm = new();
}
private class ContractForm
{
public int ClientId { get; set; }
public string ContractNumber { get; set; } = "";
public string ServiceType { get; set; } = "";
public DateTime? StartDate { get; set; }
public decimal? MonthlyFee { get; set; }
}
private void CloseDialog() { isDialogOpen = false; contractForm = new(); }
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
private sealed class ContractForm { public int ClientId { get; set; } public string ContractNumber { get; set; } = ""; public string ServiceType { get; set; } = ""; public DateTime? StartDate { get; set; } public decimal? MonthlyFee { get; set; } }
}
+116 -104
View File
@@ -8,73 +8,90 @@
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Overview</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">대시보드</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</MudText>
<div class="admin-eyebrow">Overview</div>
<h1 class="admin-page-title">대시보드</h1>
<p class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="/taxbaik/admin/blog/create">
새 포스트 작성
</MudButton>
<button type="button" class="site-button primary" @onclick='() => Nav.NavigateTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
</section>
<!-- Metrics Grid - Pure HTML div instead of MudGrid to ensure proper layout -->
<div class="admin-metric-grid">
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">이번달 문의</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #1565c0;">@summary.ThisMonthInquiries</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #1976d2;">💬</span>
</div>
<span style="font-size: 0.9rem; color: #666;">월간 상담 유입 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">신규 문의</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #e65100;">@summary.NewInquiries</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #f57c00;">⚠️</span>
</div>
<span style="font-size: 0.9rem; color: #666;">처리 대기 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">전체 포스트</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #455a64;">@summary.TotalPosts</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #607d8b;">📄</span>
</div>
<span style="font-size: 0.9rem; color: #666;">콘텐츠 자산 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">발행된 포스트</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #2e7d32;">@summary.PublishedPosts</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #388e3c;">🌐</span>
</div>
<span style="font-size: 0.9rem; color: #666;">검색 노출 대상 (클릭 시 이동)</span>
</div>
</div>
</div>
@if (upcomingFilings.Count > 0)
@if (summary is null)
{
<MudPaper Class="admin-surface mt-4" Elevation="0">
<div class="admin-metric-grid">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
<div class="admin-surface mt-4">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
<div class="admin-surface mt-4">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
}
else
{
<div class="admin-metric-grid">
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
<div class="metric-card-inner">
<span class="metric-label">이번달 문의</span>
<div class="metric-value-row">
<span class="metric-value blue">@summary.ThisMonthInquiries</span>
<span class="metric-icon">💬</span>
</div>
<span class="metric-hint">월간 상담 유입</span>
</div>
</div>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
<div class="metric-card-inner">
<span class="metric-label">신규 문의</span>
<div class="metric-value-row">
<span class="metric-value amber">@summary.NewInquiries</span>
<span class="metric-icon">⚠️</span>
</div>
<span class="metric-hint">처리 대기</span>
</div>
</div>
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="metric-card-inner">
<span class="metric-label">전체 포스트</span>
<div class="metric-value-row">
<span class="metric-value slate">@summary.TotalPosts</span>
<span class="metric-icon">📄</span>
</div>
<span class="metric-hint">콘텐츠 자산</span>
</div>
</div>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="metric-card-inner">
<span class="metric-label">발행된 포스트</span>
<div class="metric-value-row">
<span class="metric-value green">@summary.PublishedPosts</span>
<span class="metric-icon">🌐</span>
</div>
<span class="metric-hint">검색 노출 대상</span>
</div>
</div>
</div>
}
@if (upcomingFilings.Count == 0)
{
<div class="admin-surface mt-4">이번 달 마감 임박 신고가 없습니다.</div>
}
else
{
<div class="admin-surface mt-4">
<div class="admin-section-header">
<div>
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)</MudText>
<h3 class="admin-section-title">이번 달 마감 임박 신고</h3>
<p class="muted">30일 이내 신고 예정 건</p>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
<a class="site-button secondary" href="/taxbaik/admin/tax-filings">전체 일정 보기</a>
</div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>고객</th>
@@ -88,21 +105,17 @@
{
var dday = (f.DueDate.Date - DateTime.Today).Days;
<tr>
<td>
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
@f.ClientName
</MudLink>
</td>
<td><a href="@($"/taxbaik/admin/clients/{f.ClientId}")">@f.ClientName</a></td>
<td>@f.FilingType</td>
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Dark">기한 초과 (@(-dday)일)</MudChip>
<span class="status-pill dark">기한 초과 (@(-dday)일)</span>
}
else if (dday <= 7)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
<span class="status-pill danger">D-@dday</span>
}
else
{
@@ -112,19 +125,23 @@
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</table>
</div>
</div>
}
<MudPaper Class="admin-surface mt-4" Elevation="0">
@if (summary is not null)
{
<div class="admin-surface mt-4">
<div class="admin-section-header">
<div>
<MudText Typo="Typo.h6">최근 문의</MudText>
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)</MudText>
<h3 class="admin-section-title">최근 문의</h3>
<p class="muted">최근 유입된 상담 요청을 빠르게 확인합니다.</p>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
<a class="site-button secondary" href="/taxbaik/admin/inquiries">문의 전체 보기</a>
</div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>이름</th>
@@ -138,63 +155,58 @@
@foreach (var inquiry in summary.RecentInquiries)
{
<tr>
<td>
<MudLink Href="@($"/taxbaik/admin/inquiries?id={inquiry.Id}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
@inquiry.Name
</MudLink>
</td>
<td><a href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">@inquiry.Name</a></td>
<td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td>
<td>
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
@GetStatusLabel(inquiry.Status)
</MudChip>
</td>
<td><span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span></td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</table>
</div>
</div>
}
@code {
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
private string? errorMessage;
private bool isLoading = true;
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
protected override async Task OnInitializedAsync()
private AdminDashboardSummary? summary;
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
try
{
// API 클라이언트 사용 (서비스 직접 호출 X)
var summaryTask = DashboardClient.GetSummaryAsync();
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
await Task.WhenAll(summaryTask, filingsTask);
summary = await summaryTask;
upcomingFilings = (await filingsTask).ToList();
}
catch (Exception ex)
{
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
}
finally
{
isLoading = false;
StateHasChanged();
}
}
}
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
private static Color StatusColor(string status) => status switch
private static string GetStatusClass(string status) => status switch
{
"new" => Color.Warning,
"consulting" => Color.Info,
"contracted" => Color.Success,
"rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
"new" => "warning",
"consulting" => "info",
"contracted" => "success",
"rejected" => "danger",
"closed" => "dark",
_ => "default"
};
}
@@ -5,85 +5,52 @@
@using TaxBaik.Domain.Entities
@inject IFaqBrowserClient FaqClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText>
<div class="admin-eyebrow">홈페이지</div>
<h1 class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</h1>
</div>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs"
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>목록으로</button>
</section>
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
<div class="admin-surface" style="max-width:720px;">
@if (isLoading)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
}
else
{
<MudForm @ref="form" @bind-IsValid="isValid">
<MudGrid Spacing="3">
<MudItem xs="12">
<MudTextField @bind-Value="faq.Question"
Label="질문 *" Required="true"
RequiredError="질문을 입력하세요."
Counter="300" MaxLength="300"
Lines="2" AutoGrow="true"
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="faq.Answer"
Label="답변 *" Required="true"
RequiredError="답변을 입력하세요."
Lines="5" AutoGrow="true"
Placeholder="방문자에게 보여질 답변을 입력하세요." />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="faq.Category" Label="카테고리" T="string" Clearable="true">
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
<label>질문 * <textarea class="admin-input" rows="3" @bind="faq.Question"></textarea></label>
<label>답변 * <textarea class="admin-input" rows="6" @bind="faq.Answer"></textarea></label>
<label>카테고리
<select class="admin-input" @bind="faq.Category">
<option value="">선택하세요</option>
@foreach (var cat in FaqService.Categories)
{
<MudSelectItem Value="@cat">@cat</MudSelectItem>
<option value="@cat">@cat</option>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudNumericField @bind-Value="faq.SortOrder"
Label="정렬 순서"
HelperText="작을수록 위에 노출"
Min="0" Max="9999" />
</MudItem>
<MudItem xs="12" md="3" Class="d-flex align-center">
<MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
Label="@(faq.IsActive ? "노출 중" : "비활성")" />
</MudItem>
<MudItem xs="12" Class="d-flex gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="@SaveAsync" Disabled="@isSaving">
@(isSaving ? "저장 중..." : "저장")
</MudButton>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs">
취소
</MudButton>
</MudItem>
</MudGrid>
</MudForm>
</select>
</label>
<label>정렬 순서 <input class="admin-input" type="number" min="0" max="9999" @bind="SortOrderText" /></label>
<label><input type="checkbox" @bind="faq.IsActive" /> 노출 중</label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>취소</button>
</div>
</form>
}
</MudPaper>
</div>
@code {
[Parameter] public int? Id { get; set; }
private MudForm form = null!;
private Faq faq = new() { SortOrder = 10, IsActive = true };
private bool isValid;
private bool isLoading = true;
private bool isSaving;
private string SortOrderText { get => faq.SortOrder.ToString(); set => faq.SortOrder = int.TryParse(value, out var n) ? n : 0; }
protected override async Task OnInitializedAsync()
{
@@ -94,7 +61,7 @@
var existing = await FaqClient.GetByIdAsync(Id.Value);
if (existing is null)
{
Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "FAQ를 찾을 수 없습니다.");
Navigation.NavigateTo("/taxbaik/admin/faqs");
return;
}
@@ -102,7 +69,7 @@
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
Navigation.NavigateTo("/taxbaik/admin/faqs");
return;
}
@@ -112,33 +79,30 @@
private async Task SaveAsync()
{
await form.Validate();
if (!isValid) return;
isSaving = true;
try
{
if (string.IsNullOrWhiteSpace(faq.Question) || string.IsNullOrWhiteSpace(faq.Answer))
{
await JS.InvokeVoidAsync("alert", "질문과 답변을 입력하세요.");
return;
}
if (Id.HasValue)
{
var result = await FaqClient.UpdateAsync(Id.Value, faq);
if (result != null)
Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success);
else
Snackbar.Add("수정 실패", Severity.Error);
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 수정되었습니다." : "수정 실패");
}
else
{
var result = await FaqClient.CreateAsync(faq);
if (result != null)
Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success);
else
Snackbar.Add("등록 실패", Severity.Error);
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 등록되었습니다." : "등록 실패");
}
Navigation.NavigateTo("/taxbaik/admin/faqs");
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
finally
{
@@ -4,100 +4,82 @@
@using TaxBaik.Domain.Entities
@inject IFaqBrowserClient FaqClient
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>FAQ 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">FAQ 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</MudText>
<div class="admin-eyebrow">홈페이지</div>
<h1 class="admin-page-title">FAQ 관리</h1>
<p class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/faqs/create">
FAQ 등록
</MudButton>
<a class="site-button primary" href="/taxbaik/admin/faqs/create">FAQ 등록</a>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (faqs is null)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
}
else if (!faqs.Any())
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
</div>
<div class="muted">등록된 FAQ가 없습니다.</div>
}
else
{
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th style="width:60px;">순서</th>
<th>순서</th>
<th>질문</th>
<th style="width:130px;">카테고리</th>
<th style="width:90px;">상태</th>
<th style="width:160px;"></th>
<th>카테고리</th>
<th>상태</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in faqs)
{
<tr>
<td class="text-center">
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
</td>
<td>@item.SortOrder</td>
<td>@item.Question</td>
<td>@(string.IsNullOrEmpty(item.Category) ? "" : item.Category)</td>
<td><span class="status-pill @(item.IsActive ? "success" : "default")">@(item.IsActive ? "노출 중" : "비활성")</span></td>
<td>
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
@item.Question
</MudText>
</td>
<td>
@if (!string.IsNullOrEmpty(item.Category))
{
<MudChip Size="Size.Small" Color="Color.Default">@item.Category</MudChip>
}
</td>
<td>
@if (item.IsActive)
{
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
}
</td>
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
삭제
</MudButton>
</MudButtonGroup>
<div class="admin-actions">
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
</MudText>
</table>
</div>
<div class="muted mt-2">총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개</div>
}
</MudPaper>
</div>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Faq>? faqs;
protected override async Task OnInitializedAsync() => await LoadAsync();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
private async Task LoadAsync()
{
@@ -107,36 +89,32 @@
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
faqs = [];
}
}
private async Task DeleteAsync(Faq item)
{
var confirmed = await DialogService.ShowMessageBox(
"FAQ 삭제",
$"'{item.Question}' 항목을 삭제하시겠습니까?",
yesText: "삭제", cancelText: "취소");
if (confirmed != true) return;
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Question}' 항목을 삭제하시겠습니까?");
if (!confirmed) return;
try
{
var success = await FaqClient.DeleteAsync(item.Id);
if (success)
{
Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "FAQ가 삭제되었습니다.");
await LoadAsync();
}
else
{
Snackbar.Add("삭제 실패", Severity.Error);
await JS.InvokeVoidAsync("alert", "삭제 실패");
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
}
@@ -5,51 +5,41 @@
@using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>문의 등록</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">새 문의 등록</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</MudText>
<div class="admin-eyebrow">Customer Relations</div>
<h1 class="admin-page-title">새 문의 등록</h1>
<p class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</p>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<div class="admin-surface mt-4">
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
</MudPaper>
</div>
@code {
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
private void GoBack() => Navigation.NavigateTo("/taxbaik/admin/inquiries");
private async Task HandleCreate(InquiryForm.InquiryFormModel model)
{
try
{
await InquiryService.SubmitAsync(
model.Name,
model.Phone,
model.ServiceType,
model.Message,
model.Email,
ipAddress: "admin-registered");
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
await InquiryService.SubmitAsync(model.Name, model.Phone, model.ServiceType, model.Message, model.Email, ipAddress: "admin-registered");
await JS.InvokeVoidAsync("alert", "문의가 등록되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
await JS.InvokeVoidAsync("alert", ex.Message);
}
catch (Exception ex)
{
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
}
}
}
@@ -3,113 +3,75 @@
@using TaxBaik.Web.Services
@inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>문의 상세</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Inquiry Details</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 상세</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</MudText>
<div class="admin-eyebrow">Inquiry Details</div>
<h1 class="admin-page-title">문의 상세</h1>
<p class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</p>
</div>
</section>
@if (inquiry != null)
{
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ArrowBack"
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
문의 목록으로
</MudButton>
<div class="admin-page-actions">
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries")'>문의 목록으로</button>
</div>
<MudGrid Class="mt-4">
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">문의 정보</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
<MudText>@inquiry.Name</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
<MudText>@inquiry.Phone</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
<MudText>@(inquiry.Email ?? "-")</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">분야</MudText>
<MudText>@inquiry.ServiceType</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">문의 내용</MudText>
<MudPaper Class="pa-3 mt-1" Outlined="true">
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">접수일시</MudText>
<MudText>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</MudText>
</MudItem>
</MudGrid>
</MudPaper>
<div class="admin-detail-grid">
<section class="admin-surface">
<h3 class="admin-section-title">문의 정보</h3>
<div class="admin-kv-grid">
<div><span>이름</span><strong>@inquiry.Name</strong></div>
<div><span>연락처</span><strong>@inquiry.Phone</strong></div>
<div><span>이메일</span><strong>@(inquiry.Email ?? "-")</strong></div>
<div><span>분야</span><strong>@inquiry.ServiceType</strong></div>
<div class="span-2"><span>문의 내용</span><strong style="white-space: pre-wrap;">@inquiry.Message</strong></div>
<div><span>접수일시</span><strong>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</strong></div>
</div>
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">담당자 메모</MudText>
<MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)"
Lines="4" Variant="Variant.Outlined" />
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
OnClick="SaveMemo">메모 저장</MudButton>
</MudPaper>
</MudItem>
<section class="admin-surface">
<h3 class="admin-section-title">담당자 메모</h3>
<textarea class="admin-input" rows="6" @bind="adminMemo"></textarea>
<div class="admin-dialog-actions mt-3">
<button type="button" class="site-button primary" @onclick="SaveMemo">메모 저장</button>
</div>
</section>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">처리 상태</MudText>
<MudStack Spacing="2">
<section class="admin-surface">
<h3 class="admin-section-title">처리 상태</h3>
<div class="admin-stack">
@foreach (var (key, label) in InquiryStatusMapper.Labels)
{
<MudButton Variant="@(inquiry.Status == key ? Variant.Filled : Variant.Outlined)"
Color="@StatusColor(key)"
FullWidth="true"
OnClick="@(() => OnStatusChanged(key))">
@label
</MudButton>
<button type="button" class="@GetStatusButtonClass(key)" @onclick="@(() => OnStatusChanged(key))">@label</button>
}
</MudStack>
</MudPaper>
</div>
</section>
@if (inquiry.ClientId == null)
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">고객 카드 생성</MudText>
<MudText Typo="Typo.body2" Class="mb-3">이 문의를 고객 카드로 등록합니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
OnClick="ConvertToClient">
고객으로 등록
</MudButton>
</MudPaper>
<section class="admin-surface">
<h3 class="admin-section-title">고객 카드 생성</h3>
<p class="muted">이 문의를 고객 카드로 등록합니다.</p>
<button type="button" class="site-button primary" @onclick="ConvertToClient">고객으로 등록</button>
</section>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">연결된 고객</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">
고객 카드 보기
</MudButton>
</MudPaper>
<section class="admin-surface">
<h3 class="admin-section-title">연결된 고객</h3>
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">고객 카드 보기</a>
</section>
}
</MudItem>
</MudGrid>
</div>
}
else
{
<MudText>문의를 찾을 수 없습니다.</MudText>
<div class="admin-surface">문의를 찾을 수 없습니다.</div>
}
@code {
@@ -134,16 +96,16 @@ else
if (success)
{
inquiry.Status = status;
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "상태가 변경되었습니다.");
}
else
{
Snackbar.Add("상태 변경에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "상태 변경에 실패했습니다.");
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
@@ -156,16 +118,16 @@ else
if (success)
{
inquiry.AdminMemo = adminMemo;
Snackbar.Add("메모가 저장되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "메모가 저장되었습니다.");
}
else
{
Snackbar.Add("메모 저장에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "메모 저장에 실패했습니다.");
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
@@ -184,26 +146,19 @@ else
{
inquiry.ClientId = clientId;
inquiry.Status = "consulting";
Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "고객 카드가 생성되었습니다.");
}
else
{
Snackbar.Add("고객 카드 생성에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "고객 카드 생성에 실패했습니다.");
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
private Color StatusColor(string status) => status switch
{
"new" => Color.Default,
"consulting" => Color.Info,
"contracted" => Color.Success,
"rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
};
private string GetStatusButtonClass(string status)
=> inquiry?.Status == status ? "site-button primary" : "site-button secondary";
}
@@ -5,45 +5,39 @@
@using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
<PageTitle>문의 수정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 수정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의 정보를 수정합니다.</MudText>
<div class="admin-eyebrow">Customer Relations</div>
<h1 class="admin-page-title">문의 수정</h1>
<p class="admin-page-subtitle">고객 문의 정보를 수정합니다.</p>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section>
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
<div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
}
else if (inquiry == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">문의를 찾을 수 없습니다.</MudAlert>
<div class="admin-surface mt-4">문의를 찾을 수 없습니다.</div>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<div class="admin-surface mt-4">
<InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
<MudDivider Class="my-4" />
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteInquiry" Class="mt-2">
문의 삭제
</MudButton>
</MudPaper>
<div class="mt-4">
<button type="button" class="site-button secondary danger" @onclick="DeleteInquiry">문의 삭제</button>
</div>
</div>
}
@code {
[Parameter]
public int Id { get; set; }
[Parameter] public int Id { get; set; }
private Domain.Entities.Inquiry? inquiry;
private InquiryForm.InquiryFormModel? formModel;
private bool isLoading = true;
@@ -69,7 +63,7 @@ else
}
catch (Exception ex)
{
Snackbar.Add($"문의 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"문의 로드 실패: {ex.Message}");
}
finally
{
@@ -77,16 +71,11 @@ else
}
}
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
private void GoBack() => Navigation.NavigateTo("/taxbaik/admin/inquiries");
private async Task HandleUpdate(InquiryForm.InquiryFormModel model)
{
if (inquiry == null)
return;
if (inquiry == null) return;
try
{
inquiry.Name = model.Name;
@@ -97,47 +86,35 @@ else
inquiry.AdminMemo = model.AdminMemo;
if (inquiry.Status != model.Status)
{
await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status);
}
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo);
Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "문의가 수정되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
await JS.InvokeVoidAsync("alert", ex.Message);
}
catch (Exception ex)
{
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
}
}
private async Task DeleteInquiry()
{
if (inquiry == null)
return;
var result = await DialogService.ShowMessageBox(
"문의 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
return;
if (inquiry == null) return;
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
try
{
await InquiryService.DeleteAsync(inquiry.Id);
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "문의가 삭제되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
}
}
}
@@ -7,50 +7,59 @@
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Requests</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</MudText>
<div class="admin-eyebrow">Customer Requests</div>
<h1 class="admin-page-title">문의 관리</h1>
<p class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/inquiries/create">새 문의 등록</MudButton>
<button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries/create")'>새 문의 등록</button>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (isLoading)
{
<MudProgressCircular Indeterminate="true" Class="ma-4" />
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
else
{
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
<MudTabPanel Text="전체">
<div class="admin-tabbar">
<button type="button" class="admin-tab active">전체</button>
<button type="button" class="admin-tab">신규</button>
<button type="button" class="admin-tab">상담중</button>
<button type="button" class="admin-tab">계약완료</button>
<button type="button" class="admin-tab">거절</button>
<button type="button" class="admin-tab">종결</button>
</div>
<InquiryTable Inquiries="allInquiries" Status="" />
</MudTabPanel>
<MudTabPanel Text="신규">
<InquiryTable Inquiries="allInquiries" Status="new" />
</MudTabPanel>
<MudTabPanel Text="상담중">
<InquiryTable Inquiries="allInquiries" Status="consulting" />
</MudTabPanel>
<MudTabPanel Text="계약완료">
<InquiryTable Inquiries="allInquiries" Status="contracted" />
</MudTabPanel>
<MudTabPanel Text="거절">
<InquiryTable Inquiries="allInquiries" Status="rejected" />
</MudTabPanel>
<MudTabPanel Text="종결">
<InquiryTable Inquiries="allInquiries" Status="closed" />
</MudTabPanel>
</MudTabs>
}
</MudPaper>
</div>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
[Inject] private NavigationManager Navigation { get; set; } = default!;
private bool isLoading = true;
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
{
isLoading = true;
try
{
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
+29 -29
View File
@@ -1,4 +1,5 @@
@page "/admin/login"
@using Microsoft.FluentUI.AspNetCore.Components
@using System.ComponentModel.DataAnnotations
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous]
@@ -10,41 +11,40 @@
<PageTitle>로그인</PageTitle>
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
<form @onsubmit="HandleLogin" @onsubmit:preventDefault>
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="사용자명"
autocomplete="username"
@bind-Value="model.Username" />
<InputText type="password"
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="비밀번호"
autocomplete="current-password"
@bind-Value="model.Password" />
<div class="mb-4">
<InputCheckbox class="mud-checkbox" @bind-Value="model.RememberMe" />
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
<div class="admin-login-page">
<div class="admin-login-card admin-surface">
<div class="admin-login-brand">
<span class="admin-brand-mark">T</span>
<div>
<div class="admin-brand-title">TaxBaik</div>
<div class="admin-brand-subtitle">관리자 로그인</div>
</div>
</div>
<form class="admin-login-form" @onsubmit="HandleLogin" @onsubmit:preventDefault>
<label class="admin-field">
<span class="admin-field-label">사용자명</span>
<input class="admin-input" type="text" placeholder="사용자명" @bind="model.Username" autocomplete="username" />
</label>
<label class="admin-field">
<span class="admin-field-label">비밀번호</span>
<input class="admin-input" type="password" placeholder="비밀번호" @bind="model.Password" autocomplete="current-password" />
</label>
<label class="admin-login-remember">
<input type="checkbox" @bind="model.RememberMe" />
<span>아이디 저장</span>
</label>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
<div class="admin-inline-alert error" role="alert">@errorMessage</div>
}
<button type="submit"
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
disabled="@isLoading">
<button type="submit" class="site-button primary admin-login-submit" disabled="@isLoading">
@if (isLoading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>로그인 중...</span>
}
else
@@ -53,8 +53,8 @@
}
</button>
</form>
</MudPaper>
</MudContainer>
</div>
</div>
@code {
private bool isLoading = false;
@@ -2,127 +2,127 @@
@using TaxBaik.Web.Services.AdminClients
@inject IRevenueTrackingBrowserClient RevenueClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
@attribute [Authorize]
<PageTitle>수익 추적 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">수익 추적 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</MudText>
<div class="admin-eyebrow">CRM & 세무관리</div>
<h1 class="admin-page-title">수익 추적 관리</h1>
<p class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 청구 추가
</MudButton>
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 청구 추가</button>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (revenues is null)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
else if (revenues.Count == 0)
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.Payments" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">청구 기록이 없습니다.</MudText>
<div class="muted">청구 기록이 없습니다.</div>
}
else
{
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>고객</th>
<th>청구번호</th>
<th>청구일</th>
<th>청구액</th>
<th>납부여부</th>
<th>작업</th>
</tr>
</thead>
<tbody>
@foreach (var item in revenues)
{
<tr>
<td>@item.Id</td>
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
<td>@item.InvoiceNumber</td>
<td>@item.InvoiceDate.ToString("yyyy-MM-dd")</td>
<td>@item.Amount.ToString("C")</td>
<td><span class="status-pill @(item.PaymentStatus == "paid" ? "success" : "warning")">@(item.PaymentStatus == "paid" ? "납부" : "미납")</span></td>
<td>
<div class="admin-row-actions">
@if (item.PaymentStatus != "paid")
{
<button type="button" class="site-button secondary" @onclick="@(async () => await MarkPaid(item.Id))">완료</button>
}
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteRevenue(item.Id))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<MudDataGrid T="RevenueTracking"
Items="@revenues"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.InvoiceNumber" Title="청구번호" />
<PropertyColumn Property="x => x.InvoiceDate" Title="청구일" Format="yyyy-MM-dd" />
<PropertyColumn Property="x => x.Amount" Title="청구액" Format="C" />
<TemplateColumn Title="납부여부">
<CellTemplate>
@if (context.Item.PaymentStatus == "paid")
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">납부</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">미납</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.PaymentStatus != "paid")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success"
OnClick="@(async () => await MarkPaid(context.Item.Id))" Title="납부 처리" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
OnClick="@(async () => await DeleteRevenue(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
</div>
<!-- Create Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">새 청구 추가</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<dialog class="admin-dialog" open="@isDialogOpen">
<form class="admin-dialog-card" @onsubmit="SaveRevenue" @onsubmit:preventDefault="true">
<h3>새 청구 추가</h3>
<label>고객
<select class="admin-input" @bind="ClientIdText">
<option value="">선택하세요</option>
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
}
</MudSelect>
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveRevenue">저장</MudButton>
</DialogActions>
</MudDialog>
</select>
</label>
<label>청구번호 <input class="admin-input" @bind="revenueForm.InvoiceNumber" /></label>
<label>청구일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="InvoiceDateText" /></label>
<label>청구액 <input class="admin-input" type="text" placeholder="100000" @bind="AmountText" /></label>
<label>서비스 유형
<select class="admin-input" @bind="revenueForm.ServiceType">
<option value="">선택하세요</option>
<option value="기장 수수료">기장 수수료</option>
<option value="세무조정료">세무조정료</option>
<option value="세무상담료">세무상담료</option>
<option value="신고 대행료">신고 대행료</option>
<option value="자문 수수료">자문 수수료</option>
</select>
</label>
<label>납부예정일 <input class="admin-input" type="text" placeholder="2026-07-13" @bind="DueDateText" /></label>
<div class="admin-dialog-actions">
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
<button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<RevenueTracking>? revenues;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen;
private RevenueForm revenueForm = new();
protected override async Task OnInitializedAsync()
private string ClientIdText { get => revenueForm.ClientId > 0 ? revenueForm.ClientId.ToString() : ""; set => revenueForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string InvoiceDateText { get => revenueForm.InvoiceDate?.ToString("yyyy-MM-dd") ?? ""; set => revenueForm.InvoiceDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string AmountText { get => revenueForm.Amount?.ToString() ?? ""; set => revenueForm.Amount = decimal.TryParse(value, out var amt) ? amt : null; }
private string DueDateText { get => revenueForm.DueDate?.ToString("yyyy-MM-dd") ?? ""; set => revenueForm.DueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
private async Task LoadData()
@@ -130,44 +130,42 @@
try
{
revenues = await RevenueClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
}
}
private void OpenCreateDialog()
{
revenueForm = new();
revenueForm = new RevenueForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, InvoiceDate = DateTime.Today, DueDate = DateTime.Today.AddDays(14) };
isDialogOpen = true;
}
private async Task SaveRevenue()
{
if (revenueForm.ClientId <= 0 || string.IsNullOrWhiteSpace(revenueForm.InvoiceNumber) || revenueForm.Amount is null)
{
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
return;
}
try
{
var newId = await RevenueClient.CreateAsync(
revenueForm.ClientId,
revenueForm.InvoiceNumber,
revenueForm.InvoiceDate ?? DateTime.Now,
revenueForm.Amount,
revenueForm.ServiceType,
revenueForm.DueDate);
var newId = await RevenueClient.CreateAsync(revenueForm.ClientId, revenueForm.InvoiceNumber, revenueForm.InvoiceDate ?? DateTime.Today, revenueForm.Amount.Value, revenueForm.ServiceType, revenueForm.DueDate);
if (newId > 0)
{
Snackbar.Add("청구가 추가되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "청구가 추가되었습니다.");
CloseDialog();
await LoadData();
}
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
}
@@ -176,54 +174,31 @@
try
{
await RevenueClient.MarkPaidAsync(id, DateTime.Now);
Snackbar.Add("납부가 처리되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "납부가 처리되었습니다.");
await LoadData();
}
catch (Exception ex)
{
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
}
}
private async Task DeleteRevenue(int id)
{
var parameters = new DialogParameters
{
{ "Title", "삭제 확인" },
{ "Message", "이 청구를 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
if (!await JS.InvokeAsync<bool>("confirm", "이 청구를 삭제하시겠습니까?")) return;
try
{
await RevenueClient.DeleteAsync(id);
Snackbar.Add("청구가 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "청구가 삭제되었습니다.");
await LoadData();
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
}
}
private void CloseDialog()
{
isDialogOpen = false;
revenueForm = new();
}
private class RevenueForm
{
public int ClientId { get; set; }
public string InvoiceNumber { get; set; } = "";
public DateTime? InvoiceDate { get; set; }
public decimal Amount { get; set; }
public string? ServiceType { get; set; }
public DateTime? DueDate { get; set; }
}
private void CloseDialog() { isDialogOpen = false; revenueForm = new(); }
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
private sealed class RevenueForm { public int ClientId { get; set; } public string InvoiceNumber { get; set; } = ""; public DateTime? InvoiceDate { get; set; } public decimal? Amount { get; set; } public string? ServiceType { get; set; } public DateTime? DueDate { get; set; } }
}
@@ -7,123 +7,64 @@
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Season Preview</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">시즌 시뮬레이터</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</MudText>
<div class="admin-eyebrow">Season Preview</div>
<h1 class="admin-page-title">시즌 시뮬레이터</h1>
<p class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</p>
</div>
</section>
<MudGrid>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">시뮬레이션 날짜</MudText>
<MudDatePicker @bind-Date="simulationDate" Label="날짜 선택" DateFormat="yyyy-MM-dd" PickerVariant="PickerVariant.Static" />
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Class="mb-2">연간 세무 캘린더</MudText>
<div class="admin-detail-grid">
<section class="admin-surface">
<h3 class="admin-section-title">시뮬레이션 날짜</h3>
<input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="SimulationDateText" />
<div class="admin-divider"></div>
<div class="admin-stack">
@foreach (var season in TaxSeasonCalendar.Seasons)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small" FullWidth="true"
Class="mb-1" Color="Color.Primary"
OnClick="@(() => JumpToSeason(season))">
@season.StartMonth/@season.StartDay — @season.Name
</MudButton>
<button type="button" class="site-button secondary" @onclick="@(() => JumpToSeason(season))">@season.StartMonth/@season.StartDay - @season.Name</button>
}
</MudPaper>
</MudItem>
</div>
</section>
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-1">
@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요") 홈페이지 미리보기
</MudText>
<section class="admin-surface">
<h3 class="admin-section-title">홈페이지 미리보기</h3>
<p class="muted">@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요")</p>
@if (activeSeason != null)
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Class="mb-3">
@activeSeason.Name 시즌 활성
</MudChip>
<MudDivider Class="mb-4" />
<!-- Hero 섹션 미리보기 -->
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 1.5rem;">
<span class="status-pill warning">@activeSeason.Name 시즌 활성</span>
<div class="season-preview">
@if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0)
{
<div style="background: #f59e0b; color: #1a202c; display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; margin-bottom: 1rem;">
D-@activeSeason.DaysUntilDeadline 마감 임박
</div>
<div class="season-badge">D-@activeSeason.DaysUntilDeadline 마감 임박</div>
}
<div style="font-size: 1.8rem; font-weight: 800; white-space: pre-line; margin-bottom: 0.5rem; line-height: 1.3;">
@activeSeason.HeroHeadline
<div class="season-headline">@activeSeason.HeroHeadline</div>
<div class="season-subtext">@activeSeason.HeroSubtext</div>
<div class="season-cta">@activeSeason.CtaText</div>
</div>
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
@activeSeason.HeroSubtext
<div class="admin-kv-grid mt-4">
<div><span>활성 시즌 키</span><strong><code>@activeSeason.Key</code></strong></div>
<div><span>마감까지</span><strong>@(activeSeason.DaysUntilDeadline >= 0 ? $"D-{activeSeason.DaysUntilDeadline}" : $"마감 후 @(-activeSeason.DaysUntilDeadline)일")</strong></div>
<div><span>포커스 서비스</span><strong>@activeSeason.FocusService</strong></div>
<div><span>블로그 카테고리</span><strong>@activeSeason.RelatedCategorySlug</strong></div>
<div class="span-2"><span>긴박감 배지 문구</span><strong><code>@activeSeason.UrgencyBadge</code></strong></div>
</div>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<div style="background: #e53e3e; color: white; padding: 10px 20px; border-radius: 8px; font-weight: 700; font-size: 0.95rem;">
@activeSeason.CtaText
</div>
<div style="background: transparent; border: 2px solid rgba(255,255,255,0.5); color: white; padding: 10px 20px; border-radius: 8px; font-size: 0.95rem;">
서비스 안내
</div>
</div>
</div>
<MudGrid Spacing="2">
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">활성 시즌 키</MudText>
<MudText><code>@activeSeason.Key</code></MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">마감까지</MudText>
<MudText>
@if (activeSeason.DaysUntilDeadline >= 0)
{
<MudChip T="string" Size="Size.Small"
Color="@(activeSeason.DaysUntilDeadline <= 7 ? Color.Error : Color.Warning)">
D-@activeSeason.DaysUntilDeadline
</MudChip>
}
else
{
<span>마감 후 @(-activeSeason.DaysUntilDeadline)일</span>
}
</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">포커스 서비스</MudText>
<MudText>@activeSeason.FocusService</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">블로그 카테고리</MudText>
<MudText>@activeSeason.RelatedCategorySlug</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">긴박감 배지 문구</MudText>
<MudText><code>@activeSeason.UrgencyBadge</code></MudText>
</MudItem>
</MudGrid>
}
else
{
<MudAlert Severity="Severity.Info">
선택한 날짜(@(simulationDate?.ToString("MM월 dd일") ?? "-"))는 시즌 비활성 기간입니다.
홈페이지는 기본 Hero를 표시합니다.
</MudAlert>
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-top: 1.5rem;">
<div style="font-size: 1.8rem; font-weight: 800; margin-bottom: 0.5rem;">
사업자 세금, 부동산,<br/>가족자산까지
</div>
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담
</div>
<div style="background: #e53e3e; color: white; display: inline-block; padding: 10px 20px; border-radius: 8px; font-weight: 700;">
무료 상담 신청
</div>
<div class="muted">선택한 날짜는 시즌 비활성 기간입니다. 홈페이지는 기본 Hero를 표시합니다.</div>
<div class="season-preview mt-4">
<div class="season-headline">사업자 세금, 부동산,<br />가족자산까지</div>
<div class="season-subtext">세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담</div>
<div class="season-cta">무료 상담 신청</div>
</div>
}
</MudPaper>
</section>
</div>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">연간 시즌 타임라인</MudText>
<MudSimpleTable Dense="true">
<div class="admin-surface mt-4">
<h3 class="admin-section-title">연간 시즌 타임라인</h3>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>기간</th>
@@ -136,33 +77,22 @@
@foreach (var s in TaxSeasonCalendar.Seasons)
{
var isActive = activeSeason?.Key == s.Key;
<tr style="@(isActive ? "background: rgba(66,153,225,0.1);" : "")">
<td style="white-space: nowrap;">
@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay
</td>
<tr>
<td>@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay</td>
<td>@s.Name</td>
<td><code style="font-size:0.8rem;">@s.RelatedCategorySlug</code></td>
<td>
@if (isActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">활성</MudChip>
}
else
{
<span style="color: #a0aec0; font-size: 0.85rem;">비활성</span>
}
</td>
<td><code>@s.RelatedCategorySlug</code></td>
<td>@(isActive ? "활성" : "비활성")</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
</table>
</div>
</div>
@code {
private DateTime? simulationDate = DateTime.Today;
private CurrentSeasonDto? activeSeason;
private string SimulationDateText { get => simulationDate?.ToString("yyyy-MM-dd") ?? ""; set { simulationDate = DateTime.TryParse(value, out var dt) ? dt : null; ComputeSeason(); } }
protected override void OnInitialized() => ComputeSeason();
@@ -183,10 +113,7 @@
var endYearCalc = (season.EndMonth < season.StartMonth) ? date.Year + 1 : date.Year;
var deadline = new DateTime(endYearCalc, season.EndMonth, season.EndDay);
var ddays = (deadline.Date - date.Date).Days;
var badge = ddays <= 7 && ddays >= 0
? season.UrgencyBadge.Replace("{n}", ddays.ToString())
: season.UrgencyBadge;
var badge = ddays <= 7 && ddays >= 0 ? season.UrgencyBadge.Replace("{n}", ddays.ToString()) : season.UrgencyBadge;
activeSeason = new CurrentSeasonDto
{
@@ -5,78 +5,58 @@
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Interfaces
@inject IApiClient ApiClient
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>설정</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-6">
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">System</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">설정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</MudText>
<div class="admin-eyebrow">System</div>
<h1 class="admin-page-title">설정</h1>
<p class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</p>
</div>
</section>
</MudContainer>
<MudGrid>
<MudItem xs="12" md="7">
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-detail-grid">
<section class="admin-surface">
<div class="admin-section-header compact">
<div>
<MudText Typo="Typo.h6">사이트 정보</MudText>
<MudText Typo="Typo.body2">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</MudText>
<h3 class="admin-section-title">사이트 정보</h3>
<p class="muted">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</p>
</div>
</div>
<MudForm>
<MudTextField @bind-Value="phone" Label="전화번호"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" />
<form class="admin-form" @onsubmit="SaveSettings" @onsubmit:preventDefault="true">
<label>전화번호<input class="admin-input" @bind="phone" /></label>
<label>이메일<input class="admin-input" @bind="email" /></label>
<label>카카오 채널 URL<input class="admin-input" @bind="kakaoUrl" /></label>
<label>인스타그램<input class="admin-input" @bind="instagramUrl" /></label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">사이트 정보 저장</button>
</div>
</form>
</section>
<MudTextField @bind-Value="kakaoUrl" Label="카카오 채널 URL"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="instagramUrl" Label="인스타그램"
Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
@onclick="SaveSettings">사이트 정보 저장</MudButton>
</MudForm>
</MudPaper>
</MudItem>
<MudItem xs="12" md="5">
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
<section class="admin-surface">
<div class="admin-section-header compact">
<div>
<MudText Typo="Typo.h6">계정 관리</MudText>
<MudText Typo="Typo.body2">비밀번호는 12자 이상으로 관리합니다.</MudText>
<h3 class="admin-section-title">계정 관리</h3>
<p class="muted">비밀번호는 12자 이상으로 관리합니다.</p>
</div>
</div>
<MudForm>
<MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="newPassword" Label="새 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@isChangingPassword"
StartIcon="@Icons.Material.Filled.LockReset"
@onclick="ChangePassword">
<form class="admin-form" @onsubmit="ChangePassword" @onsubmit:preventDefault="true">
<label>현재 비밀번호<input class="admin-input" type="password" @bind="currentPassword" /></label>
<label>새 비밀번호<input class="admin-input" type="password" @bind="newPassword" /></label>
<label>새 비밀번호 확인<input class="admin-input" type="password" @bind="confirmNewPassword" /></label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary" disabled="@isChangingPassword">
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
</MudButton>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
</button>
</div>
</form>
</section>
</div>
@code {
private string phone = "010-4122-8268";
@@ -118,7 +98,7 @@
}
catch
{
Snackbar.Add("사이트 설정을 불러오지 못했습니다.", Severity.Warning);
await JS.InvokeVoidAsync("alert", "사이트 설정을 불러오지 못했습니다.");
}
finally
{
@@ -141,11 +121,11 @@
if (response?.Message is null)
{
Snackbar.Add("설정 저장에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "설정 저장에 실패했습니다.");
return;
}
Snackbar.Add(response.Message, Severity.Success);
await JS.InvokeVoidAsync("alert", response.Message);
}
private async Task ChangePassword()
@@ -155,13 +135,13 @@
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
{
Snackbar.Add("현재 비밀번호와 새 비밀번호를 입력하세요.", Severity.Warning);
await JS.InvokeVoidAsync("alert", "현재 비밀번호와 새 비밀번호를 입력하세요.");
return;
}
if (newPassword != confirmNewPassword)
{
Snackbar.Add("새 비밀번호 확인이 일치하지 않습니다.", Severity.Warning);
await JS.InvokeVoidAsync("alert", "새 비밀번호 확인이 일치하지 않습니다.");
return;
}
@@ -177,18 +157,18 @@
if (response?.Message == null)
{
Snackbar.Add("비밀번호 변경에 실패했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "비밀번호 변경에 실패했습니다.");
return;
}
Snackbar.Add(response.Message, Severity.Success);
await JS.InvokeVoidAsync("alert", response.Message);
currentPassword = "";
newPassword = "";
confirmNewPassword = "";
}
catch
{
Snackbar.Add("비밀번호 변경 중 오류가 발생했습니다.", Severity.Error);
await JS.InvokeVoidAsync("alert", "비밀번호 변경 중 오류가 발생했습니다.");
}
finally
{
@@ -2,199 +2,170 @@
@using TaxBaik.Web.Services.AdminClients
@inject ITaxFilingScheduleBrowserClient TaxFilingClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
@attribute [Authorize]
<PageTitle>신고 일정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText>
<div class="admin-eyebrow">CRM & 세무관리</div>
<h1 class="admin-page-title">신고 일정</h1>
<p class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</p>
</div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenCreateDialog"
StartIcon="@Icons.Material.Filled.Add">
새 일정 추가
</MudButton>
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 일정 추가</button>
</section>
<MudPaper Class="admin-surface" Elevation="0">
<div class="admin-surface">
@if (schedules is null)
{
<MudProgressLinear Indeterminate="true" />
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
else if (schedules.Count == 0)
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">신고 일정이 없습니다.</MudText>
<div class="muted">신고 일정이 없습니다.</div>
}
else
{
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>고객</th>
<th>신고 유형</th>
<th>마감일</th>
<th>신고연도</th>
<th>상태</th>
<th>작업</th>
</tr>
</thead>
<tbody>
@foreach (var item in schedules)
{
var daysLeft = (item.DueDate.Date - DateTime.Today).Days;
<tr>
<td>@item.Id</td>
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
<td>@item.FilingType</td>
<td>@item.DueDate.ToString("yyyy-MM-dd") @(daysLeft >= 0 ? $"(D-{daysLeft})" : $"(마감 {Math.Abs(daysLeft)}일 경과)")</td>
<td>@item.FilingYear</td>
<td>@(item.Status == "completed" ? "완료" : "대기")</td>
<td>
<div class="admin-row-actions">
@if (item.Status != "completed")
{
<button type="button" class="site-button secondary" @onclick="@(async () => await CompleteSchedule(item.Id))">완료</button>
}
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteSchedule(item.Id))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<MudDataGrid T="TaxFilingSchedule"
Items="@schedules"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
<TemplateColumn Title="마감일">
<CellTemplate>
@{
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
}
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
@context.Item.DueDate.ToString("yyyy-MM-dd")
@if (daysLeft >= 0)
{
<span class="ms-1">(D-@daysLeft)</span>
}
else
{
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
}
</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
<TemplateColumn Title="상태">
<CellTemplate>
@if (context.Item.Status == "completed")
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.Status != "completed")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
Color="Color.Success"
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
Title="완료" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
Title="삭제" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
</div>
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">새 신고 일정 추가</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int"
@bind-Value="scheduleForm.ClientId"
Label="고객"
Required="true"
Variant="Variant.Outlined"
FullWidth="true"
Class="mb-4">
<dialog class="admin-dialog" open="@isDialogOpen">
<form class="admin-dialog-card" @onsubmit="SaveSchedule" @onsubmit:preventDefault="true">
<h3>새 신고 일정 추가</h3>
<label>고객
<select class="admin-input" @bind="ClientIdText">
<option value="">선택하세요</option>
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
}
</MudSelect>
<MudTextField T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveSchedule">저장</MudButton>
</DialogActions>
</MudDialog>
</select>
</label>
<label>신고 유형
<select class="admin-input" @bind="scheduleForm.FilingType">
<option value="">선택하세요</option>
<option value="종합소득세">종합소득세</option>
<option value="부가가치세">부가가치세</option>
<option value="법인세">법인세</option>
<option value="원천세">원천세</option>
<option value="종합부동산세">종합부동산세</option>
<option value="양도소득세">양도소득세</option>
<option value="상속·증여세">상속·증여세</option>
<option value="세무조정">세무조정</option>
</select>
</label>
<label>마감일 <input class="admin-input" type="text" placeholder="2026-07-01" @bind="DueDateText" /></label>
<label>신고연도 <input class="admin-input" type="text" placeholder="2026" @bind="FilingYearText" /></label>
<div class="admin-dialog-actions">
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
<button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxFilingSchedule>? schedules;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen;
private TaxFilingScheduleForm scheduleForm = new();
private string ClientIdText { get => scheduleForm.ClientId > 0 ? scheduleForm.ClientId.ToString() : ""; set => scheduleForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string DueDateText { get => scheduleForm.DueDate?.ToString("yyyy-MM-dd") ?? ""; set => scheduleForm.DueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string FilingYearText { get => scheduleForm.FilingYear.ToString(); set => scheduleForm.FilingYear = int.TryParse(value, out var year) ? year : DateTime.Now.Year; }
protected override async Task OnInitializedAsync() => await LoadData();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
private async Task LoadData()
{
try
{
schedules = await TaxFilingClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
}
}
private void OpenCreateDialog()
{
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year, DueDate = DateTime.Today, ClientId = clients.FirstOrDefault()?.Id ?? 0 };
isDialogOpen = true;
}
private async Task SaveSchedule()
{
if (scheduleForm.ClientId <= 0 || string.IsNullOrWhiteSpace(scheduleForm.FilingType))
{
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
return;
}
try
{
var newId = await TaxFilingClient.CreateAsync(
scheduleForm.ClientId,
scheduleForm.FilingType,
scheduleForm.DueDate ?? DateTime.Today,
scheduleForm.FilingYear);
var newId = await TaxFilingClient.CreateAsync(scheduleForm.ClientId, scheduleForm.FilingType, scheduleForm.DueDate ?? DateTime.Today, scheduleForm.FilingYear);
if (newId > 0)
{
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
CloseDialog();
await LoadData();
}
else
{
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
}
@@ -203,51 +174,31 @@
try
{
await TaxFilingClient.MarkCompletedAsync(id);
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "신고 일정이 완료 처리되었습니다.");
await LoadData();
}
catch (Exception ex)
{
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
}
}
private async Task DeleteSchedule(int id)
{
var parameters = new DialogParameters
{
{ "Title", "삭제 확인" },
{ "Message", "이 신고 일정을 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
if (!await JS.InvokeAsync<bool>("confirm", "이 신고 일정을 삭제하시겠습니까?")) return;
try
{
await TaxFilingClient.DeleteAsync(id);
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "신고 일정이 삭제되었습니다.");
await LoadData();
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
}
}
private void CloseDialog()
{
isDialogOpen = false;
scheduleForm = new();
}
private class TaxFilingScheduleForm
{
public int ClientId { get; set; }
public string FilingType { get; set; } = "";
public DateTime? DueDate { get; set; }
public int FilingYear { get; set; } = DateTime.Now.Year;
}
private void CloseDialog() { isDialogOpen = false; scheduleForm = new(); }
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
private sealed class TaxFilingScheduleForm { public int ClientId { get; set; } public string FilingType { get; set; } = ""; public DateTime? DueDate { get; set; } public int FilingYear { get; set; } = DateTime.Now.Year; }
}
@@ -1,60 +1,66 @@
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@inject ITaxFilingBrowserClient FilingClient
@inject ISnackbar Snackbar
@inject IJSRuntime JS
@if (Filings == null || Filings.Count == 0)
{
<MudText Class="pa-4" Color="Color.Secondary">항목이 없습니다.</MudText>
<div class="muted">항목이 없습니다.</div>
}
else
{
<MudTable Items="Filings" Hover="true" Dense="true" Class="mt-2">
<HeaderContent>
<MudTh>고객</MudTh>
<MudTh>신고 유형</MudTh>
<MudTh>기한</MudTh>
<MudTh>D-day</MudTh>
<MudTh>메모</MudTh>
<MudTh>처리</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.ClientName</MudTd>
<MudTd>@context.FilingType</MudTd>
<MudTd>@context.DueDate.ToString("yyyy-MM-dd")</MudTd>
<MudTd>
@{
var dday = (context.DueDate.Date - DateTime.Today).Days;
}
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>고객</th>
<th>신고 유형</th>
<th>기한</th>
<th>D-day</th>
<th>메모</th>
<th>처리</th>
</tr>
</thead>
<tbody>
@foreach (var filing in Filings)
{
var dday = (filing.DueDate.Date - DateTime.Today).Days;
<tr>
<td>@filing.ClientName</td>
<td>@filing.FilingType</td>
<td>@filing.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">D+@(-dday)</MudChip>
<span class="status-pill danger">D+@(-dday)</span>
}
else if (dday <= 7)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">D-@dday</MudChip>
<span class="status-pill warning">D-@dday</span>
}
else
{
<MudText Typo="Typo.body2">D-@dday</MudText>
<span>D-@dday</span>
}
</MudTd>
<MudTd>@(context.Memo ?? "")</MudTd>
<MudTd>
@if (context.Status == "pending")
</td>
<td>@(filing.Memo ?? "")</td>
<td>
<div class="admin-row-actions">
@if (filing.Status == "pending")
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Success"
OnClick="@(() => MarkFiled(context))">완료</MudButton>
<button type="button" class="site-button secondary" @onclick="@(() => MarkFiled(filing))">완료</button>
}
else if (context.Status == "filed")
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">완료</MudChip>
<span class="status-pill success">완료</span>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteFiling(context.Id))" />
</MudTd>
</RowTemplate>
</MudTable>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteFiling(filing.Id))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
@@ -65,45 +71,34 @@ else
public EventCallback OnStatusChange { get; set; }
private async Task MarkFiled(TaxFiling filing)
{
try
{
filing.Status = "filed";
var result = await FilingClient.UpdateAsync(filing.Id, filing);
if (result != null)
{
Snackbar.Add("신고 완료 처리되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "신고 완료 처리되었습니다.");
await OnStatusChange.InvokeAsync();
}
else
{
Snackbar.Add("처리 실패", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", "처리 실패");
}
}
private async Task DeleteFiling(int id)
{
try
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "이 항목을 삭제하시겠습니까?");
if (!confirmed) return;
var success = await FilingClient.DeleteAsync(id);
if (success)
{
Snackbar.Add("삭제되었습니다.", Severity.Info);
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
await OnStatusChange.InvokeAsync();
}
else
{
Snackbar.Add("삭제 실패", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", "삭제 실패");
}
}
}
@@ -4,109 +4,149 @@
@using TaxBaik.Domain.Entities
@inject ITaxFilingBrowserClient FilingClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>신고 일정 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Tax Schedule</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</MudText>
<div class="admin-eyebrow">Tax Schedule</div>
<h1 class="admin-page-title">신고 일정</h1>
<p class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(() => showAddForm = !showAddForm)"
StartIcon="@Icons.Material.Filled.Add">
일정 추가
</MudButton>
<button type="button" class="site-button primary" @onclick="@(() => showAddForm = !showAddForm)">일정 추가</button>
</section>
@if (showAddForm)
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText>
<MudGrid Spacing="2">
<MudItem xs="12" sm="6" md="4">
<MudAutocomplete T="Domain.Entities.Client" @bind-Value="selectedClient"
Label="고객 검색 *"
SearchFunc="SearchClients"
ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")"
Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined">
<div class="admin-surface mb-4">
<h3 class="admin-section-title">새 신고 일정</h3>
<form class="admin-dialog-card" @onsubmit="AddFiling" @onsubmit:preventDefault="true">
<label>고객 검색
<select class="admin-input" @bind="SelectedClientIdText">
<option value="">선택하세요</option>
@foreach (var client in clients)
{
<option value="@client.Id">@GetClientDisplayName(client)</option>
}
</select>
</label>
<label>신고 유형
<select class="admin-input" @bind="newFilingType">
<option value="">선택하세요</option>
@foreach (var t in TaxFilingService.FilingTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
<option value="@t">@t</option>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
</MudItem>
<MudItem xs="12">
<MudTextField T="string" @bind-Value="newMemo" Label="메모" Variant="Variant.Outlined" />
</MudItem>
</MudGrid>
<MudStack Row="true" Class="mt-3" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddFiling">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
</MudStack>
</MudPaper>
</select>
</label>
<label>신고 기한 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="DueDateText" /></label>
<label>메모 <textarea class="admin-input" rows="3" @bind="newMemo"></textarea></label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">저장</button>
<button type="button" class="site-button secondary" @onclick='() => showAddForm = false'>취소</button>
</div>
</form>
</div>
}
<MudPaper Class="admin-surface" Elevation="0">
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
<MudTabPanel Text="신고 예정">
<FilingTable Filings="@pending" OnStatusChange="Reload" />
</MudTabPanel>
<MudTabPanel Text="신고 완료">
<FilingTable Filings="@filed" OnStatusChange="Reload" />
</MudTabPanel>
<MudTabPanel Text="기한 초과">
<FilingTable Filings="@overdue" OnStatusChange="Reload" />
</MudTabPanel>
</MudTabs>
</MudPaper>
<div class="admin-surface">
<div class="admin-tabbar">
<button type="button" class="admin-tab @(activeTab == "pending" ? "active" : "")" @onclick='() => activeTab = "pending"'>신고 예정</button>
<button type="button" class="admin-tab @(activeTab == "filed" ? "active" : "")" @onclick='() => activeTab = "filed"'>신고 완료</button>
<button type="button" class="admin-tab @(activeTab == "overdue" ? "active" : "")" @onclick='() => activeTab = "overdue"'>기한 초과</button>
</div>
@if (CurrentFilings.Count == 0)
{
<div class="muted">항목이 없습니다.</div>
}
else
{
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>고객</th>
<th>신고 유형</th>
<th>기한</th>
<th>D-day</th>
<th>메모</th>
<th>처리</th>
</tr>
</thead>
<tbody>
@foreach (var filing in CurrentFilings)
{
var dday = (filing.DueDate.Date - DateTime.Today).Days;
<tr>
<td>@filing.ClientName</td>
<td>@filing.FilingType</td>
<td>@filing.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<span class="status-pill danger">D+@(-dday)</span>
}
else if (dday <= 7)
{
<span class="status-pill warning">D-@dday</span>
}
else
{
<span>D-@dday</span>
}
</td>
<td>@(filing.Memo ?? "")</td>
<td>
<div class="admin-row-actions">
@if (filing.Status == "pending")
{
<button type="button" class="site-button secondary" @onclick="@(() => MarkFiled(filing))">완료</button>
}
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteFiling(filing.Id))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private List<Domain.Entities.TaxFiling> pending = [];
private List<Domain.Entities.TaxFiling> filed = [];
private List<Domain.Entities.TaxFiling> overdue = [];
private List<TaxFiling> allFilings = [];
private List<Client> clients = [];
private bool showAddForm;
private Domain.Entities.Client? selectedClient;
private string activeTab = "pending";
private int selectedClientId;
private string newFilingType = "";
private DateTime? newDueDate = DateTime.Today.AddDays(30);
private string newMemo = "";
private string SelectedClientIdText { get => selectedClientId > 0 ? selectedClientId.ToString() : ""; set => selectedClientId = int.TryParse(value, out var id) ? id : 0; }
private string DueDateText { get => newDueDate?.ToString("yyyy-MM-dd") ?? ""; set => newDueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private List<TaxFiling> CurrentFilings => activeTab switch
{
"filed" => allFilings.Where(x => x.Status == "filed").ToList(),
"overdue" => allFilings.Where(x => x.Status == "overdue").ToList(),
_ => allFilings.Where(x => x.Status == "pending").ToList()
};
protected override async Task OnInitializedAsync() => await Reload();
private async Task Reload()
{
try
{
var all = (await FilingClient.GetUpcomingAsync(365)).ToList();
pending = all.Where(x => x.Status == "pending").ToList();
filed = all.Where(x => x.Status == "filed").ToList();
overdue = all.Where(x => x.Status == "overdue").ToList();
allFilings = (await FilingClient.GetUpcomingAsync(365)).ToList();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
}
}
private async Task<IEnumerable<Client>> SearchClients(string value)
{
try
{
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
return items;
}
catch
{
return [];
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
@@ -114,14 +154,15 @@
{
try
{
if (selectedClient == null)
if (selectedClientId <= 0)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
return;
}
var filing = new TaxFiling
{
ClientId = selectedClient.Id,
ClientId = selectedClientId,
FilingType = newFilingType,
DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow,
Status = "pending",
@@ -131,17 +172,36 @@
if (result != null)
{
showAddForm = false;
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
await Reload();
}
else
{
Snackbar.Add("추가 실패", Severity.Error);
await JS.InvokeVoidAsync("alert", "추가 실패");
}
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
}
}
private async Task MarkFiled(TaxFiling filing)
{
filing.Status = "filed";
await FilingClient.UpdateAsync(filing.Id, filing);
await JS.InvokeVoidAsync("alert", "신고 완료 처리되었습니다.");
await Reload();
}
private async Task DeleteFiling(int id)
{
if (!await JS.InvokeAsync<bool>("confirm", "삭제하시겠습니까?")) return;
await FilingClient.DeleteAsync(id);
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
await Reload();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
}
@@ -2,123 +2,130 @@
@using TaxBaik.Web.Services.AdminClients
@inject ITaxProfileBrowserClient TaxProfileClient
@inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
@attribute [Authorize]
<PageTitle>세무 프로필</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText>
<div class="admin-eyebrow">CRM & 세무관리</div>
<h1 class="admin-page-title">세무 프로필</h1>
<p class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</p>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 프로필 추가
</MudButton>
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 프로필 추가</button>
</section>
@if (profiles == null)
<div class="admin-surface">
@if (profiles is null)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
}
else if (profiles.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">세무 프로필이 없습니다.</MudAlert>
<div class="muted">세무 프로필이 없습니다.</div>
}
else
{
<MudDataGrid T="TaxProfile"
Items="@profiles"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid mt-4">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>고객</th>
<th>사업 유형</th>
<th>위험도</th>
<th>다음 신고</th>
<th>작업</th>
</tr>
</thead>
<tbody>
@foreach (var item in profiles)
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
<tr>
<td>@item.Id</td>
<td>@(clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}"))</td>
<td>@item.BusinessType</td>
<td><span class="status-pill @(item.TaxRiskLevel == "high" ? "danger" : item.TaxRiskLevel == "normal" ? "warning" : "success")">@item.TaxRiskLevel</span></td>
<td>@(item.NextFilingDueDate?.ToString("yyyy-MM-dd") ?? "—")</td>
<td>
<div class="admin-row-actions">
<button type="button" class="admin-icon-button" @onclick="@(async () => await OpenEditDialog(item))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteProfile(item.Id))">✕</button>
</div>
</td>
</tr>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
<TemplateColumn Title="위험도">
<CellTemplate>
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
@context.Item.TaxRiskLevel
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="다음 신고">
<CellTemplate>
@if (context.Item.NextFilingDueDate.HasValue)
{
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
</tbody>
</table>
</div>
}
</div>
<!-- Create/Edit Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<dialog class="admin-dialog" open="@isDialogOpen">
<form class="admin-dialog-card" @onsubmit="SaveProfile" @onsubmit:preventDefault="true">
<h3>@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</h3>
<label>고객
<select class="admin-input" @bind="ClientIdText">
<option value="">선택하세요</option>
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
}
</MudSelect>
<MudTextField T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("low")">낮음</MudSelectItem>
<MudSelectItem Value="@("normal")">보통</MudSelectItem>
<MudSelectItem Value="@("high")">높음</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="true" Lines="2" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveProfile">저장</MudButton>
</DialogActions>
</MudDialog>
</select>
</label>
<label>사업 유형
<select class="admin-input" @bind="profileForm.BusinessType">
<option value="">선택하세요</option>
<option value="일반제조업">일반제조업</option>
<option value="도소매업">도소매업</option>
<option value="서비스업">서비스업</option>
<option value="정보통신업">정보통신업</option>
<option value="부동산업">부동산업</option>
<option value="건설업">건설업</option>
<option value="음식점업">음식점업</option>
<option value="프리랜서">프리랜서</option>
<option value="기타">기타</option>
</select>
</label>
<label>위험도
<select class="admin-input" @bind="profileForm.TaxRiskLevel">
<option value="low">낮음</option>
<option value="normal">보통</option>
<option value="high">높음</option>
</select>
</label>
<label>다음 신고 예정일 <input class="admin-input" type="text" placeholder="2026-07-01" @bind="NextFilingText" /></label>
<label>특수 사항 <textarea class="admin-input" rows="3" @bind="profileForm.SpecialNotes"></textarea></label>
<div class="admin-dialog-actions">
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
<button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxProfile>? profiles;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen;
private bool isEditMode;
private TaxProfile? editingProfile;
private TaxProfileForm profileForm = new();
private string ClientIdText { get => profileForm.ClientId > 0 ? profileForm.ClientId.ToString() : ""; set => profileForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string NextFilingText { get => profileForm.NextFilingDueDate?.ToString("yyyy-MM-dd") ?? ""; set => profileForm.NextFilingDueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
private async Task LoadData()
@@ -126,13 +133,13 @@ else
try
{
profiles = await TaxProfileClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
}
}
@@ -140,7 +147,7 @@ else
{
isEditMode = false;
editingProfile = null;
profileForm = new();
profileForm = new TaxProfileForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, TaxRiskLevel = "normal", NextFilingDueDate = DateTime.Today.AddMonths(1) };
isDialogOpen = true;
}
@@ -157,30 +164,30 @@ else
SpecialNotes = profile.SpecialNotes
};
isDialogOpen = true;
await Task.CompletedTask;
}
private async Task SaveProfile()
{
if (profileForm.ClientId <= 0 || string.IsNullOrWhiteSpace(profileForm.BusinessType))
{
await JS.InvokeVoidAsync("alert", "고객과 사업 유형을 입력하세요.");
return;
}
try
{
if (isEditMode)
if (isEditMode && editingProfile != null)
{
await TaxProfileClient.UpdateAsync(
editingProfile!.Id,
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
await JS.InvokeVoidAsync("alert", "세무 프로필이 수정되었습니다.");
}
else
{
var newId = await TaxProfileClient.CreateAsync(
profileForm.ClientId,
profileForm.BusinessType);
var newId = await TaxProfileClient.CreateAsync(profileForm.ClientId, profileForm.BusinessType);
if (newId > 0)
{
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
await TaxProfileClient.UpdateAsync(newId, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
await JS.InvokeVoidAsync("alert", "세무 프로필이 추가되었습니다.");
}
}
CloseDialog();
@@ -188,56 +195,26 @@ else
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
}
}
private async Task DeleteProfile(int id)
{
var parameters = new DialogParameters();
parameters.Add("Title", "삭제 확인");
parameters.Add("Message", "이 세무 프로필을 삭제하시겠습니까?");
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
if (!await JS.InvokeAsync<bool>("confirm", "이 세무 프로필을 삭제하시겠습니까?")) return;
try
{
await TaxProfileClient.DeleteAsync(id);
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
await JS.InvokeVoidAsync("alert", "세무 프로필이 삭제되었습니다.");
await LoadData();
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
}
}
private void CloseDialog()
{
isDialogOpen = false;
isEditMode = false;
editingProfile = null;
profileForm = new();
}
private Color GetRiskColor(string riskLevel) => riskLevel switch
{
"high" => Color.Error,
"normal" => Color.Warning,
"low" => Color.Success,
_ => Color.Default
};
private class TaxProfileForm
{
public int ClientId { get; set; }
public string BusinessType { get; set; } = "";
public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; }
public string? SpecialNotes { get; set; }
}
private void CloseDialog() { isDialogOpen = false; isEditMode = false; editingProfile = null; profileForm = new(); }
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
private sealed class TaxProfileForm { public int ClientId { get; set; } public string BusinessType { get; set; } = ""; public string TaxRiskLevel { get; set; } = "normal"; public DateTime? NextFilingDueDate { get; set; } public string? SpecialNotes { get; set; } }
}
@@ -1,20 +1,20 @@
@using MudBlazor
<MudDialog>
<DialogContent>
<MudText>@Message</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">취소</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Confirm">삭제</MudButton>
</DialogActions>
</MudDialog>
@using Microsoft.FluentUI.AspNetCore.Components
<div class="admin-dialog">
<div class="admin-dialog-title">@Title</div>
<p class="admin-dialog-message">@Message</p>
<div class="admin-dialog-actions">
<FluentButton Appearance="ButtonAppearance.Transparent" @onclick="Cancel">취소</FluentButton>
<FluentButton Appearance="ButtonAppearance.Primary" @onclick="Confirm">삭제</FluentButton>
</div>
</div>
@code {
[CascadingParameter] MudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string Title { get; set; } = "";
[Parameter] public string Message { get; set; } = "";
private void Cancel() => MudDialog.Cancel();
private void Confirm() => MudDialog.Close();
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public EventCallback OnConfirm { get; set; }
private Task Cancel() => OnCancel.InvokeAsync();
private Task Confirm() => OnConfirm.InvokeAsync();
}
+1 -1
View File
@@ -4,10 +4,10 @@
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using TaxBaik.Web.Components.Shared
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop
@using MudBlazor
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities
@using TaxBaik.Application.Services
@@ -0,0 +1,15 @@
<div class="@CssClass" aria-hidden="true">
@for (var i = 0; i < Count; i++)
{
<div class="taxbaik-skeleton-item">
<div class="taxbaik-skeleton-line taxbaik-skeleton-title"></div>
<div class="taxbaik-skeleton-line"></div>
<div class="taxbaik-skeleton-line taxbaik-skeleton-short"></div>
</div>
}
</div>
@code {
[Parameter] public int Count { get; set; } = 3;
[Parameter] public string CssClass { get; set; } = "";
}
+24
View File
@@ -0,0 +1,24 @@
@using Microsoft.AspNetCore.Components.Web
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>백원숙 세무회계</title>
<base href="/taxbaik/" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link rel="stylesheet" href="css/design-tokens.css" />
<link rel="stylesheet" href="css/ui-primitives.css" />
<link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
<link rel="stylesheet" href="css/site.css" />
<link rel="stylesheet" href="css/admin.css" />
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
</head>
<body class="site-blazor">
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="_content/Microsoft.FluentUI.AspNetCore.Components/js/lib.module.js" type="module" async></script>
<script src="js/admin-session.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,45 @@
@page "/blog"
@using TaxBaik.Application.Services
@inject BlogService BlogService
<PageTitle>블로그</PageTitle>
<section class="site-content">
<div class="site-section-header">
<h1>세무 블로그</h1>
<p>최신 세법 변화와 실무 팁을 확인하세요.</p>
</div>
@if (posts is null)
{
<Skeleton Count="6" CssClass="site-post-grid" />
}
else if (posts.Count == 0)
{
<p>게시물이 없습니다.</p>
}
else
{
<div class="site-post-grid">
@foreach (var post in posts)
{
<article class="site-post-card">
<div class="site-post-meta">@post.CategoryName</div>
<h2>@post.Title</h2>
<p>@(post.PublishedAt ?? post.CreatedAt).ToString("yyyy-MM-dd")</p>
<a class="site-button primary" href="/taxbaik/blog/@post.Slug">글 내용 보기</a>
</article>
}
</div>
}
</section>
@code {
private List<TaxBaik.Domain.Entities.BlogPost>? posts;
protected override async Task OnInitializedAsync()
{
var (items, _) = await BlogService.GetPublishedPagedAsync(1, 12);
posts = items.ToList();
}
}
+18
View File
@@ -0,0 +1,18 @@
@page "/"
@using TaxBaik.Application.Seasonal
@using TaxBaik.Application.Services
@inject SeasonalMarketingService SeasonalMarketingService
<PageTitle>백원숙 세무회계</PageTitle>
<section class="site-hero">
<div class="site-hero-copy">
<div class="site-kicker">사업자 · 부동산 · 증여 세무 상담</div>
<h1>세금과 자산을 한 번에 정리하는 맞춤형 세무 파트너</h1>
<p>사업자 세무, 부동산 거래, 가족자산 관리를 위한 통합 상담을 제공합니다.</p>
<div class="site-actions">
<a class="site-button primary" href="/taxbaik/contact">무료 상담 신청</a>
<a class="site-button secondary" href="/taxbaik/blog">블로그 보기</a>
</div>
</div>
</section>
@@ -0,0 +1,16 @@
@page "/portal"
<PageTitle>마이 포털</PageTitle>
<section class="site-content">
<div class="site-section-header">
<h1>고객 포털</h1>
<p>포털은 다음 단계에서 세무 신고와 상담 이력 데이터에 연결됩니다.</p>
</div>
<div class="site-card">
<p>현재는 인증 연결과 데이터 바인딩을 준비하는 단계입니다.</p>
<div class="site-actions">
<a class="site-button primary" href="/taxbaik/portal/login">로그인</a>
<a class="site-button secondary" href="/taxbaik/portal/register">회원가입</a>
</div>
</div>
</section>
@@ -0,0 +1,6 @@
@page "/portal/login"
<PageTitle>고객 포털 로그인</PageTitle>
<section class="site-content">
<h1>고객 포털 로그인</h1>
<p>로그인 폼은 기존 인증 흐름을 Blazor로 옮기는 다음 단계에서 연결합니다.</p>
</section>
@@ -0,0 +1,6 @@
@page "/portal/register"
<PageTitle>고객 포털 회원가입</PageTitle>
<section class="site-content">
<h1>고객 포털 회원가입</h1>
<p>회원가입 폼은 다음 단계에서 Blazor 입력 컴포넌트로 채워집니다.</p>
</section>
+14
View File
@@ -0,0 +1,14 @@
@using Microsoft.AspNetCore.Components.Routing
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Site.SiteLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>찾을 수 없음</PageTitle>
<LayoutView Layout="@typeof(TaxBaik.Web.Components.Site.SiteLayout)">
<p>요청한 페이지를 찾을 수 없습니다.</p>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,16 @@
@inherits LayoutComponentBase
<div class="site-shell">
<header class="site-topbar">
<a class="site-logo" href="/taxbaik/">백원숙 세무회계</a>
<nav class="site-nav">
<a href="/taxbaik/blog">블로그</a>
<a href="/taxbaik/portal">포털</a>
<a href="/taxbaik/contact">상담</a>
</nav>
</header>
<main class="site-main">
@Body
</main>
</div>
@@ -0,0 +1,3 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using TaxBaik.Web.Components.Shared
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Route("api/admin-dashboard")]
[Authorize]
public class AdminDashboardController : ControllerBase
{
@@ -27,6 +27,7 @@ public class AuthController : ControllerBase
return Ok(new
{
token = tokenPair.AccessToken,
accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn
@@ -45,6 +46,7 @@ public class AuthController : ControllerBase
return Ok(new
{
token = tokenPair.AccessToken,
accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn
@@ -24,6 +24,20 @@ public class ConsultingActivityController(ConsultingActivityService service) : C
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var activities = await service.GetAllAsync();
return Ok(activities);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class ContractController(ContractService service) : ControllerBase
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var contracts = await service.GetAllAsync();
return Ok(contracts);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
+3 -1
View File
@@ -32,7 +32,8 @@ public class InquiryController : ControllerBase
request.ServiceType,
request.Message,
request.Email,
HttpContext.Connection.RemoteIpAddress?.ToString());
HttpContext.Connection.RemoteIpAddress?.ToString(),
request.SuppressNotification);
return Ok(new { message = "상담 신청이 접수되었습니다." });
}
catch (ValidationException ex)
@@ -135,6 +136,7 @@ public class SubmitInquiryRequest
public string? Email { get; set; }
public string ServiceType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public bool SuppressNotification { get; set; }
}
public class UpdateStatusRequest
@@ -24,14 +24,27 @@ public class RevenueTrackingController(RevenueTrackingService service) : Control
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var revenues = await service.GetAllAsync();
return Ok(revenues);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
// GetByIdAsync가 없으면 GetByClientIdAsync를 사용하거나 별도 구현 필요
// 임시로 구현 - 실제로는 repository에 GetByIdAsync 추가 필요
return Ok(new { message = "조회됨" });
var revenue = await service.GetByIdAsync(id);
return revenue is null ? NotFound(new { error = "조회 실패", message = "해당 청구를 찾을 수 없습니다." }) : Ok(revenue);
}
catch (Exception ex)
{
@@ -24,6 +24,20 @@ public class TaxFilingScheduleController(TaxFilingScheduleService service) : Con
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var schedules = await service.GetAllAsync();
return Ok(schedules);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class TaxProfileController(TaxProfileService taxProfileService) : Control
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var profiles = await taxProfileService.GetAllAsync();
return Ok(profiles);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
-87
View File
@@ -1,87 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace TaxBaik.Web.Hubs;
/// <summary>
/// Real-time notification hub for admin dashboard
/// SOLID: Single Responsibility - Only broadcasts change notifications
/// No state management - stateless broadcast pattern
/// </summary>
[Authorize]
public class NotificationHub : Hub
{
private const string AdminGroup = "admins";
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
await base.OnConnectedAsync();
}
/// <summary>
/// Broadcast inquiry status changed to all connected admins
/// Clients should re-fetch from API to verify
/// </summary>
public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
{
await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
{
InquiryId = inquiryId,
Status = newStatus,
ChangedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast inquiry submitted (new inquiry created)
/// </summary>
public async Task NotifyInquiryCreated(int inquiryId, string name)
{
await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
{
InquiryId = inquiryId,
Name = name,
CreatedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast client created
/// </summary>
public async Task NotifyClientCreated(int clientId, string name)
{
await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
{
ClientId = clientId,
Name = name,
CreatedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast announcement published
/// </summary>
public async Task NotifyAnnouncementPublished(int announcementId, string title)
{
await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
{
AnnouncementId = announcementId,
Title = title,
PublishedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast tax filing completed
/// </summary>
public async Task NotifyFilingCompleted(int filingId, string filingType)
{
await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
{
FilingId = filingId,
FilingType = filingType,
CompletedAt = DateTime.UtcNow
});
}
}
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using Serilog.Core;
using Serilog.Events;
namespace TaxBaik.Web.Logging;
public class TelegramSink : ILogEventSink
{
private readonly string _botToken;
private readonly string _chatId;
private readonly HttpClient _httpClient;
public TelegramSink(string botToken, string chatId)
{
_botToken = botToken;
_chatId = chatId;
_httpClient = new HttpClient();
}
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < LogEventLevel.Error)
{
return;
}
// Filter out harmless client disconnect and task cancellation exceptions
if (logEvent.Exception != null)
{
var exTypeName = logEvent.Exception.GetType().FullName ?? "";
var exMessage = logEvent.Exception.Message ?? "";
if (exTypeName.Contains("JSDisconnectedException") ||
exTypeName.Contains("TaskCanceledException") ||
exMessage.Contains("JavaScript interop calls cannot be issued") ||
exMessage.Contains("circuit has disconnected"))
{
return;
}
}
// Emit is a synchronous method, so we dispatch the network call asynchronously
Task.Run(async () =>
{
try
{
var timestamp = logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz");
var level = logEvent.Level.ToString().ToUpper();
var message = logEvent.RenderMessage();
var exceptionDetails = logEvent.Exception?.ToString();
var sb = new StringBuilder();
sb.AppendLine($"<b>🚨 [{level}] 에러 발생</b>");
sb.AppendLine($"<b>시간:</b> {timestamp}");
sb.AppendLine($"<b>메시지:</b> {EscapeHtml(message)}");
if (!string.IsNullOrEmpty(exceptionDetails))
{
var escapedException = EscapeHtml(exceptionDetails);
if (escapedException.Length > 3000)
{
escapedException = escapedException.Substring(0, 3000) + "\n[이하 생략]";
}
sb.AppendLine($"<b>Exception 상세:</b>\n<pre>{escapedException}</pre>");
}
var url = $"https://api.telegram.org/bot{_botToken}/sendMessage";
var payload = new
{
chat_id = _chatId,
text = sb.ToString(),
parse_mode = "HTML"
};
var response = await _httpClient.PostAsJsonAsync(url, payload);
if (!response.IsSuccessStatusCode)
{
var errorResponse = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[TelegramSink] Failed to send log to Telegram: {response.StatusCode} - {errorResponse}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[TelegramSink] Error in TelegramSink: {ex.Message}");
}
});
}
private static string EscapeHtml(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
}
+7 -1
View File
@@ -5,7 +5,13 @@
}
<div class="container py-5" style="max-width: 600px;">
<h1 class="fw-bold mb-5">상담 신청</h1>
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h1 class="fw-bold mb-0">상담 신청</h1>
<a href="/taxbaik" class="btn btn-outline-secondary btn-sm"
onclick="if (history.length > 1) { history.back(); return false; }">
뒤로가기
</a>
</div>
@if (TempData["Success"] != null)
{
+3 -420
View File
@@ -1,422 +1,5 @@
@page
@model TaxBaik.Web.Pages.IndexModel
@page "/"
@{
var season = Model.CurrentSeason;
ViewData["Title"] = season != null
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
Layout = null;
await Html.RenderComponentAsync<TaxBaik.Web.Components.Site.App>(RenderMode.ServerPrerendered);
}
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
@if (Model.ActiveAnnouncements.Count > 0)
{
foreach (var notice in Model.ActiveAnnouncements)
{
<div class="announcement-bar announcement-bar--@notice.DisplayType">
<div class="container d-flex align-items-center gap-2 py-2">
<span class="announcement-icon">
@if (notice.DisplayType == "urgent") { <text>🚨</text> }
else if (notice.DisplayType == "banner") { <text>📢</text> }
else { <text>️</text> }
</span>
<span class="announcement-text fw-semibold">@notice.Title</span>
@if (!string.IsNullOrEmpty(notice.Content))
{
<span class="d-none d-md-inline text-muted small ms-2">— @notice.Content</span>
}
</div>
</div>
}
}
@* ─── Hero Section ─── *@
@if (season != null)
{
<section class="hero-section hero-section--seasonal text-white pt-5 pb-4">
<div class="container">
<div class="row align-items-center py-4">
<div class="col-lg-7">
<span class="badge bg-danger-badge mb-3 fs-6 px-3 py-2">
@season.UrgencyBadge
</span>
<h1 class="mb-3" style="white-space: pre-line;">@season.HeroHeadline</h1>
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
@season.HeroSubtext
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg fw-bold">
⏰ @season.CtaText
</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의
</a>
</div>
@if (season.DaysUntilDeadline <= 7)
{
<p class="mt-3 small" style="opacity: 0.8;">
마감까지 <strong>@(season.DaysUntilDeadline)일</strong> 남았습니다.
지금 바로 상담 신청하세요.
</p>
}
</div>
<div class="col-lg-5 d-none d-lg-block text-center">
<div class="seasonal-deadline-badge">
<div class="deadline-label">마감</div>
<div class="deadline-date">@season.Deadline.ToString("M월 d일")</div>
<div class="deadline-days">D-@season.DaysUntilDeadline</div>
</div>
</div>
</div>
</div>
</section>
}
else
{
<section class="hero-section text-white pt-5 pb-4">
<div class="container">
<div class="row align-items-center py-4">
<div class="col-lg-7">
<span class="badge bg-primary-badge mb-3">경험 있는 세무사의 맞춤 전략</span>
<h1 class="mb-3">
세금과 자산<br/>
<span style="color: #E8E4D8;">한 번에 해결하는</span>
</h1>
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
사업자 세무, 부동산 거래, 가족자산 관리를 위한<br/>
통합 솔루션을 제공합니다.
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의
</a>
</div>
</div>
<div class="col-lg-5 d-none d-lg-block text-center">
<div style="font-size: 120px; opacity: 0.15;">📋</div>
</div>
</div>
</div>
</section>
}
<!-- 신뢰도 스트립 — 자격과 경험 -->
<section class="trust-strip">
<div class="container">
<div class="row">
<div class="col-md-4">
<div class="trust-item">
<div class="trust-icon">🎓</div>
<h3>세무사</h3>
<p>국가공인 세무사 자격<br/>2015년 취득 · 10년 경력</p>
</div>
</div>
<div class="col-md-4">
<div class="trust-item">
<div class="trust-icon">🏢</div>
<h3>부동산중개사</h3>
<p>부동산 거래 전문 자격<br/>양도세·취득세 컨설팅</p>
</div>
</div>
<div class="col-md-4">
<div class="trust-item">
<div class="trust-icon">📊</div>
<h3>보험설계사</h3>
<p>자산관리 전문 자격<br/>가족 자산 플래닝</p>
</div>
</div>
</div>
</div>
</section>
<!-- 서비스 영역 -->
<section class="py-5">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">전문 서비스</h2>
<p class="fs-6 text-muted" style="max-width: 600px; margin: 0 auto;">
각 분야의 복잡한 세무 이슈를 경험과 노하우로 해결합니다
</p>
</div>
@{
var focusService = season?.FocusService ?? "";
// 시즌에 따라 서비스 카드 순서 결정: 시즌 관련 카드가 맨 앞
var cardOrder = focusService switch
{
"real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" },
"family-asset" => new[] { "family-asset", "business-tax", "real-estate-tax" },
_ => new[] { "business-tax", "real-estate-tax", "family-asset" }
};
}
<div class="row g-4">
@foreach (var cardKey in cardOrder)
{
var isFeatured = cardKey == focusService;
if (cardKey == "business-tax")
{
<div class="col-lg-4 col-md-6">
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
<div class="service-icon">🏪</div>
<div class="card-body pt-0">
<h3 class="card-title">사업자 세무</h3>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 정확한 기장 및 결산</li>
<li class="mb-2">✓ 세금계산서 관리</li>
<li class="mb-2">✓ 경비처리 최적화</li>
<li class="mb-2">✓ 절세 전략 수립</li>
</ul>
<p class="text-muted small">
초기부터 세무 전략을 수립하면 연간 최대 수백만 원의 절세가 가능합니다.
</p>
<a href="/taxbaik/services#business-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
else if (cardKey == "real-estate-tax")
{
<div class="col-lg-4 col-md-6">
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
<div class="service-icon">🏠</div>
<div class="card-body pt-0">
<h3 class="card-title">부동산 세금</h3>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 양도세 최소화</li>
<li class="mb-2">✓ 취득세 절감</li>
<li class="mb-2">✓ 임대소득 관리</li>
<li class="mb-2">✓ 다주택자 세무</li>
</ul>
<p class="text-muted small">
부동산 거래 시 미리 상담하면 세금 부담을 크게 줄일 수 있습니다.
</p>
<a href="/taxbaik/services#real-estate-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
else
{
<div class="col-lg-4 col-md-6">
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
<div class="service-icon">👨‍👩‍👧‍👦</div>
<div class="card-body pt-0">
<h3 class="card-title">가족자산 관리</h3>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 증여세 전략</li>
<li class="mb-2">✓ 상속세 대비</li>
<li class="mb-2">✓ 자산 이전 계획</li>
<li class="mb-2">✓ 가족법인 설립</li>
</ul>
<p class="text-muted small">
세대 이전 전에 사전 계획하면 세금 부담을 현저히 줄일 수 있습니다.
</p>
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
}
</div>
</div>
</section>
<!-- 상담 프로세스 -->
<section class="py-5" style="background: #F9F7F3;">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">상담 과정</h2>
</div>
<div class="row align-items-center">
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📞
</div>
<h4>1단계: 무료 상담</h4>
<p class="text-muted small">상황 파악 및<br/>현재 문제점 확인</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📋
</div>
<h4>2단계: 세무진단</h4>
<p class="text-muted small">자료 분석 및<br/>최적 방안 도출</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
💡
</div>
<h4>3단계: 맞춤제안</h4>
<p class="text-muted small">절세 전략 및<br/>실행 계획 제시</p>
</div>
<div class="col-md-3 text-center">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
</div>
<h4>4단계: 실행지원</h4>
<p class="text-muted small">지속적 관리 및<br/>추가 상담 제공</p>
</div>
</div>
<div class="text-center mt-5">
<p class="text-muted mb-3">상담은 온라인 또는 오프라인으로 진행되며, 완전히 비밀이 보장됩니다.</p>
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
</div>
</div>
</section>
<!-- 세무 정보 블로그 -->
<section class="py-5">
<div class="container">
<div class="text-center mb-5">
@if (season != null)
{
<div class="seasonal-blog-header mb-2">
<span class="seasonal-blog-tag">📅 @season.Name 시즌</span>
</div>
<h2 class="section-title">이번 시즌 세무 정보</h2>
<p class="text-muted">@season.Name 관련 절세 팁과 신고 가이드를 확인하세요</p>
}
else
{
<h2 class="section-title">세무 정보</h2>
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
}
</div>
@{
var hasSeasonalPosts = Model.SeasonalPosts?.Count > 0;
var hasRecentPosts = Model.RecentPosts?.Count > 0;
}
@if (hasSeasonalPosts || hasRecentPosts)
{
<div class="row g-4">
@* 시즌 관련 글 (배지 강조) *@
@if (hasSeasonalPosts)
{
@foreach (var post in Model.SeasonalPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100 blog-card--seasonal">
<div class="blog-seasonal-ribbon">이번 시즌 추천</div>
<div class="blog-placeholder">🗓️</div>
<div class="card-body">
<small class="badge bg-season-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-seasonal">자세히 보기</a>
</div>
</div>
</div>
}
}
@* 최신 글 (나머지 채우기) *@
@if (hasRecentPosts)
{
@foreach (var post in Model.RecentPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100">
<div class="blog-placeholder">📝</div>
<div class="card-body">
<small class="badge bg-primary-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">글 내용 보기</a>
</div>
</div>
</div>
}
}
</div>
<div class="text-center mt-5 d-flex justify-content-center gap-3 flex-wrap">
@if (season != null && !string.IsNullOrEmpty(season.RelatedCategorySlug))
{
<a href="/taxbaik/blog?category=@season.RelatedCategorySlug" class="btn btn-outline-seasonal btn-lg">
📅 @season.Name 전체 글 보기
</a>
}
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
</div>
}
</div>
</section>
<!-- 자주 묻는 질문 (DB 연동) -->
@if (Model.ActiveFaqs.Count > 0)
{
<section class="py-5" style="background: #F9F7F3;">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">자주 묻는 질문</h2>
<p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p>
</div>
<div class="accordion faq-accordion" id="faqAccordion">
@for (int i = 0; i < Model.ActiveFaqs.Count; i++)
{
var faqItem = Model.ActiveFaqs[i];
var collapseId = $"faq-{faqItem.Id}";
<div class="accordion-item faq-item">
<h3 class="accordion-header">
<button class="accordion-button collapsed faq-question" type="button"
data-bs-toggle="collapse" data-bs-target="#@collapseId">
@faqItem.Question
</button>
</h3>
<div id="@collapseId" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body faq-answer">
@faqItem.Answer
</div>
</div>
</div>
}
</div>
<div class="text-center mt-5">
<p class="text-muted mb-3">더 궁금한 점이 있으시면 바로 문의해 주세요</p>
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 문의하기</a>
</div>
</div>
</section>
}
<!-- 최종 CTA -->
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
<div class="container text-center">
@if (season != null)
{
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">@season.Name 마감이 다가옵니다!</h2>
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
마감 <strong>D-@(season.DaysUntilDeadline)일</strong> — 지금 바로 상담을 신청하세요.<br/>
빠른 검토로 불이익 없이 신고를 완료합니다.
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
</div>
}
else
{
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">세금 고민은 이제 끝!</h2>
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
무료 상담으로 현재 상황을 진단하고<br/>
맞춤형 절세 전략을 받아보세요.
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
</div>
}
</div>
</section>
-76
View File
@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Seasonal;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Pages;
public class IndexModel : PageModel
{
private readonly BlogService _blogService;
private readonly SeasonalMarketingService _seasonalMarketingService;
private readonly AnnouncementService _announcementService;
private readonly FaqService _faqService;
public List<BlogPost> RecentPosts { get; set; } = [];
public List<BlogPost> SeasonalPosts { get; set; } = [];
public CurrentSeasonDto? CurrentSeason { get; set; }
public List<Announcement> ActiveAnnouncements { get; set; } = [];
public List<Faq> ActiveFaqs { get; set; } = [];
public IndexModel(
BlogService blogService,
SeasonalMarketingService seasonalMarketingService,
AnnouncementService announcementService,
FaqService faqService)
{
_blogService = blogService;
_seasonalMarketingService = seasonalMarketingService;
_announcementService = announcementService;
_faqService = faqService;
}
public async Task OnGetAsync()
{
CurrentSeason = _seasonalMarketingService.GetCurrentSeason();
var announcementsTask = LoadSafeAsync(() => _announcementService.GetActiveAsync());
var faqsTask = LoadSafeAsync(() => _faqService.GetActiveAsync());
var blogTask = LoadBlogAsync();
await Task.WhenAll(announcementsTask, faqsTask, blogTask);
ActiveAnnouncements = (await announcementsTask)?.ToList() ?? [];
ActiveFaqs = (await faqsTask)?.ToList() ?? [];
}
private async Task LoadBlogAsync()
{
try
{
if (CurrentSeason is not null && !string.IsNullOrEmpty(CurrentSeason.RelatedCategorySlug))
{
var (seasonal, latest) = await _blogService.GetSeasonalPostsAsync(
CurrentSeason.RelatedCategorySlug, seasonalCount: 2, totalCount: 3);
SeasonalPosts = seasonal.ToList();
RecentPosts = latest.ToList();
}
else
{
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
RecentPosts = posts.ToList();
}
}
catch
{
RecentPosts = [];
SeasonalPosts = [];
}
}
private static async Task<IEnumerable<T>?> LoadSafeAsync<T>(Func<Task<IEnumerable<T>>> loader)
{
try { return await loader(); }
catch { return null; }
}
}
+160 -23
View File
@@ -1,34 +1,171 @@
@page "/portal"
@model TaxBaik.Web.Pages.Portal.IndexModel
@{
ViewData["Title"] = "고객 포털";
ViewData["Description"] = "고객 신고 일정, 상담 요약, 중요 알림을 확인하는 전용 포털입니다.";
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal";
ViewData["Title"] = "마이 포털 - 세무사 백원숙";
ViewData["Description"] = "고객님의 세무 신고 일정 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
}
<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">
신고 일정, 상담 요약, 승인된 알림을 확인할 수 있는 전용 공간입니다.
<div class="bg-light py-5">
<div class="container">
<!-- 상단 헤더 & 환영 문구 -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom">
<div>
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p>
<h1 class="display-6 fw-bold text-dark">안녕하세요, @(User.Identity?.Name)님!</h1>
@if (Model.ClientInfo != null)
{
<p class="text-muted mb-0">
<i class="bi bi-building"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)
| <i class="bi bi-telephone"></i> @Model.ClientInfo.Phone
</p>
<div 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 class="mt-3 mt-sm-0">
<form method="post" action="/taxbaik/portal/logout" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-box-arrow-right"></i> 로그아웃
</button>
</form>
</div>
</div>
<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>
@if (Model.ClientInfo == null)
{
<!-- 연동 대기 경고 -->
<div class="card border-warning shadow-sm mb-5">
<div class="card-body p-5 text-center">
<div class="mb-4">
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
</div>
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
<p class="text-muted max-width-md mx-auto mb-4">
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 레코드를 찾지 못했습니다.<br />
세무사 측에서 고객 등록을 완료하거나 관리자 백오피스에서 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
</p>
<a href="/taxbaik/contact" class="btn btn-primary px-4 py-2">
<i class="bi bi-chat-dots"></i> 세무사에게 문의하기
</a>
</div>
</div>
}
else
{
<div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm rounded-3 mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="h5 fw-bold text-dark mb-0">
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
</h3>
<span class="badge bg-secondary">총 @(Model.Filings.Count)건</span>
</div>
@if (!Model.Filings.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-folder-x display-4 d-block mb-3 text-secondary"></i>
등록된 세무 신고 일정이 없습니다.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th scope="col">신고 종류</th>
<th scope="col">신고 기한</th>
<th scope="col">진행 상태</th>
<th scope="col">메모</th>
</tr>
</thead>
<tbody>
@foreach (var filing in Model.Filings)
{
var dDay = (filing.DueDate - DateTime.Today).Days;
var statusClass = filing.Status switch
{
"filed" => "bg-success-subtle text-success",
"overdue" => "bg-danger-subtle text-danger",
_ => "bg-warning-subtle text-warning-emphasis"
};
var statusLabel = filing.Status switch
{
"filed" => "신고 완료",
"overdue" => "기한 초과",
_ => $"D-{dDay}"
};
<tr>
<td>
<span class="fw-bold text-dark">@filing.FilingType</span>
</td>
<td>
<span>@filing.DueDate.ToString("yyyy-MM-dd")</span>
</td>
<td>
<span class="badge @statusClass px-2.5 py-1.5 fs-7">@statusLabel</span>
</td>
<td class="text-muted small">
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</section>
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm rounded-3">
<div class="card-body p-4">
<h3 class="h5 fw-bold text-dark mb-4">
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
</h3>
@if (!Model.Consultations.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary"></i>
최근 상담 이력이 없습니다.
</div>
}
else
{
<div class="timeline">
@foreach (var activity in Model.Consultations)
{
<div class="border-start border-2 border-primary-subtle ps-3 pb-4 position-relative">
<!-- 타임라인 아이콘 -->
<div class="position-absolute start-0 translate-middle-x bg-primary rounded-circle"
style="width: 10px; height: 10px; margin-left: -1px; top: 6px;"></div>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
</div>
<p class="text-dark small mb-1 fw-semibold">@activity.Description</p>
@if (!string.IsNullOrEmpty(activity.Outcome))
{
<div class="bg-light p-2 rounded small text-muted mt-1">
<strong>결과:</strong> @activity.Outcome
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</div>
+37 -1
View File
@@ -1,5 +1,9 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
@@ -7,7 +11,39 @@ namespace TaxBaik.Web.Pages.Portal;
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
public void OnGet()
private readonly TaxFilingService _taxFilingService;
private readonly ConsultingActivityService _consultingActivityService;
private readonly ClientService _clientService;
public IndexModel(
TaxFilingService taxFilingService,
ConsultingActivityService consultingActivityService,
ClientService clientService)
{
_taxFilingService = taxFilingService;
_consultingActivityService = consultingActivityService;
_clientService = clientService;
}
public Client? ClientInfo { get; private set; }
public List<TaxFiling> Filings { get; private set; } = new();
public List<ConsultingActivity> Consultations { get; private set; } = new();
public async Task<IActionResult> OnGetAsync()
{
var clientIdClaim = User.FindFirst("client_id");
if (clientIdClaim != null && int.TryParse(clientIdClaim.Value, out var clientId))
{
ClientInfo = await _clientService.GetByIdAsync(clientId);
if (ClientInfo != null)
{
var filingsData = await _taxFilingService.GetByClientIdAsync(clientId);
Filings = filingsData.OrderBy(f => f.DueDate).ToList();
var consultationsData = await _consultingActivityService.GetByClientIdAsync(clientId);
Consultations = consultationsData.OrderByDescending(c => c.ActivityDate).ToList();
}
}
return Page();
}
}
+43 -7
View File
@@ -3,21 +3,57 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계")</title>
<meta name="description" content="@(ViewData["Description"] ?? "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담.")" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["Description"]" />
<meta property="og:image" content="@ViewData["OgImage"]" />
<meta property="og:url" content="@ViewData["OgUrl"]" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title>
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="og:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#C89D6E" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="canonical" href="@ViewData["CanonicalUrl"]" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<!-- 구조화된 데이터 (JSON-LD Schema Markup) -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "ProfessionalService",
"name": "백원숙 세무회계",
"description": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담 세무사",
"url": "http://178.104.200.7/taxbaik/",
"telephone": "010-4122-8268",
"email": "taxbaik5668@gmail.com",
"address": {
"@@type": "PostalAddress",
"addressCountry": "KR"
},
"sameAs": [
"https://www.instagram.com/taxtory5668/",
"http://pf.kakao.com/_xoxchTX"
]
}
</script>
</head>
<body class="with-mobile-cta">
<partial name="_Header" />
+3
View File
@@ -0,0 +1,3 @@
@using TaxBaik.Web
@namespace TaxBaik.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+54 -73
View File
@@ -8,8 +8,8 @@ using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services;
using Serilog;
using TaxBaik.Application;
using TaxBaik.Application.Services;
@@ -38,6 +38,13 @@ builder.Host.UseSerilog((context, config) =>
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.Enrich.FromLogContext()
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
var botToken = context.Configuration["Telegram:BotToken"];
var systemChatId = context.Configuration["Telegram:SystemChatId"] ?? context.Configuration["Telegram:ChatId"];
if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(systemChatId))
{
config.WriteTo.Sink(new TaxBaik.Web.Logging.TelegramSink(botToken, systemChatId), Serilog.Events.LogEventLevel.Error);
}
});
// Controllers (API)
@@ -45,9 +52,6 @@ builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
// SignalR (Notifications only, no state management)
builder.Services.AddSignalR();
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
@@ -64,7 +68,7 @@ if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnore
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
var key = Encoding.ASCII.GetBytes(jwtKey);
builder.Services.AddAuthentication(opts =>
var authenticationBuilder = builder.Services.AddAuthentication(opts =>
{
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -100,19 +104,30 @@ builder.Services.AddAuthentication(opts =>
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
})
.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
});
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 = builder.Configuration["Authentication:Google:ClientId"] ?? "";
opts.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "";
opts.ClientId = googleClientId;
opts.ClientSecret = googleClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-google";
})
.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
});
}
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 = builder.Configuration["Authentication:Naver:ClientId"] ?? "";
opts.ClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"] ?? "";
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";
@@ -133,12 +148,18 @@ builder.Services.AddAuthentication(opts =>
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
}
};
})
.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
});
}
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 = builder.Configuration["Authentication:Kakao:ClientId"] ?? "";
opts.ClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"] ?? "";
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";
@@ -162,6 +183,7 @@ builder.Services.AddAuthentication(opts =>
}
};
});
}
// Blazor 인증
builder.Services.AddScoped<AuthService>();
@@ -172,9 +194,6 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
// Notifications (SignalR)
builder.Services.AddScoped<INotificationService, NotificationService>();
// Telegram Notification
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
@@ -189,76 +208,60 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
// Phase 5: Tax Accounting & CRM Browser Clients
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
});
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
// UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
});
// UI & 캐시 (Fluent UI Blazor v5 우선)
builder.Services.AddFluentUIComponents();
builder.Services.AddMemoryCache();
builder.Services.AddResponseCompression(opts => {
opts.Providers.Add<GzipCompressionProvider>();
});
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
builder.Services.AddHostedService<TelegramReportBackgroundService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PortalAuthService>();
@@ -270,6 +273,7 @@ builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddInfrastructure();
builder.Services.AddApplication();
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
// Register version info
var versionInfo = new VersionInfo();
@@ -336,11 +340,9 @@ app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages();
// SignalR Hub
app.MapHub<TaxBaik.Web.Hubs.NotificationHub>("/taxbaik/notifications");
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
app.MapRazorComponents<TaxBaik.Web.Components.Site.App>()
.AddInteractiveServerRenderMode()
.AllowAnonymous();
@@ -348,27 +350,6 @@ app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
try
{
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
if (!app.Environment.IsDevelopment())
{
// 배포 완료 알림을 백그라운드에서 비동기 전송 (앱 시작 블록 방지)
_ = Task.Run(async () =>
{
try
{
using (var scope = app.Services.CreateScope())
{
var telegramService = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
await telegramService.SendInfoAsync(
"✅ 배포 완료",
$"환경: {app.Environment.EnvironmentName}\n상태: 정상 운영 중");
}
}
catch (Exception ex)
{
Log.Error(ex, "배포 완료 알림 전송 실패");
}
});
}
app.Run();
}
catch (Exception ex)
@@ -14,15 +14,24 @@ public interface IConsultingActivityBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<ConsultingActivityBrowserClient> logger)
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
: IConsultingActivityBrowserClient
{
private const string BaseUrl = "/api/consultingactivity";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -36,6 +45,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -49,6 +59,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
@@ -66,6 +77,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -83,6 +95,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var request = new { outcome, nextFollowupDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -97,6 +110,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,15 +16,24 @@ public interface IContractBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowserClient> logger)
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
: IContractBrowserClient
{
private const string BaseUrl = "/api/contract";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -64,6 +75,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -96,6 +109,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
if (response.TryGetProperty("mrr", out var mrrValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
@@ -113,6 +127,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -130,6 +145,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,15 +16,24 @@ public interface IRevenueTrackingBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<RevenueTrackingBrowserClient> logger)
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
: IRevenueTrackingBrowserClient
{
private const string BaseUrl = "/api/revenuetracking";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -67,6 +78,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -83,6 +95,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>(
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
if (response.TryGetProperty("total", out var totalValue))
@@ -101,6 +114,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -118,6 +132,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var request = new { paymentDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
response.EnsureSuccessStatusCode();
@@ -132,6 +147,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -15,15 +15,24 @@ public interface ITaxFilingScheduleBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFilingScheduleBrowserClient> logger)
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
: ITaxFilingScheduleBrowserClient
{
private const string BaseUrl = "/api/taxfilingschedule";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -37,6 +46,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -50,6 +60,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -63,6 +74,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -97,6 +110,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
response.EnsureSuccessStatusCode();
}
@@ -110,6 +124,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -17,14 +17,23 @@ public interface ITaxProfileBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
{
private const string BaseUrl = "/api/taxprofile";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -64,6 +75,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -97,6 +110,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -115,6 +129,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -129,6 +144,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
+3 -3
View File
@@ -33,10 +33,10 @@ public class AdminDashboardClient : IAdminDashboardClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
@@ -29,10 +29,10 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
+3 -3
View File
@@ -34,10 +34,10 @@ public class ClientBrowserClient : IClientBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
@@ -32,21 +32,22 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken))
{
accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(accessToken))
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken))
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (long.TryParse(ticksStr, out var ticks))
{
_tokenStore.AccessToken = accessToken;
_tokenStore.AccessToken = storedToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
}
}
}
if (string.IsNullOrEmpty(accessToken))
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
@@ -78,7 +79,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
var principal = _authService.ValidateToken(accessToken);
var principal = _authService.ValidateToken(accessToken!);
if (principal == null)
{
await LogoutAsync();
@@ -114,13 +115,14 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
if (_tokenStore.TokenExpiryTicks <= 0)
var tokenExpiryTicks = _tokenStore.TokenExpiryTicks;
if (tokenExpiryTicks is null || tokenExpiryTicks <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime((long)_tokenStore.TokenExpiryTicks, DateTimeKind.Utc);
var expiryTime = new DateTime(tokenExpiryTicks.Value, DateTimeKind.Utc);
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
}
@@ -143,17 +145,4 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private bool IsTokenExpired(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
return jwtToken.ValidTo < DateTime.UtcNow;
}
catch
{
return true;
}
}
}
+3 -3
View File
@@ -28,10 +28,10 @@ public class FaqBrowserClient : IFaqBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
+3 -3
View File
@@ -33,10 +33,10 @@ public class InquiryBrowserClient : IInquiryBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
@@ -1,72 +0,0 @@
namespace TaxBaik.Web.Services;
/// <summary>
/// Notification service for real-time admin updates
/// SOLID: Single Responsibility - Event notification only
/// Uses Blazor Server's built-in SignalR for real-time communication
/// </summary>
public interface INotificationService
{
event Func<int, string, Task>? OnInquiryStatusChanged;
event Func<int, string, Task>? OnInquiryCreated;
event Func<int, string, Task>? OnClientCreated;
event Func<int, string, Task>? OnAnnouncementPublished;
event Func<int, string, Task>? OnFilingCompleted;
Task TriggerInquiryStatusChanged(int inquiryId, string status);
Task TriggerInquiryCreated(int inquiryId, string name);
Task TriggerClientCreated(int clientId, string name);
Task TriggerAnnouncementPublished(int announcementId, string title);
Task TriggerFilingCompleted(int filingId, string filingType);
}
public class NotificationService : INotificationService
{
private readonly ILogger<NotificationService> _logger;
public NotificationService(ILogger<NotificationService> logger)
{
_logger = logger;
}
public event Func<int, string, Task>? OnInquiryStatusChanged;
public event Func<int, string, Task>? OnInquiryCreated;
public event Func<int, string, Task>? OnClientCreated;
public event Func<int, string, Task>? OnAnnouncementPublished;
public event Func<int, string, Task>? OnFilingCompleted;
public async Task TriggerInquiryStatusChanged(int inquiryId, string status)
{
_logger.LogInformation($"Inquiry {inquiryId} status changed to {status}");
if (OnInquiryStatusChanged != null)
await OnInquiryStatusChanged(inquiryId, status);
}
public async Task TriggerInquiryCreated(int inquiryId, string name)
{
_logger.LogInformation($"New inquiry {inquiryId} from {name}");
if (OnInquiryCreated != null)
await OnInquiryCreated(inquiryId, name);
}
public async Task TriggerClientCreated(int clientId, string name)
{
_logger.LogInformation($"New client {clientId}: {name}");
if (OnClientCreated != null)
await OnClientCreated(clientId, name);
}
public async Task TriggerAnnouncementPublished(int announcementId, string title)
{
_logger.LogInformation($"Announcement {announcementId} published: {title}");
if (OnAnnouncementPublished != null)
await OnAnnouncementPublished(announcementId, title);
}
public async Task TriggerFilingCompleted(int filingId, string filingType)
{
_logger.LogInformation($"Filing {filingId} ({filingType}) completed");
if (OnFilingCompleted != null)
await OnFilingCompleted(filingId, filingType);
}
}
@@ -32,10 +32,10 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
@@ -44,7 +44,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"tax-filing/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
@@ -60,7 +60,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"tax-filing/client/{clientId}", cancellationToken: ct);
$"taxfiling/client/{clientId}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
@@ -76,7 +76,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<TaxFiling>(
$"tax-filing/{id}", cancellationToken: ct);
$"taxfiling/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
@@ -90,7 +90,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("tax-filing", filing, cancellationToken: ct);
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -111,7 +111,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"tax-filing/{id}", filing, cancellationToken: ct);
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -132,7 +132,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"tax-filing/{id}", cancellationToken: ct);
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
@@ -13,6 +13,7 @@ public interface ITelegramNotificationService
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default);
}
public class TelegramNotificationService : ITelegramNotificationService
@@ -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>";
await SendMessageAsync(text, ct);
}
public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default)
{
var text = $"<b>📊 {reportTitle}</b>\n\n{reportContent}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
await SendToChat(_systemChatId, text, ct);
}
}
@@ -48,7 +48,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildDailyReportAsync(date, ct);
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
_lastDailyReportDate = date;
logger.LogInformation("Daily telegram report sent for {Date}", date);
}
@@ -63,7 +63,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
_lastWeeklyReportWeekStart = weekStart;
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
}

Some files were not shown because too many files have changed in this diff Show More