Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32c5a3d042 | |||
| 68291867f9 | |||
| d24f3f58db | |||
| 24ecf89028 | |||
| ff6651c4f2 | |||
| f892b85b7e | |||
| 62a7b2f2ef | |||
| 184ff2259b | |||
| 163812e964 | |||
| ba158f9824 | |||
| b2477d977b | |||
| 80c97fba96 | |||
| 1fb3a3c329 | |||
| abd7bbf016 | |||
| c765db37b3 | |||
| 967a784d6e | |||
| 03809bbf26 | |||
| c626c164f8 | |||
| 15f5dcf4ea | |||
| a84f842490 | |||
| 8999e51d4e | |||
| f98405b791 | |||
| ee964457d9 | |||
| 54c179b1eb | |||
| 488b8d11b7 | |||
| 65c5f19a2f | |||
| eaacbc8d7f | |||
| ac8a70a2ca | |||
| 203e674c3f | |||
| 0c014d0bdf | |||
| 904c0972ca | |||
| 7e75aeeec7 | |||
| b13eed7b7e | |||
| 4647b049b8 | |||
| 1a5ebb45bc | |||
| f197663101 | |||
| 70b57f1d4c | |||
| 428eeb6fd8 | |||
| dd68a237a1 | |||
| ef9fd523c6 | |||
| f2ab78dea2 | |||
| 1e0c0b7e1c | |||
| 1b173376ee | |||
| 1a7bc9e209 | |||
| 3be379431f | |||
| 682e2db3a3 | |||
| d9766cb5ef | |||
| 6bcb9effa8 | |||
| 186c6ef7a4 | |||
| c2e8e08f09 | |||
| 3f7cd7cd84 | |||
| 4b352df408 | |||
| a4b1234900 | |||
| a3c81c4f70 | |||
| 6e8b4e76ac | |||
| 5807e1b35e | |||
| 3e1097f585 | |||
| 917600a793 | |||
| 0d3615b44d | |||
| fc339ca9e7 | |||
| da1226994f | |||
| 6bc03ce3d9 | |||
| ecfbfc7cac | |||
| 46cb508bdf | |||
| ecabe8d9cc | |||
| 55c65810c1 | |||
| 7054d397e4 | |||
| 11fb596fc2 | |||
| ea9478f2f1 |
@@ -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
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
Blazor → Service (서버) → DB
|
||||
|
||||
✅ 현재: API-First (클라이언트-서버 분리)
|
||||
Blazor (UI만) ← API (모든 로직) ← DB
|
||||
SignalR (변경 알림만)
|
||||
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
|
||||
Blazor 데이터 변경 자동 push/broadcast 금지
|
||||
```
|
||||
|
||||
### SOLID 기반 순차 마이그레이션 전략
|
||||
@@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
|
||||
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
|
||||
|
||||
#### Phase 6: SignalR 통합
|
||||
- [ ] NotificationHub (변경 알림만)
|
||||
- [ ] Blazor에서 구독
|
||||
- [ ] 알림 후 API로 데이터 검증
|
||||
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
|
||||
- [x] NotificationHub 제거
|
||||
- [x] 데이터 변경용 INotificationService 제거
|
||||
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
|
||||
|
||||
#### Phase 7: 순차적 마이그레이션 ✅
|
||||
- [x] Blog 페이지 → API 클라이언트
|
||||
@@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080;
|
||||
- Status Color Chips (Error/Warning/Success)
|
||||
- Client 링크 (상세 페이지 연동)
|
||||
|
||||
### **Phase 6: SignalR 통합** ✅
|
||||
- NotificationHub (브로드캐스트만, 상태 관리 없음)
|
||||
- INotificationService (이벤트 기반)
|
||||
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
|
||||
- Program.cs SignalR 등록
|
||||
### **Phase 6: Lite Blazor 운영 원칙** ✅
|
||||
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
|
||||
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
|
||||
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
|
||||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -160,11 +160,11 @@ Repositories (데이터 계층)
|
||||
PostgreSQL Database
|
||||
```
|
||||
|
||||
**Blazor Server SignalR**:
|
||||
- 자동 연결 (내장 Hub connection)
|
||||
- NotificationHub 클라이언트 그룹 (admins)
|
||||
- 이벤트 기반 메시지 (상태 관리 없음)
|
||||
- 클라이언트는 알림 후 API로 데이터 검증
|
||||
**Lite Blazor 데이터 갱신**:
|
||||
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
|
||||
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
|
||||
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
|
||||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -182,10 +182,10 @@ PostgreSQL Database
|
||||
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
|
||||
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
|
||||
|
||||
**실시간 알림 (Phase 6)**:
|
||||
- [x] NotificationHub 구현
|
||||
- [x] Event-driven 알림 시스템
|
||||
- [x] Scoped DI 등록
|
||||
**Lite Blazor / 데이터 갱신 (Phase 6)**:
|
||||
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
|
||||
- [x] NotificationHub 제거
|
||||
- [x] 데이터 변경용 INotificationService 제거
|
||||
|
||||
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
|
||||
- [x] 5개 CRM/세무관리 Blazor 페이지
|
||||
@@ -564,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 서비스 파일 위치
|
||||
```
|
||||
@@ -1637,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에서 확인
|
||||
@@ -1676,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"
|
||||
|
||||
@@ -1944,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`가 있어야 배포가 실제로 시작된 것이다.
|
||||
|
||||
+120
-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,22 @@ 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 _;
|
||||
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/;
|
||||
|
||||
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
|
||||
location /admin {
|
||||
return 301 $scheme://$host/taxbaik$request_uri;
|
||||
}
|
||||
|
||||
# 루트 경로 요청을 /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 +152,33 @@ 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;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -159,13 +190,89 @@ server {
|
||||
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
|
||||
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
**라우팅 요약**:
|
||||
- `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 +491,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
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetPendingFollowupsAsync(ct);
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ public class ContractService(IContractRepository repository)
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
|
||||
await repository.GetPendingPaymentsAsync(ct);
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
|
||||
@@ -31,10 +31,16 @@ public class TaxProfileService(ITaxProfileRepository repository)
|
||||
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||
await repository.GetByClientIdAsync(clientId, ct);
|
||||
|
||||
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
||||
{
|
||||
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,6 +5,7 @@ using TaxBaik.Domain.Entities;
|
||||
public interface IConsultingActivityRepository
|
||||
{
|
||||
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
|
||||
public interface IContractRepository
|
||||
{
|
||||
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
|
||||
public interface IRevenueTrackingRepository
|
||||
{
|
||||
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
|
||||
public interface ITaxFilingScheduleRepository
|
||||
{
|
||||
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -5,6 +5,8 @@ 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);
|
||||
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, 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");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory
|
||||
activity);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<ConsultingActivity>(
|
||||
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||
FROM consulting_activities ORDER BY activity_date DESC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -16,6 +16,14 @@ public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
contract);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Contract>(
|
||||
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||
FROM contracts ORDER BY contract_date DESC");
|
||||
}
|
||||
|
||||
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -16,6 +16,14 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
|
||||
revenue);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<RevenueTracking>(
|
||||
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||
FROM revenue_tracking ORDER BY invoice_date DESC");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -16,6 +16,14 @@ public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory)
|
||||
schedule);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||
FROM tax_filing_schedules ORDER BY due_date DESC");
|
||||
}
|
||||
|
||||
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -20,6 +20,27 @@ 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();
|
||||
return await conn.QueryAsync<TaxProfile>(
|
||||
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||
FROM tax_profiles ORDER BY id DESC");
|
||||
}
|
||||
|
||||
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -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>
|
||||
@@ -32,8 +32,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="js/admin-session.js"></script>
|
||||
@@ -79,49 +77,49 @@
|
||||
},
|
||||
LayoutProperties = new LayoutProperties()
|
||||
{
|
||||
DefaultBorderRadius = "8px"
|
||||
DefaultBorderRadius = "6px"
|
||||
},
|
||||
Typography = new Typography()
|
||||
{
|
||||
Default = new Default()
|
||||
{
|
||||
FontSize = ".875rem",
|
||||
FontSize = ".8125rem",
|
||||
FontWeight = 400,
|
||||
LineHeight = 1.5
|
||||
},
|
||||
H1 = new H1()
|
||||
{
|
||||
FontSize = "2.5rem",
|
||||
FontSize = "1.75rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.2
|
||||
},
|
||||
H2 = new H2()
|
||||
{
|
||||
FontSize = "2rem",
|
||||
FontSize = "1.5rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.3
|
||||
},
|
||||
H3 = new H3()
|
||||
{
|
||||
FontSize = "1.75rem",
|
||||
FontSize = "1.25rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.3
|
||||
},
|
||||
H4 = new H4()
|
||||
{
|
||||
FontSize = "1.5rem",
|
||||
FontSize = "1.1rem",
|
||||
FontWeight = 600,
|
||||
LineHeight = 1.4
|
||||
},
|
||||
H5 = new H5()
|
||||
{
|
||||
FontSize = "1.25rem",
|
||||
FontSize = "0.95rem",
|
||||
FontWeight = 500,
|
||||
LineHeight = 1.4
|
||||
},
|
||||
H6 = new H6()
|
||||
{
|
||||
FontSize = "1rem",
|
||||
FontSize = "0.85rem",
|
||||
FontWeight = 500,
|
||||
LineHeight = 1.5
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
@inject IJSRuntime JS
|
||||
@implements IDisposable
|
||||
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout Class="admin-shell">
|
||||
<MudAppBar Elevation="0" Class="admin-topbar">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu"
|
||||
@@ -10,9 +14,9 @@
|
||||
Edge="Edge.Start"
|
||||
Class="admin-menu-button"
|
||||
OnClick="@ToggleDrawer" />
|
||||
<div class="admin-topbar-title">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
|
||||
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
|
||||
<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 />
|
||||
|
||||
|
||||
@@ -22,14 +22,22 @@
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="d-flex pa-4 gap-4 align-center">
|
||||
<MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||
</div>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (announcements is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (!announcements.Any())
|
||||
else if (!FilteredAnnouncements.Any())
|
||||
{
|
||||
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
|
||||
<div class="pa-6 text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Campaign" Style="font-size:3rem; opacity:.3;" />
|
||||
<MudText Class="mt-2 text-muted">검색 조건에 맞는 공지사항이 없습니다.</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -45,7 +53,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in announcements)
|
||||
@foreach (var item in FilteredAnnouncements)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Title</td>
|
||||
@@ -86,15 +94,38 @@
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<Announcement>? announcements;
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
private List<Announcement>? announcements;
|
||||
private string searchQuery = "";
|
||||
|
||||
private IEnumerable<Announcement> FilteredAnnouncements => announcements?
|
||||
.Where(a => string.IsNullOrEmpty(searchQuery) ||
|
||||
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadAsync();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
<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" />
|
||||
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
|
||||
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@@ -32,8 +32,24 @@
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
|
||||
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
|
||||
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
|
||||
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
|
||||
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
|
||||
@if (string.IsNullOrWhiteSpace(model.Content))
|
||||
{
|
||||
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@((MarkupString)model.Content)
|
||||
}
|
||||
</div>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
@@ -32,8 +32,8 @@ else
|
||||
{
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
|
||||
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@@ -43,8 +43,24 @@ else
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
|
||||
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
|
||||
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
|
||||
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
|
||||
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
|
||||
@if (string.IsNullOrWhiteSpace(model.Content))
|
||||
{
|
||||
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@((MarkupString)model.Content)
|
||||
}
|
||||
</div>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
@@ -15,14 +15,19 @@
|
||||
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="d-flex pa-4 gap-4 align-center">
|
||||
<MudTextField @bind-Value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||
</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.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
|
||||
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Title" Title="제목" />
|
||||
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
||||
@@ -50,16 +55,36 @@
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||
private string searchQuery = "";
|
||||
private bool isLoading = true;
|
||||
private int currentPage = 1;
|
||||
private int totalPages = 1;
|
||||
private int totalPosts = 0;
|
||||
private const int PageSize = 20;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
private IEnumerable<TaxBaik.Domain.Entities.BlogPost> FilteredPosts => posts?
|
||||
.Where(p => string.IsNullOrEmpty(searchQuery) ||
|
||||
p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||
(p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))) ?? Enumerable.Empty<TaxBaik.Domain.Entities.BlogPost>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadPosts();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadPosts();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPosts()
|
||||
|
||||
@@ -129,6 +129,9 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Client>? clients;
|
||||
private string searchText = "";
|
||||
private string statusFilter = "";
|
||||
@@ -137,7 +140,21 @@
|
||||
private int totalPages;
|
||||
private const int PageSize = 20;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
|
||||
@@ -100,10 +100,17 @@
|
||||
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<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" />
|
||||
@@ -116,6 +123,9 @@
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<ConsultingActivity>? activities;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
@@ -124,9 +134,20 @@
|
||||
private ConsultingActivity? editingActivity;
|
||||
private ConsultingActivityForm activityForm = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadData();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -134,9 +155,9 @@
|
||||
try
|
||||
{
|
||||
activities = await ActivityClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -147,7 +168,11 @@
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
editingActivity = null;
|
||||
activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
|
||||
activityForm = new ConsultingActivityForm
|
||||
{
|
||||
ActivityDate = DateTime.Now,
|
||||
ClientId = clients.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -167,6 +192,16 @@
|
||||
|
||||
private async Task SaveActivity()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (editingActivity == null)
|
||||
@@ -238,6 +273,12 @@
|
||||
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; }
|
||||
|
||||
@@ -21,122 +21,162 @@
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
|
||||
새 계약 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (contracts is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (contracts.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
|
||||
계약이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="Contract"
|
||||
Items="@contracts"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
@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>
|
||||
|
||||
<!-- 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)
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</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)
|
||||
</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)
|
||||
{
|
||||
<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>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteContract(selectedContract?.Id ?? 0))">삭제</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveContract" id="btn-save-contract">저장</MudButton>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 계약 추가</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudTextField T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveContract">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
</div>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Contract>? contracts;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private decimal mrr = 0;
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private bool isEditMode;
|
||||
private Contract? selectedContract;
|
||||
private ContractForm contractForm = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadData();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -144,9 +184,9 @@
|
||||
try
|
||||
{
|
||||
contracts = await ContractClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -155,18 +195,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
private void PrepareCreate()
|
||||
{
|
||||
contractForm = new();
|
||||
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()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (contractForm.ClientId == null) return;
|
||||
var newId = await ContractClient.CreateAsync(
|
||||
contractForm.ClientId,
|
||||
contractForm.ClientId.Value,
|
||||
contractForm.ContractNumber,
|
||||
contractForm.ServiceType,
|
||||
contractForm.StartDate ?? DateTime.Now,
|
||||
@@ -175,7 +246,7 @@
|
||||
if (newId > 0)
|
||||
{
|
||||
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
PrepareCreate();
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
@@ -203,6 +274,10 @@
|
||||
{
|
||||
await ContractClient.DeleteAsync(id);
|
||||
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
|
||||
if (selectedContract?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -211,15 +286,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
contractForm = new();
|
||||
}
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
|
||||
private class ContractForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
public int? ClientId { get; set; }
|
||||
public string ContractNumber { get; set; } = "";
|
||||
public string ServiceType { get; set; } = "";
|
||||
public DateTime? StartDate { get; set; }
|
||||
|
||||
@@ -17,49 +17,58 @@
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<!-- Metrics Grid - Pure HTML div instead of MudGrid to ensure proper layout -->
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||
}
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
|
||||
}
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<div class="admin-metric-grid">
|
||||
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))' style="cursor: pointer;">
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">이번달 문의</span>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||
<span style="font-size: 2rem; font-weight: 700; color: #1565c0;">@summary.ThisMonthInquiries</span>
|
||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #1976d2;">💬</span>
|
||||
<div 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 style="font-size: 0.9rem; color: #666;">월간 상담 유입 (클릭 시 이동)</span>
|
||||
<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"))' style="cursor: pointer;">
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">신규 문의</span>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||
<span style="font-size: 2rem; font-weight: 700; color: #e65100;">@summary.NewInquiries</span>
|
||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #f57c00;">⚠️</span>
|
||||
<div 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 style="font-size: 0.9rem; color: #666;">처리 대기 (클릭 시 이동)</span>
|
||||
<span class="admin-metric-card-caption">처리 대기 (클릭 시 이동)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">전체 포스트</span>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||
<span style="font-size: 2rem; font-weight: 700; color: #455a64;">@summary.TotalPosts</span>
|
||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #607d8b;">📄</span>
|
||||
<div 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 style="font-size: 0.9rem; color: #666;">콘텐츠 자산 (클릭 시 이동)</span>
|
||||
<span class="admin-metric-card-caption">콘텐츠 자산 (클릭 시 이동)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">발행된 포스트</span>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||
<span style="font-size: 2rem; font-weight: 700; color: #2e7d32;">@summary.PublishedPosts</span>
|
||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #388e3c;">🌐</span>
|
||||
<div 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 style="font-size: 0.9rem; color: #666;">검색 노출 대상 (클릭 시 이동)</span>
|
||||
<span class="admin-metric-card-caption">검색 노출 대상 (클릭 시 이동)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,31 +167,45 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
||||
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
||||
private string? errorMessage;
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try
|
||||
if (firstRender)
|
||||
{
|
||||
// API 클라이언트 사용 (서비스 직접 호출 X)
|
||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
// API 클라이언트 사용 (서비스 직접 호출 X)
|
||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||
|
||||
await Task.WhenAll(summaryTask, filingsTask);
|
||||
summary = await summaryTask;
|
||||
upcomingFilings = (await filingsTask).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
await Task.WhenAll(summaryTask, filingsTask);
|
||||
summary = await summaryTask;
|
||||
upcomingFilings = (await filingsTask).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,16 +22,21 @@
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="d-flex pa-4 gap-4 align-center">
|
||||
<MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||
</div>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (faqs is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (!faqs.Any())
|
||||
else if (!FilteredFaqs.Any())
|
||||
{
|
||||
<div class="pa-6 text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
|
||||
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
|
||||
<MudText Class="mt-2 text-muted">검색 조건에 맞는 FAQ가 없습니다.</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -39,7 +44,7 @@
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">순서</th>
|
||||
<th style="width:110px;">순서</th>
|
||||
<th>질문</th>
|
||||
<th style="width:130px;">카테고리</th>
|
||||
<th style="width:90px;">상태</th>
|
||||
@@ -47,11 +52,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in faqs)
|
||||
@foreach (var item in FilteredFaqs)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
|
||||
<td>
|
||||
<div class="d-flex align-center justify-start gap-1">
|
||||
<MudText Typo="Typo.body2" Class="mr-2">@item.SortOrder</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropUp" Size="Size.Small" OnClick="@(() => MoveUpAsync(item))" Style="padding:2px;" Dense="true" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropDown" Size="Size.Small" OnClick="@(() => MoveDownAsync(item))" Style="padding:2px;" Dense="true" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||
@@ -77,10 +86,10 @@
|
||||
<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>
|
||||
@@ -89,21 +98,45 @@
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<Faq>? faqs;
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
private List<Faq>? faqs;
|
||||
private string searchQuery = "";
|
||||
|
||||
private IEnumerable<Faq> FilteredFaqs => faqs?
|
||||
.Where(f => string.IsNullOrEmpty(searchQuery) ||
|
||||
f.Question.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||
(f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
faqs = (await FaqClient.GetAllAsync()).ToList();
|
||||
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -112,6 +145,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MoveUpAsync(Faq item)
|
||||
{
|
||||
if (faqs == null) return;
|
||||
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
|
||||
var index = sorted.IndexOf(item);
|
||||
if (index <= 0) return;
|
||||
|
||||
var prev = sorted[index - 1];
|
||||
var temp = item.SortOrder;
|
||||
item.SortOrder = prev.SortOrder;
|
||||
prev.SortOrder = temp;
|
||||
|
||||
if (item.SortOrder == prev.SortOrder)
|
||||
{
|
||||
prev.SortOrder = item.SortOrder + 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await FaqClient.UpdateAsync(item.Id, item);
|
||||
await FaqClient.UpdateAsync(prev.Id, prev);
|
||||
Snackbar.Add("순서가 상향되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MoveDownAsync(Faq item)
|
||||
{
|
||||
if (faqs == null) return;
|
||||
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
|
||||
var index = sorted.IndexOf(item);
|
||||
if (index < 0 || index >= sorted.Count - 1) return;
|
||||
|
||||
var next = sorted[index + 1];
|
||||
var temp = item.SortOrder;
|
||||
item.SortOrder = next.SortOrder;
|
||||
next.SortOrder = temp;
|
||||
|
||||
if (item.SortOrder == next.SortOrder)
|
||||
{
|
||||
next.SortOrder = item.SortOrder + 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await FaqClient.UpdateAsync(item.Id, item);
|
||||
await FaqClient.UpdateAsync(next.Id, next);
|
||||
Snackbar.Add("순서가 하향되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Faq item)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
|
||||
@@ -46,11 +46,31 @@ else
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private bool isLoading = true;
|
||||
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
|
||||
|
||||
@@ -96,13 +96,19 @@
|
||||
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudTextField T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
<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>
|
||||
@@ -113,6 +119,9 @@
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<RevenueTracking>? revenues;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
@@ -120,9 +129,20 @@
|
||||
private bool isDialogOpen;
|
||||
private RevenueForm revenueForm = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadData();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -130,9 +150,9 @@
|
||||
try
|
||||
{
|
||||
revenues = await RevenueClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -142,12 +162,27 @@
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
revenueForm = new();
|
||||
revenueForm = new RevenueForm
|
||||
{
|
||||
ClientId = clients.FirstOrDefault()?.Id ?? 0,
|
||||
InvoiceDate = DateTime.Today,
|
||||
DueDate = DateTime.Today.AddDays(14)
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveRevenue()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var newId = await RevenueClient.CreateAsync(
|
||||
@@ -217,6 +252,12 @@
|
||||
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; }
|
||||
|
||||
@@ -14,150 +14,201 @@
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenCreateDialog"
|
||||
StartIcon="@Icons.Material.Filled.Add">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-schedule">
|
||||
새 일정 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (schedules is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (schedules.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
|
||||
신고 일정이 없습니다.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="TaxFilingSchedule"
|
||||
Items="@schedules"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
@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)
|
||||
{
|
||||
<span class="ms-1">(D-@daysLeft)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
|
||||
}
|
||||
</MudChip>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
|
||||
<TemplateColumn Title="상태">
|
||||
<CellTemplate>
|
||||
@if (context.Item.Status == "completed")
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
@if (context.Item.Status != "completed")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
|
||||
Color="Color.Success"
|
||||
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
|
||||
Title="완료" />
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
|
||||
Title="삭제" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudItem>
|
||||
|
||||
<!-- 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)
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
|
||||
<TemplateColumn Title="마감일">
|
||||
<CellTemplate>
|
||||
@{
|
||||
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
|
||||
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
|
||||
}
|
||||
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
|
||||
@context.Item.DueDate.ToString("yyyy-MM-dd")
|
||||
@if (daysLeft >= 0)
|
||||
{
|
||||
<span class="ms-1">(D-@daysLeft)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
|
||||
}
|
||||
</MudChip>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
|
||||
<TemplateColumn Title="상태">
|
||||
<CellTemplate>
|
||||
@if (context.Item.Status == "completed")
|
||||
</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")
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
|
||||
<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
|
||||
{
|
||||
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSchedule" id="btn-save-schedule">저장</MudButton>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
@if (context.Item.Status != "completed")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
|
||||
Color="Color.Success"
|
||||
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
|
||||
Title="완료" />
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
|
||||
Title="삭제" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">새 신고 일정 추가</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int"
|
||||
@bind-Value="scheduleForm.ClientId"
|
||||
Label="고객"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveSchedule">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
</div>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxFilingSchedule>? schedules;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private bool isEditMode;
|
||||
private TaxFilingSchedule? selectedSchedule;
|
||||
private TaxFilingScheduleForm scheduleForm = new();
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadData();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
schedules = await TaxFilingClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -165,18 +216,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
private void PrepareCreate()
|
||||
{
|
||||
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
|
||||
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 (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (scheduleForm.ClientId == null) return;
|
||||
var newId = await TaxFilingClient.CreateAsync(
|
||||
scheduleForm.ClientId,
|
||||
scheduleForm.ClientId.Value,
|
||||
scheduleForm.FilingType,
|
||||
scheduleForm.DueDate ?? DateTime.Today,
|
||||
scheduleForm.FilingYear);
|
||||
@@ -184,7 +266,7 @@
|
||||
if (newId > 0)
|
||||
{
|
||||
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
|
||||
CloseDialog();
|
||||
PrepareCreate();
|
||||
await LoadData();
|
||||
}
|
||||
else
|
||||
@@ -204,6 +286,10 @@
|
||||
{
|
||||
await TaxFilingClient.MarkCompletedAsync(id);
|
||||
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
|
||||
if (selectedSchedule?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -229,6 +315,10 @@
|
||||
{
|
||||
await TaxFilingClient.DeleteAsync(id);
|
||||
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
|
||||
if (selectedSchedule?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -237,15 +327,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
scheduleForm = new();
|
||||
}
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
|
||||
private class TaxFilingScheduleForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
public int? ClientId { get; set; }
|
||||
public string FilingType { get; set; } = "";
|
||||
public DateTime? DueDate { get; set; }
|
||||
public int FilingYear { get; set; } = DateTime.Now.Year;
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
|
||||
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
|
||||
return items;
|
||||
}
|
||||
catch
|
||||
@@ -110,6 +110,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
private async Task AddFiling()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@inject ITaxProfileBrowserClient TaxProfileClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ICommonCodeBrowserClient CommonCodeClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@attribute [Authorize]
|
||||
@@ -14,7 +15,7 @@
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-profile">
|
||||
새 프로필 추가
|
||||
</MudButton>
|
||||
</section>
|
||||
@@ -23,102 +24,139 @@
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (profiles.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">세무 프로필이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudDataGrid T="TaxProfile"
|
||||
Items="@profiles"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Virtualize="true"
|
||||
RowsPerPage="30"
|
||||
Class="admin-grid mt-4">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||
<TemplateColumn Title="고객">
|
||||
<CellTemplate>
|
||||
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||
<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>
|
||||
|
||||
<!-- 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)
|
||||
{
|
||||
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||
@clientName
|
||||
</MudLink>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
|
||||
새로 작성
|
||||
</MudButton>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
|
||||
<TemplateColumn Title="위험도">
|
||||
<CellTemplate>
|
||||
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
|
||||
@context.Item.TaxRiskLevel
|
||||
</MudChip>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="다음 신고">
|
||||
<CellTemplate>
|
||||
@if (context.Item.NextFilingDueDate.HasValue)
|
||||
{
|
||||
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn Title="작업" Sortable="false">
|
||||
<CellTemplate>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
|
||||
</MudButtonGroup>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
</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>
|
||||
}
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="form">
|
||||
<MudSelect T="int" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
@foreach (var client in clients)
|
||||
{
|
||||
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
<MudSelectItem Value="@("low")">낮음</MudSelectItem>
|
||||
<MudSelectItem Value="@("normal")">보통</MudSelectItem>
|
||||
<MudSelectItem Value="@("high")">높음</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="true" Lines="2" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||
<MudButton Color="Color.Primary" OnClick="SaveProfile">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxProfile>? profiles;
|
||||
private List<Client> clients = [];
|
||||
private Dictionary<int, string> clientMap = new();
|
||||
private List<CommonCode> businessTypes = [];
|
||||
private List<CommonCode> riskLevels = [];
|
||||
private MudForm? form;
|
||||
private bool isDialogOpen;
|
||||
private bool isEditMode;
|
||||
private TaxProfile? editingProfile;
|
||||
private TaxProfile? selectedProfile;
|
||||
private TaxProfileForm profileForm = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await LoadData();
|
||||
if (firstRender)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
@@ -126,9 +164,35 @@ else
|
||||
try
|
||||
{
|
||||
profiles = await TaxProfileClient.GetAllAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||
clients = clientItems.ToList();
|
||||
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -136,18 +200,23 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
private void PrepareCreate()
|
||||
{
|
||||
selectedProfile = null;
|
||||
isEditMode = false;
|
||||
editingProfile = null;
|
||||
profileForm = new();
|
||||
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,
|
||||
@@ -156,34 +225,50 @@ else
|
||||
NextFilingDueDate = profile.NextFilingDueDate,
|
||||
SpecialNotes = profile.SpecialNotes
|
||||
};
|
||||
isDialogOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveProfile()
|
||||
{
|
||||
if (form != null)
|
||||
{
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
{
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (isEditMode)
|
||||
if (isEditMode && selectedProfile != null)
|
||||
{
|
||||
await TaxProfileClient.UpdateAsync(
|
||||
editingProfile!.Id,
|
||||
profileForm.BusinessType,
|
||||
null,
|
||||
profileForm.NextFilingDueDate,
|
||||
profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
|
||||
await TaxProfileClient.UpdateAsync(selectedProfile.Id, profileForm.BusinessType,
|
||||
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!profileForm.ClientId.HasValue)
|
||||
{
|
||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
var newId = await TaxProfileClient.CreateAsync(
|
||||
profileForm.ClientId,
|
||||
profileForm.ClientId.Value,
|
||||
profileForm.BusinessType);
|
||||
if (newId > 0)
|
||||
{
|
||||
await TaxProfileClient.UpdateAsync(
|
||||
newId,
|
||||
profileForm.BusinessType,
|
||||
null,
|
||||
profileForm.NextFilingDueDate,
|
||||
profileForm.TaxRiskLevel);
|
||||
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
|
||||
}
|
||||
}
|
||||
CloseDialog();
|
||||
PrepareCreate();
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -208,6 +293,10 @@ else
|
||||
{
|
||||
await TaxProfileClient.DeleteAsync(id);
|
||||
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
|
||||
if (selectedProfile?.Id == id)
|
||||
{
|
||||
PrepareCreate();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -216,14 +305,6 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
isDialogOpen = false;
|
||||
isEditMode = false;
|
||||
editingProfile = null;
|
||||
profileForm = new();
|
||||
}
|
||||
|
||||
private Color GetRiskColor(string riskLevel) => riskLevel switch
|
||||
{
|
||||
"high" => Color.Error,
|
||||
@@ -232,9 +313,16 @@ else
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private static string GetClientDisplayName(Client client)
|
||||
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||
? client.CompanyName
|
||||
: !string.IsNullOrWhiteSpace(client.Name)
|
||||
? client.Name
|
||||
: $"Client #{client.Id}";
|
||||
|
||||
private class TaxProfileForm
|
||||
{
|
||||
public int ClientId { get; set; }
|
||||
public int? ClientId { get; set; }
|
||||
public string BusinessType { get; set; } = "";
|
||||
public string TaxRiskLevel { get; set; } = "normal";
|
||||
public DateTime? NextFilingDueDate { get; set; }
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
|
||||
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Route("api/admin-dashboard")]
|
||||
[Authorize]
|
||||
public class AdminDashboardController : ControllerBase
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,20 @@ public class ConsultingActivityController(ConsultingActivityService service) : C
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var activities = await service.GetAllAsync();
|
||||
return Ok(activities);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,20 @@ public class ContractController(ContractService service) : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contracts = await service.GetAllAsync();
|
||||
return Ok(contracts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,20 @@ public class RevenueTrackingController(RevenueTrackingService service) : Control
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var revenues = await service.GetAllAsync();
|
||||
return Ok(revenues);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,20 @@ public class TaxFilingScheduleController(TaxFilingScheduleService service) : Con
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var schedules = await service.GetAllAsync();
|
||||
return Ok(schedules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,20 @@ public class TaxProfileController(TaxProfileService taxProfileService) : Control
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profiles = await taxProfileService.GetAllAsync();
|
||||
return Ok(profiles);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("client/{clientId:int}")]
|
||||
public async Task<IActionResult> GetByClientId(int clientId)
|
||||
{
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace TaxBaik.Web.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time notification hub for admin dashboard
|
||||
/// SOLID: Single Responsibility - Only broadcasts change notifications
|
||||
/// No state management - stateless broadcast pattern
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class NotificationHub : Hub
|
||||
{
|
||||
private const string AdminGroup = "admins";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast inquiry status changed to all connected admins
|
||||
/// Clients should re-fetch from API to verify
|
||||
/// </summary>
|
||||
public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
|
||||
{
|
||||
InquiryId = inquiryId,
|
||||
Status = newStatus,
|
||||
ChangedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast inquiry submitted (new inquiry created)
|
||||
/// </summary>
|
||||
public async Task NotifyInquiryCreated(int inquiryId, string name)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
|
||||
{
|
||||
InquiryId = inquiryId,
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast client created
|
||||
/// </summary>
|
||||
public async Task NotifyClientCreated(int clientId, string name)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
|
||||
{
|
||||
ClientId = clientId,
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast announcement published
|
||||
/// </summary>
|
||||
public async Task NotifyAnnouncementPublished(int announcementId, string title)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
|
||||
{
|
||||
AnnouncementId = announcementId,
|
||||
Title = title,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast tax filing completed
|
||||
/// </summary>
|
||||
public async Task NotifyFilingCompleted(int filingId, string filingType)
|
||||
{
|
||||
await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
|
||||
{
|
||||
FilingId = filingId,
|
||||
FilingType = filingType,
|
||||
CompletedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,20 @@ public class TelegramSink : ILogEventSink
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out harmless client disconnect and task cancellation exceptions
|
||||
if (logEvent.Exception != null)
|
||||
{
|
||||
var exTypeName = logEvent.Exception.GetType().FullName ?? "";
|
||||
var exMessage = logEvent.Exception.Message ?? "";
|
||||
if (exTypeName.Contains("JSDisconnectedException") ||
|
||||
exTypeName.Contains("TaskCanceledException") ||
|
||||
exMessage.Contains("JavaScript interop calls cannot be issued") ||
|
||||
exMessage.Contains("circuit has disconnected"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit is a synchronous method, so we dispatch the network call asynchronously
|
||||
Task.Run(async () =>
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,21 +3,57 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@(ViewData["Title"] ?? "백원숙 세무회계")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] ?? "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담.")" />
|
||||
<meta property="og:title" content="@ViewData["Title"]" />
|
||||
<meta property="og:description" content="@ViewData["Description"]" />
|
||||
<meta property="og:image" content="@ViewData["OgImage"]" />
|
||||
<meta property="og:url" content="@ViewData["OgUrl"]" />
|
||||
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta property="og:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
|
||||
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "http://178.104.200.7/taxbaik/")" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
|
||||
|
||||
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
|
||||
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
|
||||
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
|
||||
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="theme-color" content="#C89D6E" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="canonical" href="@ViewData["CanonicalUrl"]" />
|
||||
<link 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" />
|
||||
|
||||
<!-- 구조화된 데이터 (JSON-LD Schema Markup) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "ProfessionalService",
|
||||
"name": "백원숙 세무회계",
|
||||
"description": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담 세무사",
|
||||
"url": "http://178.104.200.7/taxbaik/",
|
||||
"telephone": "010-4122-8268",
|
||||
"email": "taxbaik5668@gmail.com",
|
||||
"address": {
|
||||
"@@type": "PostalAddress",
|
||||
"addressCountry": "KR"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/taxtory5668/",
|
||||
"http://pf.kakao.com/_xoxchTX"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="with-mobile-cta">
|
||||
<partial name="_Header" />
|
||||
|
||||
+17
-30
@@ -52,9 +52,6 @@ builder.Services.AddControllers();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// SignalR (Notifications only, no state management)
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Razor Pages + Blazor Server 통합
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
@@ -197,9 +194,6 @@ builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
// Notifications (SignalR)
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
|
||||
// Telegram Notification
|
||||
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
|
||||
|
||||
@@ -214,70 +208,65 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
|
||||
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
|
||||
// Phase 5: Tax Accounting & CRM Browser Clients
|
||||
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
});
|
||||
|
||||
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;
|
||||
config.PopoverOptions.ThrowOnDuplicateProvider = false;
|
||||
});
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddResponseCompression(opts => {
|
||||
@@ -361,8 +350,6 @@ app.MapControllers();
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapRazorPages();
|
||||
|
||||
// SignalR Hub
|
||||
app.MapHub<TaxBaik.Web.Hubs.NotificationHub>("/taxbaik/notifications");
|
||||
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
|
||||
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
|
||||
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,24 @@ public interface IConsultingActivityBrowserClient
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<ConsultingActivityBrowserClient> logger)
|
||||
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
|
||||
: IConsultingActivityBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/consultingactivity";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -36,6 +45,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -49,6 +59,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
|
||||
@@ -66,6 +77,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -83,6 +95,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { outcome, nextFollowupDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -97,6 +110,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -16,15 +16,24 @@ public interface IContractBrowserClient
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowserClient> logger)
|
||||
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
|
||||
: IContractBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/contract";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -38,6 +47,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -51,6 +61,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -64,6 +75,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||
@@ -80,6 +92,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||
@@ -96,6 +109,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
|
||||
if (response.TryGetProperty("mrr", out var mrrValue))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
|
||||
@@ -113,6 +127,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -130,6 +145,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -16,15 +16,24 @@ public interface IRevenueTrackingBrowserClient
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<RevenueTrackingBrowserClient> logger)
|
||||
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
|
||||
: IRevenueTrackingBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/revenuetracking";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -38,6 +47,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -51,6 +61,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||
@@ -67,6 +78,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||
@@ -83,6 +95,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>(
|
||||
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
|
||||
if (response.TryGetProperty("total", out var totalValue))
|
||||
@@ -101,6 +114,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -118,6 +132,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { paymentDate };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -132,6 +147,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -15,15 +15,24 @@ public interface ITaxFilingScheduleBrowserClient
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFilingScheduleBrowserClient> logger)
|
||||
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
|
||||
: ITaxFilingScheduleBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/taxfilingschedule";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -37,6 +46,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -50,6 +60,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -63,6 +74,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
|
||||
@@ -80,6 +92,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -97,6 +110,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
@@ -110,6 +124,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -17,14 +17,23 @@ public interface ITaxProfileBrowserClient
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
|
||||
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
|
||||
{
|
||||
private const string BaseUrl = "/api/taxprofile";
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
else
|
||||
httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -38,6 +47,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -51,6 +61,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -64,6 +75,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||
@@ -80,6 +92,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
|
||||
if (response.TryGetProperty("data", out var data))
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||
@@ -97,6 +110,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -115,6 +129,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -129,6 +144,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ public class AdminDashboardClient : IAdminDashboardClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -29,10 +29,10 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -34,10 +34,10 @@ public class ClientBrowserClient : IClientBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||
|
||||
@@ -32,21 +32,22 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
|
||||
if (!string.IsNullOrEmpty(storedToken))
|
||||
{
|
||||
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
|
||||
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
|
||||
if (long.TryParse(ticksStr, out var ticks))
|
||||
{
|
||||
_tokenStore.AccessToken = accessToken;
|
||||
_tokenStore.AccessToken = storedToken;
|
||||
_tokenStore.RefreshToken = refreshToken;
|
||||
_tokenStore.TokenExpiryTicks = ticks;
|
||||
accessToken = storedToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
@@ -78,7 +79,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
}
|
||||
}
|
||||
|
||||
var principal = _authService.ValidateToken(accessToken);
|
||||
var principal = _authService.ValidateToken(accessToken ?? string.Empty);
|
||||
if (principal == null)
|
||||
{
|
||||
await LogoutAsync();
|
||||
@@ -114,13 +115,13 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
private bool ShouldRefreshToken()
|
||||
{
|
||||
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
|
||||
if (_tokenStore.TokenExpiryTicks <= 0)
|
||||
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
|
||||
return false;
|
||||
|
||||
const int refreshThresholdSeconds = 300;
|
||||
try
|
||||
{
|
||||
var expiryTime = new DateTime((long)_tokenStore.TokenExpiryTicks, DateTimeKind.Utc);
|
||||
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
|
||||
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
|
||||
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ public class FaqBrowserClient : IFaqBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -33,10 +33,10 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Notification service for real-time admin updates
|
||||
/// SOLID: Single Responsibility - Event notification only
|
||||
/// Uses Blazor Server's built-in SignalR for real-time communication
|
||||
/// </summary>
|
||||
public interface INotificationService
|
||||
{
|
||||
event Func<int, string, Task>? OnInquiryStatusChanged;
|
||||
event Func<int, string, Task>? OnInquiryCreated;
|
||||
event Func<int, string, Task>? OnClientCreated;
|
||||
event Func<int, string, Task>? OnAnnouncementPublished;
|
||||
event Func<int, string, Task>? OnFilingCompleted;
|
||||
|
||||
Task TriggerInquiryStatusChanged(int inquiryId, string status);
|
||||
Task TriggerInquiryCreated(int inquiryId, string name);
|
||||
Task TriggerClientCreated(int clientId, string name);
|
||||
Task TriggerAnnouncementPublished(int announcementId, string title);
|
||||
Task TriggerFilingCompleted(int filingId, string filingType);
|
||||
}
|
||||
|
||||
public class NotificationService : INotificationService
|
||||
{
|
||||
private readonly ILogger<NotificationService> _logger;
|
||||
|
||||
public NotificationService(ILogger<NotificationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public event Func<int, string, Task>? OnInquiryStatusChanged;
|
||||
public event Func<int, string, Task>? OnInquiryCreated;
|
||||
public event Func<int, string, Task>? OnClientCreated;
|
||||
public event Func<int, string, Task>? OnAnnouncementPublished;
|
||||
public event Func<int, string, Task>? OnFilingCompleted;
|
||||
|
||||
public async Task TriggerInquiryStatusChanged(int inquiryId, string status)
|
||||
{
|
||||
_logger.LogInformation($"Inquiry {inquiryId} status changed to {status}");
|
||||
if (OnInquiryStatusChanged != null)
|
||||
await OnInquiryStatusChanged(inquiryId, status);
|
||||
}
|
||||
|
||||
public async Task TriggerInquiryCreated(int inquiryId, string name)
|
||||
{
|
||||
_logger.LogInformation($"New inquiry {inquiryId} from {name}");
|
||||
if (OnInquiryCreated != null)
|
||||
await OnInquiryCreated(inquiryId, name);
|
||||
}
|
||||
|
||||
public async Task TriggerClientCreated(int clientId, string name)
|
||||
{
|
||||
_logger.LogInformation($"New client {clientId}: {name}");
|
||||
if (OnClientCreated != null)
|
||||
await OnClientCreated(clientId, name);
|
||||
}
|
||||
|
||||
public async Task TriggerAnnouncementPublished(int announcementId, string title)
|
||||
{
|
||||
_logger.LogInformation($"Announcement {announcementId} published: {title}");
|
||||
if (OnAnnouncementPublished != null)
|
||||
await OnAnnouncementPublished(announcementId, title);
|
||||
}
|
||||
|
||||
public async Task TriggerFilingCompleted(int filingId, string filingType)
|
||||
{
|
||||
_logger.LogInformation($"Filing {filingId} ({filingType}) completed");
|
||||
if (OnFilingCompleted != null)
|
||||
await OnFilingCompleted(filingId, filingType);
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,10 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
}
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||
@@ -44,7 +44,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
|
||||
$"tax-filing/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
|
||||
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
@@ -60,7 +60,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
|
||||
$"tax-filing/client/{clientId}", cancellationToken: ct);
|
||||
$"taxfiling/client/{clientId}", cancellationToken: ct);
|
||||
return result?.Data ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
@@ -76,7 +76,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<TaxFiling>(
|
||||
$"tax-filing/{id}", cancellationToken: ct);
|
||||
$"taxfiling/{id}", cancellationToken: ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -90,7 +90,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync("tax-filing", filing, cancellationToken: ct);
|
||||
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
@@ -111,7 +111,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PutAsJsonAsync($"tax-filing/{id}", filing, cancellationToken: ct);
|
||||
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
@@ -132,7 +132,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
|
||||
try
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.DeleteAsync($"tax-filing/{id}", cancellationToken: ct);
|
||||
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
|
||||
@@ -10,12 +10,12 @@ using System.Text.Json;
|
||||
/// </summary>
|
||||
public class TokenRefreshHandler : DelegatingHandler
|
||||
{
|
||||
private readonly ITokenStore _tokenStore;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TokenRefreshHandler> _logger;
|
||||
|
||||
public TokenRefreshHandler(ITokenStore tokenStore, ILogger<TokenRefreshHandler> logger)
|
||||
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger)
|
||||
{
|
||||
_tokenStore = tokenStore;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@ public class TokenRefreshHandler : DelegatingHandler
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
|
||||
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
|
||||
|
||||
// 요청에 access token 추가
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
request.Headers.Authorization = new("Bearer", tokenStore.AccessToken);
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
@@ -34,15 +37,15 @@ public class TokenRefreshHandler : DelegatingHandler
|
||||
// 401 응답이면 토큰 갱신 시도
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken))
|
||||
if (!string.IsNullOrEmpty(tokenStore.RefreshToken))
|
||||
{
|
||||
var newTokenPair = await RefreshTokenAsync(_tokenStore.RefreshToken, request, cancellationToken);
|
||||
var newTokenPair = await RefreshTokenAsync(tokenStore.RefreshToken, request, cancellationToken);
|
||||
if (newTokenPair != null)
|
||||
{
|
||||
// TokenStore에 토큰 저장
|
||||
_tokenStore.AccessToken = newTokenPair.AccessToken;
|
||||
_tokenStore.RefreshToken = newTokenPair.RefreshToken;
|
||||
_tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
|
||||
tokenStore.AccessToken = newTokenPair.AccessToken;
|
||||
tokenStore.RefreshToken = newTokenPair.RefreshToken;
|
||||
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
|
||||
|
||||
// 새 토큰으로 재요청
|
||||
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
|
||||
@@ -51,7 +54,7 @@ public class TokenRefreshHandler : DelegatingHandler
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
|
||||
_tokenStore.Clear();
|
||||
tokenStore.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -445,11 +445,12 @@ textarea:focus-visible {
|
||||
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 {
|
||||
@@ -1641,3 +1642,58 @@ textarea:focus-visible {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -746,3 +746,162 @@ img {
|
||||
.faq-answer ul li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* ===== 프리미엄 고도화 & 마이크로 인터랙션 (2026-06-30) ===== */
|
||||
|
||||
/* 영어/숫자용 폰트 클래스 */
|
||||
.font-numeric, .font-heading-en {
|
||||
font-family: 'Outfit', 'Inter', 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
|
||||
/* 히어로 섹션 프리미엄 개편 (메쉬 그라데이션 및 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes floatAnimation {
|
||||
0% { transform: translateY(0px) scale(1); }
|
||||
50% { transform: translateY(-30px) scale(1.05); }
|
||||
100% { transform: translateY(0px) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes floatAnimation2 {
|
||||
0% { transform: translateX(0px) rotate(0deg); }
|
||||
50% { transform: translateX(20px) translateY(15px) rotate(10deg); }
|
||||
100% { transform: translateX(0px) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* 신뢰도 스트립 카드 리뉴얼 */
|
||||
.trust-strip {
|
||||
background-color: var(--color-bg-alt);
|
||||
padding: 3rem 0;
|
||||
margin-top: -1.5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.trust-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px rgba(61, 40, 23, 0.1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.trust-item:hover .trust-icon {
|
||||
transform: scale(1.15) rotate(5deg);
|
||||
}
|
||||
|
||||
/* 서비스 카드 고도화 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 타임라인 컴포넌트 뷰티화 */
|
||||
.timeline-item-modern {
|
||||
border-left: 2px solid rgba(200, 157, 110, 0.4);
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -100,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,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<!-- 메인 홈 -->
|
||||
<url>
|
||||
<loc>http://178.104.200.7/taxbaik/</loc>
|
||||
<lastmod>2026-06-29</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<!-- 고객 포털 -->
|
||||
<url>
|
||||
<loc>http://178.104.200.7/taxbaik/portal</loc>
|
||||
<lastmod>2026-06-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<!-- 이용약관 -->
|
||||
<url>
|
||||
<loc>http://178.104.200.7/taxbaik/terms</loc>
|
||||
<lastmod>2026-06-29</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<!-- 개인정보처리방침 -->
|
||||
<url>
|
||||
<loc>http://178.104.200.7/taxbaik/privacy</loc>
|
||||
<lastmod>2026-06-29</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -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,138 @@
|
||||
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
|
||||
server {
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
|
||||
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
|
||||
location /admin {
|
||||
return 301 $scheme://$host/taxbaik$request_uri;
|
||||
}
|
||||
|
||||
# 루트 경로 요청을 /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 ====="
|
||||
@@ -6,6 +6,8 @@ const password = process.env.E2E_ADMIN_PASSWORD;
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('admin CRM pages', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
|
||||
await loginThroughAdminUi(page, baseUrl, username, password);
|
||||
@@ -88,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 }) => {
|
||||
@@ -121,4 +122,63 @@ test.describe('admin CRM pages', () => {
|
||||
|
||||
expect(consoleErrors, 'no console errors during CRM navigation').toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('TaxProfiles form displays valid business type combo choices', async ({ page }) => {
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
|
||||
await addButton.click();
|
||||
|
||||
// 분할 편집기(admin-editor-panel) 노출 대기
|
||||
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 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');
|
||||
await expect(popover.getByText('일반제조업')).toBeVisible({ timeout: 5000 });
|
||||
await expect(popover.getByText('도소매업')).toBeVisible({ timeout: 5000 });
|
||||
await expect(popover.getByText('서비스업')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('TaxFilingSchedules form displays filing type combo choices', async ({ page }) => {
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-filing-schedules`);
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 일정 추가/ });
|
||||
await addButton.click();
|
||||
|
||||
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
test('Contracts form displays service type combo choices', async ({ page }) => {
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/contracts`);
|
||||
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /새 계약 추가/ });
|
||||
await addButton.click();
|
||||
|
||||
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
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`);
|
||||
|
||||
@@ -24,7 +24,14 @@ export async function getAdminToken(
|
||||
}
|
||||
|
||||
export async function installAdminToken(page: Page, token: string) {
|
||||
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
|
||||
await page.addInitScript(value => {
|
||||
localStorage.setItem('accessToken', value);
|
||||
localStorage.setItem('refreshToken', 'ci-test-refresh-token');
|
||||
// Calculate C# Ticks for 1 hour from now: (JS_ms * 10000) + 621355968000000000
|
||||
const expiryMs = Date.now() + 3600 * 1000;
|
||||
const ticks = (expiryMs * 10000) + 621355968000000000;
|
||||
localStorage.setItem('tokenExpiry', ticks.toString());
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function loginThroughAdminUi(
|
||||
@@ -51,6 +58,22 @@ export async function navigateInBlazor(page: Page, targetUrl: string) {
|
||||
|
||||
window.location.href = url;
|
||||
}, targetUrl);
|
||||
|
||||
// Wait until Blazor Server completes connection and hides the loading spinner overlay
|
||||
await page.locator('#blazor-loading').waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {});
|
||||
|
||||
// Give the SPA router a brief window to unmount the previous page and mount the loading spinner
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Also wait for MudBlazor's dynamic loading spinners to disappear (ensuring the grid is interactive)
|
||||
const spinner = page.locator('.mud-progress-circular, .mud-progress-linear-bar');
|
||||
try {
|
||||
if (await spinner.count() > 0) {
|
||||
await spinner.first().waitFor({ state: 'hidden', timeout: 10000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Suppress timeout if the spinner was already gone or never showed up
|
||||
}
|
||||
}
|
||||
|
||||
export async function findInquiryByName(
|
||||
|
||||
Reference in New Issue
Block a user