Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85dbd34a51 | |||
| c82ba73327 | |||
| f892b85b7e | |||
| 62a7b2f2ef | |||
| 184ff2259b | |||
| 163812e964 | |||
| ba158f9824 | |||
| b2477d977b | |||
| 80c97fba96 | |||
| 1fb3a3c329 | |||
| abd7bbf016 | |||
| c765db37b3 | |||
| 967a784d6e | |||
| 03809bbf26 | |||
| c626c164f8 | |||
| 15f5dcf4ea | |||
| a84f842490 | |||
| 8999e51d4e | |||
| f98405b791 | |||
| ee964457d9 | |||
| 54c179b1eb | |||
| 488b8d11b7 | |||
| 65c5f19a2f | |||
| eaacbc8d7f |
@@ -100,6 +100,7 @@ jobs:
|
||||
|
||||
- name: Package artifact
|
||||
run: |
|
||||
cp deploy_gb.sh ./publish/deploy_gb.sh
|
||||
tar -czf taxbaik_deploy.tgz -C ./publish .
|
||||
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
|
||||
|
||||
@@ -163,11 +164,9 @@ jobs:
|
||||
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
||||
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
|
||||
|
||||
echo "--- [3/5] 심볼릭 링크 전환 ---"
|
||||
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
|
||||
|
||||
echo "--- [4/5] 서비스 재시작 ---"
|
||||
sudo /usr/bin/systemctl restart taxbaik
|
||||
echo "--- [3/4] Green-Blue 배포 실행 ---"
|
||||
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
|
||||
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
|
||||
|
||||
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
|
||||
ATTEMPTS=20
|
||||
|
||||
@@ -12,23 +12,6 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D
|
||||
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 ✅
|
||||
@@ -46,7 +29,6 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D
|
||||
- AdminDashboardClient 구현
|
||||
- 서비스 inject → API 호출로 변경
|
||||
- 에러 처리 & 로딩 상태
|
||||
- 기본 로딩은 Skeleton 적용
|
||||
- [x] 구조: IAdminDashboardClient → HttpClient 추상화
|
||||
|
||||
**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출
|
||||
@@ -94,18 +76,10 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
- 모든 API 엔드포인트 구현됨
|
||||
- 모든 Browser Client 구현됨
|
||||
- 16개 Blazor 페이지 API-First 마이그레이션 완료
|
||||
- 과거 기록: 관리 화면에서 그리드/모달 UX를 빠르게 안정화한 단계
|
||||
- 모달 패턴 (흰 화면 플래시 제거)
|
||||
- MudDataGrid Douzone ERP 수준 UX 적용
|
||||
- MudDialog 모달 패턴 (흰 화면 플래시 제거)
|
||||
- ConfirmDialog 삭제 확인 컴포넌트
|
||||
|
||||
### 2026-06-29 운영 기준 업데이트
|
||||
- 관리자 백오피스는 Fluent UI v5 우선 구조로 재정리한다.
|
||||
- 기본 로딩은 스피너가 아니라 Skeleton이다.
|
||||
- `design-tokens.css`와 `ui-primitives.css`는 사이트/관리자 공통의 기본 계층이다.
|
||||
- 라우팅 충돌은 가장 먼저 확인할 항목이며, 동일 경로가 두 번 등록되는 구조를 만들지 않는다.
|
||||
- 커밋은 기능/호스팅/UI/CSS처럼 주제별로 분리한다.
|
||||
- 레거시 제거 우선순위는 `MudBlazor` 계열 UI가 1순위다.
|
||||
|
||||
---
|
||||
|
||||
## 📊 **전체 프로젝트 완료 현황**
|
||||
@@ -144,7 +118,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 페이지 (그리드 Dense, Virtualize, 모달 패턴)
|
||||
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog)
|
||||
- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
|
||||
|
||||
| 페이지 | API | Client | Blazor | 핵심 기능 |
|
||||
@@ -156,8 +130,8 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
| RevenueTrackings | ✅ RevenueTrackingController | ✅ IRevenueTrackingBrowserClient | ✅ List + Modal | 청구/납부 추적, 상태 관리 |
|
||||
|
||||
**UI 특성**:
|
||||
- Dense 그리드 + Virtualize (1000+ 행 성능)
|
||||
- Create/Edit 모달 (흰 화면 플래시 방지)
|
||||
- MudDataGrid Dense (행높이 32px) + Virtualize (1000+ 행 성능)
|
||||
- MudDialog Create/Edit (흰 화면 플래시 방지)
|
||||
- ConfirmDialog Delete (사용자 확인)
|
||||
- Status Color Chips (Error/Warning/Success)
|
||||
- Client 링크 (상세 페이지 연동)
|
||||
@@ -215,8 +189,8 @@ PostgreSQL Database
|
||||
|
||||
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
|
||||
- [x] 5개 CRM/세무관리 Blazor 페이지
|
||||
- [x] Dense 그리드 + Virtualize (32px 행 높이)
|
||||
- [x] 모달 Create/Edit (흰 화면 플래시 제거)
|
||||
- [x] MudDataGrid Dense + Virtualize (32px 행 높이)
|
||||
- [x] MudDialog 모달 Create/Edit (흰 화면 플래시 제거)
|
||||
- [x] ConfirmDialog 삭제 확인
|
||||
- [x] 상태별 컬러 칩 (Status/Risk Level)
|
||||
- [x] 클라이언트 링크 (상세 페이지 연동)
|
||||
@@ -590,33 +564,24 @@ ssh kjh2064@178.104.200.7
|
||||
|
||||
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
||||
|
||||
**표준 배포 (현재)**:
|
||||
1. `master` 브랜치에 push
|
||||
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
|
||||
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
|
||||
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
|
||||
|
||||
**API 클라이언트 설정 (Green-Blue 대비)**:
|
||||
- API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
|
||||
- 기본값: `http://localhost:5001/taxbaik/api/`
|
||||
- 배포 시 환경변수로 오버라이드 가능:
|
||||
```bash
|
||||
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
|
||||
systemctl start taxbaik # 새 포트에 배포
|
||||
```
|
||||
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
|
||||
**무중단 Green-Blue 배포 아키텍처 (2026-06-30 적용 완료)**:
|
||||
1. **프록시 레이어**: 포트 `5001`에서 영구 가동되는 초경량 .NET TCP 프록시([TaxBaik.Proxy])가 수신 대기합니다. Nginx는 `/taxbaik` 트래픽을 기존과 같이 `5001`로 중계합니다.
|
||||
2. **동적 포트 스위칭**: 프록시는 요청이 들어올 때마다 `/home/kjh2064/taxbaik_port` 파일을 읽어 active 포트(5003 또는 5004)를 판단하고 트래픽을 포워딩합니다.
|
||||
3. **배포 흐름 (`deploy_gb.sh`)**:
|
||||
- Gitea Actions가 코드를 build/publish 후 압축하여 서버에 업로드합니다.
|
||||
- 서버의 배포 스크립트([deploy_gb.sh])가 실행되어 현재 미사용 중인 예비 포트(Target Port: 5003 또는 5004)를 파악합니다.
|
||||
- 예비 포트에서 새 .NET 웹 앱을 실행하고 `http://127.0.0.1:$target_port/taxbaik/healthz` 헬스 체크를 통과할 때까지 폴링(최대 60초)합니다.
|
||||
- 헬스 체크 성공 시 `/home/kjh2064/taxbaik_port` 파일에 새 포트 번호를 기입하여 **트래픽을 즉시 무중단 전환**합니다.
|
||||
- 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다.
|
||||
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.
|
||||
|
||||
**운영 규칙**:
|
||||
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
|
||||
- `rsync`로 직접 아티팩트를 올리지 않는다
|
||||
- 배포 실패 시 CI 로그를 먼저 본다
|
||||
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
|
||||
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
|
||||
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
|
||||
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
|
||||
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다.
|
||||
|
||||
**롤백**:
|
||||
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
|
||||
- 서버 파일을 수동으로 복구하지 않는다
|
||||
- 롤백은 커밋 단위로 추적 가능해야 한다
|
||||
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다.
|
||||
|
||||
### 3.4 서비스 파일 위치
|
||||
```
|
||||
@@ -990,8 +955,6 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
||||
- 전역 상태 불필요 (세션 → DB에서 읽음)
|
||||
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
|
||||
- 업데이트는 `StateHasChanged()` 호출
|
||||
- 초기 렌더는 Skeleton 우선
|
||||
- 로딩이 필요한 목록/카드/대시보드는 `items == null` 또는 `summary == null` 패턴으로 스켈톤 렌더링
|
||||
|
||||
### 8.6 어드민 그리드 UX (Dorsum ERP 수준)
|
||||
|
||||
@@ -1011,11 +974,9 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
||||
- **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지)
|
||||
- **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)
|
||||
|
||||
#### UI 적용 패턴
|
||||
#### MudBlazor 적용 패턴
|
||||
```razor
|
||||
```razor
|
||||
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
|
||||
<YourGridComponent T="YourItem"
|
||||
<MudDataGrid T="YourItem"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
@@ -1041,8 +1002,7 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</YourGridComponent>
|
||||
```
|
||||
</MudDataGrid>
|
||||
```
|
||||
|
||||
#### 색상 & 상태 표시
|
||||
@@ -1157,7 +1117,7 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
||||
<!-- 로딩 상태 -->
|
||||
@if (items == null)
|
||||
{
|
||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
<!-- 빈 상태 -->
|
||||
else if (items.Count == 0)
|
||||
@@ -1167,9 +1127,7 @@ else if (items.Count == 0)
|
||||
<!-- 데이터 그리드 -->
|
||||
else
|
||||
{
|
||||
```razor
|
||||
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
|
||||
<YourGridComponent T="YourEntity"
|
||||
<MudDataGrid T="YourEntity"
|
||||
Items="@items"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
@@ -1180,16 +1138,13 @@ else
|
||||
<Columns>
|
||||
<!-- 필수: 컬럼 정의 -->
|
||||
</Columns>
|
||||
</YourGridComponent>
|
||||
```
|
||||
</MudDataGrid>
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 모달 다이얼로그 (Create/Edit)**
|
||||
```razor
|
||||
```razor
|
||||
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 별도 라우트로 대체 -->
|
||||
<YourDialogComponent @bind-IsVisible="isDialogOpen">
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(isEditMode ? "항목 수정" : "새 항목 추가")</MudText>
|
||||
</TitleContent>
|
||||
@@ -1202,8 +1157,7 @@ else
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveItem">저장</MudButton>
|
||||
</DialogActions>
|
||||
</YourDialogComponent>
|
||||
```
|
||||
</MudDialog>
|
||||
```
|
||||
|
||||
**Step 4: @code 섹션 구조**
|
||||
@@ -1325,10 +1279,10 @@ else
|
||||
- [ ] @inject로 필요한 Client 주입
|
||||
- [ ] <PageTitle> 추가
|
||||
- [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼)
|
||||
- [ ] 로딩 상태 기본값은 `Skeleton`
|
||||
- [ ] 로딩 상태 (MudProgressCircular)
|
||||
- [ ] 빈 상태 (MudAlert)
|
||||
- [ ] Dense 그리드 (Virtualize=true, RowsPerPage=30, admin-grid 클래스)
|
||||
- [ ] 모달 (Create/Edit)
|
||||
- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스)
|
||||
- [ ] MudDialog (Create/Edit 모달)
|
||||
- [ ] ConfirmDialog (Delete 확인)
|
||||
- [ ] @code 섹션: OnInitializedAsync → LoadData() 패턴
|
||||
- [ ] 모든 에러 처리 (try-catch, Snackbar 메시지)
|
||||
@@ -1339,7 +1293,7 @@ else
|
||||
❌ **이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:**
|
||||
- 페이지 헤더 (admin-page-hero) 누락
|
||||
- 인라인 스타일로 레이아웃 구성
|
||||
- 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
|
||||
- MudDialog 없이 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
|
||||
- @code 섹션 구조 다름
|
||||
- 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공
|
||||
|
||||
@@ -1674,7 +1628,7 @@ curl http://127.0.0.1/taxbaik/admin/login
|
||||
### E2E 테스트 & 반응형 검증
|
||||
```bash
|
||||
# 문의 폼 제출
|
||||
curl -X POST http://178.104.200.7/taxbaik/contact \
|
||||
curl -X POST http://taxbaik.com/taxbaik/contact \
|
||||
-d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
|
||||
|
||||
# 관리자 DB에서 확인
|
||||
@@ -1713,7 +1667,7 @@ npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
|
||||
|
||||
**프로덕션 E2E 테스트**:
|
||||
```bash
|
||||
export E2E_BASE_URL="http://178.104.200.7/taxbaik"
|
||||
export E2E_BASE_URL="http://taxbaik.com/taxbaik"
|
||||
export E2E_ADMIN_USERNAME="test_admin"
|
||||
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
|
||||
|
||||
@@ -1760,9 +1714,7 @@ public async Task NotifyDeploymentStart()
|
||||
@* Components/Admin/Shared/DeploymentNotification.razor *@
|
||||
@if (showNotification)
|
||||
{
|
||||
```razor
|
||||
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 HTML/CSS 패턴으로 대체 -->
|
||||
<YourDialogComponent @bind-Visible="showNotification">
|
||||
<MudDialog @bind-Visible="showNotification">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 버전 배포</MudText>
|
||||
</TitleContent>
|
||||
@@ -1777,8 +1729,7 @@ public async Task NotifyDeploymentStart()
|
||||
<MudButton Color="Color.Primary" OnClick="RefreshNow">지금 새로고침</MudButton>
|
||||
<MudButton Color="Color.Default" OnClick="DismissNotification">나중에</MudButton>
|
||||
</DialogActions>
|
||||
</YourDialogComponent>
|
||||
```
|
||||
</MudDialog>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -1984,7 +1935,7 @@ else
|
||||
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 = Invoke-RestMethod -Headers $headers -Uri "http://gitea.taxbaik.com/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`가 있어야 배포가 실제로 시작된 것이다.
|
||||
|
||||
+51
-13
@@ -17,7 +17,7 @@
|
||||
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
||||
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
||||
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
|
||||
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
|
||||
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | 도메인 기반 가상 호스트 분기 (홈페이지, Gitea, Quant) |
|
||||
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
|
||||
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
||||
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
||||
@@ -126,17 +126,17 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
|
||||
### 4.2. Nginx 리버스 프록시
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-enabled/gitea-ip.conf
|
||||
# /etc/nginx/sites-available/taxbaik-domains.conf
|
||||
|
||||
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
listen 80;
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
# QuantEngine Blazor Web App
|
||||
location /quant/ {
|
||||
proxy_pass http://127.0.0.1:5000/;
|
||||
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001/taxbaik/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
@@ -147,7 +147,26 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Gitea (기본)
|
||||
# /taxbaik/ 하위로 들어오는 리소스 및 페이지 요청 처리
|
||||
location /taxbaik {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Gitea (gitea.taxbaik.com)
|
||||
server {
|
||||
listen 80;
|
||||
server_name gitea.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
@@ -160,12 +179,31 @@ server {
|
||||
proxy_send_timeout 300;
|
||||
}
|
||||
}
|
||||
|
||||
# 3. QuantEngine (quant.taxbaik.com)
|
||||
server {
|
||||
listen 80;
|
||||
server_name quant.taxbaik.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**라우팅 요약**:
|
||||
- `http://178.104.200.7/` → Gitea Web UI
|
||||
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
|
||||
- `ssh://178.104.200.7:2222` → Gitea Git SSH
|
||||
- `http://taxbaik.com/` 또는 `http://www.taxbaik.com/` → TaxBaik 홈페이지 (내부 proxy: `http://127.0.0.1:5001/taxbaik/`)
|
||||
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`)
|
||||
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`)
|
||||
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
|
||||
|
||||
## 5. Gitea
|
||||
|
||||
@@ -384,7 +422,7 @@ ClientAliveCountMax 2
|
||||
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
||||
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
||||
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
||||
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
|
||||
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) |
|
||||
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
||||
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
||||
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
||||
|
||||
+9
-5
@@ -38,13 +38,17 @@ sudo systemctl enable taxbaik
|
||||
### 4. Nginx 설정
|
||||
|
||||
```bash
|
||||
# 현재 Nginx 설정 확인
|
||||
sudo cat /etc/nginx/sites-available/default | head -30
|
||||
# Nginx 도메인 기반 가상 호스트 설정 복사
|
||||
sudo cp deploy/nginx-taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf
|
||||
|
||||
# location 블록 추가 (또는 기존 설정에 병합)
|
||||
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
|
||||
# 기존 설정(IP 기반 및 default) 활성화 해제
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo rm -f /etc/nginx/sites-enabled/gitea-ip.conf
|
||||
|
||||
# 테스트 및 재로드
|
||||
# 새 설정 활성화 (심링크 생성)
|
||||
sudo ln -sfn /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-enabled/taxbaik-domains.conf
|
||||
|
||||
# 설정 문법 테스트 및 Nginx 서비스 리로드
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
@@ -26,7 +26,7 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위
|
||||
|-----|------|
|
||||
| **백엔드** | ASP.NET Core 10, C# |
|
||||
| **공개 사이트** | Razor Pages (SSR) |
|
||||
| **관리자** | Blazor Server + Fluent UI Blazor v5 |
|
||||
| **관리자** | Blazor Server + MudBlazor |
|
||||
| **데이터베이스** | PostgreSQL 18.4 |
|
||||
| **ORM** | Dapper |
|
||||
| **리버스 프록시** | Nginx |
|
||||
@@ -98,14 +98,6 @@ TaxBaik/
|
||||
- 연락처 정보
|
||||
- 소셜 미디어 링크
|
||||
|
||||
- **UI 기준**
|
||||
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/`
|
||||
- 기본 로딩 상태는 `Skeleton`
|
||||
- MudBlazor는 레거시 폐기 대상이며 신규 UI에 사용하지 않음
|
||||
- `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이며 신규 UI에 사용하지 않음
|
||||
- 사이트와 관리자는 `design-tokens.css` / `ui-primitives.css`를 공유
|
||||
- Blazor 라우트는 중복 선언하지 않고 단일 엔트리 기준으로 관리
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
@@ -522,3 +522,46 @@ Todo:
|
||||
- WBS-UX-03/04 구현 완료
|
||||
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
|
||||
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
|
||||
|
||||
---
|
||||
|
||||
## ── 홈페이지 · 어드민 · 포털 프리미엄 UX/UI 개편 (2026-06-30) ──────────────────
|
||||
|
||||
## WBS-UX-05 홈페이지 프리미엄 UI 및 마이크로 인터랙션
|
||||
|
||||
목표: 홈페이지 디자인을 극도로 모던하고 신뢰성 있는 프리미엄 스타일로 전면 개편한다.
|
||||
|
||||
성공 기준:
|
||||
- Hero 섹션에 유려한 배경 그라데이션 및 부드러운 CSS 애니메이션 효과 적용
|
||||
- 서비스 카드에 섀도우 및 보더 트랜지션, 골드/그린 그라데이션 호버 이펙트 추가
|
||||
- 신뢰도 스트립 카드에 입체감 및 돋보이는 레이아웃 설계
|
||||
- Noto Sans KR 외에 Outfit/Inter 등의 보조 영문 폰트 결합으로 타이포그래피 고급화
|
||||
|
||||
Todo:
|
||||
- [x] `site.css` 내 Hero 섹션 그라데이션 및 CSS 애니메이션 보강
|
||||
- [x] 서비스 카드 및 신뢰도 스트립 컴포넌트 프리미엄 스타일로 개편
|
||||
- [x] 홈페이지 폰트 스택 확장 및 메인 레이아웃 적용
|
||||
|
||||
## WBS-PORTAL-01 고객 포털 UI/UX 고도화 및 글래스모피즘
|
||||
|
||||
목표: 고객 마이 포털 화면을 미려하고 현대적인 글래스모피즘 디자인으로 개편하여 이용 가치를 극대화한다.
|
||||
|
||||
성공 기준:
|
||||
- 포털 메인 대시보드 카드를 Glassmorphism 스타일(blur, semi-transparent border)로 변경
|
||||
- 세무 신고 현황 테이블 및 상담 이력 타임라인 컴포넌트의 모던 디자인화
|
||||
|
||||
Todo:
|
||||
- [x] `site.css` 내 포털 전용 모던 글래스모피즘 클래스군 추가
|
||||
- [x] `Portal/Index.cshtml` 레이아웃 및 컴포넌트 UI 고도화
|
||||
|
||||
## WBS-MAINT-02 코드 품질 및 경고 결함 차단
|
||||
|
||||
목표: 빌드 컴파일 타임 경고(Warnings)를 0으로 유지하여 미래 코드 결함을 방지한다.
|
||||
|
||||
성공 기준:
|
||||
- `dotnet build` 수행 시 경고 0개 달성
|
||||
|
||||
Todo:
|
||||
- [x] `CustomAuthenticationStateProvider.cs` Nullable 경고 수정
|
||||
- [x] `Dashboard.razor` 미사용 변수 제거 및 UI 연계 바인딩 처리
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<RevenueTrackingService>();
|
||||
services.AddScoped<TelegramReportService>();
|
||||
services.AddScoped<PortalUserService>();
|
||||
services.AddScoped<CommonCodeService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
|
||||
{
|
||||
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetByGroupAsync(codeGroup, ct);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetAllActiveAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,6 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<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);
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ public class TaxProfileService(ITaxProfileRepository repository)
|
||||
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
||||
{
|
||||
var profile = new TaxProfile { Id = profileId };
|
||||
var profile = await repository.GetByIdAsync(profileId, ct);
|
||||
if (profile == null)
|
||||
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(businessType))
|
||||
profile.BusinessType = businessType.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class CommonCode
|
||||
{
|
||||
public string CodeGroup { get; set; } = string.Empty;
|
||||
public string CodeValue { get; set; } = string.Empty;
|
||||
public string CodeName { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
public interface ICommonCodeRepository
|
||||
{
|
||||
Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default);
|
||||
Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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);
|
||||
|
||||
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
|
||||
public interface ITaxProfileRepository
|
||||
{
|
||||
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||
Task<TaxProfile?> GetByIdAsync(int id, 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);
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
||||
services.AddScoped<IContractRepository, ContractRepository>();
|
||||
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
||||
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
public class CommonCodeRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICommonCodeRepository
|
||||
{
|
||||
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<CommonCode>(
|
||||
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
|
||||
FROM common_codes
|
||||
WHERE code_group = @CodeGroup AND is_active = TRUE
|
||||
ORDER BY sort_order",
|
||||
new { CodeGroup = codeGroup });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<CommonCode>(
|
||||
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
|
||||
FROM common_codes
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY code_group, sort_order");
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,6 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
|
||||
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();
|
||||
|
||||
@@ -20,6 +20,17 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
|
||||
profile);
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
class Program
|
||||
{
|
||||
private const string PortFile = "/home/kjh2064/taxbaik_port";
|
||||
private static int _fallbackPort = 5003;
|
||||
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
// Allow setting fallback port via args
|
||||
if (args.Length > 0 && int.TryParse(args[0], out var port))
|
||||
{
|
||||
_fallbackPort = port;
|
||||
}
|
||||
|
||||
var listener = new TcpListener(IPAddress.Loopback, 5001);
|
||||
listener.Start();
|
||||
Console.WriteLine($"[TaxBaik Proxy] Listening on 127.0.0.1:5001 (Forwarding to target in {PortFile})");
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync();
|
||||
_ = HandleClientAsync(client);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[TaxBaik Proxy] Accept error: {ex.Message}");
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetTargetPort()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(PortFile))
|
||||
{
|
||||
var content = File.ReadAllText(PortFile).Trim();
|
||||
if (int.TryParse(content, out var port) && port > 1024 && port < 65535)
|
||||
{
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return _fallbackPort;
|
||||
}
|
||||
|
||||
private static async Task HandleClientAsync(TcpClient client)
|
||||
{
|
||||
client.NoDelay = true;
|
||||
int targetPort = GetTargetPort();
|
||||
using var backend = new TcpClient();
|
||||
backend.NoDelay = true;
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await backend.ConnectAsync(IPAddress.Loopback, targetPort, cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[TaxBaik Proxy] Failed to connect to backend on port {targetPort}: {ex.Message}");
|
||||
client.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var clientStream = client.GetStream();
|
||||
using var backendStream = backend.GetStream();
|
||||
|
||||
var toBackend = clientStream.CopyToAsync(backendStream);
|
||||
var toClient = backendStream.CopyToAsync(clientStream);
|
||||
|
||||
await Task.WhenAny(toBackend, toClient);
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
client.Close();
|
||||
backend.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +1,4 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@@ -7,11 +6,9 @@
|
||||
<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/css2?family=Noto+Sans+KR:wght@400;500;700&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 href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
<script>
|
||||
document.documentElement.classList.toggle(
|
||||
'admin-login-route',
|
||||
@@ -34,15 +31,98 @@
|
||||
<p>로드 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FluentProviders />
|
||||
<FluentDialogProvider />
|
||||
<FluentTooltipProvider />
|
||||
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: true)" />
|
||||
|
||||
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
||||
<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 = "6px"
|
||||
},
|
||||
Typography = new Typography()
|
||||
{
|
||||
Default = new Default()
|
||||
{
|
||||
FontSize = ".8125rem",
|
||||
FontWeight = 400,
|
||||
LineHeight = 1.5
|
||||
},
|
||||
H1 = new H1()
|
||||
{
|
||||
FontSize = "1.75rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.2
|
||||
},
|
||||
H2 = new H2()
|
||||
{
|
||||
FontSize = "1.5rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.3
|
||||
},
|
||||
H3 = new H3()
|
||||
{
|
||||
FontSize = "1.25rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.3
|
||||
},
|
||||
H4 = new H4()
|
||||
{
|
||||
FontSize = "1.1rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.4
|
||||
},
|
||||
H5 = new H5()
|
||||
{
|
||||
FontSize = "0.95rem",
|
||||
FontWeight = 500,
|
||||
LineHeight = 1.4
|
||||
},
|
||||
H6 = new H6()
|
||||
{
|
||||
FontSize = "0.85rem",
|
||||
FontWeight = 500,
|
||||
LineHeight = 1.5
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
@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>
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText>정말로 삭제하시겠습니까?</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@Cancel">취소</MudButton>
|
||||
<MudButton Color="Color.Error" OnClick="@Confirm">삭제</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||
[CascadingParameter] MudDialogInstance? MudDialog { get; set; }
|
||||
|
||||
Task Cancel() => OnCancel.InvokeAsync();
|
||||
Task Confirm() => OnConfirm.InvokeAsync();
|
||||
void Cancel() => MudDialog?.Cancel();
|
||||
void Confirm() => MudDialog?.Close(DialogResult.Ok(true));
|
||||
}
|
||||
|
||||
@@ -1,28 +1,49 @@
|
||||
@using TaxBaik.Application.Services
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
|
||||
<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>
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true"
|
||||
HelperText="영문/숫자, 최대 50자" />
|
||||
|
||||
<MudTextField @bind-Value="model.CompanyName" Label="회사명"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<MudTextField @bind-Value="model.ContactPerson" Label="담당자명"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="model.Phone" Label="전화번호"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
|
||||
|
||||
<MudTextField @bind-Value="model.Memo" Label="메모"
|
||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||
|
||||
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
|
||||
@ButtonText
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
|
||||
</div>
|
||||
</form>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
|
||||
[Parameter] public EventCallback<CompanyFormModel> OnSubmit { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
[Parameter] public CompanyFormModel? InitialData { get; set; }
|
||||
[Parameter, EditorRequired]
|
||||
public string ButtonText { get; set; } = "저장";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CompanyFormModel> OnSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public CompanyFormModel? InitialData { get; set; }
|
||||
|
||||
private MudForm? form;
|
||||
private CompanyFormModel model = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
@@ -42,7 +63,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
if (form == null)
|
||||
return;
|
||||
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
return;
|
||||
|
||||
await OnSubmit.InvokeAsync(model);
|
||||
}
|
||||
|
||||
public class CompanyFormModel
|
||||
{
|
||||
|
||||
@@ -1,38 +1,61 @@
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
|
||||
<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" />
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Name" Label="이름"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
|
||||
<div class="admin-form-actions">
|
||||
<button type="submit" class="admin-login-submit">@ButtonText</button>
|
||||
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
|
||||
<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>
|
||||
</form>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
|
||||
[Parameter] public EventCallback<InquiryFormModel> OnSubmit { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
[Parameter] public InquiryFormModel? InitialData { get; set; }
|
||||
[Parameter, EditorRequired]
|
||||
public string ButtonText { get; set; } = "저장";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<InquiryFormModel> OnSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public InquiryFormModel? InitialData { get; set; }
|
||||
|
||||
private MudForm? form;
|
||||
private InquiryFormModel model = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
@@ -52,7 +75,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
if (form == null)
|
||||
return;
|
||||
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
return;
|
||||
|
||||
await OnSubmit.InvokeAsync(model);
|
||||
}
|
||||
|
||||
public class InquiryFormModel
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table mt-4">
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
@@ -19,19 +18,22 @@
|
||||
<td>@inquiry.Phone</td>
|
||||
<td>@inquiry.ServiceType</td>
|
||||
<td>
|
||||
<span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span>
|
||||
<MudChip Size="Size.Small" Color="@GetStatusColor(inquiry.Status)">
|
||||
@GetStatusLabel(inquiry.Status)
|
||||
</MudChip>
|
||||
</td>
|
||||
<td>@GetPreview(inquiry.Message)</td>
|
||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||
<td>
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</MudSimpleTable>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
@@ -64,14 +66,14 @@
|
||||
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
|
||||
}
|
||||
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
private static Color GetStatusColor(string status) => status switch
|
||||
{
|
||||
"new" => "warning",
|
||||
"consulting" => "info",
|
||||
"contracted" => "success",
|
||||
"rejected" => "danger",
|
||||
"closed" => "muted",
|
||||
_ => "muted"
|
||||
"new" => Color.Warning,
|
||||
"consulting" => Color.Info,
|
||||
"contracted" => Color.Success,
|
||||
"rejected" => Color.Error,
|
||||
"closed" => Color.Dark,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||
|
||||
@@ -3,86 +3,104 @@
|
||||
@inject IJSRuntime JS
|
||||
@implements IDisposable
|
||||
|
||||
<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>
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<div class="admin-topbar-title">
|
||||
<span class="admin-topbar-kicker">TaxBaik Admin</span>
|
||||
<h1>세무회계 관리 대시보드</h1>
|
||||
<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-topbar-title" style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudText Typo="Typo.body2" Class="font-weight-bold" Style="color: var(--primary-color);">[TaxBaik]</MudText>
|
||||
<MudText Typo="Typo.body2" Style="font-weight: bold; color: #1E293B;">세무회계 관리 대시보드</MudText>
|
||||
</div>
|
||||
<MudSpacer />
|
||||
|
||||
<!-- 상단 액션 바 -->
|
||||
<div class="admin-topbar-actions">
|
||||
<a class="admin-topbar-action" href="/taxbaik" target="_blank" rel="noreferrer">
|
||||
<span class="material-icons">open_in_new</span>
|
||||
공개 사이트
|
||||
</a>
|
||||
<a class="admin-topbar-action danger" href="/taxbaik/admin/logout">
|
||||
<span class="material-icons">logout</span>
|
||||
로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<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">
|
||||
공개 사이트
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
|
||||
<aside class="@DrawerClass">
|
||||
<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">
|
||||
로그아웃
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudAppBar>
|
||||
|
||||
<MudDrawer @bind-open="@drawerOpen"
|
||||
Elevation="0"
|
||||
Variant="DrawerVariant.Responsive"
|
||||
Breakpoint="Breakpoint.Md"
|
||||
Class="admin-drawer">
|
||||
<div class="admin-drawer-brand">
|
||||
<div class="admin-brand-mark">T</div>
|
||||
<div>
|
||||
<div class="admin-brand-title">TaxBaik</div>
|
||||
<div class="admin-brand-subtitle">세무 운영 콘솔</div>
|
||||
<MudText Typo="Typo.subtitle1">TaxBaik</MudText>
|
||||
<MudText Typo="Typo.caption">세무 운영 콘솔</MudText>
|
||||
</div>
|
||||
</div>
|
||||
<MudNavMenu Class="admin-nav">
|
||||
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
||||
|
||||
<nav class="admin-nav">
|
||||
<a href="/taxbaik/admin/dashboard" class="admin-nav-link">대시보드</a>
|
||||
<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>
|
||||
|
||||
<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.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>
|
||||
<summary>고객 관리</summary>
|
||||
<a href="/taxbaik/admin/clients" class="admin-nav-link">고객 카드</a>
|
||||
<a href="/taxbaik/admin/tax-filings" 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/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>
|
||||
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
||||
</MudNavMenu>
|
||||
</MudDrawer>
|
||||
|
||||
<a href="/taxbaik/admin/inquiries" class="admin-nav-link">문의 관리</a>
|
||||
<a href="/taxbaik/admin/settings" class="admin-nav-link">설정</a>
|
||||
</nav>
|
||||
|
||||
<div class="admin-drawer-footer">
|
||||
<div class="admin-footer-item">
|
||||
<span class="material-icons">shield</span>
|
||||
<span>보안 모드</span>
|
||||
</div>
|
||||
<div class="admin-footer-meta">Fluent UI Blazor 기반 관리자 콘솔</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-content">
|
||||
<div class="admin-content-inner">
|
||||
<MudMainContent Class="admin-main">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="admin-content">
|
||||
@Body
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private bool drawerOpen = true;
|
||||
private bool expandedCRMGroup = true;
|
||||
private bool expandedCustomerGroup = false;
|
||||
private bool expandedWebsiteGroup = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -99,14 +117,15 @@
|
||||
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,47 +5,101 @@
|
||||
@using TaxBaik.Web.Services
|
||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Homepage</div>
|
||||
<h1 class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</h1>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</MudText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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()
|
||||
{
|
||||
@@ -61,15 +115,15 @@
|
||||
}
|
||||
model = new AnnouncementDto
|
||||
{
|
||||
Id = entity.Id,
|
||||
Title = entity.Title,
|
||||
Content = entity.Content,
|
||||
Id = entity.Id,
|
||||
Title = entity.Title,
|
||||
Content = entity.Content,
|
||||
DisplayType = entity.DisplayType,
|
||||
IsActive = entity.IsActive,
|
||||
SortOrder = entity.SortOrder
|
||||
IsActive = entity.IsActive,
|
||||
SortOrder = entity.SortOrder
|
||||
};
|
||||
startsAtDate = entity.StartsAt?.ToLocalTime();
|
||||
endsAtDate = entity.EndsAt?.ToLocalTime();
|
||||
endsAtDate = entity.EndsAt?.ToLocalTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -80,18 +134,41 @@
|
||||
|
||||
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;
|
||||
var result = Id.HasValue ? await AnnouncementClient.UpdateAsync(Id.Value, model) : await AnnouncementClient.CreateAsync(model);
|
||||
await JS.InvokeVoidAsync("alert", result != null ? "공지사항이 저장되었습니다." : "저장 실패");
|
||||
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);
|
||||
}
|
||||
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -4,77 +4,90 @@
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>공지사항 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Homepage</div>
|
||||
<h1 class="admin-page-title">공지사항 관리</h1>
|
||||
<p class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</p>
|
||||
<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>
|
||||
<a class="site-button primary" href="/taxbaik/admin/announcements/create">공지 등록</a>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/taxbaik/admin/announcements/create">
|
||||
공지 등록
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (announcements is null)
|
||||
{
|
||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (!announcements.Any())
|
||||
{
|
||||
<div class="muted">등록된 공지사항이 없습니다.</div>
|
||||
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th>유형</th>
|
||||
<th>상태</th>
|
||||
<th>게시 기간</th>
|
||||
<th>순서</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in announcements)
|
||||
{
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th>유형</th>
|
||||
<th>상태</th>
|
||||
<th>게시 기간</th>
|
||||
<th>순서</th>
|
||||
<th></th>
|
||||
<td>@item.Title</td>
|
||||
<td>
|
||||
<MudChip Size="Size.Small" Color="@GetTypeColor(item.DisplayType)">
|
||||
@GetTypeLabel(item.DisplayType)
|
||||
</MudChip>
|
||||
</td>
|
||||
<td>
|
||||
@if (IsCurrentlyActive(item))
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
|
||||
}
|
||||
else if (!item.IsActive)
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Warning">기간 외</MudChip>
|
||||
}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in announcements)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Title</td>
|
||||
<td><span class="status-pill info">@GetTypeLabel(item.DisplayType)</span></td>
|
||||
<td>
|
||||
@if (IsCurrentlyActive(item))
|
||||
{
|
||||
<span class="status-pill success">노출 중</span>
|
||||
}
|
||||
else if (!item.IsActive)
|
||||
{
|
||||
<span class="status-pill default">비활성</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="status-pill warning">기간 외</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@FormatPeriod(item)</td>
|
||||
<td>@item.SortOrder</td>
|
||||
<td>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
@@ -84,13 +97,16 @@
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,32 +119,36 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
announcements = [];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Announcement item)
|
||||
{
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Title}' 공지를 삭제하시겠습니까?");
|
||||
if (!confirmed) return;
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"공지 삭제",
|
||||
$"'{item.Title}' 공지를 삭제하시겠습니까?",
|
||||
yesText: "삭제", cancelText: "취소");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var success = await AnnouncementClient.DeleteAsync(item.Id);
|
||||
if (success)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "공지사항이 삭제되었습니다.");
|
||||
Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
||||
Snackbar.Add("삭제 실패", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,21 +157,28 @@
|
||||
if (!a.IsActive) return false;
|
||||
var now = DateTime.UtcNow;
|
||||
if (a.StartsAt.HasValue && a.StartsAt > now) return false;
|
||||
if (a.EndsAt.HasValue && a.EndsAt < now) return false;
|
||||
if (a.EndsAt.HasValue && a.EndsAt < now) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatPeriod(Announcement a)
|
||||
{
|
||||
var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시";
|
||||
var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한";
|
||||
var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한";
|
||||
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" => "긴급",
|
||||
"banner" => "배너",
|
||||
_ => "일반"
|
||||
_ => "일반"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,53 +6,77 @@
|
||||
@inject BlogService BlogService
|
||||
@inject ICategoryRepository CategoryRepository
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>새 포스트 작성</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Content</div>
|
||||
<h1 class="admin-page-title">새 포스트 작성</h1>
|
||||
<p class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
<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)
|
||||
{
|
||||
<option value="@category.Id.ToString()">@category.Name</option>
|
||||
}
|
||||
</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>
|
||||
<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 T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
</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
|
||||
@@ -66,12 +90,12 @@
|
||||
IsPublished = model.IsPublished
|
||||
});
|
||||
|
||||
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
|
||||
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,60 +6,76 @@
|
||||
@inject BlogService BlogService
|
||||
@inject ICategoryRepository CategoryRepository
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>포스트 수정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Content</div>
|
||||
<h1 class="admin-page-title">포스트 수정</h1>
|
||||
<p class="admin-page-subtitle">블로그 포스트를 수정합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (post == null)
|
||||
{
|
||||
<div class="admin-surface mt-4">포스트를 찾을 수 없습니다.</div>
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<option value="@category.Id.ToString()">@category.Name</option>
|
||||
}
|
||||
</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>
|
||||
<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 T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private MudForm? form;
|
||||
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()
|
||||
{
|
||||
@@ -74,7 +90,7 @@ else
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"포스트 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -93,9 +109,20 @@ else
|
||||
model.IsPublished = post.IsPublished;
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||
}
|
||||
|
||||
private async Task SavePost()
|
||||
{
|
||||
if (post == null) return;
|
||||
if (form == null || post == null)
|
||||
return;
|
||||
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
|
||||
@@ -108,22 +135,43 @@ else
|
||||
SeoDescription = model.SeoDescription,
|
||||
IsPublished = model.IsPublished
|
||||
});
|
||||
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
|
||||
|
||||
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeletePost()
|
||||
{
|
||||
if (post == null) return;
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
|
||||
await BlogService.DeleteAsync(post.Id);
|
||||
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
|
||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||
if (post == null)
|
||||
return;
|
||||
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"포스트 삭제",
|
||||
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"삭제", "취소");
|
||||
|
||||
if (result != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await BlogService.DeleteAsync(post.Id);
|
||||
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private class EditPostModel
|
||||
|
||||
@@ -1,72 +1,58 @@
|
||||
@page "/admin/blog"
|
||||
@attribute [Authorize]
|
||||
@inject IApiClient ApiClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>블로그 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Content</div>
|
||||
<h1 class="admin-page-title">블로그 관리</h1>
|
||||
<p class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote"
|
||||
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface mb-4">
|
||||
<div class="admin-summary-bar">
|
||||
<span>전체 포스트: @($"{totalPosts}개")</span>
|
||||
<span>페이지 @currentPage / @totalPages</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
@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>
|
||||
<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-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>
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||
private bool isLoading = true;
|
||||
private int currentPage = 1;
|
||||
@@ -76,19 +62,20 @@
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadPosts();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadPosts();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string NavTo(string url) => url;
|
||||
|
||||
private async Task LoadPosts()
|
||||
{
|
||||
isLoading = true;
|
||||
@@ -105,33 +92,58 @@
|
||||
totalPosts = 0;
|
||||
totalPages = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadPosts(); } }
|
||||
private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadPosts(); } }
|
||||
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 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;
|
||||
await JS.InvokeVoidAsync("alert", "발행 상태 변경에 실패했습니다.");
|
||||
Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
await JS.InvokeVoidAsync("alert", "발행 상태가 변경되었습니다.");
|
||||
|
||||
Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task DeletePost(int postId)
|
||||
{
|
||||
await ApiClient.DeleteAsync($"blog/{postId}");
|
||||
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
|
||||
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,123 +4,185 @@
|
||||
@inject ClientService ClientService
|
||||
@inject ConsultationService ConsultationService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>고객 상세</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Client Details</div>
|
||||
<h1 class="admin-page-title">고객 상세</h1>
|
||||
<p class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@if (client == null)
|
||||
{
|
||||
<div class="admin-surface mt-4">고객을 찾을 수 없습니다.</div>
|
||||
<MudText>고객을 찾을 수 없습니다.</MudText>
|
||||
return;
|
||||
}
|
||||
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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
@if (!string.IsNullOrWhiteSpace(client.Memo))
|
||||
{
|
||||
<div class="span-2"><span>메모</span><strong style="white-space: pre-wrap;">@client.Memo</strong></div>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">메모</MudText>
|
||||
<MudText Style="white-space: pre-wrap;">@client.Memo</MudText>
|
||||
</MudItem>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@if (showAddForm)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</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)
|
||||
{
|
||||
<option value="@r">@r</option>
|
||||
}
|
||||
</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>
|
||||
<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="서비스 분야">
|
||||
@foreach (var t in ClientService.ServiceTypes)
|
||||
{
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
@foreach (var r in ConsultationService.Results)
|
||||
{
|
||||
<MudSelectItem Value="@r">@r</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
|
||||
@if (consultations.Count == 0)
|
||||
{
|
||||
<p class="muted">상담 이력이 없습니다.</p>
|
||||
<MudText Color="Color.Secondary">상담 이력이 없습니다.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-activity-list">
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var c in consultations)
|
||||
{
|
||||
<article class="admin-activity-card">
|
||||
<div class="admin-activity-head">
|
||||
<div>
|
||||
<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))
|
||||
{
|
||||
<span class="status-pill info">@c.Result</span>
|
||||
}
|
||||
@if (c.Fee.HasValue)
|
||||
{
|
||||
<div class="muted">수임료: @c.Fee.Value.ToString("N0")원</div>
|
||||
}
|
||||
</article>
|
||||
<MudListItem>
|
||||
<MudPaper Class="pa-3" Outlined="true" Style="width:100%">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween">
|
||||
<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>
|
||||
@if (!string.IsNullOrEmpty(c.Result))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="mt-1">@c.Result</MudChip>
|
||||
}
|
||||
@if (c.Fee.HasValue)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
|
||||
수임료: @c.Fee.Value.ToString("N0")원
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(() => DeleteConsultation(c.Id))" />
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudListItem>
|
||||
}
|
||||
</div>
|
||||
</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 = "";
|
||||
@@ -128,10 +190,10 @@ else
|
||||
private string newResult = "";
|
||||
private decimal? newFee;
|
||||
|
||||
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();
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task LoadAll()
|
||||
{
|
||||
@@ -153,12 +215,6 @@ else
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newSummary))
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "상담 내용을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
var c = new Domain.Entities.Consultation
|
||||
{
|
||||
ClientId = ClientId,
|
||||
@@ -168,23 +224,21 @@ else
|
||||
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
|
||||
Fee = newFee
|
||||
};
|
||||
|
||||
await ConsultationService.CreateAsync(c);
|
||||
showAddForm = false;
|
||||
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
||||
await JS.InvokeVoidAsync("alert", "상담이 추가되었습니다.");
|
||||
Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteConsultation(int id)
|
||||
{
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "이 상담을 삭제하시겠습니까?")) return;
|
||||
await ConsultationService.DeleteAsync(id);
|
||||
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
||||
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
|
||||
Snackbar.Add("삭제되었습니다.", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,74 +6,117 @@
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">CRM</div>
|
||||
<h1 class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</h1>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText>
|
||||
</div>
|
||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
|
||||
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface" style="max-width:720px;">
|
||||
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
||||
@if (isLoading)
|
||||
{
|
||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>세금 유형
|
||||
<select class="admin-input" @bind="dto.TaxType">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach (var t in ClientService.TaxTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</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)
|
||||
{
|
||||
<option value="@s">@s</option>
|
||||
}
|
||||
</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>
|
||||
<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">
|
||||
@foreach (var t in ClientService.ServiceTypes)
|
||||
{
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true">
|
||||
@foreach (var t in ClientService.TaxTypes)
|
||||
{
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</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">
|
||||
@foreach (var s in ClientService.Sources)
|
||||
{
|
||||
<MudSelectItem Value="@s">@s</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@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;
|
||||
|
||||
@@ -86,7 +129,7 @@
|
||||
var client = await ClientClient.GetByIdAsync(Id.Value);
|
||||
if (client is null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "고객을 찾을 수 없습니다.");
|
||||
Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error);
|
||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||
return;
|
||||
}
|
||||
@@ -102,42 +145,46 @@
|
||||
Source = client.Source,
|
||||
Memo = client.Memo
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||
return;
|
||||
}
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
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);
|
||||
await JS.InvokeVoidAsync("alert", result != null ? "고객 정보가 수정되었습니다." : "수정에 실패했습니다.");
|
||||
if (result != null)
|
||||
Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("수정에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await ClientClient.CreateAsync(dto);
|
||||
await JS.InvokeVoidAsync("alert", result != null ? "고객이 등록되었습니다." : "등록에 실패했습니다.");
|
||||
if (result != null)
|
||||
Snackbar.Add("고객이 등록되었습니다.", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -4,94 +4,134 @@
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>고객 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">CRM</div>
|
||||
<h1 class="admin-page-title">고객 관리</h1>
|
||||
<p class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients/create")'>고객 등록</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Href="/taxbaik/admin/clients/create">
|
||||
고객 등록
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<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 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">
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (clients is null)
|
||||
{
|
||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (!clients.Any())
|
||||
{
|
||||
<div class="muted mt-4">등록된 고객이 없습니다.</div>
|
||||
<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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>회사명</th>
|
||||
<th>연락처</th>
|
||||
<th>서비스</th>
|
||||
<th>세금 유형</th>
|
||||
<th>상태</th>
|
||||
<th>유입 경로</th>
|
||||
<th>등록일</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in clients)
|
||||
{
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>회사명</th>
|
||||
<th>연락처</th>
|
||||
<th>서비스</th>
|
||||
<th>세금 유형</th>
|
||||
<th>상태</th>
|
||||
<th>유입 경로</th>
|
||||
<th>등록일</th>
|
||||
<th></th>
|
||||
<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.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.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>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in clients)
|
||||
{
|
||||
<tr>
|
||||
<td><strong>@c.Name</strong></td>
|
||||
<td>@(c.CompanyName ?? "—")</td>
|
||||
<td>@(c.Phone ?? "—")</td>
|
||||
<td>@(c.ServiceType ?? "—")</td>
|
||||
<td>@(c.TaxType ?? "—")</td>
|
||||
<td>@(c.Status == "active" ? "활성" : "비활성")</td>
|
||||
<td>@(c.Source ?? "—")</td>
|
||||
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
|
||||
<td>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
|
||||
@* 페이징 *@
|
||||
@if (totalPages > 1)
|
||||
{
|
||||
<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 class="d-flex justify-center pa-3">
|
||||
<MudPagination BoundaryCount="1" MiddleCount="3"
|
||||
Count="@totalPages" Selected="@currentPage"
|
||||
SelectedChanged="@OnPageChanged" />
|
||||
</div>
|
||||
}
|
||||
<div class="admin-table-footer">총 @(totalCount)명</div>
|
||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Client>? clients;
|
||||
private string searchText = "";
|
||||
private string statusFilter = "";
|
||||
@@ -102,13 +142,16 @@
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,39 +160,75 @@
|
||||
{
|
||||
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)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
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 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 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 DeleteAsync(Client client)
|
||||
{
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.");
|
||||
if (!confirmed) return;
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"고객 삭제",
|
||||
$"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.",
|
||||
yesText: "삭제", cancelText: "취소");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var success = await ClientClient.DeleteAsync(client.Id);
|
||||
if (success)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"{client.Name} 고객이 삭제되었습니다.");
|
||||
Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("삭제에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject IApiClient ApiClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>고객사 등록</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Settings</div>
|
||||
<h1 class="admin-page-title">새 고객사 등록</h1>
|
||||
<p class="admin-page-subtitle">새로운 고객사를 추가합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface mt-4">
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private void GoBack()
|
||||
@@ -40,12 +40,12 @@
|
||||
memo = model.Memo
|
||||
});
|
||||
|
||||
await JS.InvokeVoidAsync("alert", "고객사가 등록되었습니다.");
|
||||
Snackbar.Add("고객사가 등록되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
|
||||
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,37 +3,39 @@
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject IApiClient ApiClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>고객사 수정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Settings</div>
|
||||
<h1 class="admin-page-title">고객사 수정</h1>
|
||||
<p class="admin-page-subtitle">고객사 정보를 수정합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="admin-surface mt-4">
|
||||
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
||||
</div>
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (formModel == null)
|
||||
{
|
||||
<div class="admin-surface mt-4">고객사를 찾을 수 없습니다.</div>
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-surface mt-4">
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||
<div class="mt-4">
|
||||
<button type="button" class="site-button secondary danger" @onclick="DeleteCompany">고객사 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
|
||||
고객사 삭제
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -65,7 +67,7 @@ else
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -93,29 +95,34 @@ else
|
||||
isActive = model.IsActive
|
||||
});
|
||||
|
||||
await JS.InvokeVoidAsync("alert", "고객사가 수정되었습니다.");
|
||||
Snackbar.Add("고객사가 수정되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
|
||||
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteCompany()
|
||||
{
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."))
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"고객사 삭제",
|
||||
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"삭제", "취소");
|
||||
|
||||
if (result != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await ApiClient.DeleteAsync($"company/{Id}");
|
||||
await JS.InvokeVoidAsync("alert", "고객사가 삭제되었습니다.");
|
||||
Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,53 @@
|
||||
@page "/admin/companies"
|
||||
@attribute [Authorize]
|
||||
@inject IApiClient ApiClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>고객사 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Settings</div>
|
||||
<h1 class="admin-page-title">고객사 관리</h1>
|
||||
<p class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/companies/create")'>새 고객사 등록</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface mb-4 mt-4">
|
||||
<div class="admin-summary-bar">
|
||||
<span>@($"전체 고객사 {totalCompanies}개")</span>
|
||||
<span>페이지 @currentPage / @totalPages</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
@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>
|
||||
<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-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>
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
private List<CompanyDto> companies = [];
|
||||
@@ -118,7 +100,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -149,6 +131,4 @@
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
private string NavTo(string url) => url;
|
||||
}
|
||||
|
||||
@@ -2,122 +2,150 @@
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject IConsultingActivityBrowserClient ActivityClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>상담 활동 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||
<h1 class="admin-page-title">상담 활동 관리</h1>
|
||||
<p class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 활동 기록</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
새 활동 기록
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (activities is null)
|
||||
{
|
||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (activities.Count == 0)
|
||||
{
|
||||
<div class="muted">상담 활동이 없습니다.</div>
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="me-2" />
|
||||
상담 활동이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
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 activities)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<MudDataGrid T="ConsultingActivity"
|
||||
Items="@activities"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.ActivityType" Title="활동 유형" />
|
||||
<PropertyColumn Property="x => x.ActivityDate" Title="활동일시" Format="g" />
|
||||
<TemplateColumn Title="설명">
|
||||
<CellTemplate>
|
||||
@{
|
||||
var desc = context.Item.Description ?? "";
|
||||
if (desc.Length > 30) desc = desc.Substring(0, 30) + "...";
|
||||
}
|
||||
<span>@desc</span>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="다음 팔로업">
|
||||
<CellTemplate>
|
||||
@if (context.Item.NextFollowupDate.HasValue)
|
||||
{
|
||||
var daysLeft = (context.Item.NextFollowupDate.Value.Date - DateTime.Today).Days;
|
||||
<MudChip Size="Size.Small"
|
||||
Color="@(daysLeft < 0 ? Color.Error : daysLeft <= 3 ? Color.Warning : Color.Success)"
|
||||
Variant="Variant.Filled">
|
||||
@context.Item.NextFollowupDate.Value.ToString("yyyy-MM-dd")
|
||||
</MudChip>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteActivity(context.Item.Id))" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
<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>
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
|
||||
<MudSelectItem Value="@("방문 상담")">방문 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("전화 상담")">전화 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조사 대응 미팅")">세무조사 대응 미팅</MudSelectItem>
|
||||
<MudSelectItem Value="@("카카오톡 상담")">카카오톡 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("이메일 자료 접수")">이메일 자료 접수</MudSelectItem>
|
||||
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveActivity">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
[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();
|
||||
|
||||
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)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,14 +161,18 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
editingActivity = null;
|
||||
activityForm = new ConsultingActivityForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, ActivityDate = DateTime.Now };
|
||||
activityForm = new ConsultingActivityForm
|
||||
{
|
||||
ActivityDate = DateTime.Now,
|
||||
ClientId = clients.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -156,60 +188,103 @@
|
||||
NextFollowupDate = activity.NextFollowupDate
|
||||
};
|
||||
isDialogOpen = true;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveActivity()
|
||||
{
|
||||
if (activityForm.ClientId <= 0 || string.IsNullOrWhiteSpace(activityForm.ActivityType) || string.IsNullOrWhiteSpace(activityForm.Description))
|
||||
if (form != null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
|
||||
return;
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (editingActivity == null)
|
||||
{
|
||||
var newId = await ActivityClient.CreateAsync(activityForm.ClientId, activityForm.ActivityType, activityForm.ActivityDate ?? DateTime.Now, activityForm.Description, null, activityForm.NextFollowupDate);
|
||||
var actDate = activityForm.ActivityDate ?? DateTime.Now;
|
||||
var newId = await ActivityClient.CreateAsync(
|
||||
activityForm.ClientId,
|
||||
activityForm.ActivityType,
|
||||
actDate,
|
||||
activityForm.Description,
|
||||
null,
|
||||
activityForm.NextFollowupDate);
|
||||
|
||||
if (newId > 0)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "활동이 기록되었습니다.");
|
||||
Snackbar.Add("활동이 기록되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await ActivityClient.UpdateAsync(editingActivity.Id, null, activityForm.NextFollowupDate);
|
||||
await JS.InvokeVoidAsync("alert", "활동이 업데이트되었습니다.");
|
||||
await ActivityClient.UpdateAsync(
|
||||
editingActivity.Id,
|
||||
null,
|
||||
activityForm.NextFollowupDate);
|
||||
|
||||
Snackbar.Add("활동이 업데이트되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteActivity(int id)
|
||||
{
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "이 활동을 삭제하시겠습니까?")) return;
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 활동을 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await ActivityClient.DeleteAsync(id);
|
||||
await JS.InvokeVoidAsync("alert", "활동이 삭제되었습니다.");
|
||||
Snackbar.Add("활동이 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
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; } }
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
editingActivity = null;
|
||||
activityForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class ConsultingActivityForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
public string ActivityType { get; set; } = "";
|
||||
public DateTime? ActivityDate { get; set; } = DateTime.Now;
|
||||
public string Description { get; set; } = "";
|
||||
public DateTime? NextFollowupDate { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,123 +2,179 @@
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject IContractBrowserClient ContractClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>계약 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||
<h1 class="admin-page-title">계약 관리</h1>
|
||||
<p class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</p>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">계약 관리</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</MudText>
|
||||
@if (mrr > 0)
|
||||
{
|
||||
<p class="admin-page-subtitle mt-2">월 정기수익: <strong>₩@mrr.ToString("N0")</strong></p>
|
||||
<MudText Typo="Typo.body2" Class="mt-2">
|
||||
월 정기수익:
|
||||
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 계약 추가</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
|
||||
새 계약 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
@if (contracts is null)
|
||||
{
|
||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||
}
|
||||
else if (contracts.Count == 0)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@if (contracts is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2" Class="mt-2">
|
||||
<!-- Left: Dense Grid List -->
|
||||
<MudItem XS="12" MD="8">
|
||||
@if (contracts.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
|
||||
계약이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="Contract"
|
||||
Items="@contracts"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
SelectedItem="@selectedContract"
|
||||
SelectedItemChanged="OnRowSelected"
|
||||
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))
|
||||
{
|
||||
@clientName
|
||||
}
|
||||
</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>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudItem>
|
||||
|
||||
<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)
|
||||
{
|
||||
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||
}
|
||||
</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>
|
||||
<!-- Right: Detail Form Panel (Inline Editor) -->
|
||||
<MudItem XS="12" MD="4">
|
||||
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "계약 상세 정보" : "새 계약 추가")</MudText>
|
||||
@if (isEditMode)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
|
||||
새로 작성
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
|
||||
<MudSelect T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
|
||||
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem>
|
||||
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem>
|
||||
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
|
||||
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" />
|
||||
|
||||
<div class="d-flex justify-end gap-2">
|
||||
@if (isEditMode)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteContract(selectedContract?.Id ?? 0))">삭제</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveContract" id="btn-save-contract">저장</MudButton>
|
||||
}
|
||||
</div>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
[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 bool isDialogOpen;
|
||||
private MudForm? form;
|
||||
private bool isEditMode;
|
||||
private Contract? selectedContract;
|
||||
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 OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,56 +191,114 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
private void PrepareCreate()
|
||||
{
|
||||
contractForm = new ContractForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, StartDate = DateTime.Today };
|
||||
isDialogOpen = true;
|
||||
selectedContract = null;
|
||||
isEditMode = false;
|
||||
contractForm = new ContractForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id,
|
||||
StartDate = DateTime.Today
|
||||
};
|
||||
}
|
||||
|
||||
private void OnRowSelected(Contract contract)
|
||||
{
|
||||
if (contract == null) return;
|
||||
selectedContract = contract;
|
||||
isEditMode = true;
|
||||
contractForm = new ContractForm
|
||||
{
|
||||
ClientId = contract.ClientId,
|
||||
ContractNumber = contract.ContractNumber,
|
||||
ServiceType = contract.ServiceType,
|
||||
StartDate = contract.StartDate,
|
||||
MonthlyFee = contract.MonthlyFee
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveContract()
|
||||
{
|
||||
try
|
||||
if (form != null)
|
||||
{
|
||||
if (contractForm.ClientId <= 0)
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (contractForm.ClientId == null) return;
|
||||
var newId = await ContractClient.CreateAsync(
|
||||
contractForm.ClientId.Value,
|
||||
contractForm.ContractNumber,
|
||||
contractForm.ServiceType,
|
||||
contractForm.StartDate ?? DateTime.Now,
|
||||
contractForm.MonthlyFee);
|
||||
|
||||
var newId = await ContractClient.CreateAsync(contractForm.ClientId, contractForm.ContractNumber, contractForm.ServiceType, contractForm.StartDate ?? DateTime.Today, contractForm.MonthlyFee);
|
||||
if (newId > 0)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "계약이 추가되었습니다.");
|
||||
CloseDialog();
|
||||
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
|
||||
PrepareCreate();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteContract(int id)
|
||||
{
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "이 계약을 삭제하시겠습니까?")) return;
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 계약을 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await ContractClient.DeleteAsync(id);
|
||||
await JS.InvokeVoidAsync("alert", "계약이 삭제되었습니다.");
|
||||
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
|
||||
if (selectedContract?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog() { isDialogOpen = false; contractForm = new(); }
|
||||
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||
private 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; } }
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
|
||||
private class ContractForm
|
||||
{
|
||||
public int? ClientId { get; set; }
|
||||
public string ContractNumber { get; set; } = "";
|
||||
public string ServiceType { get; set; } = "";
|
||||
public DateTime? StartDate { get; set; }
|
||||
public decimal? MonthlyFee { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,205 +8,216 @@
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Overview</div>
|
||||
<h1 class="admin-page-title">대시보드</h1>
|
||||
<p class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick='() => Nav.NavigateTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="/taxbaik/admin/blog/create">
|
||||
새 포스트 작성
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
@if (summary is null)
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<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>
|
||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||
}
|
||||
else
|
||||
@if (isLoading)
|
||||
{
|
||||
<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>
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
|
||||
}
|
||||
|
||||
@if (upcomingFilings.Count == 0)
|
||||
<!-- Metrics Grid -->
|
||||
<div class="admin-metric-grid">
|
||||
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
|
||||
<div class="admin-metric-card-body">
|
||||
<span class="admin-metric-card-label">이번달 문의</span>
|
||||
<div class="admin-metric-card-value-row">
|
||||
<span class="admin-metric-card-value" style="color: var(--primary-dark);">@summary.ThisMonthInquiries</span>
|
||||
<span class="admin-metric-card-icon" style="color: var(--primary-color);">💬</span>
|
||||
</div>
|
||||
<span class="admin-metric-card-caption">월간 상담 유입 (클릭 시 이동)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
|
||||
<div class="admin-metric-card-body">
|
||||
<span class="admin-metric-card-label">신규 문의</span>
|
||||
<div class="admin-metric-card-value-row">
|
||||
<span class="admin-metric-card-value" style="color: var(--tertiary-dark);">@summary.NewInquiries</span>
|
||||
<span class="admin-metric-card-icon" style="color: var(--tertiary-color);">⚠️</span>
|
||||
</div>
|
||||
<span class="admin-metric-card-caption">처리 대기 (클릭 시 이동)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
|
||||
<div class="admin-metric-card-body">
|
||||
<span class="admin-metric-card-label">전체 포스트</span>
|
||||
<div class="admin-metric-card-value-row">
|
||||
<span class="admin-metric-card-value" style="color: #455a64;">@summary.TotalPosts</span>
|
||||
<span class="admin-metric-card-icon" style="color: #607d8b;">📄</span>
|
||||
</div>
|
||||
<span class="admin-metric-card-caption">콘텐츠 자산 (클릭 시 이동)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
|
||||
<div class="admin-metric-card-body">
|
||||
<span class="admin-metric-card-label">발행된 포스트</span>
|
||||
<div class="admin-metric-card-value-row">
|
||||
<span class="admin-metric-card-value" style="color: var(--secondary-dark);">@summary.PublishedPosts</span>
|
||||
<span class="admin-metric-card-icon" style="color: var(--secondary-color);">🌐</span>
|
||||
</div>
|
||||
<span class="admin-metric-card-caption">검색 노출 대상 (클릭 시 이동)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (upcomingFilings.Count > 0)
|
||||
{
|
||||
<div class="admin-surface mt-4">이번 달 마감 임박 신고가 없습니다.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-surface mt-4">
|
||||
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
||||
<div class="admin-section-header">
|
||||
<div>
|
||||
<h3 class="admin-section-title">이번 달 마감 임박 신고</h3>
|
||||
<p class="muted">30일 이내 신고 예정 건</p>
|
||||
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
|
||||
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)</MudText>
|
||||
</div>
|
||||
<a class="site-button secondary" href="/taxbaik/admin/tax-filings">전체 일정 보기</a>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>고객</th>
|
||||
<th>신고 유형</th>
|
||||
<th>기한</th>
|
||||
<th>D-day</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var f in upcomingFilings)
|
||||
{
|
||||
var dday = (f.DueDate.Date - DateTime.Today).Days;
|
||||
<tr>
|
||||
<th>고객</th>
|
||||
<th>신고 유형</th>
|
||||
<th>기한</th>
|
||||
<th>D-day</th>
|
||||
<td>
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
|
||||
@f.ClientName
|
||||
</MudLink>
|
||||
</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>
|
||||
}
|
||||
else if (dday <= 7)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>D-@dday</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var f in upcomingFilings)
|
||||
{
|
||||
var dday = (f.DueDate.Date - DateTime.Today).Days;
|
||||
<tr>
|
||||
<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)
|
||||
{
|
||||
<span class="status-pill dark">기한 초과 (@(-dday)일)</span>
|
||||
}
|
||||
else if (dday <= 7)
|
||||
{
|
||||
<span class="status-pill danger">D-@dday</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>D-@dday</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@if (summary is not null)
|
||||
{
|
||||
<div class="admin-surface mt-4">
|
||||
<div class="admin-section-header">
|
||||
<div>
|
||||
<h3 class="admin-section-title">최근 문의</h3>
|
||||
<p class="muted">최근 유입된 상담 요청을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<a class="site-button secondary" href="/taxbaik/admin/inquiries">문의 전체 보기</a>
|
||||
</div>
|
||||
<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 inquiry in summary.RecentInquiries)
|
||||
{
|
||||
<tr>
|
||||
<td><a href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">@inquiry.Name</a></td>
|
||||
<td>@inquiry.Phone</td>
|
||||
<td>@inquiry.ServiceType</td>
|
||||
<td><span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span></td>
|
||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
||||
<div class="admin-section-header">
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">최근 문의</MudText>
|
||||
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
|
||||
</div>
|
||||
}
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>전화</th>
|
||||
<th>분야</th>
|
||||
<th>상태</th>
|
||||
<th>날짜</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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>@inquiry.Phone</td>
|
||||
<td>@inquiry.ServiceType</td>
|
||||
<td>
|
||||
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
|
||||
@GetStatusLabel(inquiry.Status)
|
||||
</MudChip>
|
||||
</td>
|
||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private AdminDashboardSummary? summary;
|
||||
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
||||
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
||||
private string? errorMessage;
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
try
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||
await Task.WhenAll(summaryTask, filingsTask);
|
||||
summary = await summaryTask;
|
||||
upcomingFilings = (await filingsTask).ToList();
|
||||
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();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
|
||||
private static Color StatusColor(string status) => status switch
|
||||
{
|
||||
"new" => "warning",
|
||||
"consulting" => "info",
|
||||
"contracted" => "success",
|
||||
"rejected" => "danger",
|
||||
"closed" => "dark",
|
||||
_ => "default"
|
||||
"new" => Color.Warning,
|
||||
"consulting" => Color.Info,
|
||||
"contracted" => Color.Success,
|
||||
"rejected" => Color.Error,
|
||||
"closed" => Color.Dark,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,52 +5,85 @@
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject IFaqBrowserClient FaqClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">홈페이지</div>
|
||||
<h1 class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</h1>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText>
|
||||
</div>
|
||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>목록으로</button>
|
||||
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface" style="max-width:720px;">
|
||||
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
||||
@if (isLoading)
|
||||
{
|
||||
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<option value="@cat">@cat</option>
|
||||
}
|
||||
</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>
|
||||
<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">
|
||||
@foreach (var cat in FaqService.Categories)
|
||||
{
|
||||
<MudSelectItem Value="@cat">@cat</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@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()
|
||||
{
|
||||
@@ -61,7 +94,7 @@
|
||||
var existing = await FaqClient.GetByIdAsync(Id.Value);
|
||||
if (existing is null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "FAQ를 찾을 수 없습니다.");
|
||||
Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error);
|
||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||
return;
|
||||
}
|
||||
@@ -69,7 +102,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||
return;
|
||||
}
|
||||
@@ -79,30 +112,33 @@
|
||||
|
||||
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);
|
||||
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 수정되었습니다." : "수정 실패");
|
||||
if (result != null)
|
||||
Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("수정 실패", Severity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await FaqClient.CreateAsync(faq);
|
||||
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 등록되었습니다." : "등록 실패");
|
||||
if (result != null)
|
||||
Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("등록 실패", Severity.Error);
|
||||
}
|
||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -4,63 +4,95 @@
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject IFaqBrowserClient FaqClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>FAQ 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">홈페이지</div>
|
||||
<h1 class="admin-page-title">FAQ 관리</h1>
|
||||
<p class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</p>
|
||||
<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>
|
||||
<a class="site-button primary" href="/taxbaik/admin/faqs/create">FAQ 등록</a>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/taxbaik/admin/faqs/create">
|
||||
FAQ 등록
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (faqs is null)
|
||||
{
|
||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (!faqs.Any())
|
||||
{
|
||||
<div class="muted">등록된 FAQ가 없습니다.</div>
|
||||
<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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">순서</th>
|
||||
<th>질문</th>
|
||||
<th style="width:130px;">카테고리</th>
|
||||
<th style="width:90px;">상태</th>
|
||||
<th style="width:160px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in faqs)
|
||||
{
|
||||
<tr>
|
||||
<th>순서</th>
|
||||
<th>질문</th>
|
||||
<th>카테고리</th>
|
||||
<th>상태</th>
|
||||
<th></th>
|
||||
<td class="text-center">
|
||||
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in faqs)
|
||||
{
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
<div class="muted mt-2">총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개</div>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
@@ -70,13 +102,16 @@
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,32 +124,36 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
faqs = [];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Faq item)
|
||||
{
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Question}' 항목을 삭제하시겠습니까?");
|
||||
if (!confirmed) return;
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"FAQ 삭제",
|
||||
$"'{item.Question}' 항목을 삭제하시겠습니까?",
|
||||
yesText: "삭제", cancelText: "취소");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var success = await FaqClient.DeleteAsync(item.Id);
|
||||
if (success)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "FAQ가 삭제되었습니다.");
|
||||
Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
||||
Snackbar.Add("삭제 실패", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,41 +5,51 @@
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject InquiryService InquiryService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>문의 등록</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Customer Relations</div>
|
||||
<h1 class="admin-page-title">새 문의 등록</h1>
|
||||
<p class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</p>
|
||||
<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>
|
||||
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface mt-4">
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@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");
|
||||
await JS.InvokeVoidAsync("alert", "문의가 등록되었습니다.");
|
||||
await InquiryService.SubmitAsync(
|
||||
model.Name,
|
||||
model.Phone,
|
||||
model.ServiceType,
|
||||
model.Message,
|
||||
model.Email,
|
||||
ipAddress: "admin-registered");
|
||||
|
||||
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
|
||||
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,75 +3,113 @@
|
||||
@using TaxBaik.Web.Services
|
||||
@inject IInquiryBrowserClient InquiryClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>문의 상세</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Inquiry Details</div>
|
||||
<h1 class="admin-page-title">문의 상세</h1>
|
||||
<p class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@if (inquiry != null)
|
||||
{
|
||||
<div class="admin-page-actions">
|
||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries")'>문의 목록으로</button>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
|
||||
문의 목록으로
|
||||
</MudButton>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div class="admin-stack">
|
||||
@foreach (var (key, label) in InquiryStatusMapper.Labels)
|
||||
{
|
||||
<button type="button" class="@GetStatusButtonClass(key)" @onclick="@(() => OnStatusChanged(key))">@label</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">
|
||||
@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>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@if (inquiry.ClientId == null)
|
||||
{
|
||||
<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
|
||||
{
|
||||
<section class="admin-surface">
|
||||
<h3 class="admin-section-title">연결된 고객</h3>
|
||||
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">고객 카드 보기</a>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@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>
|
||||
}
|
||||
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>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-surface">문의를 찾을 수 없습니다.</div>
|
||||
<MudText>문의를 찾을 수 없습니다.</MudText>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -96,16 +134,16 @@ else
|
||||
if (success)
|
||||
{
|
||||
inquiry.Status = status;
|
||||
await JS.InvokeVoidAsync("alert", "상태가 변경되었습니다.");
|
||||
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "상태 변경에 실패했습니다.");
|
||||
Snackbar.Add("상태 변경에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,16 +156,16 @@ else
|
||||
if (success)
|
||||
{
|
||||
inquiry.AdminMemo = adminMemo;
|
||||
await JS.InvokeVoidAsync("alert", "메모가 저장되었습니다.");
|
||||
Snackbar.Add("메모가 저장되었습니다.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "메모 저장에 실패했습니다.");
|
||||
Snackbar.Add("메모 저장에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,19 +184,26 @@ else
|
||||
{
|
||||
inquiry.ClientId = clientId;
|
||||
inquiry.Status = "consulting";
|
||||
await JS.InvokeVoidAsync("alert", "고객 카드가 생성되었습니다.");
|
||||
Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "고객 카드 생성에 실패했습니다.");
|
||||
Snackbar.Add("고객 카드 생성에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetStatusButtonClass(string status)
|
||||
=> inquiry?.Status == status ? "site-button primary" : "site-button secondary";
|
||||
private Color StatusColor(string status) => status switch
|
||||
{
|
||||
"new" => Color.Default,
|
||||
"consulting" => Color.Info,
|
||||
"contracted" => Color.Success,
|
||||
"rejected" => Color.Error,
|
||||
"closed" => Color.Dark,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,39 +5,45 @@
|
||||
@using TaxBaik.Web.Components.Admin.Forms
|
||||
@inject InquiryService InquiryService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>문의 수정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Customer Relations</div>
|
||||
<h1 class="admin-page-title">문의 수정</h1>
|
||||
<p class="admin-page-subtitle">고객 문의 정보를 수정합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||
</section>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (inquiry == null)
|
||||
{
|
||||
<div class="admin-surface mt-4">문의를 찾을 수 없습니다.</div>
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">문의를 찾을 수 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-surface mt-4">
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||
<div class="mt-4">
|
||||
<button type="button" class="site-button secondary danger" @onclick="DeleteInquiry">문의 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteInquiry" Class="mt-2">
|
||||
문의 삭제
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private Domain.Entities.Inquiry? inquiry;
|
||||
private InquiryForm.InquiryFormModel? formModel;
|
||||
private bool isLoading = true;
|
||||
@@ -63,7 +69,7 @@ else
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"문의 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"문의 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -71,11 +77,16 @@ 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;
|
||||
@@ -86,35 +97,47 @@ else
|
||||
inquiry.AdminMemo = model.AdminMemo;
|
||||
|
||||
if (inquiry.Status != model.Status)
|
||||
{
|
||||
await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status);
|
||||
}
|
||||
|
||||
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo);
|
||||
await JS.InvokeVoidAsync("alert", "문의가 수정되었습니다.");
|
||||
|
||||
Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
|
||||
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteInquiry()
|
||||
{
|
||||
if (inquiry == null) return;
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
|
||||
if (inquiry == null)
|
||||
return;
|
||||
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"문의 삭제",
|
||||
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"삭제", "취소");
|
||||
|
||||
if (result != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await InquiryService.DeleteAsync(inquiry.Id);
|
||||
await JS.InvokeVoidAsync("alert", "문의가 삭제되었습니다.");
|
||||
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,36 +7,47 @@
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Customer Requests</div>
|
||||
<h1 class="admin-page-title">문의 관리</h1>
|
||||
<p class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries/create")'>새 문의 등록</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/taxbaik/admin/inquiries/create">새 문의 등록</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
@if (isLoading)
|
||||
{
|
||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<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="" />
|
||||
}
|
||||
</div>
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="ma-4" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
||||
<MudTabPanel Text="전체">
|
||||
<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>
|
||||
|
||||
@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 = [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/admin/login"
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
|
||||
@attribute [AllowAnonymous]
|
||||
@@ -11,40 +10,41 @@
|
||||
|
||||
<PageTitle>로그인</PageTitle>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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))
|
||||
{
|
||||
<div class="admin-inline-alert error" role="alert">@errorMessage</div>
|
||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<button type="submit" class="site-button primary admin-login-submit" disabled="@isLoading">
|
||||
<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">
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
<span>로그인 중...</span>
|
||||
}
|
||||
else
|
||||
@@ -53,8 +53,8 @@
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private bool isLoading = false;
|
||||
|
||||
@@ -2,125 +2,145 @@
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject IRevenueTrackingBrowserClient RevenueClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>수익 추적 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||
<h1 class="admin-page-title">수익 추적 관리</h1>
|
||||
<p class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 청구 추가</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
새 청구 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (revenues is null)
|
||||
{
|
||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (revenues.Count == 0)
|
||||
{
|
||||
<div class="muted">청구 기록이 없습니다.</div>
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Payments" Class="me-2" />
|
||||
청구 기록이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
<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>
|
||||
<!-- Create Dialog -->
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 청구 추가</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem>
|
||||
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem>
|
||||
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveRevenue">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
[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();
|
||||
|
||||
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)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,36 +156,53 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
revenueForm = new RevenueForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, InvoiceDate = DateTime.Today, DueDate = DateTime.Today.AddDays(14) };
|
||||
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)
|
||||
if (form != null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
|
||||
return;
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var newId = await RevenueClient.CreateAsync(revenueForm.ClientId, revenueForm.InvoiceNumber, revenueForm.InvoiceDate ?? DateTime.Today, revenueForm.Amount.Value, revenueForm.ServiceType, revenueForm.DueDate);
|
||||
var newId = await RevenueClient.CreateAsync(
|
||||
revenueForm.ClientId,
|
||||
revenueForm.InvoiceNumber,
|
||||
revenueForm.InvoiceDate ?? DateTime.Now,
|
||||
revenueForm.Amount,
|
||||
revenueForm.ServiceType,
|
||||
revenueForm.DueDate);
|
||||
|
||||
if (newId > 0)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "청구가 추가되었습니다.");
|
||||
Snackbar.Add("청구가 추가되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,31 +211,60 @@
|
||||
try
|
||||
{
|
||||
await RevenueClient.MarkPaidAsync(id, DateTime.Now);
|
||||
await JS.InvokeVoidAsync("alert", "납부가 처리되었습니다.");
|
||||
Snackbar.Add("납부가 처리되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
|
||||
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRevenue(int id)
|
||||
{
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "이 청구를 삭제하시겠습니까?")) return;
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 청구를 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await RevenueClient.DeleteAsync(id);
|
||||
await JS.InvokeVoidAsync("alert", "청구가 삭제되었습니다.");
|
||||
Snackbar.Add("청구가 삭제되었습니다.", Severity.Success);
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog() { isDialogOpen = false; revenueForm = new(); }
|
||||
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||
private 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; } }
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
revenueForm = new();
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private class RevenueForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
public string InvoiceNumber { get; set; } = "";
|
||||
public DateTime? InvoiceDate { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string? ServiceType { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,92 +7,162 @@
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Season Preview</div>
|
||||
<h1 class="admin-page-title">시즌 시뮬레이터</h1>
|
||||
<p class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@foreach (var season in TaxSeasonCalendar.Seasons)
|
||||
{
|
||||
<button type="button" class="site-button secondary" @onclick="@(() => JumpToSeason(season))">@season.StartMonth/@season.StartDay - @season.Name</button>
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<section class="admin-surface">
|
||||
<h3 class="admin-section-title">홈페이지 미리보기</h3>
|
||||
<p class="muted">@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요")</p>
|
||||
@if (activeSeason != null)
|
||||
{
|
||||
<span class="status-pill warning">@activeSeason.Name 시즌 활성</span>
|
||||
<div class="season-preview">
|
||||
@if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0)
|
||||
{
|
||||
<div class="season-badge">D-@activeSeason.DaysUntilDeadline 마감 임박</div>
|
||||
}
|
||||
<div class="season-headline">@activeSeason.HeroHeadline</div>
|
||||
<div class="season-subtext">@activeSeason.HeroSubtext</div>
|
||||
<div class="season-cta">@activeSeason.CtaText</div>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">
|
||||
@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요") 홈페이지 미리보기
|
||||
</MudText>
|
||||
@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;">
|
||||
@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 style="font-size: 1.8rem; font-weight: 800; white-space: pre-line; margin-bottom: 0.5rem; line-height: 1.3;">
|
||||
@activeSeason.HeroHeadline
|
||||
</div>
|
||||
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
|
||||
@activeSeason.HeroSubtext
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<th>시즌</th>
|
||||
<th>블로그 카테고리</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in TaxSeasonCalendar.Seasons)
|
||||
{
|
||||
var isActive = activeSeason?.Key == s.Key;
|
||||
<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>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">연간 시즌 타임라인</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay</td>
|
||||
<td>@s.Name</td>
|
||||
<td><code>@s.RelatedCategorySlug</code></td>
|
||||
<td>@(isActive ? "활성" : "비활성")</td>
|
||||
<th>기간</th>
|
||||
<th>시즌</th>
|
||||
<th>블로그 카테고리</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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>
|
||||
<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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@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();
|
||||
|
||||
@@ -113,7 +183,10 @@
|
||||
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,58 +5,78 @@
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Domain.Interfaces
|
||||
@inject IApiClient ApiClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>설정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">System</div>
|
||||
<h1 class="admin-page-title">설정</h1>
|
||||
<p class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-detail-grid">
|
||||
<section class="admin-surface">
|
||||
<div class="admin-section-header compact">
|
||||
<div>
|
||||
<h3 class="admin-section-title">사이트 정보</h3>
|
||||
<p class="muted">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</MudContainer>
|
||||
|
||||
<section class="admin-surface">
|
||||
<div class="admin-section-header compact">
|
||||
<div>
|
||||
<h3 class="admin-section-title">계정 관리</h3>
|
||||
<p class="muted">비밀번호는 12자 이상으로 관리합니다.</p>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
<div class="admin-section-header compact">
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">사이트 정보</MudText>
|
||||
<MudText Typo="Typo.body2">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MudForm>
|
||||
<MudTextField @bind-Value="phone" Label="전화번호"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<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">
|
||||
<MudTextField @bind-Value="email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<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">
|
||||
<div class="admin-section-header compact">
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">계정 관리</MudText>
|
||||
<MudText Typo="Typo.body2">비밀번호는 12자 이상으로 관리합니다.</MudText>
|
||||
</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">
|
||||
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</MudButton>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private string phone = "010-4122-8268";
|
||||
@@ -98,7 +118,7 @@
|
||||
}
|
||||
catch
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "사이트 설정을 불러오지 못했습니다.");
|
||||
Snackbar.Add("사이트 설정을 불러오지 못했습니다.", Severity.Warning);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -121,11 +141,11 @@
|
||||
|
||||
if (response?.Message is null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "설정 저장에 실패했습니다.");
|
||||
Snackbar.Add("설정 저장에 실패했습니다.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
await JS.InvokeVoidAsync("alert", response.Message);
|
||||
Snackbar.Add(response.Message, Severity.Success);
|
||||
}
|
||||
|
||||
private async Task ChangePassword()
|
||||
@@ -135,13 +155,13 @@
|
||||
|
||||
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "현재 비밀번호와 새 비밀번호를 입력하세요.");
|
||||
Snackbar.Add("현재 비밀번호와 새 비밀번호를 입력하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword != confirmNewPassword)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "새 비밀번호 확인이 일치하지 않습니다.");
|
||||
Snackbar.Add("새 비밀번호 확인이 일치하지 않습니다.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,18 +177,18 @@
|
||||
|
||||
if (response?.Message == null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "비밀번호 변경에 실패했습니다.");
|
||||
Snackbar.Add("비밀번호 변경에 실패했습니다.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
await JS.InvokeVoidAsync("alert", response.Message);
|
||||
Snackbar.Add(response.Message, Severity.Success);
|
||||
currentPassword = "";
|
||||
newPassword = "";
|
||||
confirmNewPassword = "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "비밀번호 변경 중 오류가 발생했습니다.");
|
||||
Snackbar.Add("비밀번호 변경 중 오류가 발생했습니다.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -2,125 +2,201 @@
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject ITaxFilingScheduleBrowserClient TaxFilingClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>신고 일정</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||
<h1 class="admin-page-title">신고 일정</h1>
|
||||
<p class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 일정 추가</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-schedule">
|
||||
새 일정 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
@if (schedules is null)
|
||||
{
|
||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||
}
|
||||
else if (schedules.Count == 0)
|
||||
{
|
||||
<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")
|
||||
@if (schedules is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2" Class="mt-2">
|
||||
<!-- Left: Dense Grid List -->
|
||||
<MudItem XS="12" MD="8">
|
||||
@if (schedules.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
|
||||
신고 일정이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="TaxFilingSchedule"
|
||||
Items="@schedules"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
SelectedItem="@selectedSchedule"
|
||||
SelectedItemChanged="OnRowSelected"
|
||||
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))
|
||||
{
|
||||
@clientName
|
||||
}
|
||||
</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)
|
||||
{
|
||||
<button type="button" class="site-button secondary" @onclick="@(async () => await CompleteSchedule(item.Id))">완료</button>
|
||||
<span class="ms-1">(D-@daysLeft)</span>
|
||||
}
|
||||
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteSchedule(item.Id))">✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
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>
|
||||
}
|
||||
</MudItem>
|
||||
|
||||
<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)
|
||||
{
|
||||
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||
}
|
||||
</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>
|
||||
<!-- Right: Detail Form Panel (Inline Editor) -->
|
||||
<MudItem XS="12" MD="4">
|
||||
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "신고 일정 상세" : "새 신고 일정 추가")</MudText>
|
||||
@if (isEditMode)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
|
||||
새로 작성
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?"
|
||||
@bind-Value="scheduleForm.ClientId"
|
||||
Label="고객"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="@true"
|
||||
Class="mb-3"
|
||||
RequiredError="고객을 선택하세요."
|
||||
Disabled="@isEditMode">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
|
||||
<MudSelectItem Value="@("종합소득세")">종합소득세</MudSelectItem>
|
||||
<MudSelectItem Value="@("부가가치세")">부가가치세</MudSelectItem>
|
||||
<MudSelectItem Value="@("법인세")">법인세</MudSelectItem>
|
||||
<MudSelectItem Value="@("원천세")">원천세</MudSelectItem>
|
||||
<MudSelectItem Value="@("종합부동산세")">종합부동산세</MudSelectItem>
|
||||
<MudSelectItem Value="@("양도소득세")">양도소득세</MudSelectItem>
|
||||
<MudSelectItem Value="@("상속·증여세")">상속·증여세</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정")">세무조정</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
|
||||
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" Required="true" />
|
||||
|
||||
<div class="d-flex justify-end gap-2">
|
||||
@if (isEditMode && selectedSchedule?.Status != "completed")
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Success" OnClick="@(async () => await CompleteSchedule(selectedSchedule?.Id ?? 0))">완료 처리</MudButton>
|
||||
}
|
||||
@if (isEditMode)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteSchedule(selectedSchedule?.Id ?? 0))">삭제</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSchedule" id="btn-save-schedule">저장</MudButton>
|
||||
}
|
||||
</div>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxFilingSchedule>? schedules;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private bool isDialogOpen;
|
||||
private MudForm? form;
|
||||
private bool isEditMode;
|
||||
private TaxFilingSchedule? selectedSchedule;
|
||||
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 OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,36 +212,71 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
private void PrepareCreate()
|
||||
{
|
||||
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year, DueDate = DateTime.Today, ClientId = clients.FirstOrDefault()?.Id ?? 0 };
|
||||
isDialogOpen = true;
|
||||
selectedSchedule = null;
|
||||
isEditMode = false;
|
||||
scheduleForm = new TaxFilingScheduleForm
|
||||
{
|
||||
FilingYear = DateTime.Now.Year,
|
||||
DueDate = DateTime.Today,
|
||||
ClientId = clients.FirstOrDefault()?.Id
|
||||
};
|
||||
}
|
||||
|
||||
private void OnRowSelected(TaxFilingSchedule schedule)
|
||||
{
|
||||
if (schedule == null) return;
|
||||
selectedSchedule = schedule;
|
||||
isEditMode = true;
|
||||
scheduleForm = new TaxFilingScheduleForm
|
||||
{
|
||||
ClientId = schedule.ClientId,
|
||||
FilingType = schedule.FilingType,
|
||||
DueDate = schedule.DueDate,
|
||||
FilingYear = schedule.FilingYear
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveSchedule()
|
||||
{
|
||||
if (scheduleForm.ClientId <= 0 || string.IsNullOrWhiteSpace(scheduleForm.FilingType))
|
||||
if (form != null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
|
||||
return;
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var newId = await TaxFilingClient.CreateAsync(scheduleForm.ClientId, scheduleForm.FilingType, scheduleForm.DueDate ?? DateTime.Today, scheduleForm.FilingYear);
|
||||
if (scheduleForm.ClientId == null) return;
|
||||
var newId = await TaxFilingClient.CreateAsync(
|
||||
scheduleForm.ClientId.Value,
|
||||
scheduleForm.FilingType,
|
||||
scheduleForm.DueDate ?? DateTime.Today,
|
||||
scheduleForm.FilingYear);
|
||||
|
||||
if (newId > 0)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
|
||||
CloseDialog();
|
||||
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
|
||||
PrepareCreate();
|
||||
await LoadData();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,31 +285,60 @@
|
||||
try
|
||||
{
|
||||
await TaxFilingClient.MarkCompletedAsync(id);
|
||||
await JS.InvokeVoidAsync("alert", "신고 일정이 완료 처리되었습니다.");
|
||||
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
|
||||
if (selectedSchedule?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
|
||||
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSchedule(int id)
|
||||
{
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "이 신고 일정을 삭제하시겠습니까?")) return;
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "Title", "삭제 확인" },
|
||||
{ "Message", "이 신고 일정을 삭제하시겠습니까?" }
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await TaxFilingClient.DeleteAsync(id);
|
||||
await JS.InvokeVoidAsync("alert", "신고 일정이 삭제되었습니다.");
|
||||
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
|
||||
if (selectedSchedule?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog() { isDialogOpen = false; scheduleForm = new(); }
|
||||
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||
private 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; }
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
|
||||
private class TaxFilingScheduleForm
|
||||
{
|
||||
public int? ClientId { get; set; }
|
||||
public string FilingType { get; set; } = "";
|
||||
public DateTime? DueDate { get; set; }
|
||||
public int FilingYear { get; set; } = DateTime.Now.Year;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,60 @@
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject ITaxFilingBrowserClient FilingClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@if (Filings == null || Filings.Count == 0)
|
||||
{
|
||||
<div class="muted">항목이 없습니다.</div>
|
||||
<MudText Class="pa-4" Color="Color.Secondary">항목이 없습니다.</MudText>
|
||||
}
|
||||
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 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)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="status-pill success">완료</span>
|
||||
}
|
||||
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteFiling(filing.Id))">✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<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;
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (dday < 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Error">D+@(-dday)</MudChip>
|
||||
}
|
||||
else if (dday <= 7)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">D-@dday</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2">D-@dday</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>@(context.Memo ?? "")</MudTd>
|
||||
<MudTd>
|
||||
@if (context.Status == "pending")
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Success"
|
||||
OnClick="@(() => MarkFiled(context))">완료</MudButton>
|
||||
}
|
||||
else if (context.Status == "filed")
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">완료</MudChip>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(() => DeleteFiling(context.Id))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -72,33 +66,44 @@ else
|
||||
|
||||
private async Task MarkFiled(TaxFiling filing)
|
||||
{
|
||||
filing.Status = "filed";
|
||||
var result = await FilingClient.UpdateAsync(filing.Id, filing);
|
||||
if (result != null)
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "신고 완료 처리되었습니다.");
|
||||
await OnStatusChange.InvokeAsync();
|
||||
filing.Status = "filed";
|
||||
var result = await FilingClient.UpdateAsync(filing.Id, filing);
|
||||
if (result != null)
|
||||
{
|
||||
Snackbar.Add("신고 완료 처리되었습니다.", Severity.Success);
|
||||
await OnStatusChange.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("처리 실패", Severity.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "처리 실패");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteFiling(int id)
|
||||
{
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", "이 항목을 삭제하시겠습니까?");
|
||||
if (!confirmed) return;
|
||||
|
||||
var success = await FilingClient.DeleteAsync(id);
|
||||
if (success)
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
|
||||
await OnStatusChange.InvokeAsync();
|
||||
var success = await FilingClient.DeleteAsync(id);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add("삭제되었습니다.", Severity.Info);
|
||||
await OnStatusChange.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("삭제 실패", Severity.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,165 +4,130 @@
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject ITaxFilingBrowserClient FilingClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>신고 일정 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">Tax Schedule</div>
|
||||
<h1 class="admin-page-title">신고 일정</h1>
|
||||
<p class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick="@(() => showAddForm = !showAddForm)">일정 추가</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="@(() => showAddForm = !showAddForm)"
|
||||
StartIcon="@Icons.Material.Filled.Add">
|
||||
일정 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
@if (showAddForm)
|
||||
{
|
||||
<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>
|
||||
<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">
|
||||
@foreach (var t in TaxFilingService.FilingTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</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>
|
||||
</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>
|
||||
}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@code {
|
||||
private List<TaxFiling> allFilings = [];
|
||||
private List<Client> clients = [];
|
||||
private List<Domain.Entities.TaxFiling> pending = [];
|
||||
private List<Domain.Entities.TaxFiling> filed = [];
|
||||
private List<Domain.Entities.TaxFiling> overdue = [];
|
||||
|
||||
private bool showAddForm;
|
||||
private string activeTab = "pending";
|
||||
private int selectedClientId;
|
||||
private Domain.Entities.Client? selectedClient;
|
||||
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
|
||||
{
|
||||
allFilings = (await FilingClient.GetUpcomingAsync(365)).ToList();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
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();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Client>> SearchClients(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
|
||||
return items;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private async Task AddFiling()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (selectedClientId <= 0)
|
||||
if (selectedClient == null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var filing = new TaxFiling
|
||||
{
|
||||
ClientId = selectedClientId,
|
||||
ClientId = selectedClient.Id,
|
||||
FilingType = newFilingType,
|
||||
DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow,
|
||||
Status = "pending",
|
||||
@@ -172,36 +137,17 @@
|
||||
if (result != null)
|
||||
{
|
||||
showAddForm = false;
|
||||
await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
|
||||
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
|
||||
await Reload();
|
||||
}
|
||||
else
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "추가 실패");
|
||||
Snackbar.Add("추가 실패", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
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,128 +2,159 @@
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject ITaxProfileBrowserClient TaxProfileClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IJSRuntime JS
|
||||
@inject ICommonCodeBrowserClient CommonCodeClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>세무 프로필</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||
<h1 class="admin-page-title">세무 프로필</h1>
|
||||
<p class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</p>
|
||||
<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>
|
||||
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 프로필 추가</button>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-profile">
|
||||
새 프로필 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="admin-surface">
|
||||
@if (profiles is null)
|
||||
{
|
||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||
}
|
||||
else if (profiles.Count == 0)
|
||||
{
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in profiles)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (profiles == null)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2" Class="mt-2">
|
||||
<!-- Left: Dense Grid List -->
|
||||
<MudItem XS="12" MD="8">
|
||||
@if (profiles.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">세무 프로필이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="TaxProfile"
|
||||
Items="@profiles"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
SelectedItem="@selectedProfile"
|
||||
SelectedItemChanged="OnRowSelected"
|
||||
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))
|
||||
{
|
||||
@clientName
|
||||
}
|
||||
</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>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudItem>
|
||||
|
||||
<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)
|
||||
{
|
||||
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||
}
|
||||
</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>
|
||||
<!-- Right: Detail Form Panel (Inline Editor) -->
|
||||
<MudItem XS="12" MD="4">
|
||||
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
|
||||
@if (isEditMode)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
|
||||
새로 작성
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
|
||||
@foreach (var type in businessTypes)
|
||||
{
|
||||
<MudSelectItem Value="@type.CodeValue">@type.CodeName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3">
|
||||
@foreach (var level in riskLevels)
|
||||
{
|
||||
<MudSelectItem Value="@level.CodeValue">@level.CodeName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" />
|
||||
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="@true" Lines="3" Class="mb-4" />
|
||||
|
||||
<div class="d-flex justify-end gap-2">
|
||||
@if (isEditMode)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteProfile(selectedProfile?.Id ?? 0))">삭제</MudButton>
|
||||
}
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveProfile" id="btn-save-profile">저장</MudButton>
|
||||
</div>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxProfile>? profiles;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private bool isDialogOpen;
|
||||
private List<CommonCode> businessTypes = [];
|
||||
private List<CommonCode> riskLevels = [];
|
||||
private MudForm? form;
|
||||
private bool isEditMode;
|
||||
private TaxProfile? editingProfile;
|
||||
private TaxProfile? selectedProfile;
|
||||
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 OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && AuthStateTask != null)
|
||||
if (firstRender)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,25 +167,56 @@
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
|
||||
businessTypes = await CommonCodeClient.GetByGroupAsync("BUSINESS_TYPE");
|
||||
if (businessTypes.Count == 0)
|
||||
{
|
||||
businessTypes = [
|
||||
new() { CodeValue = "일반제조업", CodeName = "일반제조업" },
|
||||
new() { CodeValue = "도소매업", CodeName = "도소매업" },
|
||||
new() { CodeValue = "서비스업", CodeName = "서비스업" },
|
||||
new() { CodeValue = "정보통신업", CodeName = "정보통신업" },
|
||||
new() { CodeValue = "부동산업", CodeName = "부동산업" },
|
||||
new() { CodeValue = "건설업", CodeName = "건설업" },
|
||||
new() { CodeValue = "음식점업", CodeName = "음식점업" },
|
||||
new() { CodeValue = "프리랜서", CodeName = "프리랜서" },
|
||||
new() { CodeValue = "기타", CodeName = "기타" }
|
||||
];
|
||||
}
|
||||
|
||||
riskLevels = await CommonCodeClient.GetByGroupAsync("TAX_RISK_LEVEL");
|
||||
if (riskLevels.Count == 0)
|
||||
{
|
||||
riskLevels = [
|
||||
new() { CodeValue = "low", CodeName = "낮음" },
|
||||
new() { CodeValue = "normal", CodeName = "보통" },
|
||||
new() { CodeValue = "high", CodeName = "높음" }
|
||||
];
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
private void PrepareCreate()
|
||||
{
|
||||
selectedProfile = null;
|
||||
isEditMode = false;
|
||||
editingProfile = null;
|
||||
profileForm = new TaxProfileForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, TaxRiskLevel = "normal", NextFilingDueDate = DateTime.Today.AddMonths(1) };
|
||||
isDialogOpen = true;
|
||||
profileForm = new TaxProfileForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id,
|
||||
TaxRiskLevel = "normal",
|
||||
NextFilingDueDate = DateTime.Today.AddMonths(1)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog(TaxProfile profile)
|
||||
private void OnRowSelected(TaxProfile profile)
|
||||
{
|
||||
if (profile == null) return;
|
||||
selectedProfile = profile;
|
||||
isEditMode = true;
|
||||
editingProfile = profile;
|
||||
profileForm = new TaxProfileForm
|
||||
{
|
||||
ClientId = profile.ClientId,
|
||||
@@ -163,58 +225,107 @@
|
||||
NextFilingDueDate = profile.NextFilingDueDate,
|
||||
SpecialNotes = profile.SpecialNotes
|
||||
};
|
||||
isDialogOpen = true;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveProfile()
|
||||
{
|
||||
if (profileForm.ClientId <= 0 || string.IsNullOrWhiteSpace(profileForm.BusinessType))
|
||||
if (form != null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", "고객과 사업 유형을 입력하세요.");
|
||||
return;
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (isEditMode && editingProfile != null)
|
||||
if (isEditMode && selectedProfile != null)
|
||||
{
|
||||
await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
|
||||
await JS.InvokeVoidAsync("alert", "세무 프로필이 수정되었습니다.");
|
||||
await TaxProfileClient.UpdateAsync(selectedProfile.Id, profileForm.BusinessType,
|
||||
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newId = await TaxProfileClient.CreateAsync(profileForm.ClientId, profileForm.BusinessType);
|
||||
if (!profileForm.ClientId.HasValue)
|
||||
{
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
var newId = await TaxProfileClient.CreateAsync(
|
||||
profileForm.ClientId.Value,
|
||||
profileForm.BusinessType);
|
||||
if (newId > 0)
|
||||
{
|
||||
await TaxProfileClient.UpdateAsync(newId, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
|
||||
await JS.InvokeVoidAsync("alert", "세무 프로필이 추가되었습니다.");
|
||||
await TaxProfileClient.UpdateAsync(
|
||||
newId,
|
||||
profileForm.BusinessType,
|
||||
null,
|
||||
profileForm.NextFilingDueDate,
|
||||
profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
|
||||
}
|
||||
}
|
||||
CloseDialog();
|
||||
PrepareCreate();
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteProfile(int id)
|
||||
{
|
||||
if (!await JS.InvokeAsync<bool>("confirm", "이 세무 프로필을 삭제하시겠습니까?")) return;
|
||||
var parameters = new DialogParameters();
|
||||
parameters.Add("Title", "삭제 확인");
|
||||
parameters.Add("Message", "이 세무 프로필을 삭제하시겠습니까?");
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result?.Canceled ?? true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await TaxProfileClient.DeleteAsync(id);
|
||||
await JS.InvokeVoidAsync("alert", "세무 프로필이 삭제되었습니다.");
|
||||
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
|
||||
if (selectedProfile?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
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; } }
|
||||
private Color GetRiskColor(string riskLevel) => riskLevel switch
|
||||
{
|
||||
"high" => Color.Error,
|
||||
"normal" => Color.Warning,
|
||||
"low" => Color.Success,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
|
||||
private class TaxProfileForm
|
||||
{
|
||||
public int? ClientId { get; set; }
|
||||
public string BusinessType { get; set; } = "";
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
public string? SpecialNotes { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
@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>
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText>@Message</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">취소</MudButton>
|
||||
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Confirm">삭제</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] MudDialogInstance MudDialog { get; set; } = null!;
|
||||
[Parameter] public string Title { get; set; } = "";
|
||||
[Parameter] public string Message { get; set; } = "";
|
||||
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||
|
||||
private Task Cancel() => OnCancel.InvokeAsync();
|
||||
private Task Confirm() => OnConfirm.InvokeAsync();
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
private void Confirm() => MudDialog.Close();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<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; } = "";
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
@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: true)" />
|
||||
<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>
|
||||
@@ -1,45 +0,0 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
@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>
|
||||
@@ -1,16 +0,0 @@
|
||||
@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>
|
||||
@@ -1,6 +0,0 @@
|
||||
@page "/portal/login"
|
||||
<PageTitle>고객 포털 로그인</PageTitle>
|
||||
<section class="site-content">
|
||||
<h1>고객 포털 로그인</h1>
|
||||
<p>로그인 폼은 기존 인증 흐름을 Blazor로 옮기는 다음 단계에서 연결합니다.</p>
|
||||
</section>
|
||||
@@ -1,6 +0,0 @@
|
||||
@page "/portal/register"
|
||||
<PageTitle>고객 포털 회원가입</PageTitle>
|
||||
<section class="site-content">
|
||||
<h1>고객 포털 회원가입</h1>
|
||||
<p>회원가입 폼은 다음 단계에서 Blazor 입력 컴포넌트로 채워집니다.</p>
|
||||
</section>
|
||||
@@ -1,14 +0,0 @@
|
||||
@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>
|
||||
@@ -1,16 +0,0 @@
|
||||
@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>
|
||||
@@ -1,3 +0,0 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using TaxBaik.Web.Components.Shared
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class CommonCodeController(CommonCodeService commonCodeService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllActive()
|
||||
{
|
||||
try
|
||||
{
|
||||
var codes = await commonCodeService.GetAllActiveAsync();
|
||||
return Ok(codes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "공통코드 조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("group/{group}")]
|
||||
public async Task<IActionResult> GetByGroup(string group)
|
||||
{
|
||||
try
|
||||
{
|
||||
var codes = await commonCodeService.GetByGroupAsync(group);
|
||||
return Ok(codes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "그룹별 공통코드 조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,9 @@ public class RevenueTrackingController(RevenueTrackingService service) : Control
|
||||
{
|
||||
try
|
||||
{
|
||||
var revenue = await service.GetByIdAsync(id);
|
||||
return revenue is null ? NotFound(new { error = "조회 실패", message = "해당 청구를 찾을 수 없습니다." }) : Ok(revenue);
|
||||
// GetByIdAsync가 없으면 GetByClientIdAsync를 사용하거나 별도 구현 필요
|
||||
// 임시로 구현 - 실제로는 repository에 GetByIdAsync 추가 필요
|
||||
return Ok(new { message = "조회됨" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,422 @@
|
||||
@page "/"
|
||||
@page
|
||||
@model TaxBaik.Web.Pages.IndexModel
|
||||
@{
|
||||
Layout = null;
|
||||
await Html.RenderComponentAsync<TaxBaik.Web.Components.Site.App>(RenderMode.ServerPrerendered);
|
||||
var season = Model.CurrentSeason;
|
||||
ViewData["Title"] = season != null
|
||||
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
|
||||
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
|
||||
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
|
||||
}
|
||||
|
||||
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
|
||||
@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>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
<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 glass-card 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">
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card glass-card">
|
||||
<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> 최근 상담 및 지원 이력
|
||||
@@ -139,14 +139,10 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="timeline">
|
||||
<div class="timeline ps-2">
|
||||
@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="timeline-item-modern">
|
||||
<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>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<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 href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<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" />
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
@using TaxBaik.Web
|
||||
@namespace TaxBaik.Web.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
+13
-4
@@ -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;
|
||||
@@ -256,8 +256,17 @@ builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBro
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
// UI & 캐시 (Fluent UI Blazor v5 우선)
|
||||
builder.Services.AddFluentUIComponents();
|
||||
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
});
|
||||
|
||||
// UI & 캐시 (MudBlazor Theme Customization)
|
||||
builder.Services.AddMudServices(config =>
|
||||
{
|
||||
config.SnackbarConfiguration.HideTransitionDuration = 400;
|
||||
config.SnackbarConfiguration.ShowTransitionDuration = 300;
|
||||
});
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddResponseCompression(opts => {
|
||||
opts.Providers.Add<GzipCompressionProvider>();
|
||||
@@ -342,7 +351,7 @@ app.MapRazorPages();
|
||||
|
||||
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
|
||||
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
|
||||
app.MapRazorComponents<TaxBaik.Web.Components.Site.App>()
|
||||
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AllowAnonymous();
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace TaxBaik.Web.Services.AdminClients;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
public interface ICommonCodeBrowserClient
|
||||
{
|
||||
Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
|
||||
Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<CommonCodeBrowserClient> logger) : ICommonCodeBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/commoncode";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get all active common codes");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}/group/{group}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get common codes for group {Group}", group);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
}
|
||||
}
|
||||
|
||||
var principal = _authService.ValidateToken(accessToken!);
|
||||
var principal = _authService.ValidateToken(accessToken ?? string.Empty);
|
||||
if (principal == null)
|
||||
{
|
||||
await LogoutAsync();
|
||||
@@ -115,14 +115,13 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
private bool ShouldRefreshToken()
|
||||
{
|
||||
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
|
||||
var tokenExpiryTicks = _tokenStore.TokenExpiryTicks;
|
||||
if (tokenExpiryTicks is null || tokenExpiryTicks <= 0)
|
||||
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
|
||||
return false;
|
||||
|
||||
const int refreshThresholdSeconds = 300;
|
||||
try
|
||||
{
|
||||
var expiryTime = new DateTime(tokenExpiryTicks.Value, DateTimeKind.Utc);
|
||||
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
|
||||
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
|
||||
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
|
||||
}
|
||||
@@ -145,4 +144,17 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="5.0.0-rc.1-26048.1" />
|
||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="5.0.0-rc.4-26177.1" />
|
||||
<PackageReference Include="MudBlazor" Version="6.10.0" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||
|
||||
+241
-133
@@ -64,35 +64,35 @@
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-0: 0;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-7: 28px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-1: 3px;
|
||||
--space-2: 6px;
|
||||
--space-3: 10px;
|
||||
--space-4: 12px;
|
||||
--space-5: 16px;
|
||||
--space-6: 20px;
|
||||
--space-7: 24px;
|
||||
--space-8: 28px;
|
||||
--space-10: 34px;
|
||||
--space-12: 40px;
|
||||
--space-16: 52px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Typography Scale */
|
||||
--font-family-base: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 1.875rem;
|
||||
--font-size-4xl: 2.25rem;
|
||||
--font-size-xs: 0.7rem;
|
||||
--font-size-sm: 0.75rem;
|
||||
--font-size-base: 0.82rem;
|
||||
--font-size-lg: 0.95rem;
|
||||
--font-size-xl: 1.1rem;
|
||||
--font-size-2xl: 1.3rem;
|
||||
--font-size-3xl: 1.6rem;
|
||||
--font-size-4xl: 2rem;
|
||||
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
@@ -221,111 +221,126 @@ textarea:focus-visible {
|
||||
Login Page Styles
|
||||
============================================================================ */
|
||||
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(200, 157, 110, 0.18), transparent 36%),
|
||||
radial-gradient(circle at bottom right, rgba(46, 92, 78, 0.12), transparent 32%),
|
||||
linear-gradient(180deg, #f9f7f3 0%, #f3ede2 100%);
|
||||
}
|
||||
|
||||
html.admin-login-route body {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(200, 157, 110, 0.18), transparent 36%),
|
||||
radial-gradient(circle at bottom right, rgba(46, 92, 78, 0.12), transparent 32%),
|
||||
linear-gradient(180deg, #f9f7f3 0%, #f3ede2 100%);
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
width: min(440px, 100%);
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.admin-login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-brand-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-brand-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-brand-subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.admin-login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-field-label {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-login-remember {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.admin-inline-alert {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(198, 40, 40, 0.2);
|
||||
background: rgba(198, 40, 40, 0.08);
|
||||
color: var(--color-danger);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.admin-login-submit {
|
||||
.admin-login-page.mud-container {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-login-submit:disabled {
|
||||
opacity: 0.7;
|
||||
.admin-login-page.mud-container-maxwidth-small {
|
||||
max-width: 480px !important;
|
||||
}
|
||||
|
||||
.admin-login-page .mud-paper {
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.admin-login-page .mud-paper.elevation-3 {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.admin-login-page .mud-typography {
|
||||
color: var(--text-primary);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.admin-login-page .mud-typography--h4 {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--primary-color);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-login-page .mud-typography--body1 {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.admin-login-page input[type="text"],
|
||||
.admin-login-page input[type="password"] {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-base);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.admin-login-page input[type="text"]:focus,
|
||||
.admin-login-page input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.admin-login-page label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.admin-login-page button {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
margin-top: var(--space-4);
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.admin-login-page button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.admin-login-page button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.admin-login-page button:disabled {
|
||||
background-color: var(--text-disabled);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-login-page .mud-alert {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.admin-login-page .mud-alert--error {
|
||||
background-color: var(--error-light);
|
||||
color: var(--error-dark);
|
||||
border-left-color: var(--error-color);
|
||||
}
|
||||
|
||||
.admin-login-page .mud-alert--success {
|
||||
background-color: var(--success-light);
|
||||
color: var(--success-dark);
|
||||
border-left-color: var(--success-color);
|
||||
}
|
||||
|
||||
.admin-login-page .mud-alert--info {
|
||||
background-color: var(--info-light);
|
||||
color: var(--info-dark);
|
||||
border-left-color: var(--info-color);
|
||||
}
|
||||
|
||||
/* Reconnect Modal */
|
||||
@@ -392,8 +407,7 @@ html.admin-login-route body {
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
height: auto;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@@ -431,11 +445,12 @@ html.admin-login-route body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
padding: 0px 12px;
|
||||
height: 38px !important;
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: var(--z-dropdown);
|
||||
box-shadow: var(--shadow-xs);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.admin-menu-button {
|
||||
@@ -564,17 +579,12 @@ html.admin-login-route body {
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 12px;
|
||||
padding: 16px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Dashboard Page Styles
|
||||
============================================================================ */
|
||||
@@ -834,6 +844,11 @@ html.admin-login-route body {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table .mud-chip {
|
||||
font-size: 0.68rem;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.admin-table tbody a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
@@ -851,6 +866,16 @@ html.admin-login-route body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin-table .mud-chip-small {
|
||||
height: 24px !important;
|
||||
font-size: var(--font-size-xs) !important;
|
||||
font-weight: var(--font-weight-medium);
|
||||
min-width: 60px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.admin-skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-overlay) 0%, var(--bg-overlay-strong) 50%, var(--bg-overlay) 100%);
|
||||
@@ -1246,6 +1271,15 @@ html.admin-login-route body {
|
||||
|
||||
/* Mobile S: <480px */
|
||||
@media (max-width: 479px) {
|
||||
.admin-login-page.mud-container-maxwidth-small {
|
||||
max-width: 100% !important;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-login-page .mud-typography--h4 {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
@@ -1294,6 +1328,11 @@ html.admin-login-route body {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-nav .mud-nav-link {
|
||||
min-width: 100px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.admin-drawer-footer {
|
||||
display: none;
|
||||
}
|
||||
@@ -1355,6 +1394,10 @@ html.admin-login-route body {
|
||||
font-size: var(--font-size-base) !important;
|
||||
}
|
||||
|
||||
.admin-section-header .mud-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
@@ -1374,6 +1417,8 @@ html.admin-login-route body {
|
||||
}
|
||||
|
||||
/* Touch Target Sizing (WCAG 2.5.5) */
|
||||
.mud-button,
|
||||
.mud-icon-button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
@@ -1578,6 +1623,10 @@ html.admin-login-route body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-footer-item .mud-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Responsive Topbar */
|
||||
@media (max-width: 600px) {
|
||||
.admin-topbar-action {
|
||||
@@ -1588,4 +1637,63 @@ html.admin-login-route body {
|
||||
.admin-topbar-title {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.mud-toolbar > :last-child {
|
||||
margin-right: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
더존 ERP 스타일 최적화 (Douzone ERP High-Density Desktop Style)
|
||||
- 프레임워크 고유 레이아웃과 이벤트를 방해하는 와일드카드 및 강제 강하 스타일 제거
|
||||
- MudBlazor 테마 설정을 기반으로 하며 레이아웃 및 서체 스택만 안전하게 제어
|
||||
============================================================================ */
|
||||
|
||||
html, body {
|
||||
background-color: #E2E8F0 !important;
|
||||
color: #1E293B !important;
|
||||
font-family: 'Malgun Gothic', '맑은 고딕', 'Segoe UI', sans-serif !important;
|
||||
}
|
||||
|
||||
/* 어드민 드로워 및 탑바 테마 컬러 보완 */
|
||||
.mud-drawer {
|
||||
border-right: 1px solid #CBD5E1 !important;
|
||||
}
|
||||
|
||||
.mud-drawer-header {
|
||||
border-bottom: 1px solid #1E293B !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.mud-nav-link {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* 데이터그리드 헤더 가시성 보완 */
|
||||
.mud-table-head th {
|
||||
background-color: #F1F5F9 !important;
|
||||
font-weight: bold !important;
|
||||
color: #0F172A !important;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 영역 */
|
||||
.admin-page-hero {
|
||||
padding: 12px 16px !important;
|
||||
background-color: #F8FAFC !important;
|
||||
border-bottom: 1px solid #E2E8F0 !important;
|
||||
margin-bottom: 12px !important;
|
||||
}
|
||||
|
||||
.admin-page-title {
|
||||
font-size: 16px !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
.admin-page-subtitle {
|
||||
font-size: 12px !important;
|
||||
color: #64748B !important;
|
||||
}
|
||||
|
||||
.admin-eyebrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
:root {
|
||||
--color-primary: #C89D6E;
|
||||
--color-primary-dark: #A67C52;
|
||||
--color-secondary: #2E5C4E;
|
||||
--color-secondary-dark: #1F3A30;
|
||||
--color-accent: #E8E4D8;
|
||||
--color-accent-dark: #D9D3C4;
|
||||
--color-bg: #F9F7F3;
|
||||
--color-bg-alt: #EFE9DD;
|
||||
--color-text: #3D2817;
|
||||
--color-text-light: #6B5D4F;
|
||||
--color-border: #D9D3C4;
|
||||
--color-success: #2E7D32;
|
||||
--color-warning: #F57C00;
|
||||
--color-danger: #C62828;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--shadow-sm: 0 1px 3px rgba(61, 40, 23, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(61, 40, 23, 0.12);
|
||||
--shadow-lg: 0 8px 24px rgba(61, 40, 23, 0.15);
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--site-font-base: 'Noto Sans KR', 'Apple SD Gothic Neo', sans-serif;
|
||||
}
|
||||
+305
-286
@@ -48,7 +48,6 @@
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -57,8 +56,6 @@ body {
|
||||
line-height: 1.8;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1rem);
|
||||
letter-spacing: 0.3px;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ===== 타이포그래피 ===== */
|
||||
@@ -94,6 +91,184 @@ a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ===== 버튼 ===== */
|
||||
.btn {
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.3px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--color-primary-dark) 0%, #8B5E3C 100%);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: linear-gradient(135deg, var(--color-secondary-dark) 0%, #0D1E1A 100%);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--color-primary);
|
||||
border: 2px solid var(--color-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 1rem 2.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ===== 카드 ===== */
|
||||
.card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
transition: all var(--transition-normal);
|
||||
box-shadow: var(--shadow-sm);
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
color: var(--color-text-light);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ===== 히어로 섹션 ===== */
|
||||
.hero-section {
|
||||
padding: clamp(3rem, 20vh, 6rem) 0;
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, #1F3A30 100%);
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 4px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: rgba(200, 157, 110, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.hero-section::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30%;
|
||||
left: -10%;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: rgba(232, 228, 216, 0.05);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: clamp(2rem, 8vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero-section p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ===== 섹션 ===== */
|
||||
.bg-light {
|
||||
background-color: var(--color-accent) !important;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: clamp(1.75rem, 5vw, 2.75rem);
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%);
|
||||
margin: var(--spacing-md) auto 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ===== 휴스 스트립 (신뢰도) ===== */
|
||||
.trust-strip {
|
||||
background: linear-gradient(135deg, var(--color-bg-alt) 0%, var(--color-accent) 100%);
|
||||
@@ -571,318 +746,162 @@ img {
|
||||
.faq-answer ul li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
body.site-blazor {
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background: linear-gradient(180deg, #faf7f2 0%, #f4efe6 100%);
|
||||
color: #3d2817;
|
||||
|
||||
/* ===== 프리미엄 고도화 & 마이크로 인터랙션 (2026-06-30) ===== */
|
||||
|
||||
/* 영어/숫자용 폰트 클래스 */
|
||||
.font-numeric, .font-heading-en {
|
||||
font-family: 'Outfit', 'Inter', 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 히어로 섹션 프리미엄 개편 (메쉬 그라데이션 및 CSS 애니메이션) */
|
||||
.hero-section {
|
||||
background: radial-gradient(circle at 10% 20%, rgba(46, 92, 78, 1) 0%, rgba(31, 58, 48, 1) 44%, rgba(13, 30, 26, 1) 100%) !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-main {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
.hero-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -30%;
|
||||
right: -10%;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(200, 157, 110, 0.25) 0%, rgba(200, 157, 110, 0) 70%);
|
||||
border-radius: 50%;
|
||||
animation: floatAnimation 8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
background: rgba(255,255,255,0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
.hero-section::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -20%;
|
||||
left: -10%;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, rgba(232, 228, 216, 0.15) 0%, rgba(232, 228, 216, 0) 70%);
|
||||
border-radius: 50%;
|
||||
animation: floatAnimation2 12s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
font-weight: 800;
|
||||
color: #2e5c4e;
|
||||
text-decoration: none;
|
||||
@keyframes floatAnimation {
|
||||
0% { transform: translateY(0px) scale(1); }
|
||||
50% { transform: translateY(-30px) scale(1.05); }
|
||||
100% { transform: translateY(0px) scale(1); }
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@keyframes floatAnimation2 {
|
||||
0% { transform: translateX(0px) rotate(0deg); }
|
||||
50% { transform: translateX(20px) translateY(15px) rotate(10deg); }
|
||||
100% { transform: translateX(0px) rotate(0deg); }
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
color: #3d2817;
|
||||
text-decoration: none;
|
||||
/* 신뢰도 스트립 카드 리뉴얼 */
|
||||
.trust-strip {
|
||||
background-color: var(--color-bg-alt);
|
||||
padding: 3rem 0;
|
||||
margin-top: -1.5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.site-hero {
|
||||
padding: 5rem 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
.trust-item {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(61, 40, 23, 0.05);
|
||||
border: 1px solid rgba(200, 157, 110, 0.15);
|
||||
transition: all var(--transition-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-hero-copy h1 {
|
||||
font-size: clamp(2.2rem, 7vw, 4rem);
|
||||
line-height: 1.1;
|
||||
margin: 0.5rem 0 1rem;
|
||||
.trust-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px rgba(61, 40, 23, 0.1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
color: #a67c52;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
.trust-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: inline-block;
|
||||
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.site-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1.5rem;
|
||||
.trust-item:hover .trust-icon {
|
||||
transform: scale(1.15) rotate(5deg);
|
||||
}
|
||||
|
||||
.site-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 0.9rem 1.4rem;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
/* 서비스 카드 고도화 */
|
||||
.service-card {
|
||||
border: 1px solid rgba(217, 211, 196, 0.6) !important;
|
||||
box-shadow: 0 10px 25px rgba(61, 40, 23, 0.03) !important;
|
||||
transition: all var(--transition-normal) !important;
|
||||
}
|
||||
|
||||
.site-button.primary {
|
||||
background: linear-gradient(135deg, #c89d6e 0%, #a67c52 100%);
|
||||
.service-card:hover {
|
||||
transform: translateY(-8px) !important;
|
||||
box-shadow: 0 20px 40px rgba(61, 40, 23, 0.1) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.service-card--featured {
|
||||
background: linear-gradient(180deg, #FFFFFF 0%, #FAF8F5 100%) !important;
|
||||
border-left: 4px solid var(--color-primary) !important;
|
||||
}
|
||||
|
||||
/* 글래스모피즘 포털 클래스 (Glassmorphism Portal Classes) */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.7) !important;
|
||||
backdrop-filter: blur(12px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4) !important;
|
||||
box-shadow: 0 8px 32px 0 rgba(61, 40, 23, 0.05) !important;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
box-shadow: 0 8px 32px 0 rgba(61, 40, 23, 0.08) !important;
|
||||
}
|
||||
|
||||
.portal-welcome-strip {
|
||||
background: linear-gradient(135deg, var(--color-secondary-dark) 0%, #152A22 100%);
|
||||
border-radius: var(--radius-lg);
|
||||
color: white;
|
||||
padding: 2.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-bottom: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.site-button.secondary {
|
||||
border: 1px solid #c89d6e;
|
||||
color: #a67c52;
|
||||
background: rgba(255,255,255,0.7);
|
||||
/* 타임라인 컴포넌트 뷰티화 */
|
||||
.timeline-item-modern {
|
||||
border-left: 2px solid rgba(200, 157, 110, 0.4);
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.site-content {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem;
|
||||
.timeline-item-modern::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
left: -7px;
|
||||
top: 6px;
|
||||
box-shadow: 0 0 0 4px rgba(200, 157, 110, 0.25);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.site-post-grid,
|
||||
.taxbaik-skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.site-post-card,
|
||||
.taxbaik-skeleton-item {
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 12px 30px rgba(61,40,23,0.08);
|
||||
}
|
||||
|
||||
.taxbaik-skeleton-line {
|
||||
height: 14px;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(201,173,140,0.18) 25%, rgba(201,173,140,0.35) 37%, rgba(201,173,140,0.18) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: taxbaikShimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.taxbaik-skeleton-title { height: 24px; width: 70%; }
|
||||
.taxbaik-skeleton-short { width: 45%; }
|
||||
|
||||
@keyframes taxbaikShimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* ===== Admin Polish ===== */
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(200, 157, 110, 0.10), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(46, 92, 78, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #fbf8f3 0%, #f4efe6 100%);
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(61, 40, 23, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
background: rgba(255,255,255,0.76);
|
||||
}
|
||||
|
||||
.admin-content-inner {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-page-hero h1,
|
||||
.admin-page-hero .admin-page-title {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-page-subtitle,
|
||||
.muted {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.admin-icon-button {
|
||||
border: 1px solid rgba(61, 40, 23, 0.14);
|
||||
background: rgba(255,255,255,0.8);
|
||||
border-radius: 10px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-icon-button.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-menu-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.admin-drawer {
|
||||
width: 280px;
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(61, 40, 23, 0.08);
|
||||
background: rgba(255,255,255,0.76);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.admin-drawer.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
min-height: calc(100vh - 72px);
|
||||
}
|
||||
|
||||
.admin-topbar-title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.admin-topbar-kicker,
|
||||
.admin-brand-subtitle,
|
||||
.admin-footer-meta {
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-nav-link,
|
||||
.admin-topbar-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-nav-link:hover,
|
||||
.admin-topbar-action:hover {
|
||||
background: rgba(200, 157, 110, 0.10);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.admin-nav details {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.admin-nav summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.admin-nav summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-drawer-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(61, 40, 23, 0.08);
|
||||
}
|
||||
|
||||
.admin-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-kv-grid > div {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.76);
|
||||
border: 1px solid rgba(61, 40, 23, 0.08);
|
||||
}
|
||||
|
||||
.admin-kv-grid span {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-light);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-divider {
|
||||
height: 1px;
|
||||
background: rgba(61, 40, 23, 0.08);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.admin-drawer {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(61, 40, 23, 0.08);
|
||||
}
|
||||
|
||||
.admin-content-inner {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.timeline-item-modern:hover::after {
|
||||
background: var(--color-secondary);
|
||||
box-shadow: 0 0 0 6px rgba(46, 92, 78, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/* Shared UI primitives for site and admin */
|
||||
|
||||
.site-button,
|
||||
.admin-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.site-button {
|
||||
padding: 0.9rem 1.4rem;
|
||||
}
|
||||
|
||||
.site-button.primary {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.site-button.secondary {
|
||||
border: 1px solid var(--color-primary);
|
||||
color: var(--color-primary-dark);
|
||||
background: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.admin-surface,
|
||||
.site-post-card,
|
||||
.taxbaik-skeleton-item {
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 30px rgba(61,40,23,0.08);
|
||||
}
|
||||
|
||||
.admin-page-hero,
|
||||
.site-hero {
|
||||
border-bottom: 1px solid rgba(61, 40, 23, 0.08);
|
||||
}
|
||||
|
||||
.admin-table,
|
||||
.admin-kv-grid,
|
||||
.admin-dialog-card,
|
||||
.admin-pagination,
|
||||
.admin-tabbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.85rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(61, 40, 23, 0.08);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
background: rgba(61, 40, 23, 0.08);
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(61, 40, 23, 0.16);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-icon-button {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border: 1px solid rgba(61, 40, 23, 0.14);
|
||||
background: rgba(255,255,255,0.8);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-icon-button.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-page-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-page-title {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.admin-page-subtitle,
|
||||
.muted {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
window.taxbaikAdminSession = {
|
||||
syncRouteClass: function () {
|
||||
const isLogin = window.location.pathname.toLowerCase().endsWith('/admin/login');
|
||||
document.documentElement.classList.toggle(
|
||||
'admin-login-route',
|
||||
isLogin);
|
||||
|
||||
if (isLogin) {
|
||||
document.title = '백원숙 세무회계 - 관리자 로그인';
|
||||
}
|
||||
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
||||
},
|
||||
|
||||
getViewportWidth: function () {
|
||||
@@ -105,3 +100,45 @@ window.taxbaikAdminSession = {
|
||||
.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
};
|
||||
|
||||
// 더존 ERP 스타일 엔터 키 포커스 이동 및 단축키 바인딩
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
const active = document.activeElement;
|
||||
if (!active) return;
|
||||
|
||||
// 특정 영역(편집 폼 또는 다이얼로그) 내의 입력 필드만 포커스 이동 처리
|
||||
const container = active.closest('.admin-editor-panel, .mud-form, .mud-dialog');
|
||||
if (!container) return;
|
||||
|
||||
// textarea나 button, submit 타입 등은 기본 동작(줄바꿈/제출) 유지
|
||||
if (active.tagName === 'TEXTAREA' ||
|
||||
active.tagName === 'BUTTON' ||
|
||||
active.getAttribute('type') === 'submit' ||
|
||||
active.classList.contains('mud-button-root')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// 포커스 이동 가능한 모든 입력 요소 수집
|
||||
const focusables = Array.from(container.querySelectorAll('input, select, textarea, button'))
|
||||
.filter(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return el.tabIndex >= 0 &&
|
||||
!el.disabled &&
|
||||
el.getAttribute('aria-disabled') !== 'true' &&
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden';
|
||||
});
|
||||
|
||||
const index = focusables.indexOf(active);
|
||||
if (index > -1 && index < focusables.length - 1) {
|
||||
const nextEl = focusables[index + 1];
|
||||
nextEl.focus();
|
||||
if (typeof nextEl.select === 'function') {
|
||||
nextEl.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Create common_codes table
|
||||
CREATE TABLE IF NOT EXISTS common_codes (
|
||||
code_group VARCHAR(50) NOT NULL,
|
||||
code_value VARCHAR(50) NOT NULL,
|
||||
code_name VARCHAR(100) NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
PRIMARY KEY (code_group, code_value)
|
||||
);
|
||||
|
||||
-- Seed data for BUSINESS_TYPE
|
||||
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
|
||||
('BUSINESS_TYPE', '일반제조업', '일반제조업', 10),
|
||||
('BUSINESS_TYPE', '도소매업', '도소매업', 20),
|
||||
('BUSINESS_TYPE', '서비스업', '서비스업', 30),
|
||||
('BUSINESS_TYPE', '정보통신업', '정보통신업', 40),
|
||||
('BUSINESS_TYPE', '부동산업', '부동산업', 50),
|
||||
('BUSINESS_TYPE', '건설업', '건설업', 60),
|
||||
('BUSINESS_TYPE', '음식점업', '음식점업', 70),
|
||||
('BUSINESS_TYPE', '프리랜서', '프리랜서', 80),
|
||||
('BUSINESS_TYPE', '기타', '기타', 90)
|
||||
ON CONFLICT (code_group, code_value) DO NOTHING;
|
||||
|
||||
-- Seed data for TAX_RISK_LEVEL
|
||||
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
|
||||
('TAX_RISK_LEVEL', 'low', '낮음', 10),
|
||||
('TAX_RISK_LEVEL', 'normal', '보통', 20),
|
||||
('TAX_RISK_LEVEL', 'high', '높음', 30)
|
||||
ON CONFLICT (code_group, code_value) DO NOTHING;
|
||||
|
||||
-- Seed data for FILING_TYPE
|
||||
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
|
||||
('FILING_TYPE', '종합소득세', '종합소득세', 10),
|
||||
('FILING_TYPE', '부가가치세', '부가가치세', 20),
|
||||
('FILING_TYPE', '법인세', '법인세', 30),
|
||||
('FILING_TYPE', '원천세', '원천세', 40),
|
||||
('FILING_TYPE', '양도소득세', '양도소득세', 50),
|
||||
('FILING_TYPE', '상속/증여세', '상속/증여세', 60)
|
||||
ON CONFLICT (code_group, code_value) DO NOTHING;
|
||||
|
||||
-- Seed data for SERVICE_TYPE
|
||||
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
|
||||
('SERVICE_TYPE', '개인 기장대리', '개인 기장대리', 10),
|
||||
('SERVICE_TYPE', '법인 기장대리', '법인 기장대리', 20),
|
||||
('SERVICE_TYPE', '세무조정', '세무조정', 30),
|
||||
('SERVICE_TYPE', '세무컨설팅', '세무컨설팅', 40),
|
||||
('SERVICE_TYPE', '불복청구', '불복청구', 50)
|
||||
ON CONFLICT (code_group, code_value) DO NOTHING;
|
||||
@@ -0,0 +1,132 @@
|
||||
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
|
||||
server {
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001/taxbaik/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# /taxbaik/ 하위로 들어오는 리소스 및 페이지 요청 처리
|
||||
location /taxbaik {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
|
||||
# 2. Gitea (gitea.taxbaik.com)
|
||||
server {
|
||||
server_name gitea.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
|
||||
# 3. QuantEngine (quant.taxbaik.com)
|
||||
server {
|
||||
server_name quant.taxbaik.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = www.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
if ($host = taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = gitea.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name gitea.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = quant.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name quant.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DEPLOY_HOME="/home/kjh2064"
|
||||
PORT_FILE="$DEPLOY_HOME/taxbaik_port"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
echo "===== 🚀 TaxBaik Green/Blue Deployment Script ====="
|
||||
|
||||
# 1. Determine active port
|
||||
ACTIVE_PORT=5003
|
||||
if [ -f "$PORT_FILE" ]; then
|
||||
ACTIVE_PORT=$(cat "$PORT_FILE" | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
# 2. Determine target port
|
||||
TARGET_PORT=5003
|
||||
if [ "$ACTIVE_PORT" -eq 5003 ]; then
|
||||
TARGET_PORT=5004
|
||||
else
|
||||
TARGET_PORT=5003
|
||||
fi
|
||||
|
||||
echo "Active Port: $ACTIVE_PORT"
|
||||
echo "Target Port: $TARGET_PORT"
|
||||
|
||||
# 3. New deploy dir is passed as first argument
|
||||
DEPLOY_DIR="$1"
|
||||
if [ -z "$DEPLOY_DIR" ]; then
|
||||
echo "Error: Deployment directory argument required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploy Directory: $DEPLOY_DIR"
|
||||
|
||||
# 4. Start the new app on the target port
|
||||
echo "=== Starting New App on Port $TARGET_PORT ==="
|
||||
cd "$DEPLOY_DIR"
|
||||
export ASPNETCORE_ENVIRONMENT=Production
|
||||
export ASPNETCORE_URLS="http://127.0.0.1:$TARGET_PORT"
|
||||
|
||||
# Run dotnet process
|
||||
nohup /usr/bin/dotnet TaxBaik.Web.dll > "web_${TARGET_PORT}.log" 2>&1 &
|
||||
NEW_PID=$!
|
||||
sleep 2
|
||||
|
||||
# Verify process is running
|
||||
if ! ps -p $NEW_PID > /dev/null; then
|
||||
echo "❌ Failed to start dotnet process on port $TARGET_PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Health Check Loop
|
||||
echo "=== Health Checking Port $TARGET_PORT ==="
|
||||
ATTEMPTS=20
|
||||
SUCCESS=false
|
||||
for i in $(seq 1 $ATTEMPTS); do
|
||||
STATUS=$(curl -sf -o /dev/null -w '%{http_code}' "http://127.0.0.1:${TARGET_PORT}/taxbaik/healthz" 2>/dev/null || echo "000")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "✓ Health check passed on port $TARGET_PORT (Attempt $i/$ATTEMPTS)"
|
||||
SUCCESS=true
|
||||
break
|
||||
fi
|
||||
echo " Waiting for health check... ($i/$ATTEMPTS, Status: $STATUS)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$SUCCESS" = "false" ]; then
|
||||
echo "❌ Health check failed. Rolling back..."
|
||||
kill -9 $NEW_PID || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 6. Switch Traffic
|
||||
echo "=== Switching Traffic to Port $TARGET_PORT ==="
|
||||
echo "$TARGET_PORT" > "$PORT_FILE"
|
||||
echo "✓ Traffic routed to $TARGET_PORT"
|
||||
|
||||
# 7. Terminate Old App
|
||||
echo "=== Stopping Old App on Port $ACTIVE_PORT ==="
|
||||
# Find PID listening on ACTIVE_PORT
|
||||
OLD_PID=$(ss -tlnp | grep ":$ACTIVE_PORT " | grep -oP 'pid=\K\d+' | head -n1)
|
||||
if [ -n "$OLD_PID" ]; then
|
||||
echo "Killing old process PID: $OLD_PID"
|
||||
kill -15 $OLD_PID || kill -9 $OLD_PID
|
||||
echo "✓ Old process terminated"
|
||||
else
|
||||
echo "No old process found on port $ACTIVE_PORT"
|
||||
fi
|
||||
|
||||
# 8. Cleanup old deployment directories (Keep last 5)
|
||||
echo "=== Cleaning Up Old Deployments ==="
|
||||
ls -1dt $DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null | tail -n +6 | xargs rm -rf 2>/dev/null || true
|
||||
echo "✓ Cleanup completed"
|
||||
|
||||
echo "===== ✅ Green/Blue Deployment Completed Successfully ====="
|
||||
@@ -90,14 +90,13 @@ test.describe('admin CRM pages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('TaxProfiles modal dialog opens on add button click', async ({ page }) => {
|
||||
test('TaxProfiles editor panel is visible on add button click', async ({ page }) => {
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
|
||||
await expect(addButton).toBeVisible();
|
||||
await addButton.click();
|
||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/tax-profiles$/);
|
||||
await expect(addButton).toBeVisible();
|
||||
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('No console errors on CRM page navigation', async ({ page }) => {
|
||||
@@ -129,16 +128,15 @@ test.describe('admin CRM pages', () => {
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
|
||||
await addButton.click();
|
||||
|
||||
// JS 네이티브 클릭으로 강제 격발하여 오프셋 씹힘 소멸
|
||||
await addButton.evaluate(el => (el as HTMLButtonElement).click());
|
||||
// 분할 편집기(admin-editor-panel) 노출 대기
|
||||
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 편집 모달이 화면에 안정적으로 표시될 때까지 대기
|
||||
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// mud-select 컨테이너 자체 클릭 (이벤트 핸들러 직접 격발)
|
||||
const select = page.locator('.mud-select').filter({ hasText: '사업 유형' }).first();
|
||||
await select.evaluate(el => (el as HTMLDivElement).click());
|
||||
// mud-select 내의 input 클릭 (이벤트 핸들러 격발 유도)
|
||||
const select = page.locator('.admin-editor-panel .mud-select').filter({ hasText: '사업 유형' }).first();
|
||||
await page.waitForTimeout(1500);
|
||||
await select.locator('input').click();
|
||||
|
||||
// 활성화된 팝오버(.mud-popover-open) 내에서 텍스트 노출 검증
|
||||
const popover = page.locator('.mud-popover-open');
|
||||
@@ -152,12 +150,13 @@ test.describe('admin CRM pages', () => {
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 일정 추가/ });
|
||||
await addButton.evaluate(el => (el as HTMLButtonElement).click());
|
||||
await addButton.click();
|
||||
|
||||
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const select = page.locator('.mud-select').filter({ hasText: '신고 유형' }).first();
|
||||
await select.evaluate(el => (el as HTMLDivElement).click());
|
||||
const select = page.locator('.admin-editor-panel .mud-select').filter({ hasText: '신고 유형' }).first();
|
||||
await page.waitForTimeout(1500);
|
||||
await select.locator('input').click();
|
||||
|
||||
const popover = page.locator('.mud-popover-open');
|
||||
await expect(popover.getByText('종합소득세')).toBeVisible({ timeout: 5000 });
|
||||
@@ -169,15 +168,17 @@ test.describe('admin CRM pages', () => {
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 계약 추가/ });
|
||||
await addButton.evaluate(el => (el as HTMLButtonElement).click());
|
||||
await addButton.click();
|
||||
|
||||
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const select = page.locator('.mud-select').filter({ hasText: '서비스 유형' }).first();
|
||||
await select.evaluate(el => (el as HTMLDivElement).click());
|
||||
const select = page.locator('.admin-editor-panel .mud-select').filter({ hasText: '서비스 유형' }).first();
|
||||
await page.waitForTimeout(1500);
|
||||
await select.locator('input').click();
|
||||
|
||||
const popover = page.locator('.mud-popover-open');
|
||||
await expect(popover.getByText('개인 기장대리')).toBeVisible({ timeout: 5000 });
|
||||
await expect(popover.getByText('법인 기장대리')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ const TEST_PASSWORD = process.env.E2E_ADMIN_PASSWORD || 'TestAdmin@123456';
|
||||
* - 운영(localhost): http://localhost/taxbaik (Nginx 라우팅 → active 포트)
|
||||
* - 로컬 직접 테스트: http://127.0.0.1:5001/taxbaik (개발 포트)
|
||||
*/
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://localhost/taxbaik').replace(/\/$/, '');
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
/**
|
||||
* API를 통한 테스트 데이터 생성
|
||||
@@ -121,7 +121,7 @@ test.describe('admin responsive design (test_admin account)', () => {
|
||||
return window.getComputedStyle(el).fontSize;
|
||||
});
|
||||
const size = parseFloat(fontSize);
|
||||
expect(size).toBeGreaterThanOrEqual(11); // 최소 11px
|
||||
expect(size).toBeGreaterThanOrEqual(9.5); // ERP 최소 9.5px
|
||||
}
|
||||
|
||||
console.log(`✅ ${device.name} - PASS`);
|
||||
|
||||
Reference in New Issue
Block a user