Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d45dbbc06d | |||
| e65612def8 | |||
| bb11a1bb87 | |||
| ae9380ddb3 | |||
| d8c52583ba | |||
| 585f426f0b | |||
| c8cf654131 | |||
| ebdcb4fd22 | |||
| 0ffb149296 | |||
| 870b51ece4 | |||
| b1ac7129d9 | |||
| 500d163ebc | |||
| d780fecf8c | |||
| b1601b0305 | |||
| e6253fdc83 | |||
| c885c6b234 | |||
| 96c7ab5e54 | |||
| 3f486d9fe9 | |||
| f68c968aed | |||
| 984da933ca | |||
| 3dd1cbb6ce | |||
| a3d294b6ff | |||
| e2d3eb9195 | |||
| 77aaed814c | |||
| d7ca51b741 | |||
| bc210969e2 | |||
| 6642f3d6f1 | |||
| 67f2f4b5d6 | |||
| faf4273e6d | |||
| 15c261a49d | |||
| b06c0f99fb | |||
| ad55bd1884 | |||
| e0b8d4e370 | |||
| e65f01b196 | |||
| 124b3b4dfc | |||
| 3785bc7a70 | |||
| bd44ec7c5f | |||
| cb47349a25 | |||
| b3cab87539 | |||
| 1fc3b6c0a4 | |||
| da9f49c973 | |||
| 1839c2c3d1 | |||
| df4c555dd1 | |||
| e1348226c6 | |||
| 97e7cfb867 | |||
| 11772d1f46 | |||
| 84e0577e89 | |||
| 31cc5603c9 | |||
| 0d36d27631 | |||
| 60c31d7ccb | |||
| 42a0d2ae3b | |||
| e599ef9ad8 | |||
| 223d916012 | |||
| f1cc0ca35c | |||
| e1325a1688 | |||
| 29b25cb1b4 | |||
| 8d72d2a0c2 | |||
| 1cdb172b07 | |||
| 864497e56f | |||
| 19c9b9b17a | |||
| 988b166118 | |||
| 78d3990484 | |||
| b3c4ee430d | |||
| 7b27f748de | |||
| abad1630b6 | |||
| 6ffff70ece | |||
| ed8ac34542 | |||
| 6b14ce929e | |||
| e830c08263 | |||
| a1065e8233 | |||
| 7cdb0bf8e9 | |||
| 8bea85df96 | |||
| 127490906b | |||
| ada05e254d | |||
| 7602f5be59 | |||
| 777cdcd918 | |||
| 0f6ba33af3 | |||
| 6d263c20bf | |||
| c9bf4f4f6f | |||
| b12d2ae0c6 | |||
| f9cbafdb3d | |||
| 64de7d2304 | |||
| 1f628b49a8 | |||
| a4a2499c7d | |||
| 6b11b64135 | |||
| a60451b95f | |||
| 2a046d0393 | |||
| 62ce89359a | |||
| 32c5a3d042 | |||
| 68291867f9 | |||
| d24f3f58db | |||
| 71cd2c1129 | |||
| 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 |
@@ -49,12 +49,13 @@ jobs:
|
|||||||
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
||||||
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.json" 2>/dev/null || true)"
|
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.json" 2>/dev/null || true)"
|
||||||
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/blog/accountant-mistakes-5" || true)"
|
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/blog/accountant-mistakes-5" || true)"
|
||||||
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ]; then
|
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/admin/login" || true)"
|
||||||
|
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
|
||||||
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ $i -lt 20 ]; then
|
if [ $i -lt 20 ]; then
|
||||||
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
|
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, login=${LOGIN_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
|
||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -72,6 +73,23 @@ jobs:
|
|||||||
echo "Running E2E tests on Desktop Chrome (production verification)"
|
echo "Running E2E tests on Desktop Chrome (production verification)"
|
||||||
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
|
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
|
||||||
|
|
||||||
|
- name: API smoke verification
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
E2E_ADMIN_USERNAME: test_admin
|
||||||
|
E2E_ADMIN_PASSWORD: TestAdmin@123456
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TOKEN="$(curl -s -X POST "http://${DEPLOY_HOST}/taxbaik/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c 'import sys, json; print(json.load(sys.stdin)["accessToken"])')"
|
||||||
|
test -n "$TOKEN"
|
||||||
|
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null
|
||||||
|
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/faq" >/dev/null
|
||||||
|
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/announcement" >/dev/null
|
||||||
|
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null
|
||||||
|
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.svg" >/dev/null
|
||||||
|
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.ico" >/dev/null
|
||||||
|
curl -fsS "http://${DEPLOY_HOST}/taxbaik/robots.txt" >/dev/null
|
||||||
|
|
||||||
- name: Browser E2E summary
|
- name: Browser E2E summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+46
-10
@@ -33,6 +33,9 @@ jobs:
|
|||||||
- name: Publish Web
|
- name: Publish Web
|
||||||
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
|
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
|
||||||
|
|
||||||
|
- name: Publish Proxy
|
||||||
|
run: dotnet publish TaxBaik.Proxy/ -c Release -o ./publish/proxy
|
||||||
|
|
||||||
- name: Write production secrets
|
- name: Write production secrets
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -67,13 +70,27 @@ jobs:
|
|||||||
)'
|
)'
|
||||||
test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; }
|
test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; }
|
||||||
|
|
||||||
|
- name: Verify proxy artifact
|
||||||
|
run: |
|
||||||
|
test -s ./publish/proxy/TaxBaik.Proxy.dll || { echo "TaxBaik.Proxy.dll missing" >&2; exit 1; }
|
||||||
|
test -s ./publish/proxy/TaxBaik.Proxy.runtimeconfig.json || { echo "TaxBaik.Proxy.runtimeconfig.json missing" >&2; exit 1; }
|
||||||
|
|
||||||
- name: Copy migrations
|
- name: Copy migrations
|
||||||
run: cp -r db/migrations ./publish/migrations || true
|
run: mkdir -p ./publish/db && cp -r db/migrations ./publish/db/ || true
|
||||||
|
|
||||||
|
- name: Validate migration version uniqueness
|
||||||
|
run: bash scripts/validate_migrations.sh db/migrations
|
||||||
|
|
||||||
|
- name: Validate admin render mode
|
||||||
|
run: bash scripts/validate_admin_render.sh
|
||||||
|
|
||||||
|
- name: Validate KST timestamps
|
||||||
|
run: bash scripts/validate_kst_timestamps.sh
|
||||||
|
|
||||||
- name: Generate build info
|
- name: Generate build info
|
||||||
run: |
|
run: |
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||||
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
BUILD_TIME=$(TZ=Asia/Seoul date +'%Y-%m-%d %H:%M:%S KST')
|
||||||
mkdir -p ./publish/wwwroot
|
mkdir -p ./publish/wwwroot
|
||||||
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
||||||
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
|
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
|
||||||
@@ -100,13 +117,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Package artifact
|
- name: Package artifact
|
||||||
run: |
|
run: |
|
||||||
|
cp deploy_gb.sh ./publish/deploy_gb.sh
|
||||||
|
mkdir -p ./publish/scripts
|
||||||
|
cp scripts/validate_migrations.sh ./publish/scripts/validate_migrations.sh
|
||||||
|
chmod +x ./publish/scripts/validate_migrations.sh
|
||||||
|
cp scripts/validate_admin_render.sh ./publish/scripts/validate_admin_render.sh
|
||||||
|
chmod +x ./publish/scripts/validate_admin_render.sh
|
||||||
tar -czf taxbaik_deploy.tgz -C ./publish .
|
tar -czf taxbaik_deploy.tgz -C ./publish .
|
||||||
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
|
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
|
||||||
|
|
||||||
- name: Deploy & verify on server
|
- name: Deploy & verify on server
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
export TAXBAIK_DEPLOY_FROM_CI=1
|
||||||
|
TIMESTAMP=$(TZ=Asia/Seoul date +%Y%m%d_%H%M%S)
|
||||||
COMMIT=$(git rev-parse --short HEAD)
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||||
@@ -148,7 +172,7 @@ jobs:
|
|||||||
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
|
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
|
||||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||||
-o ServerAliveInterval=10 \
|
-o ServerAliveInterval=10 \
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
"$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE
|
||||||
set -e
|
set -e
|
||||||
DEPLOY_HOME="/home/kjh2064"
|
DEPLOY_HOME="/home/kjh2064"
|
||||||
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
||||||
@@ -162,12 +186,17 @@ jobs:
|
|||||||
echo "--- [2/5] 운영 설정 검증 ---"
|
echo "--- [2/5] 운영 설정 검증 ---"
|
||||||
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
||||||
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
|
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
|
||||||
|
test -s "\$DEPLOY_DIR/proxy/TaxBaik.Proxy.dll" \
|
||||||
|
|| { echo "FATAL: TaxBaik.Proxy.dll 없음" >&2; exit 1; }
|
||||||
|
|
||||||
echo "--- [3/5] 심볼릭 링크 전환 ---"
|
echo "--- [3/5] 마이그레이션 사전 검증 ---"
|
||||||
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
|
test -x "\$DEPLOY_DIR/scripts/validate_migrations.sh" \
|
||||||
|
|| { echo "FATAL: validate_migrations.sh 없음" >&2; exit 1; }
|
||||||
|
"\$DEPLOY_DIR/scripts/validate_migrations.sh" "\$DEPLOY_DIR/db/migrations" "postgresql://taxbaik:taxbaik123@localhost:5432/taxbaikdb"
|
||||||
|
|
||||||
echo "--- [4/5] 서비스 재시작 ---"
|
echo "--- [4/5] Green-Blue 배포 실행 ---"
|
||||||
sudo /usr/bin/systemctl restart taxbaik
|
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
|
||||||
|
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
|
||||||
|
|
||||||
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
|
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
|
||||||
ATTEMPTS=20
|
ATTEMPTS=20
|
||||||
@@ -191,13 +220,20 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "✓ [3/4] 버전 정보 확인 완료"
|
echo "✓ [3/4] 버전 정보 확인 완료"
|
||||||
|
|
||||||
# 검증 3: 관리자 로그인 페이지
|
# 검증 4: 5001 프록시 확인
|
||||||
|
if ! ss -tlnp | grep -q ':5001 '; then
|
||||||
|
echo "❌ 5001 프록시가 실행 중이 아님" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ [4/5] 5001 프록시 확인 완료"
|
||||||
|
|
||||||
|
# 검증 5: 관리자 로그인 페이지
|
||||||
LOGIN_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000")
|
LOGIN_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000")
|
||||||
if [ "\$LOGIN_STATUS" != "200" ]; then
|
if [ "\$LOGIN_STATUS" != "200" ]; then
|
||||||
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
|
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✓ [4/4] 관리자 페이지 로드 완료"
|
echo "✓ [5/5] 관리자 페이지 로드 완료"
|
||||||
|
|
||||||
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
||||||
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
||||||
|
|||||||
@@ -0,0 +1,777 @@
|
|||||||
|
# 블로그 포스트 작성 템플릿
|
||||||
|
|
||||||
|
## 정확성 원칙 (법적 책임 수반)
|
||||||
|
|
||||||
|
블로그는 **사실 기반, 세법 기반, 데이터 기반**이어야 합니다. 추측이나 예상은 법적 문제를 일으킬 수 있습니다.
|
||||||
|
|
||||||
|
### 절대 금지 표현
|
||||||
|
|
||||||
|
- "아마도", "할 것 같다", "추측된다" (추측)
|
||||||
|
- "대략", "정도일 거다", "보통" (예상)
|
||||||
|
- "좋을 것 같다", "나쁠 것 같다" (의견)
|
||||||
|
- 증거 없는 "모두", "항상", "누구나" (일반화)
|
||||||
|
- 출처 없는 통계 ("80% 고객이", "평균 X만 원")
|
||||||
|
|
||||||
|
### 필수 요소
|
||||||
|
|
||||||
|
**1. 세법 기반**:
|
||||||
|
- 모든 주장에 세법/시행령/고시 인용
|
||||||
|
- 조항 명시: "소득세법 제XX조에 따르면"
|
||||||
|
- 최신 기준 명시: "2025년 기준"
|
||||||
|
- 변경사항 반영: "전년도와 다르게..."
|
||||||
|
|
||||||
|
**2. 사실 기반**:
|
||||||
|
- 실제 일어난 고객 사례만 사용
|
||||||
|
- 가정일 경우 명시: "예를 들어, 만약 이렇다면"
|
||||||
|
- 가상 사례는 "예시 사례"라고 명확히
|
||||||
|
- 개인정보는 익명화 (이름, 나이는 일반적인 표현)
|
||||||
|
|
||||||
|
**3. 데이터 기반**:
|
||||||
|
- 객관적 수치만 사용 (국세청 통계, 협회 자료)
|
||||||
|
- 출처 명시: "2025년 세무청 통계에 따르면"
|
||||||
|
- 구체적 금액: "약 50만 원" (범위 표현)
|
||||||
|
- 비교 데이터: "작년 대비 X% 증가"
|
||||||
|
|
||||||
|
**4. 사례 제시 시 확인 사항**:
|
||||||
|
```
|
||||||
|
✅ 실제 고객인가? (공개 가능한 정보만)
|
||||||
|
✅ 세법을 정확하게 적용했는가?
|
||||||
|
✅ 금액 계산이 정확한가?
|
||||||
|
✅ 이 사례가 대표적인가? (극단적 사례면 명시)
|
||||||
|
✅ 다른 고객에게도 적용 가능한가?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 카테고리 필수 규칙
|
||||||
|
|
||||||
|
**모든 블로그 포스트는 반드시 하나의 카테고리에 할당되어야 합니다. (NOT NULL)**
|
||||||
|
|
||||||
|
### 카테고리별 포스트 배치
|
||||||
|
|
||||||
|
| 카테고리 | 최소 포스트 | 주제 범위 |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| 사업자 세무 | 3개 | 기장, 세무신고, 부가세, 종합소득세 |
|
||||||
|
| 부동산 세금 | 3개 | 월세, 양도세, 상속세(부동산) |
|
||||||
|
| 종합소득세 | 3개 | 프리랜서, 부업, 경비 처리 |
|
||||||
|
| 부가가치세 | 3개 | 신고, 기한, 간이과세 vs 일반과세 |
|
||||||
|
| 가족자산·증여 | 3개 | 자녀 증여, 상속, 자산 이전 |
|
||||||
|
|
||||||
|
### 카테고리 할당 규칙
|
||||||
|
|
||||||
|
1. **명확한 주제 분류**: 포스트 내용이 카테고리 범위에 명확하게 해당
|
||||||
|
2. **중복 금지**: 한 포스트는 정확히 하나의 카테고리에만 할당
|
||||||
|
3. **균형 배치**: 각 카테고리당 최소 3개씩 (고객 검색 UX)
|
||||||
|
4. **검색 최적화**: 고객이 카테고리로 찾을 때 관련 포스트 3개 이상 노출
|
||||||
|
|
||||||
|
### 카테고리 미할당 시 (오류)
|
||||||
|
- ❌ category_id = NULL (데이터베이스 제약 위반)
|
||||||
|
- ❌ SQL 실행 실패 (NOT NULL 제약)
|
||||||
|
- ❌ 블로그 페이지 노출 불가
|
||||||
|
|
||||||
|
**이 규칙은 모든 포스트 생성/수정 시 필수 준수사항입니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 철학: 고객이 느끼는 여정
|
||||||
|
|
||||||
|
### 1️⃣ 기초: "이 정도는 할 수 있어요"
|
||||||
|
- 고객이 배울 수 있는 기본 개념
|
||||||
|
- 실제 사례로 구체화
|
||||||
|
- 단계별 설명
|
||||||
|
|
||||||
|
### 2️⃣ 현실: "하지만 복잡하네요"
|
||||||
|
- 겹겹이 쌓인 세부사항들
|
||||||
|
- 매년 바뀌는 세법
|
||||||
|
- "이거 일일이 다 챙기기 어렵다"는 느낌
|
||||||
|
|
||||||
|
### 3️⃣ 해결: "세무사와 함께면 괜찮아요"
|
||||||
|
- 디테일 자동 관리
|
||||||
|
- 세법 변화 자동 반영
|
||||||
|
- 고객은 사업에만 집중
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**고객이 글을 읽은 후 느끼는 것**:
|
||||||
|
|
||||||
|
1️⃣ 읽고 나서: "아, 이 정도는 내가 할 수 있겠네"
|
||||||
|
2️⃣ 생각해보니: "근데 이 모든 걸 매년 챙기기는... 힘들겠는데?"
|
||||||
|
3️⃣ 결론: "그럼 전문가 도움을 받는 게 낫겠다"
|
||||||
|
|
||||||
|
→ 자연스럽게 세무사의 필요성을 깨달음 (강요 아님)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 템플릿 (복사해서 사용)
|
||||||
|
|
||||||
|
### Step 1: 도입부 (공감)
|
||||||
|
```markdown
|
||||||
|
# [제목]
|
||||||
|
|
||||||
|
"[구체적 상황]?"
|
||||||
|
"많은 [직업]들이 이 상황을 겪습니다."
|
||||||
|
|
||||||
|
→ 독자가 자신의 상황을 발견하도록
|
||||||
|
```
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```markdown
|
||||||
|
# 동네 카페 월세 낼 때 세금이 안 나와요 - 정말 그럴까?
|
||||||
|
|
||||||
|
"사업을 시작했는데 세금을 낸 적이 없어요"
|
||||||
|
"많은 소규모 사업자들이 이렇게 생각합니다."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: 실제 사례 (구체적 페르소나)
|
||||||
|
|
||||||
|
**필수 정보**:
|
||||||
|
- 이름, 나이, 직업, 사업 경력
|
||||||
|
- 월/연간 매출 (현실적 수치)
|
||||||
|
- 실제 겪은 문제/성공 사례
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 상황: [지역] [카테고리]를 운영하는 [이름]님 ([나이]세, 사업 [년]차)
|
||||||
|
|
||||||
|
**기본 정보**:
|
||||||
|
- 위치: [구체적 위치]
|
||||||
|
- 월 매출: [금액]
|
||||||
|
- 월 경비: [주요 항목들]
|
||||||
|
|
||||||
|
### 원래는 이렇게 했어요 (실패 사례)
|
||||||
|
→ [실제 실수 1]
|
||||||
|
→ [실제 실수 2]
|
||||||
|
→ **결과**: 세금을 [X만 원] 더 내게 됨 (또는 세무조사 대상)
|
||||||
|
|
||||||
|
### 바뀐 후 (성공 사례)
|
||||||
|
→ [해결책 1]
|
||||||
|
→ [해결책 2]
|
||||||
|
→ **결과**: 세금을 [X만 원] 절약함 (또는 안정적인 운영)
|
||||||
|
```
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```markdown
|
||||||
|
### 상황: 강남 역삼동에서 카페를 운영하는 김민수님 (34세, 사업 3년차)
|
||||||
|
|
||||||
|
**기본 정보**:
|
||||||
|
- 위치: 강남역 3번 출구 근처
|
||||||
|
- 월 매출: 약 600만 원 (평일 200만, 주말 400만)
|
||||||
|
- 월 경비: 월세 150만, 재료비 180만, 직원급여 100만 원
|
||||||
|
|
||||||
|
### 원래는 이렇게 했어요
|
||||||
|
→ "세금은 큰 회사나 내는 거라고 생각했어요"
|
||||||
|
→ 영수증도 대충 정리하고
|
||||||
|
→ **결과**: 세무청에서 3년치를 추징받고 가산세까지...손해 70만 원
|
||||||
|
|
||||||
|
### 바뀐 후
|
||||||
|
→ 매달 영수증을 정리해서
|
||||||
|
→ 세무사와 년 1회 기장 상담
|
||||||
|
→ **결과**: 세금도 명확하고, 추징도 없음. 심플하고 안전
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: 계산 & 설명
|
||||||
|
|
||||||
|
**구조**:
|
||||||
|
1. **기본 정보 확인** (위에서 제시한 사례 요약)
|
||||||
|
2. **단계별 계산** (Step 1, Step 2, ... 명확히)
|
||||||
|
3. **표로 시각화**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 계산 방법
|
||||||
|
|
||||||
|
### Step 1️⃣: 매출 정리
|
||||||
|
월 600만 원 × 12개월 = 연 7,200만 원
|
||||||
|
|
||||||
|
### Step 2️⃣: 경비 계산
|
||||||
|
|
||||||
|
월 경비 구성:
|
||||||
|
- 월세: 150만 원 (연 1,800만 원)
|
||||||
|
- 재료비: 180만 원 (연 2,160만 원)
|
||||||
|
- 직원급여: 100만 원 (연 1,200만 원)
|
||||||
|
- 기타: 20만 원 (연 240만 원)
|
||||||
|
- **월 합계: 450만 원**
|
||||||
|
- **연 합계: 5,400만 원**
|
||||||
|
|
||||||
|
### Step 3️⃣: 순이익
|
||||||
|
7,200만 - 5,400만 = **1,800만 원**
|
||||||
|
|
||||||
|
### Step 4️⃣: 세금
|
||||||
|
1,800만 원 × 약 6% = **약 108만 원/년**
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🎭 Step 3.5: 악마는 디테일이다 (선택사항이지만 강력함)
|
||||||
|
|
||||||
|
**구조**: "간단해 보이지만, 실제로는..."
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 겉으로는 간단해 보여요... 하지만
|
||||||
|
|
||||||
|
### 📄 "영수증을 정리하세요"라고 했는데
|
||||||
|
|
||||||
|
**겉으로는**:
|
||||||
|
→ 영수증을 모으기만 하면 돼
|
||||||
|
|
||||||
|
**현실의 디테일**:
|
||||||
|
→ 이 영수증은 인정되고, 이건 안 됨 (세법)
|
||||||
|
→ 이건 개인비? 사업비? (판단)
|
||||||
|
→ 카드값이랑 현금값이랑 다르면? (대사)
|
||||||
|
→ 3년 지났는데 영수증을 못 찾으면? (소송)
|
||||||
|
→ 세무청이 불인정하면? (항의 절차)
|
||||||
|
|
||||||
|
**세무사가 처리하는 것**:
|
||||||
|
✅ 어떤 영수증이 인정될지 사전에 판단
|
||||||
|
✅ 개인비와 사업비의 경계 명확히
|
||||||
|
✅ 세법 변경사항 적용
|
||||||
|
✅ 세무청 부인시 대응 준비
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 "매출과 경비를 기록하세요"라고 했는데
|
||||||
|
|
||||||
|
**겉으로는**:
|
||||||
|
→ 엑셀에 숫자만 입력하면 돼
|
||||||
|
|
||||||
|
**현실의 디테일**:
|
||||||
|
→ 카드 명세서와 입금액이 안 맞음 (환불? 수수료?)
|
||||||
|
→ 한 달간 매출을 빼먹음 (추가 계산)
|
||||||
|
→ 같은 카테고리인데 세법상 다르게 분류돼야 함 (부가세/소득세 다름)
|
||||||
|
→ 작년에 잘못 입력한 게 발견됨 (수정신고)
|
||||||
|
→ 월별로 편차가 커서 세무청이 의심함 (설명 준비)
|
||||||
|
|
||||||
|
**세무사가 처리하는 것**:
|
||||||
|
✅ 카드명세서 vs 입금액 정산
|
||||||
|
✅ 누락된 부분 찾아서 추가
|
||||||
|
✅ 세법상 올바른 분류
|
||||||
|
✅ 이전년도 오류 수정신고
|
||||||
|
✅ 세무청 질의에 대한 근거 제시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ "정확하게 기장하면 세금이 확정돼요"라고 했는데
|
||||||
|
|
||||||
|
**겉으로는**:
|
||||||
|
→ 기장만 잘하면 세금 끝
|
||||||
|
|
||||||
|
**현실의 디테일**:
|
||||||
|
→ 같은 사업도 절세 방법이 다양함 (어떤 게 맞나?)
|
||||||
|
→ 올해는 이렇게, 내년은 저렇게? (일관성)
|
||||||
|
→ 부가세와 소득세 둘 다 고려해야 함 (연계 계산)
|
||||||
|
→ 세무조사가 오면 3년치를 모두 봄 (소급 처리)
|
||||||
|
→ 이의신청/항소하려면? (법적 절차)
|
||||||
|
|
||||||
|
**세무사가 처리하는 것**:
|
||||||
|
✅ 최적의 절세 전략 제시
|
||||||
|
✅ 연도별 일관된 기장 방식 유지
|
||||||
|
✅ 부가세/소득세 동시 최적화
|
||||||
|
✅ 세무조사 대비 사전 정리
|
||||||
|
✅ 이의신청/항소 등 법적 대응
|
||||||
|
```
|
||||||
|
|
||||||
|
**💡 핵심**:
|
||||||
|
- 기초는 누구나 배울 수 있어요
|
||||||
|
- **하지만 디테일을 모두 처리하려면?**
|
||||||
|
- **그 디테일들이 바로 세무사가 하는 일**
|
||||||
|
- **디테일 하나 놓쳤다가 가산세 50만 원... 이래서 세무사 비용이 아깝지 않음**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔄 Step 3.6: 세법은 계속 바뀐다 (매년 업데이트)
|
||||||
|
|
||||||
|
**구조**: "올해의 세법 변화"를 포스트 작성 시점에 맞춰 갱신
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 그런데 세법은 해마다 바뀝니다
|
||||||
|
|
||||||
|
### 📋 [연도] 변경사항들 (꼭 알아야 할 것들)
|
||||||
|
|
||||||
|
**✅ 2025년 부가세 변화**:
|
||||||
|
- 신고 기한이 [날짜]로 변경됨
|
||||||
|
- 영세사업자 기준이 [금액]로 상향조정됨
|
||||||
|
- 새로운 공제 항목이 추가됨: [항목들]
|
||||||
|
|
||||||
|
**✅ 2025년 소득세 변화**:
|
||||||
|
- 기본공제가 [금액]에서 [금액]로 증가
|
||||||
|
- 자녀 공제 조건이 변경됨
|
||||||
|
- 월급 원천징수 기준이 조정됨
|
||||||
|
|
||||||
|
**✅ 2025년 새로운 제도**:
|
||||||
|
- 소상공인 세금 감면 확대
|
||||||
|
- 청년사업자 지원 강화
|
||||||
|
- 부가가치세 간편신청 범위 확대
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**혼자서 할 때의 문제**:
|
||||||
|
❌ "작년 기준으로 기장했는데 올해 기준이 바뀐 거야?"
|
||||||
|
❌ "이 공제가 되는 건지 안 되는 건지 모르겠어"
|
||||||
|
❌ "새로운 제도가 나왔다는 것도 몰랐어"
|
||||||
|
❌ "처음 다시 계산해야 하나?"
|
||||||
|
|
||||||
|
**세무사가 처리하는 것**:
|
||||||
|
✅ 매년 변경사항 자동 추적
|
||||||
|
✅ 당신의 상황에 맞는 새로운 공제 적용
|
||||||
|
✅ 이전년도 재계산 필요시 수정신고
|
||||||
|
✅ 연중 세법 개정 소식 안내
|
||||||
|
✅ 새로운 지원 정책 놓치지 않게 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 결과 비교: 혼자 할 때 vs 세무사와 함께
|
||||||
|
|
||||||
|
**세법 변화 추적**
|
||||||
|
- 혼자: "어? 규칙이 바뀌었네?"
|
||||||
|
- 세무사: 자동으로 적용됨
|
||||||
|
|
||||||
|
**새로운 공제**
|
||||||
|
- 혼자: 놓치기 쉬움
|
||||||
|
- 세무사: 모두 적용됨
|
||||||
|
|
||||||
|
**매년 재계산**
|
||||||
|
- 혼자: 직접 해야 함
|
||||||
|
- 세무사: 자동 갱신
|
||||||
|
|
||||||
|
**마음 편함**
|
||||||
|
- 혼자: 불안감 ("맞나?")
|
||||||
|
- 세무사: 확신 ("전문가가 관리")
|
||||||
|
|
||||||
|
**투자 시간**
|
||||||
|
- 혼자: 당신의 시간
|
||||||
|
- 세무사: 포함 (전문가 비용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약: 왜 세무사가 필요한가
|
||||||
|
|
||||||
|
**기초는 배울 수 있지만**:
|
||||||
|
- 세법은 매년 바뀌고
|
||||||
|
- 당신은 본업이 있어서 추적이 어렵고
|
||||||
|
- 실수 하나가 가산세 50만 원...
|
||||||
|
|
||||||
|
**그래서 세무사가 있으면**:
|
||||||
|
- 변화를 자동으로 적용해주고
|
||||||
|
- 새 제도도 놓치지 않아주고
|
||||||
|
- 당신은 사업에만 집중
|
||||||
|
|
||||||
|
→ **결국 시간, 돈, 스트레스 모두 절약**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 💡 Step 4: 실무 팁 (3~5개)
|
||||||
|
|
||||||
|
**구조**: ✅ 이렇게 하세요 / ❌ 이렇게 하면 안 돼요
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 이렇게 하면 세금이 명확해요
|
||||||
|
|
||||||
|
### ✅ 해야 할 것
|
||||||
|
1. **영수증 정리** - 매달 봉투에 모아두기
|
||||||
|
2. **기본 기록** - 엑셀에 간단히 기입
|
||||||
|
3. **연 1회 점검** - 세무사와 기본 상담
|
||||||
|
4. **투명성** - 세무청 신고는 정확하게
|
||||||
|
|
||||||
|
### ❌ 하면 안 되는 것
|
||||||
|
1. **영수증 버리기** - 나중에 증거 없음
|
||||||
|
2. **개인비와 섞기** - 기장 혼란
|
||||||
|
3. **신고 늦추기** - 가산세 발생
|
||||||
|
4. **과하게 깎기** - 세무조사 리스크
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📝 Step 5: 결론
|
||||||
|
|
||||||
|
고객이 읽은 후 자연스럽게 결론을 내리도록:
|
||||||
|
|
||||||
|
**구조**:
|
||||||
|
1. 기초는 할 수 있다 (긍정)
|
||||||
|
2. 근데 복잡하네요 (현실 직시)
|
||||||
|
3. 그래서 세무사가 필요하구나 (자연스러운 깨달음)
|
||||||
|
|
||||||
|
**고객이 느끼는 여정**:
|
||||||
|
- 처음: "아, 이 정도는 내가 할 수 있겠네"
|
||||||
|
- 중간: "근데 이 모든 걸 매년 챙기기는..."
|
||||||
|
- 결론: "전문가 도움이 낫겠다"
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 기초는 누구나 할 수 있어요
|
||||||
|
|
||||||
|
**이 정도면 자신이 충분히 가능합니다**:
|
||||||
|
- 소규모 사업 (월 500만~1,000만 원)
|
||||||
|
- 단순 경비 (재료, 임차료 등)
|
||||||
|
- 월 1회 정도 기본 정리
|
||||||
|
|
||||||
|
→ 영수증 정리 + 기본 엑셀 기입면 충분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 하지만 이렇게 복잡하면 전문가 도움이 효율적입니다
|
||||||
|
|
||||||
|
**세무사 상담을 권하는 경우**:
|
||||||
|
- 📊 월 매출이 2,000만 원을 넘어갈 때
|
||||||
|
- 💼 여러 사업을 동시에 운영할 때
|
||||||
|
- 🏠 부동산 등 추가 수입이 있을 때
|
||||||
|
- 📈 직원을 여러 명 두고 있을 때
|
||||||
|
- 🌍 해외 거래나 수입이 있을 때
|
||||||
|
|
||||||
|
### 실제 효과: 숫자로 본 세무사의 가치
|
||||||
|
|
||||||
|
**절세액**
|
||||||
|
- 혼자: X만 원
|
||||||
|
- 세무사: X + 200만 원
|
||||||
|
- 차이: +200만 원 절약
|
||||||
|
|
||||||
|
**세무조사 스트레스**
|
||||||
|
- 혼자: 매년 불안
|
||||||
|
- 세무사: 안정적 대응
|
||||||
|
- 차이: 심리적 안정
|
||||||
|
|
||||||
|
**시간 투자**
|
||||||
|
- 혼자: 월 10시간
|
||||||
|
- 세무사: 월 1시간
|
||||||
|
- 차이: 월 9시간 자유
|
||||||
|
|
||||||
|
**세무사 비용**
|
||||||
|
- 혼자: 0원
|
||||||
|
- 세무사: 약 100만 원/년
|
||||||
|
- 차이: -100만 원
|
||||||
|
|
||||||
|
**실제 이익**
|
||||||
|
- 혼자: 순이익
|
||||||
|
- 세무사: 순이익 + 100만 원
|
||||||
|
- 차이: +100만 원 순이익
|
||||||
|
|
||||||
|
**돈을 쓰는 이유**:
|
||||||
|
- 세금 절약: 절세 200만 원 - 비용 100만 원 = 순 100만 원 이득
|
||||||
|
- 시간 절약: 월 9시간(연 108시간) = 사업에 집중
|
||||||
|
- 스트레스 감소: 세무조사 불안 제거
|
||||||
|
- 리스크 관리: 실수로 인한 가산세 방지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
**기본 개념을 아는 것만으로도**:
|
||||||
|
- 실수를 줄이고
|
||||||
|
- 세금을 절약하고
|
||||||
|
- 세무사와의 상담이 훨씬 효율적
|
||||||
|
|
||||||
|
당신의 상황이 어느 정도인지 판단하고,
|
||||||
|
필요할 때 전문가와 함께 하세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 작성 체크리스트
|
||||||
|
|
||||||
|
### 내용
|
||||||
|
- [ ] **실제 사례**: 동네 카페/편의점/학원 같은 주변 상황
|
||||||
|
- [ ] **구체적 페르소나**: 이름, 나이, 직업, 사업 경력
|
||||||
|
- [ ] **실제 금액**: 매출, 경비, 세금 (현실적 수치)
|
||||||
|
- [ ] **Before/After**: 실패 사례 → 성공 사례
|
||||||
|
- [ ] **절세 효과**: "X만 원 절약" 또는 "손해를 막음"
|
||||||
|
- [ ] **계산**: Step별로 명확, 표 포함
|
||||||
|
- [ ] **악마는 디테일**: "겉으로는 간단해 보이지만 실제로는..." (세무사가 처리하는 디테일들)
|
||||||
|
- [ ] **세법 변화**: 해당 연도의 세법 변경사항과 그 영향 설명
|
||||||
|
|
||||||
|
### 톤
|
||||||
|
- [ ] **교육적**: 개념을 이해하도록
|
||||||
|
- [ ] **격려적**: 경고/협박 없음
|
||||||
|
- [ ] **현실적**: 복잡할 수 있다는 인정
|
||||||
|
- [ ] **세무사 자연스러운 유도**: "필요할 때 도움되는 선택"
|
||||||
|
- [ ] **임파워먼트**: "기초는 누구나 할 수 있어요"
|
||||||
|
|
||||||
|
### 표현
|
||||||
|
- [ ] **중학교 수준**: 어려운 용어는 () 설명
|
||||||
|
- [ ] **이모지**: 🏠💰✅❌📊 등으로 시각화
|
||||||
|
- [ ] **짧은 문장**: 한 문장에 한 개념
|
||||||
|
- [ ] **표와 리스트**: 수치는 표로, 항목은 리스트로
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 피해야 할 표현 (한국세무사협회 광고 규칙 준수)
|
||||||
|
|
||||||
|
### ❌ **절대 금지 표현** (법적 위반 위험)
|
||||||
|
|
||||||
|
**1. 과도한 절세 약속 & 절대 표현**:
|
||||||
|
- ❌ "50만 원 절약 가능"
|
||||||
|
- ❌ "최대한 경비를 깎아줍니다"
|
||||||
|
- ❌ "세금을 반으로 줄여드립니다"
|
||||||
|
- ❌ "세금을 덜 냅니다" (보장으로 해석)
|
||||||
|
- ❌ "가장 많이 절세해드립니다"
|
||||||
|
- ✅ "이 사례에서는 약 50만 원 절약되었습니다" (과거 사례만)
|
||||||
|
- ✅ "정확한 경비 처리로 세법에 따른 정당한 공제를 받을 수 있습니다" (법적 근거)
|
||||||
|
- ✅ "경비를 빠짐없이 처리합니다" (객관적 프로세스)
|
||||||
|
|
||||||
|
**2. 보장 표현 (불가능한 결과 약속)**:
|
||||||
|
- ❌ "반드시 세금을 줄입니다"
|
||||||
|
- ❌ "세무조사 안 받게 해드립니다"
|
||||||
|
- ❌ "100% 절세를 보장합니다"
|
||||||
|
- ❌ "세금을 보장합니다"
|
||||||
|
- ✅ "정확한 신고로 세무조사 리스크를 최소화합니다"
|
||||||
|
- ✅ "세법에 따른 정당한 공제를 받을 수 있습니다"
|
||||||
|
|
||||||
|
**3. 무료 & 가격 표현**:
|
||||||
|
- ❌ "무료로 세금 절약해드립니다"
|
||||||
|
- ❌ "최저가 신고료"
|
||||||
|
- ❌ "가장 저렴한 가격"
|
||||||
|
- ✅ "합리적인 비용으로 전문 서비스를 제공합니다"
|
||||||
|
|
||||||
|
**4. 절대/최상급 표현**:
|
||||||
|
- ❌ "반드시", "무조건", "반듯이", "항상", "절대"
|
||||||
|
- ❌ "최고", "최우수", "1등", "유일"
|
||||||
|
- ❌ "모든", "완벽하게"
|
||||||
|
- ✅ "일반적으로", "대부분의 경우", "보통"
|
||||||
|
|
||||||
|
**5. 과도한 단순화 표현**:
|
||||||
|
- ❌ "매우 편합니다", "너무 쉽습니다"
|
||||||
|
- ❌ "아무도 실수할 수 없습니다"
|
||||||
|
- ❌ "5분이면 끝납니다"
|
||||||
|
- ✅ "기초 개념을 배울 수 있습니다"
|
||||||
|
- ✅ "복잡한 부분은 전문가가 관리합니다"
|
||||||
|
|
||||||
|
**6. 객관적 증거 없는 수치**:
|
||||||
|
- ❌ "평균 170만 원 절약" (근거 없으면)
|
||||||
|
- ❌ "고객의 80%가 만족" (통계 없으면)
|
||||||
|
- ❌ "보통 2배의 환급" (데이터 없으면)
|
||||||
|
- ✅ "이 사례에서는 약 170만 원 절약되었습니다"
|
||||||
|
- ✅ "많은 고객들이 정확한 기장의 필요성을 느낍니다"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ **안전한 표현 (권장)**
|
||||||
|
|
||||||
|
| 대신 이렇게 | 이유 |
|
||||||
|
|----------|------|
|
||||||
|
| "정확한 기장으로 세법에 따른 공제를 받을 수 있습니다" | 법적 근거 (보장 아님) |
|
||||||
|
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
|
||||||
|
| "이 사례에서는 약 50만 원 절약되었습니다" | 과거 사례 (보장 아님) |
|
||||||
|
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
|
||||||
|
| "세무조사 대비 근거를 정리합니다" | 예방적 표현 |
|
||||||
|
| "당신의 상황에 맞는 최선의 방법을 제시합니다" | 개별 맞춤형 |
|
||||||
|
| "세법이 자주 바뀌므로 전문가 도움이 효율적입니다" | 필요성 설명 |
|
||||||
|
| "이 정도는 자신이 충분히 가능합니다" | 존중과 임파워먼트 |
|
||||||
|
| "복잡한 경우는 전문가와 상담하세요" | 선택지 제시 |
|
||||||
|
| "정확하게 하면 나중에 편합니다" | 미래 가치 (현재 보장 아님) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📋 블로그 작성 시 광고 규칙 체크리스트
|
||||||
|
|
||||||
|
- [ ] **절세 약속 제거**: "최대한", "반드시", "보장", "무조건" 단어 없음
|
||||||
|
- [ ] **보장 표현 제거**: "세무조사 안 받게", "100% 절세", "확실" 제거
|
||||||
|
- [ ] **무료/가격 표현 제거**: "무료", "최저가", "가장 저렴" 제거
|
||||||
|
- [ ] **절대 표현 제거**: "항상", "절대", "모두", "완벽" 제거
|
||||||
|
- [ ] **최상급 제거**: "최고", "최우수", "1등" (객관적 증거 있으면 가능)
|
||||||
|
- [ ] **과도한 단순화 제거**: "매우 쉽습니다", "아무도 실수할 수 없음" 제거
|
||||||
|
- [ ] **수치는 사례로**: "절약 가능" → "이 사례에서는 약 X만 원 절약"
|
||||||
|
- [ ] **객관성 유지**: 구체적 사례 + 과거형 표현 사용
|
||||||
|
- [ ] **필요성 설명**: "왜 필요한가" → 이해와 선택 유도
|
||||||
|
- [ ] **세무사협회 규정 준수**: 법적 문제 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시즌별 주제 예시
|
||||||
|
|
||||||
|
| 월 | 추천 주제 | 톤 |
|
||||||
|
|----|---------|-----|
|
||||||
|
| 1월 | 부가세 2기 신고 기한 | "이 정도면 자신이 가능" |
|
||||||
|
| 5월 | 종소세 신고 방법 | "핵심 개념 + 전문가 도움 타이밍" |
|
||||||
|
| 7월 | 부가세 1기 신고 | "기초 정리 방법" |
|
||||||
|
| 11월 | 다음해 준비 | "계획하면 편해요" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 실수 방지 체크리스트 (과거 오류 기록)
|
||||||
|
|
||||||
|
**이전에 반복된 실수들을 기록하여, 같은 실수를 하지 않도록 합니다.**
|
||||||
|
|
||||||
|
### 1️⃣ 카테고리 할당 실수 ❌
|
||||||
|
|
||||||
|
**과거 오류**: 포스트를 만들 때 category_id를 NULL로 두었음
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- DB NOT NULL 제약 위반
|
||||||
|
- 블로그 페이지에 노출 안 됨
|
||||||
|
- 고객이 카테고리로 검색 불가
|
||||||
|
|
||||||
|
**예방책**:
|
||||||
|
- ✅ **SQL INSERT 시 반드시 category_id 명시**
|
||||||
|
- ✅ **포스트 작성 전에 카테고리 결정**
|
||||||
|
- ✅ **DB 적용 후 category_id NOT NULL 확인**
|
||||||
|
- ✅ **각 카테고리별 최소 3개 이상 포스트 유지**
|
||||||
|
|
||||||
|
**SQL 예시** (권장):
|
||||||
|
```sql
|
||||||
|
INSERT INTO blog_posts (title, slug, content, category_id, is_published, ...)
|
||||||
|
VALUES ('제목', 'slug', $$본문$$, 1, true, ...);
|
||||||
|
-- category_id 절대 생략 금지!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ 내용 길이 부족 ❌
|
||||||
|
|
||||||
|
**과거 오류**: 에이전트가 지침(1,500~2,500자)을 무시하고 간단한 버전(500자)으로 생성
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 고객 설득력 부족
|
||||||
|
- 계산 예시 없음
|
||||||
|
- 3단계 구조 불완전
|
||||||
|
- 세법 인용 부족
|
||||||
|
|
||||||
|
**예방책**:
|
||||||
|
- ✅ **각 포스트 최소 1,500자 이상 (추천 2,000~2,500자)**
|
||||||
|
- ✅ **포스트 작성 후 글자 수 확인: `LENGTH(content) >= 1500`**
|
||||||
|
- ✅ **항상 실제 사례 포함** (이름, 나이, 직업, 구체적 상황)
|
||||||
|
- ✅ **항상 계산 과정 포함** (절세액 수치화)
|
||||||
|
- ✅ **3단계 구조 필수** (1️⃣ 기초 → 2️⃣ 현실 → 3️⃣ 해결책)
|
||||||
|
|
||||||
|
**확인 쿼리**:
|
||||||
|
```sql
|
||||||
|
SELECT id, title, LENGTH(content) as length FROM blog_posts
|
||||||
|
WHERE LENGTH(content) < 1500; -- 부족한 포스트 검출
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ 테이블 사용 금지 ❌
|
||||||
|
|
||||||
|
**과거 오류**: 마크다운 테이블(`| |---|---|`) 사용
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 지침 위반 (리스트만 사용)
|
||||||
|
- 모바일에서 가독성 저하
|
||||||
|
- 유지보수 어려움
|
||||||
|
|
||||||
|
**예방책**:
|
||||||
|
- ✅ **테이블 금지, 리스트만 사용** (- 또는 숫자 목록)
|
||||||
|
- ✅ **작성 후 `| |` 패턴 검색으로 테이블 확인**
|
||||||
|
- ✅ **수치/계산은 리스트 형식**:
|
||||||
|
|
||||||
|
**❌ 금지 (테이블)**:
|
||||||
|
```markdown
|
||||||
|
| 항목 | 월 | 연간 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 월세 | 150만 | 1,800만 |
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ 권장 (리스트)**:
|
||||||
|
```markdown
|
||||||
|
월 경비 구성:
|
||||||
|
- 월세: 150만 원 (연 1,800만 원)
|
||||||
|
- 재료비: 180만 원 (연 2,160만 원)
|
||||||
|
- 직원급여: 100만 원 (연 1,200만 원)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ 계산 예시 누락 ❌
|
||||||
|
|
||||||
|
**과거 오류**: 포스트에 개념만 있고 실제 계산 예시 부족
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 고객이 "내 상황에 얼마나 해당하나" 판단 어려움
|
||||||
|
- 추상적 설명으로 설득력 감소
|
||||||
|
- 세무사 필요성 전달 미흡
|
||||||
|
|
||||||
|
**예방책**:
|
||||||
|
- ✅ **모든 포스트에 구체적 계산 예시 필수**
|
||||||
|
- ✅ **절세액을 수치로 제시** ("약 50만 원 절약")
|
||||||
|
- ✅ **단계별 계산 과정 포함** (Step 1️⃣, 2️⃣, 3️⃣, 4️⃣)
|
||||||
|
- ✅ **실제 사례로 숫자 구체화**:
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```markdown
|
||||||
|
### Step 1️⃣: 매출 정리
|
||||||
|
월 600만 원 × 12개월 = 연 7,200만 원
|
||||||
|
|
||||||
|
### Step 2️⃣: 경비 계산
|
||||||
|
- 월세: 150만 원 → 연 1,800만 원
|
||||||
|
- 재료비: 180만 원 → 연 2,160만 원
|
||||||
|
합계: 5,400만 원
|
||||||
|
|
||||||
|
### Step 3️⃣: 순이익
|
||||||
|
7,200만 - 5,400만 = 1,800만 원
|
||||||
|
|
||||||
|
### Step 4️⃣: 세금
|
||||||
|
1,800만 원 × 약 6% = **약 108만 원/년**
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ 카테고리 주제 불일치 ❌
|
||||||
|
|
||||||
|
**과거 오류**: 포스트 주제와 카테고리가 맞지 않음
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 고객이 원하는 정보 검색 불가
|
||||||
|
- 카테고리 신뢰도 저하
|
||||||
|
- UX 혼란
|
||||||
|
|
||||||
|
**예방책**:
|
||||||
|
- ✅ **포스트 작성 전 카테고리 명확히 결정**
|
||||||
|
- ✅ **포스트 주제와 카테고리 일관성 검증**:
|
||||||
|
|
||||||
|
| 포스트 | 카테고리 | 확인 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| 프리랜서 경비 | 종합소득세 (3) | ✅ 맞음 |
|
||||||
|
| 월세 신고 | 부동산 세금 (2) | ✅ 맞음 |
|
||||||
|
| 자녀 증여세 | 가족자산·증여 (5) | ✅ 맞음 |
|
||||||
|
| 사업자 기장 | 사업자 세무 (1) | ✅ 맞음 |
|
||||||
|
| 부가세 신고 | 부가가치세 (4) | ✅ 맞음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6️⃣ 정확한 세법 인용 누락 ❌
|
||||||
|
|
||||||
|
**과거 오류**: 일부 포스트에서 법조 명시 부족
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 정확성 원칙 위반
|
||||||
|
- 법적 책임 불명확
|
||||||
|
- 고객 신뢰도 저하
|
||||||
|
|
||||||
|
**예방책**:
|
||||||
|
- ✅ **모든 주요 내용에 세법 조항 인용 필수**
|
||||||
|
- ✅ **형식**: "소득세법 제XX조에 따르면"
|
||||||
|
- ✅ **연도 기준 명시**: "2025년 기준"
|
||||||
|
- ✅ **포스트 끝에 "법적 근거" 섹션 필수**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**법적 근거**:
|
||||||
|
- 소득세법 제29조 (수입금액의 계산)
|
||||||
|
- 국세기본법 제47조 (가산세)
|
||||||
|
- 소득세법 제160조 (증빙 보관)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 포스트 최종 체크리스트
|
||||||
|
|
||||||
|
모든 포스트를 DB에 등록하기 전에 다음을 확인하세요:
|
||||||
|
|
||||||
|
- [ ] **카테고리 할당**: `category_id NOT NULL` (필수)
|
||||||
|
- [ ] **내용 길이**: `LENGTH(content) >= 1500` (최소 1,500자)
|
||||||
|
- [ ] **테이블 확인**: `| |` 패턴 없음 (리스트만)
|
||||||
|
- [ ] **계산 예시**: Step 1️⃣~4️⃣ 포함 (절세액 수치)
|
||||||
|
- [ ] **세법 인용**: 모든 주요 내용에 법조 명시
|
||||||
|
- [ ] **카테고리 일치**: 포스트 주제 ↔ 카테고리 일관성
|
||||||
|
- [ ] **3단계 구조**: 1️⃣ 기초 → 2️⃣ 현실 → 3️⃣ 해결책
|
||||||
|
- [ ] **광고 규칙**: 금지 표현(보장, 최저가, 무료) 없음
|
||||||
|
- [ ] **사례 포함**: 실제 상황 + 이름/나이/직업 구체화
|
||||||
|
- [ ] **정확성**: 추측/예상/의견 표현 없음
|
||||||
|
|
||||||
|
**체크 쿼리**:
|
||||||
|
```sql
|
||||||
|
-- DB 적용 후 확인
|
||||||
|
SELECT id, title, LENGTH(content), category_id
|
||||||
|
FROM blog_posts
|
||||||
|
WHERE LENGTH(content) < 1500 OR category_id IS NULL
|
||||||
|
ORDER BY id;
|
||||||
|
-- 결과 없음이 정상!
|
||||||
|
```
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
# CLAUDE.md — TaxBaik 개발 지침
|
# CLAUDE.md — TaxBaik 운영 메모
|
||||||
|
|
||||||
|
## 우선 기준
|
||||||
|
|
||||||
|
1. [docs/INDEX.md](./docs/INDEX.md)
|
||||||
|
2. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
|
||||||
|
3. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md)
|
||||||
|
4. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
|
||||||
|
5. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)
|
||||||
|
|
||||||
|
이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보 기준은 위 문서를 따른다.
|
||||||
|
|
||||||
|
## Gitea Token Rule
|
||||||
|
|
||||||
|
- `GITEA_TOKEN_TAXBAIK`만 사용한다.
|
||||||
|
- `GITEA_TOKEN`은 사용하지 않는다.
|
||||||
|
- dispatch 전에는 `GET /api/v1/user`로 토큰 유효성을 먼저 확인한다.
|
||||||
|
|
||||||
## 🏗️ **아키텍처 리팩토링 (API-First 전환)**
|
## 🏗️ **아키텍처 리팩토링 (API-First 전환)**
|
||||||
|
|
||||||
@@ -12,14 +28,6 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D
|
|||||||
Blazor 데이터 변경 자동 push/broadcast 금지
|
Blazor 데이터 변경 자동 push/broadcast 금지
|
||||||
```
|
```
|
||||||
|
|
||||||
### UI 기준 원칙 (2026-06-29 추가)
|
|
||||||
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/` 기준으로 한다.
|
|
||||||
- 신규 또는 리팩토링 UI는 Fluent UI Blazor v5 패턴을 우선 적용한다.
|
|
||||||
- MudBlazor는 기존 호환 레거시로만 유지하고, 새 UI는 Fluent v5 또는 순수 HTML/CSS로 작성한다.
|
|
||||||
- 기본 로딩 상태는 `Skeleton`이다. `MudProgressCircular` / `MudProgressLinear`는 예외적으로만 사용한다.
|
|
||||||
- 목록, 카드, 대시보드, 상세 페이지의 초기 데이터 상태는 스켈톤으로 먼저 렌더링하고, 데이터 수신 후 실제 UI로 교체한다.
|
|
||||||
- 로딩 중 블로킹 스피너보다 스켈톤을 우선한다.
|
|
||||||
|
|
||||||
### SOLID 기반 순차 마이그레이션 전략
|
### SOLID 기반 순차 마이그레이션 전략
|
||||||
|
|
||||||
#### Phase 1-3: API Foundations ✅
|
#### Phase 1-3: API Foundations ✅
|
||||||
@@ -37,7 +45,6 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D
|
|||||||
- AdminDashboardClient 구현
|
- AdminDashboardClient 구현
|
||||||
- 서비스 inject → API 호출로 변경
|
- 서비스 inject → API 호출로 변경
|
||||||
- 에러 처리 & 로딩 상태
|
- 에러 처리 & 로딩 상태
|
||||||
- 기본 로딩은 Skeleton 적용
|
|
||||||
- [x] 구조: IAdminDashboardClient → HttpClient 추상화
|
- [x] 구조: IAdminDashboardClient → HttpClient 추상화
|
||||||
|
|
||||||
**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출
|
**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출
|
||||||
@@ -85,7 +92,7 @@ _refreshTokenExpirationMinutes = 10080;
|
|||||||
- 모든 API 엔드포인트 구현됨
|
- 모든 API 엔드포인트 구현됨
|
||||||
- 모든 Browser Client 구현됨
|
- 모든 Browser Client 구현됨
|
||||||
- 16개 Blazor 페이지 API-First 마이그레이션 완료
|
- 16개 Blazor 페이지 API-First 마이그레이션 완료
|
||||||
- MudDataGrid Douzone ERP 수준 UX 적용
|
- MudDataGrid 더존 세무회계프로그램 UX 수준 적용
|
||||||
- MudDialog 모달 패턴 (흰 화면 플래시 제거)
|
- MudDialog 모달 패턴 (흰 화면 플래시 제거)
|
||||||
- ConfirmDialog 삭제 확인 컴포넌트
|
- ConfirmDialog 삭제 확인 컴포넌트
|
||||||
|
|
||||||
@@ -128,7 +135,7 @@ _refreshTokenExpirationMinutes = 10080;
|
|||||||
- 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking)
|
- 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking)
|
||||||
- 5개 Browser Client (API-First 패턴)
|
- 5개 Browser Client (API-First 패턴)
|
||||||
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog)
|
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog)
|
||||||
- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
|
- 더존 세무회계프로그램 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
|
||||||
|
|
||||||
| 페이지 | API | Client | Blazor | 핵심 기능 |
|
| 페이지 | API | Client | Blazor | 핵심 기능 |
|
||||||
|------|---|---|---|---------|
|
|------|---|---|---|---------|
|
||||||
@@ -573,33 +580,24 @@ ssh kjh2064@178.104.200.7
|
|||||||
|
|
||||||
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
||||||
|
|
||||||
**표준 배포 (현재)**:
|
**무중단 Green-Blue 배포 아키텍처 (2026-06-30 적용 완료)**:
|
||||||
1. `master` 브랜치에 push
|
1. **프록시 레이어**: 포트 `5001`에서 영구 가동되는 초경량 .NET TCP 프록시([TaxBaik.Proxy])가 수신 대기합니다. Nginx는 `/taxbaik` 트래픽을 기존과 같이 `5001`로 중계합니다.
|
||||||
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
|
2. **동적 포트 스위칭**: 프록시는 요청이 들어올 때마다 `/home/kjh2064/taxbaik_port` 파일을 읽어 active 포트(5003 또는 5004)를 판단하고 트래픽을 포워딩합니다.
|
||||||
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
|
3. **배포 흐름 (`deploy_gb.sh`)**:
|
||||||
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
|
- Gitea Actions가 코드를 build/publish 후 압축하여 서버에 업로드합니다.
|
||||||
|
- 서버의 배포 스크립트([deploy_gb.sh])가 실행되어 현재 미사용 중인 예비 포트(Target Port: 5003 또는 5004)를 파악합니다.
|
||||||
**API 클라이언트 설정 (Green-Blue 대비)**:
|
- 예비 포트에서 새 .NET 웹 앱을 실행하고 `http://127.0.0.1:$target_port/taxbaik/healthz` 헬스 체크를 통과할 때까지 폴링(최대 60초)합니다.
|
||||||
- API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
|
- 헬스 체크 성공 시 `/home/kjh2064/taxbaik_port` 파일에 새 포트 번호를 기입하여 **트래픽을 즉시 무중단 전환**합니다.
|
||||||
- 기본값: `http://localhost:5001/taxbaik/api/`
|
- 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다.
|
||||||
- 배포 시 환경변수로 오버라이드 가능:
|
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.
|
||||||
```bash
|
|
||||||
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
|
|
||||||
systemctl start taxbaik # 새 포트에 배포
|
|
||||||
```
|
|
||||||
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
|
|
||||||
|
|
||||||
**운영 규칙**:
|
**운영 규칙**:
|
||||||
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
|
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
|
||||||
- `rsync`로 직접 아티팩트를 올리지 않는다
|
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
|
||||||
- 배포 실패 시 CI 로그를 먼저 본다
|
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다.
|
||||||
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
|
|
||||||
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
|
|
||||||
|
|
||||||
**롤백**:
|
**롤백**:
|
||||||
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
|
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다.
|
||||||
- 서버 파일을 수동으로 복구하지 않는다
|
|
||||||
- 롤백은 커밋 단위로 추적 가능해야 한다
|
|
||||||
|
|
||||||
### 3.4 서비스 파일 위치
|
### 3.4 서비스 파일 위치
|
||||||
```
|
```
|
||||||
@@ -763,6 +761,22 @@ ssh kjh2064@178.104.200.7 crontab -l | grep backup
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 5-1. 블로그 & FAQ 콘텐츠 작성 규칙
|
||||||
|
|
||||||
|
**핵심**: 고객 임파워먼트 (당신도 할 수 있습니다!)
|
||||||
|
- ✅ 주변에서 흔히 보는 실제 사례 (이름, 나이, 직업 구체화)
|
||||||
|
- ✅ 절세 효과 수치화 ("세금을 X만 원 절약했습니다")
|
||||||
|
- ✅ 중학교 2학년도 이해 가능한 수준
|
||||||
|
- ✅ 단계별 설명 + 표로 시각화
|
||||||
|
- ✅ 결론: "정확하게 하면 이런 이점이 있습니다" (임파워먼트)
|
||||||
|
|
||||||
|
**피해야 할 톤**: "복잡하니까 맡기세요" (세무사 의존성 강화)
|
||||||
|
**세무사 언급**: "더 복잡하면 전문가와 상담하세요" (선택지)
|
||||||
|
|
||||||
|
**자세한 템플릿 및 체크리스트**: `BLOG_TEMPLATE.md` 참고
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 6. 코드 규칙
|
## 6. 코드 규칙
|
||||||
|
|
||||||
### 6.1 C# 네이밍
|
### 6.1 C# 네이밍
|
||||||
@@ -973,12 +987,10 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
|||||||
- 전역 상태 불필요 (세션 → DB에서 읽음)
|
- 전역 상태 불필요 (세션 → DB에서 읽음)
|
||||||
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
|
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
|
||||||
- 업데이트는 `StateHasChanged()` 호출
|
- 업데이트는 `StateHasChanged()` 호출
|
||||||
- 초기 렌더는 Skeleton 우선
|
|
||||||
- 로딩이 필요한 목록/카드/대시보드는 `items == null` 또는 `summary == null` 패턴으로 스켈톤 렌더링
|
|
||||||
|
|
||||||
### 8.6 어드민 그리드 UX (Dorsum ERP 수준)
|
### 8.6 어드민 그리드 UX (더존 세무회계프로그램 수준)
|
||||||
|
|
||||||
**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + ERP 수준의 상호작용성
|
**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + 더존식 상호작용성
|
||||||
|
|
||||||
#### 그리드 기본 원칙
|
#### 그리드 기본 원칙
|
||||||
- **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거)
|
- **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거)
|
||||||
@@ -994,7 +1006,7 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
|||||||
- **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지)
|
- **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지)
|
||||||
- **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)
|
- **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)
|
||||||
|
|
||||||
#### UI 적용 패턴
|
#### MudBlazor 적용 패턴
|
||||||
```razor
|
```razor
|
||||||
<MudDataGrid T="YourItem"
|
<MudDataGrid T="YourItem"
|
||||||
Dense="true"
|
Dense="true"
|
||||||
@@ -1137,7 +1149,7 @@ Admin 로그인 페이지만 [AllowAnonymous]:
|
|||||||
<!-- 로딩 상태 -->
|
<!-- 로딩 상태 -->
|
||||||
@if (items == null)
|
@if (items == null)
|
||||||
{
|
{
|
||||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||||
}
|
}
|
||||||
<!-- 빈 상태 -->
|
<!-- 빈 상태 -->
|
||||||
else if (items.Count == 0)
|
else if (items.Count == 0)
|
||||||
@@ -1299,7 +1311,7 @@ else
|
|||||||
- [ ] @inject로 필요한 Client 주입
|
- [ ] @inject로 필요한 Client 주입
|
||||||
- [ ] <PageTitle> 추가
|
- [ ] <PageTitle> 추가
|
||||||
- [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼)
|
- [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼)
|
||||||
- [ ] 로딩 상태 기본값은 `Skeleton`
|
- [ ] 로딩 상태 (MudProgressCircular)
|
||||||
- [ ] 빈 상태 (MudAlert)
|
- [ ] 빈 상태 (MudAlert)
|
||||||
- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스)
|
- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스)
|
||||||
- [ ] MudDialog (Create/Edit 모달)
|
- [ ] MudDialog (Create/Edit 모달)
|
||||||
@@ -1648,7 +1660,7 @@ curl http://127.0.0.1/taxbaik/admin/login
|
|||||||
### E2E 테스트 & 반응형 검증
|
### E2E 테스트 & 반응형 검증
|
||||||
```bash
|
```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=테스트"
|
-d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
|
||||||
|
|
||||||
# 관리자 DB에서 확인
|
# 관리자 DB에서 확인
|
||||||
@@ -1687,7 +1699,7 @@ npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
|
|||||||
|
|
||||||
**프로덕션 E2E 테스트**:
|
**프로덕션 E2E 테스트**:
|
||||||
```bash
|
```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_USERNAME="test_admin"
|
||||||
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
|
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
|
||||||
|
|
||||||
@@ -1955,7 +1967,7 @@ else
|
|||||||
2. **Actions run 생성 확인**
|
2. **Actions run 생성 확인**
|
||||||
```powershell
|
```powershell
|
||||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
|
$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
|
$runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
|
||||||
```
|
```
|
||||||
`deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.
|
`deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.
|
||||||
|
|||||||
+120
-13
@@ -17,7 +17,7 @@
|
|||||||
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
|
||||||
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
|
||||||
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
|
| 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 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
|
||||||
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
|
||||||
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
|
||||||
@@ -126,17 +126,22 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
|
|||||||
### 4.2. Nginx 리버스 프록시
|
### 4.2. Nginx 리버스 프록시
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# /etc/nginx/sites-enabled/gitea-ip.conf
|
# /etc/nginx/sites-available/taxbaik-domains.conf
|
||||||
|
|
||||||
|
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
server_name taxbaik.com www.taxbaik.com;
|
||||||
listen [::]:80 default_server;
|
|
||||||
server_name _;
|
|
||||||
client_max_body_size 512M;
|
client_max_body_size 512M;
|
||||||
|
|
||||||
# QuantEngine Blazor Web App
|
|
||||||
location /quant/ {
|
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
|
||||||
proxy_pass http://127.0.0.1:5000/;
|
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_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "Upgrade";
|
||||||
@@ -147,7 +152,33 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -159,13 +190,89 @@ server {
|
|||||||
proxy_connect_timeout 300;
|
proxy_connect_timeout 300;
|
||||||
proxy_send_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://taxbaik.com/` 또는 `http://www.taxbaik.com/` → TaxBaik 홈페이지 (내부 proxy: `http://127.0.0.1:5001/taxbaik/`)
|
||||||
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
|
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`)
|
||||||
- `ssh://178.104.200.7:2222` → Gitea Git SSH
|
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`)
|
||||||
|
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
|
||||||
|
|
||||||
## 5. Gitea
|
## 5. Gitea
|
||||||
|
|
||||||
@@ -384,7 +491,7 @@ ClientAliveCountMax 2
|
|||||||
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
|
||||||
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
|
||||||
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
|
||||||
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
|
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) |
|
||||||
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
|
||||||
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
|
||||||
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
|
||||||
|
|||||||
+44
-11
@@ -19,32 +19,46 @@ GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;
|
|||||||
|
|
||||||
### 2. 환경 변수 설정
|
### 2. 환경 변수 설정
|
||||||
|
|
||||||
**Web 서비스** (`/etc/systemd/system/taxbaik.service`):
|
**Web 서비스** (`/etc/systemd/system/taxbaik.service`, 백엔드 전용):
|
||||||
```ini
|
```ini
|
||||||
[Service]
|
[Service]
|
||||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
Environment=ASPNETCORE_URLS=http://127.0.0.1:5001
|
Environment=ASPNETCORE_URLS=http://127.0.0.1:5004
|
||||||
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
|
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**프록시 서비스** (`/etc/systemd/system/taxbaik-proxy.service`, 5001 진입점):
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/dotnet TaxBaik.Proxy.dll
|
||||||
|
WorkingDirectory=/home/kjh2064/taxbaik_active
|
||||||
|
Restart=always
|
||||||
|
```
|
||||||
|
|
||||||
### 3. systemd 서비스 파일 설치
|
### 3. systemd 서비스 파일 설치
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp deploy/taxbaik.service /etc/systemd/system/
|
sudo cp deploy/taxbaik.service /etc/systemd/system/
|
||||||
|
sudo cp deploy/taxbaik-proxy.service /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable taxbaik
|
sudo systemctl enable taxbaik
|
||||||
|
sudo systemctl enable taxbaik-proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Nginx 설정
|
### 4. Nginx 설정
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 현재 Nginx 설정 확인
|
# Nginx 도메인 기반 가상 호스트 설정 복사
|
||||||
sudo cat /etc/nginx/sites-available/default | head -30
|
sudo cp deploy/nginx-taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf
|
||||||
|
|
||||||
# location 블록 추가 (또는 기존 설정에 병합)
|
# 기존 설정(IP 기반 및 default) 활성화 해제
|
||||||
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
|
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 nginx -t
|
||||||
sudo systemctl reload nginx
|
sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
@@ -65,7 +79,7 @@ sudo systemctl reload nginx
|
|||||||
master 브랜치 push → build → test → publish → restart → health check → Playwright
|
master 브랜치 push → build → test → publish → restart → health check → Playwright
|
||||||
```
|
```
|
||||||
|
|
||||||
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
|
수동 배포는 사용하지 않습니다. `deploy_gb.sh`는 `TAXBAIK_DEPLOY_FROM_CI=1`이 없으면 즉시 종료하므로, 배포는 반드시 Gitea Actions에서만 실행됩니다.
|
||||||
|
|
||||||
## 마이그레이션 자동 실행
|
## 마이그레이션 자동 실행
|
||||||
|
|
||||||
@@ -128,6 +142,7 @@ ls -la ~/deployments/ | grep taxbaik
|
|||||||
|
|
||||||
# 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000)
|
# 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000)
|
||||||
ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active
|
ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active
|
||||||
|
sudo systemctl restart taxbaik-proxy
|
||||||
sudo systemctl restart taxbaik
|
sudo systemctl restart taxbaik
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,10 +154,10 @@ sudo systemctl restart taxbaik
|
|||||||
ssh kjh2064@178.104.200.7
|
ssh kjh2064@178.104.200.7
|
||||||
|
|
||||||
# 서비스 상태
|
# 서비스 상태
|
||||||
systemctl status taxbaik
|
systemctl status taxbaik taxbaik-proxy
|
||||||
|
|
||||||
# 포트 확인
|
# 포트 확인
|
||||||
netstat -tlnp | grep -E '5001'
|
netstat -tlnp | grep -E '5001|5004'
|
||||||
|
|
||||||
# 프로세스 확인
|
# 프로세스 확인
|
||||||
ps aux | grep TaxBaik
|
ps aux | grep TaxBaik
|
||||||
@@ -165,9 +180,27 @@ journalctl -u taxbaik -f
|
|||||||
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
||||||
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection \"Upgrade\"` 헤더가 모두 있는지 확인 |
|
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection \"Upgrade\"` 헤더가 모두 있는지 확인 |
|
||||||
| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 |
|
| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 |
|
||||||
| 503 Service Unavailable | 앱 미시작 | `sudo systemctl restart taxbaik` |
|
| 503 Service Unavailable | 백엔드 또는 프록시 미시작 | `sudo systemctl restart taxbaik-proxy taxbaik` |
|
||||||
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
|
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
|
||||||
|
|
||||||
|
## 운영 복구 순서
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kjh2064@178.104.200.7
|
||||||
|
sudo cp /home/kjh2064/taxbaik.service /etc/systemd/system/taxbaik.service
|
||||||
|
sudo cp /home/kjh2064/taxbaik-proxy.service /etc/systemd/system/taxbaik-proxy.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart taxbaik-proxy
|
||||||
|
sudo systemctl restart taxbaik
|
||||||
|
curl -I http://127.0.0.1:5001/taxbaik/admin/login
|
||||||
|
```
|
||||||
|
|
||||||
|
## 원라인 점검
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kjh2064@178.104.200.7 'systemctl status taxbaik taxbaik-proxy --no-pager --lines=3 && ss -tlnp | grep -E ":5001 |:5004 " && curl -fsSI http://127.0.0.1:5001/taxbaik/admin/login && curl -fsS http://127.0.0.1:5001/taxbaik/favicon.svg >/dev/null && curl -fsS http://127.0.0.1:5001/taxbaik/robots.txt >/dev/null'
|
||||||
|
```
|
||||||
|
|
||||||
## 초기 데이터
|
## 초기 데이터
|
||||||
|
|
||||||
### 관리자 계정
|
### 관리자 계정
|
||||||
|
|||||||
+8
-40
@@ -48,29 +48,7 @@ ssh kjh2064@178.104.200.7 'bash ~/SERVER_SETUP.sh'
|
|||||||
# ~/taxbaik_active
|
# ~/taxbaik_active
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2단계: 첫 배포 (수동)
|
### 2단계: Gitea Actions 설정
|
||||||
|
|
||||||
```bash
|
|
||||||
# 로컬에서 실행
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
|
|
||||||
# SSH 키 설정 (필요시)
|
|
||||||
export DEPLOY_USER="kjh2064"
|
|
||||||
export DEPLOY_HOST="178.104.200.7"
|
|
||||||
|
|
||||||
# 배포
|
|
||||||
rsync -avz --delete ./publish/ \
|
|
||||||
$DEPLOY_USER@$DEPLOY_HOST:~/deployments/taxbaik_${TIMESTAMP}/
|
|
||||||
|
|
||||||
# 심링크 변경 및 시작
|
|
||||||
ssh $DEPLOY_USER@$DEPLOY_HOST << EOF
|
|
||||||
ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active
|
|
||||||
sudo systemctl start taxbaik
|
|
||||||
sudo systemctl status taxbaik
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3단계: Gitea Actions 설정 (선택)
|
|
||||||
|
|
||||||
**Gitea 저장소 Settings → Secrets 추가**:
|
**Gitea 저장소 Settings → Secrets 추가**:
|
||||||
- `DEPLOY_USER`: `kjh2064`
|
- `DEPLOY_USER`: `kjh2064`
|
||||||
@@ -217,8 +195,8 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
|
|||||||
| 증상 | 원인 | 해결 방법 |
|
| 증상 | 원인 | 해결 방법 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
|
||||||
| 502 Bad Gateway | 앱 미실행 | `sudo systemctl restart taxbaik` |
|
| 502 Bad Gateway | 프록시 또는 백엔드 미실행 | `sudo systemctl restart taxbaik-proxy taxbaik` |
|
||||||
| 503 Service Unavailable | 앱 충돌 | 로그 확인: `journalctl -u taxbaik -n 50` |
|
| 503 Service Unavailable | 백엔드 충돌 또는 비밀값 누락 | 로그 확인: `journalctl -u taxbaik -n 50` |
|
||||||
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
|
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
|
||||||
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
|
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
|
||||||
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
|
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
|
||||||
@@ -230,11 +208,11 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
|
|||||||
### 실시간 모니터링
|
### 실시간 모니터링
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 터미널 1: 웹 서비스 로그
|
# 터미널 1: 백엔드 로그
|
||||||
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
||||||
|
|
||||||
# 터미널 2: 통합 서비스 로그
|
# 터미널 2: 프록시 로그
|
||||||
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
|
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-proxy -f'
|
||||||
|
|
||||||
# 터미널 3: Nginx 로그
|
# 터미널 3: Nginx 로그
|
||||||
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
|
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
|
||||||
@@ -246,13 +224,7 @@ ssh kjh2064@178.104.200.7 'watch -n 1 "ps aux | grep TaxBaik"'
|
|||||||
### 정기적 검사
|
### 정기적 검사
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 일일 체크 (cron job)
|
# 일일 체크는 CI 배포 후 자동 검증으로 대체
|
||||||
0 9 * * * /home/kjh2064/health-check.sh
|
|
||||||
|
|
||||||
# 내용:
|
|
||||||
#!/bin/bash
|
|
||||||
curl -f http://127.0.0.1:5001/taxbaik || systemctl restart taxbaik
|
|
||||||
curl -f http://127.0.0.1:5001/taxbaik/admin/login || systemctl restart taxbaik
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -268,11 +240,6 @@ git commit -m "기능: 새로운 기능 추가"
|
|||||||
git push origin master
|
git push origin master
|
||||||
|
|
||||||
# 2. Gitea Actions가 자동으로 배포
|
# 2. Gitea Actions가 자동으로 배포
|
||||||
# 또는 수동 배포:
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
dotnet publish TaxBaik.Web -c Release -o ./publish
|
|
||||||
rsync -avz ./publish/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/
|
|
||||||
ssh kjh2064@178.104.200.7 "ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active && sudo systemctl restart taxbaik"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 롤백 절차
|
### 롤백 절차
|
||||||
@@ -284,6 +251,7 @@ ssh kjh2064@178.104.200.7 'ls -la ~/deployments/ | grep taxbaik'
|
|||||||
# 롤백 (예: 이전 버전이 taxbaik_20260625_100000)
|
# 롤백 (예: 이전 버전이 taxbaik_20260625_100000)
|
||||||
ssh kjh2064@178.104.200.7 << EOF
|
ssh kjh2064@178.104.200.7 << EOF
|
||||||
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
|
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
|
||||||
|
sudo systemctl restart taxbaik-proxy
|
||||||
sudo systemctl restart taxbaik
|
sudo systemctl restart taxbaik
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위
|
|||||||
|-----|------|
|
|-----|------|
|
||||||
| **백엔드** | ASP.NET Core 10, C# |
|
| **백엔드** | ASP.NET Core 10, C# |
|
||||||
| **공개 사이트** | Razor Pages (SSR) |
|
| **공개 사이트** | Razor Pages (SSR) |
|
||||||
| **관리자** | Blazor Server + Fluent UI Blazor v5 |
|
| **관리자** | Blazor Server + MudBlazor |
|
||||||
| **데이터베이스** | PostgreSQL 18.4 |
|
| **데이터베이스** | PostgreSQL 18.4 |
|
||||||
| **ORM** | Dapper |
|
| **ORM** | Dapper |
|
||||||
| **리버스 프록시** | Nginx |
|
| **리버스 프록시** | Nginx |
|
||||||
@@ -98,11 +98,6 @@ TaxBaik/
|
|||||||
- 연락처 정보
|
- 연락처 정보
|
||||||
- 소셜 미디어 링크
|
- 소셜 미디어 링크
|
||||||
|
|
||||||
- **UI 기준**
|
|
||||||
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/`
|
|
||||||
- 기본 로딩 상태는 `Skeleton`
|
|
||||||
- 기존 MudBlazor 패턴은 레거시 호환용으로만 유지
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
@@ -173,7 +168,7 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
|
|||||||
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
|
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
|
||||||
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
|
||||||
|
|
||||||
수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
|
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -275,7 +270,13 @@ echo $ConnectionStrings__Default
|
|||||||
|
|
||||||
## 문서
|
## 문서
|
||||||
|
|
||||||
- [CLAUDE.md](./CLAUDE.md) - LLM 개발 지침 (9개 섹션)
|
- [docs/INDEX.md](./docs/INDEX.md) - 현재 개발 기준 인덱스
|
||||||
|
- [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md) - 코드 품질, API-first, CI/CD 하네스
|
||||||
|
- [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md) - 더존식 어드민 UX 원칙과 템플릿 기준
|
||||||
|
- [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md) - 공통코드 저장값/컬럼 길이/하드코딩 금지 기준
|
||||||
|
- [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md) - 콤보/검색/선택 입력 정책
|
||||||
|
- [docs/ADMIN_PATTERN_CRITIQUE_WBS.md](./docs/ADMIN_PATTERN_CRITIQUE_WBS.md) - 어드민 패턴 비판 및 정량 WBS
|
||||||
|
- [CLAUDE.md](./CLAUDE.md) - 보조 LLM 개발 지침
|
||||||
- [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 배포 완전 가이드
|
- [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 배포 완전 가이드
|
||||||
- [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트
|
- [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트
|
||||||
|
|
||||||
|
|||||||
@@ -522,3 +522,46 @@ Todo:
|
|||||||
- WBS-UX-03/04 구현 완료
|
- WBS-UX-03/04 구현 완료
|
||||||
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
|
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
|
||||||
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
|
- 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 연계 바인딩 처리
|
||||||
|
|
||||||
|
|||||||
@@ -44,15 +44,34 @@ public class BlogServiceTests
|
|||||||
Assert.Equal("같은-제목-2", post.Slug);
|
Assert.Equal("같은-제목-2", post.Slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_SoftDeletesPost_AndExcludesFromSlugLookup()
|
||||||
|
{
|
||||||
|
var repository = new FakeBlogPostRepository
|
||||||
|
{
|
||||||
|
Posts =
|
||||||
|
[
|
||||||
|
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
|
await service.DeleteAsync(1);
|
||||||
|
|
||||||
|
Assert.NotNull(repository.Posts.Single().DeletedAt);
|
||||||
|
Assert.Null(await service.GetBySlugAsync("delete-me"));
|
||||||
|
Assert.Null(await service.GetByIdAsync(1));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeBlogPostRepository : IBlogPostRepository
|
private sealed class FakeBlogPostRepository : IBlogPostRepository
|
||||||
{
|
{
|
||||||
public List<BlogPost> Posts { get; init; } = [];
|
public List<BlogPost> Posts { get; init; } = [];
|
||||||
|
|
||||||
public Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
|
public Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult(Posts.FirstOrDefault(x => x.Id == id));
|
Task.FromResult(Posts.FirstOrDefault(x => x.Id == id && x.DeletedAt == null));
|
||||||
|
|
||||||
public Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
|
public Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished));
|
Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished && x.DeletedAt == null));
|
||||||
|
|
||||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
public Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||||
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
|
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
|
||||||
@@ -74,6 +93,13 @@ public class BlogServiceTests
|
|||||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||||
|
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var items = Posts.Where(x => x.DeletedAt != null).ToList();
|
||||||
|
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||||
|
}
|
||||||
|
|
||||||
public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
post.Id = Posts.Count + 1;
|
post.Id = Posts.Count + 1;
|
||||||
@@ -83,7 +109,23 @@ public class BlogServiceTests
|
|||||||
|
|
||||||
public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
public Task DeleteAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||||
|
if (post != null)
|
||||||
|
post.DeletedAt = DateTime.UtcNow;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ArchiveAsync(int id, CancellationToken cancellationToken = default) => DeleteAsync(id, cancellationToken);
|
||||||
|
|
||||||
|
public Task RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||||
|
if (post != null)
|
||||||
|
post.DeletedAt = null;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace TaxBaik.Application.Tests;
|
||||||
|
|
||||||
|
using TaxBaik.Web.Components.Admin.Shared;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class BusinessDayCalculatorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2026, 2, 14, 2026, 2, 19)]
|
||||||
|
[InlineData(2026, 8, 15, 2026, 8, 18)]
|
||||||
|
[InlineData(2026, 10, 3, 2026, 10, 6)]
|
||||||
|
[InlineData(2026, 9, 24, 2026, 9, 28)]
|
||||||
|
[InlineData(2027, 2, 6, 2027, 2, 10)]
|
||||||
|
[InlineData(2027, 10, 9, 2027, 10, 12)]
|
||||||
|
public void GetEffectiveDueDate_SkipsWeekendHolidayAndSubstituteHoliday(
|
||||||
|
int dueYear, int dueMonth, int dueDay,
|
||||||
|
int expectedYear, int expectedMonth, int expectedDay)
|
||||||
|
{
|
||||||
|
var effective = BusinessDayCalculator.GetEffectiveDueDate(new DateOnly(dueYear, dueMonth, dueDay));
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), effective);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2026, 2, 19, 0)]
|
||||||
|
[InlineData(2026, 2, 20, -1)]
|
||||||
|
[InlineData(2026, 2, 18, 1)]
|
||||||
|
public void GetDday_UsesEffectiveDueDate(
|
||||||
|
int refYear, int refMonth, int refDay,
|
||||||
|
int expectedDays)
|
||||||
|
{
|
||||||
|
var dday = BusinessDayCalculator.GetDday(new DateOnly(2026, 2, 14), new DateOnly(refYear, refMonth, refDay));
|
||||||
|
|
||||||
|
Assert.Equal(expectedDays, dday);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
namespace TaxBaik.Application.Tests;
|
||||||
|
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class CommonCodeServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task UpsertAsync_TrimsAndRejectsWhitespaceInCodeValue()
|
||||||
|
{
|
||||||
|
var repository = new FakeCommonCodeRepository();
|
||||||
|
var service = new CommonCodeService(repository);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() => service.UpsertAsync(new CommonCode
|
||||||
|
{
|
||||||
|
CodeGroup = " CLIENT_STATUS ",
|
||||||
|
CodeValue = "active code",
|
||||||
|
CodeName = " 활성 "
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpsertAsync_TrimsAndPersistsNormalizedValues()
|
||||||
|
{
|
||||||
|
var repository = new FakeCommonCodeRepository();
|
||||||
|
var service = new CommonCodeService(repository);
|
||||||
|
|
||||||
|
await service.UpsertAsync(new CommonCode
|
||||||
|
{
|
||||||
|
CodeGroup = " CLIENT_STATUS ",
|
||||||
|
CodeValue = "active",
|
||||||
|
CodeName = " 활성 ",
|
||||||
|
SortOrder = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
var saved = Assert.Single(repository.SavedCodes);
|
||||||
|
Assert.Equal("CLIENT_STATUS", saved.CodeGroup);
|
||||||
|
Assert.Equal("active", saved.CodeValue);
|
||||||
|
Assert.Equal("활성", saved.CodeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeCommonCodeRepository : ICommonCodeRepository
|
||||||
|
{
|
||||||
|
public List<CommonCode> SavedCodes { get; } = [];
|
||||||
|
|
||||||
|
public Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<IEnumerable<string>>([]);
|
||||||
|
|
||||||
|
public Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||||
|
|
||||||
|
public Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||||
|
|
||||||
|
public Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<CommonCode?>(null);
|
||||||
|
|
||||||
|
public Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
SavedCodes.Add(new CommonCode
|
||||||
|
{
|
||||||
|
CodeGroup = code.CodeGroup,
|
||||||
|
CodeValue = code.CodeValue,
|
||||||
|
CodeName = code.CodeName,
|
||||||
|
SortOrder = code.SortOrder,
|
||||||
|
IsActive = code.IsActive
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,22 @@ public class InquiryServiceTests
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var existing = Inquiries.FirstOrDefault(x => x.Id == inquiry.Id);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Name = inquiry.Name;
|
||||||
|
existing.Phone = inquiry.Phone;
|
||||||
|
existing.Email = inquiry.Email;
|
||||||
|
existing.ServiceType = inquiry.ServiceType;
|
||||||
|
existing.Message = inquiry.Message;
|
||||||
|
existing.Status = inquiry.Status;
|
||||||
|
existing.AdminMemo = inquiry.AdminMemo;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
|
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace TaxBaik.Application.Tests;
|
||||||
|
|
||||||
|
using TaxBaik.Application.Seasonal;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class SeasonalMarketingServiceTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2026, 1, 25, 2026, 1, 26)]
|
||||||
|
[InlineData(2026, 2, 28, 2026, 3, 3)]
|
||||||
|
[InlineData(2026, 3, 31, 2026, 3, 31)]
|
||||||
|
[InlineData(2026, 5, 31, 2026, 6, 1)]
|
||||||
|
[InlineData(2026, 7, 25, 2026, 7, 27)]
|
||||||
|
[InlineData(2026, 11, 30, 2026, 11, 30)]
|
||||||
|
[InlineData(2026, 12, 31, 2026, 12, 31)]
|
||||||
|
public void SeasonalDeadlines_ApplyBusinessDayRollForward(
|
||||||
|
int year, int month, int day,
|
||||||
|
int expectedYear, int expectedMonth, int expectedDay)
|
||||||
|
{
|
||||||
|
var deadline = new DateOnly(year, month, day);
|
||||||
|
var effective = BusinessDayCalculator.GetEffectiveBusinessDate(deadline);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), effective);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2026, 7, 24, 2026, 7, 27, 3)]
|
||||||
|
[InlineData(2026, 7, 25, 2026, 7, 27, 2)]
|
||||||
|
[InlineData(2026, 7, 26, 2026, 7, 27, 1)]
|
||||||
|
public void SeasonalDeadlines_UseBusinessDayDiff(
|
||||||
|
int refYear, int refMonth, int refDay,
|
||||||
|
int expectedYear, int expectedMonth, int expectedDay,
|
||||||
|
int expectedDays)
|
||||||
|
{
|
||||||
|
var deadline = new DateOnly(2026, 7, 25);
|
||||||
|
var reference = new DateOnly(refYear, refMonth, refDay);
|
||||||
|
var days = BusinessDayCalculator.GetBusinessDayDiff(deadline, reference);
|
||||||
|
|
||||||
|
Assert.Equal(expectedDays, days);
|
||||||
|
Assert.Equal(new DateOnly(expectedYear, expectedMonth, expectedDay), BusinessDayCalculator.GetEffectiveBusinessDate(deadline));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,5 +18,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\TaxBaik.Web\TaxBaik.Web.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -12,3 +12,21 @@ public class CreateBlogPostDto
|
|||||||
public bool IsPublished { get; set; }
|
public bool IsPublished { get; set; }
|
||||||
public int? AuthorId { get; set; }
|
public int? AuthorId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class BlogPostResponseDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public int? CategoryId { get; set; }
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? SeoTitle { get; set; }
|
||||||
|
public string? SeoDescription { get; set; }
|
||||||
|
public string? ThumbnailUrl { get; set; }
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
public int? AuthorId { get; set; }
|
||||||
|
public int ViewCount { get; set; }
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace TaxBaik.Application.DTOs;
|
||||||
|
|
||||||
|
public class SubmitInquiryDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string ServiceType { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public bool SuppressNotification { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TaxBaik.Application.DTOs;
|
||||||
|
|
||||||
|
public class UpdateInquiryDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string ServiceType { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string? AdminMemo { get; set; }
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<RevenueTrackingService>();
|
services.AddScoped<RevenueTrackingService>();
|
||||||
services.AddScoped<TelegramReportService>();
|
services.AddScoped<TelegramReportService>();
|
||||||
services.AddScoped<PortalUserService>();
|
services.AddScoped<PortalUserService>();
|
||||||
|
services.AddScoped<CommonCodeService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace TaxBaik.Application.Seasonal;
|
||||||
|
|
||||||
|
public static class BusinessDayCalculator
|
||||||
|
{
|
||||||
|
private static readonly HashSet<DateOnly> HolidayDates = new()
|
||||||
|
{
|
||||||
|
// 2026
|
||||||
|
new DateOnly(2026, 1, 1),
|
||||||
|
new DateOnly(2026, 2, 16),
|
||||||
|
new DateOnly(2026, 2, 17),
|
||||||
|
new DateOnly(2026, 2, 18),
|
||||||
|
new DateOnly(2026, 3, 1),
|
||||||
|
new DateOnly(2026, 3, 2),
|
||||||
|
new DateOnly(2026, 5, 5),
|
||||||
|
new DateOnly(2026, 5, 25),
|
||||||
|
new DateOnly(2026, 6, 6),
|
||||||
|
new DateOnly(2026, 8, 15),
|
||||||
|
new DateOnly(2026, 8, 16),
|
||||||
|
new DateOnly(2026, 8, 17),
|
||||||
|
new DateOnly(2026, 9, 24),
|
||||||
|
new DateOnly(2026, 9, 25),
|
||||||
|
new DateOnly(2026, 9, 26),
|
||||||
|
new DateOnly(2026, 10, 3),
|
||||||
|
new DateOnly(2026, 10, 4),
|
||||||
|
new DateOnly(2026, 10, 5),
|
||||||
|
new DateOnly(2026, 10, 9),
|
||||||
|
new DateOnly(2026, 12, 25),
|
||||||
|
|
||||||
|
// 2027
|
||||||
|
new DateOnly(2027, 1, 1),
|
||||||
|
new DateOnly(2027, 2, 6),
|
||||||
|
new DateOnly(2027, 2, 7),
|
||||||
|
new DateOnly(2027, 2, 8),
|
||||||
|
new DateOnly(2027, 2, 9),
|
||||||
|
new DateOnly(2027, 3, 1),
|
||||||
|
new DateOnly(2027, 3, 2),
|
||||||
|
new DateOnly(2027, 5, 5),
|
||||||
|
new DateOnly(2027, 5, 13),
|
||||||
|
new DateOnly(2027, 6, 6),
|
||||||
|
new DateOnly(2027, 8, 15),
|
||||||
|
new DateOnly(2027, 8, 16),
|
||||||
|
new DateOnly(2027, 9, 14),
|
||||||
|
new DateOnly(2027, 9, 15),
|
||||||
|
new DateOnly(2027, 9, 16),
|
||||||
|
new DateOnly(2027, 10, 3),
|
||||||
|
new DateOnly(2027, 10, 4),
|
||||||
|
new DateOnly(2027, 10, 9),
|
||||||
|
new DateOnly(2027, 10, 10),
|
||||||
|
new DateOnly(2027, 10, 11),
|
||||||
|
new DateOnly(2027, 12, 25),
|
||||||
|
new DateOnly(2027, 12, 26)
|
||||||
|
};
|
||||||
|
|
||||||
|
public static DateOnly GetEffectiveBusinessDate(DateOnly date)
|
||||||
|
{
|
||||||
|
var effectiveDate = date;
|
||||||
|
while (!IsBusinessDay(effectiveDate))
|
||||||
|
{
|
||||||
|
effectiveDate = effectiveDate.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetBusinessDayDiff(DateOnly date, DateOnly referenceDate)
|
||||||
|
{
|
||||||
|
var effectiveDate = GetEffectiveBusinessDate(date);
|
||||||
|
return effectiveDate.DayNumber - referenceDate.DayNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBusinessDay(DateOnly date)
|
||||||
|
=> date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday
|
||||||
|
&& !HolidayDates.Contains(date);
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ public static class TaxSeasonCalendar
|
|||||||
Name = "부가가치세 1기 확정신고",
|
Name = "부가가치세 1기 확정신고",
|
||||||
StartMonth = 7, StartDay = 1,
|
StartMonth = 7, StartDay = 1,
|
||||||
EndMonth = 7, EndDay = 25,
|
EndMonth = 7, EndDay = 25,
|
||||||
HeroHeadline = "부가가치세 1기\n7월 25일 마감",
|
HeroHeadline = "부가가치세 1기\n7월 27일 마감",
|
||||||
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
|
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
|
||||||
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||||
FocusService = "business-tax",
|
FocusService = "business-tax",
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
|||||||
int page, int pageSize, CancellationToken ct = default) =>
|
int page, int pageSize, CancellationToken ct = default) =>
|
||||||
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BlogPost>, int)> GetArchivedPagedAsync(
|
||||||
|
int page, int pageSize, CancellationToken ct = default) =>
|
||||||
|
await repository.GetArchivedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||||
|
|
||||||
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
|
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ValidatePost(post);
|
ValidatePost(post);
|
||||||
@@ -110,6 +114,18 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
|||||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ArchiveAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await repository.ArchiveAsync(id, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RestoreAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await repository.RestoreAsync(id, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
|
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.IncrementViewCountAsync(id, ct);
|
await repository.IncrementViewCountAsync(id, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,6 @@ using TaxBaik.Domain.Interfaces;
|
|||||||
|
|
||||||
public class ClientService(IClientRepository repository)
|
public class ClientService(IClientRepository repository)
|
||||||
{
|
{
|
||||||
public static readonly string[] ServiceTypes =
|
|
||||||
["기장", "부동산", "증여·상속", "종합소득세", "법인세", "부가가치세", "기타"];
|
|
||||||
|
|
||||||
public static readonly string[] TaxTypes =
|
|
||||||
["개인사업자", "법인사업자", "면세사업자", "근로소득자", "기타"];
|
|
||||||
|
|
||||||
public static readonly string[] Sources =
|
|
||||||
["홈페이지 문의", "소개", "직접 방문", "카카오 채널", "블로그", "기타"];
|
|
||||||
|
|
||||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||||
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) =>
|
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) =>
|
||||||
await repository.GetPagedAsync(Math.Max(1, page), Math.Clamp(pageSize, 1, 100), status, search, ct);
|
await repository.GetPagedAsync(Math.Max(1, page), Math.Clamp(pageSize, 1, 100), status, search, ct);
|
||||||
@@ -81,7 +72,7 @@ public class ClientService(IClientRepository repository)
|
|||||||
Phone = phone?.Trim(),
|
Phone = phone?.Trim(),
|
||||||
ServiceType = serviceType,
|
ServiceType = serviceType,
|
||||||
Status = "active",
|
Status = "active",
|
||||||
Source = "홈페이지 문의"
|
Source = "홈페이지문의"
|
||||||
};
|
};
|
||||||
return await repository.CreateAsync(client, ct);
|
return await repository.CreateAsync(client, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
private const int MaxCodeGroupLength = 80;
|
||||||
|
private const int MaxCodeValueLength = 120;
|
||||||
|
private const int MaxCodeNameLength = 200;
|
||||||
|
|
||||||
|
public async Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await commonCodeRepository.GetAllGroupsAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await commonCodeRepository.GetAsync(codeGroup, codeValue, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Normalize(code);
|
||||||
|
await commonCodeRepository.UpsertAsync(code, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await commonCodeRepository.DeleteAsync(NormalizeToken(codeGroup, nameof(codeGroup), MaxCodeGroupLength), NormalizeToken(codeValue, nameof(codeValue), MaxCodeValueLength), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Normalize(CommonCode code)
|
||||||
|
{
|
||||||
|
code.CodeGroup = NormalizeToken(code.CodeGroup, nameof(code.CodeGroup), MaxCodeGroupLength, disallowWhitespace: true);
|
||||||
|
code.CodeValue = NormalizeToken(code.CodeValue, nameof(code.CodeValue), MaxCodeValueLength, disallowWhitespace: true);
|
||||||
|
code.CodeName = NormalizeToken(code.CodeName, nameof(code.CodeName), MaxCodeNameLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeToken(string value, string fieldName, int maxLength, bool disallowWhitespace = false)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
throw new ValidationException($"{fieldName}은(는) 필수입니다.");
|
||||||
|
|
||||||
|
if (normalized.Length > maxLength)
|
||||||
|
throw new ValidationException($"{fieldName}은(는) 최대 {maxLength}자까지 입력할 수 있습니다.");
|
||||||
|
|
||||||
|
if (disallowWhitespace && normalized.Any(char.IsWhiteSpace))
|
||||||
|
throw new ValidationException($"{fieldName}에는 공백을 사용할 수 없습니다.");
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using TaxBaik.Domain.Interfaces;
|
|||||||
public class FaqService(IFaqRepository repository)
|
public class FaqService(IFaqRepository repository)
|
||||||
{
|
{
|
||||||
public static readonly string[] Categories =
|
public static readonly string[] Categories =
|
||||||
["기장·세금신고", "부동산", "증여·상속", "기타"];
|
["기장세금신고", "부동산", "증여상속", "기타"];
|
||||||
|
|
||||||
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
||||||
await repository.GetActiveAsync(ct);
|
await repository.GetActiveAsync(ct);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ namespace TaxBaik.Application.Services;
|
|||||||
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Enums;
|
using TaxBaik.Domain.Enums;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
@@ -72,6 +73,37 @@ public class InquiryService(
|
|||||||
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) =>
|
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) =>
|
||||||
await repository.UpdateAdminMemoAsync(id, adminMemo, ct);
|
await repository.UpdateAdminMemoAsync(id, adminMemo, ct);
|
||||||
|
|
||||||
|
public async Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var inquiry = await repository.GetByIdAsync(id, ct);
|
||||||
|
if (inquiry == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
throw new ValidationException("이름을 입력하세요.");
|
||||||
|
|
||||||
|
if (!PhoneRegex.IsMatch(dto.Phone))
|
||||||
|
throw new ValidationException("올바른 전화번호를 입력하세요. (예: 010-1234-5678)");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Message))
|
||||||
|
throw new ValidationException("문의 내용을 입력하세요.");
|
||||||
|
|
||||||
|
if (!InquiryStatusMapper.TryParse(dto.Status, out var parsedStatus))
|
||||||
|
throw new ValidationException("지원하지 않는 문의 상태입니다.");
|
||||||
|
|
||||||
|
inquiry.Name = dto.Name.Trim();
|
||||||
|
inquiry.Phone = dto.Phone.Trim();
|
||||||
|
inquiry.Email = string.IsNullOrWhiteSpace(dto.Email) ? null : dto.Email.Trim();
|
||||||
|
inquiry.ServiceType = string.IsNullOrWhiteSpace(dto.ServiceType) ? "기타" : dto.ServiceType.Trim();
|
||||||
|
inquiry.Message = dto.Message.Trim();
|
||||||
|
inquiry.Status = InquiryStatusMapper.ToStorageValue(parsedStatus);
|
||||||
|
inquiry.AdminMemo = dto.AdminMemo;
|
||||||
|
|
||||||
|
await repository.UpdateAsync(inquiry, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
return inquiry;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) =>
|
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) =>
|
||||||
await repository.LinkClientAsync(inquiryId, clientId, ct);
|
await repository.LinkClientAsync(inquiryId, clientId, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
|||||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
await repository.GetByClientIdAsync(clientId, ct);
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
public async Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
|
||||||
await repository.GetByIdAsync(id, ct);
|
|
||||||
|
|
||||||
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
|
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
await repository.GetAllAsync(ct);
|
await repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public class SeasonalMarketingService
|
|||||||
|
|
||||||
if (today >= start && today <= end)
|
if (today >= start && today <= end)
|
||||||
{
|
{
|
||||||
var days = (end - today).Days;
|
var effectiveEnd = BusinessDayCalculator.GetEffectiveBusinessDate(DateOnly.FromDateTime(end)).ToDateTime(TimeOnly.MinValue);
|
||||||
|
var days = BusinessDayCalculator.GetBusinessDayDiff(DateOnly.FromDateTime(end), DateOnly.FromDateTime(today));
|
||||||
return new CurrentSeasonDto
|
return new CurrentSeasonDto
|
||||||
{
|
{
|
||||||
Key = season.Key,
|
Key = season.Key,
|
||||||
@@ -27,7 +28,7 @@ public class SeasonalMarketingService
|
|||||||
RelatedCategorySlug = season.RelatedCategorySlug,
|
RelatedCategorySlug = season.RelatedCategorySlug,
|
||||||
CtaText = season.CtaText,
|
CtaText = season.CtaText,
|
||||||
DaysUntilDeadline = days,
|
DaysUntilDeadline = days,
|
||||||
Deadline = end
|
Deadline = effectiveEnd
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using TaxBaik.Domain.Interfaces;
|
|||||||
|
|
||||||
public class TaxFilingService(ITaxFilingRepository repository)
|
public class TaxFilingService(ITaxFilingRepository repository)
|
||||||
{
|
{
|
||||||
public static readonly string[] FilingTypes =
|
|
||||||
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
|
|
||||||
|
|
||||||
public static readonly string[] Statuses =
|
public static readonly string[] Statuses =
|
||||||
["pending", "filed", "overdue"];
|
["pending", "filed", "overdue"];
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ public class TaxProfileService(ITaxProfileRepository repository)
|
|||||||
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||||
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
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))
|
if (!string.IsNullOrWhiteSpace(businessType))
|
||||||
profile.BusinessType = businessType.Trim();
|
profile.BusinessType = businessType.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class BlogPost
|
|||||||
public bool IsPublished { get; set; }
|
public bool IsPublished { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
|
|
||||||
// Navigation property (populated via LEFT JOIN, not stored in DB)
|
// Navigation property (populated via LEFT JOIN, not stored in DB)
|
||||||
public string? CategoryName { get; set; }
|
public string? CategoryName { get; set; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,8 +12,12 @@ public interface IBlogPostRepository
|
|||||||
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
||||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||||
int page, int pageSize, CancellationToken cancellationToken = default);
|
int page, int pageSize, CancellationToken cancellationToken = default);
|
||||||
|
Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||||
|
int page, int pageSize, CancellationToken cancellationToken = default);
|
||||||
Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task ArchiveAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task RestoreAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
|
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface ICommonCodeRepository
|
||||||
|
{
|
||||||
|
Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default);
|
||||||
|
Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default);
|
||||||
|
Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
|
||||||
|
Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default);
|
||||||
|
Task UpsertAsync(CommonCode code, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public interface IInquiryRepository
|
|||||||
Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||||
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
|
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default);
|
||||||
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
||||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
|
|||||||
public interface IRevenueTrackingRepository
|
public interface IRevenueTrackingRepository
|
||||||
{
|
{
|
||||||
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||||
Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
|
||||||
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
|
|||||||
public interface ITaxProfileRepository
|
public interface ITaxProfileRepository
|
||||||
{
|
{
|
||||||
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||||
|
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
||||||
services.AddScoped<IContractRepository, ContractRepository>();
|
services.AddScoped<IContractRepository, ContractRepository>();
|
||||||
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
||||||
|
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||||
FROM blog_posts bp
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
WHERE bp.id = @Id",
|
WHERE bp.id = @Id AND bp.deleted_at IS NULL",
|
||||||
new { Id = id });
|
new { Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +25,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||||
FROM blog_posts bp
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
WHERE bp.slug = @Slug AND bp.is_published = TRUE",
|
WHERE bp.slug = @Slug AND bp.is_published = TRUE AND bp.deleted_at IS NULL",
|
||||||
new { Slug = slug });
|
new { Slug = slug });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +41,15 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
using var reader = await conn.QueryMultipleAsync(
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||||
FROM blog_posts bp
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
WHERE bp.is_published = TRUE AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId)
|
WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId)
|
||||||
ORDER BY bp.published_at DESC
|
ORDER BY bp.published_at DESC
|
||||||
LIMIT @PageSize OFFSET @Offset;
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
SELECT COUNT(*) FROM blog_posts
|
SELECT COUNT(*) FROM blog_posts
|
||||||
WHERE is_published = TRUE AND (@CategoryId::int IS NULL OR category_id = @CategoryId);",
|
WHERE is_published = TRUE AND deleted_at IS NULL AND (@CategoryId::int IS NULL OR category_id = @CategoryId);",
|
||||||
new { CategoryId = categoryId, PageSize = pageSize, Offset = offset });
|
new { CategoryId = categoryId, PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||||
@@ -64,10 +64,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return await conn.QueryAsync<BlogPost>(
|
return await conn.QueryAsync<BlogPost>(
|
||||||
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
|
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
|
||||||
bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url,
|
bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url,
|
||||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||||
FROM blog_posts bp
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
WHERE bp.is_published = TRUE AND c.slug = @CategorySlug
|
WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND c.slug = @CategorySlug
|
||||||
ORDER BY bp.published_at DESC
|
ORDER BY bp.published_at DESC
|
||||||
LIMIT @Limit",
|
LIMIT @Limit",
|
||||||
new { CategorySlug = categorySlug, Limit = limit });
|
new { CategorySlug = categorySlug, Limit = limit });
|
||||||
@@ -82,6 +82,7 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||||
FROM blog_posts bp
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
WHERE bp.deleted_at IS NULL
|
||||||
ORDER BY bp.created_at DESC");
|
ORDER BY bp.created_at DESC");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,13 +95,14 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
using var reader = await conn.QueryMultipleAsync(
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||||
FROM blog_posts bp
|
FROM blog_posts bp
|
||||||
LEFT JOIN categories c ON bp.category_id = c.id
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
WHERE bp.deleted_at IS NULL
|
||||||
ORDER BY bp.created_at DESC
|
ORDER BY bp.created_at DESC
|
||||||
LIMIT @PageSize OFFSET @Offset;
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
SELECT COUNT(*) FROM blog_posts;",
|
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NULL;",
|
||||||
new { PageSize = pageSize, Offset = offset });
|
new { PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||||
@@ -109,6 +111,30 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return (items, total);
|
return (items, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||||
|
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
|
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||||
|
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||||
|
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||||
|
FROM blog_posts bp
|
||||||
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
WHERE bp.deleted_at IS NOT NULL
|
||||||
|
ORDER BY bp.deleted_at DESC
|
||||||
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
|
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NOT NULL;",
|
||||||
|
new { PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
|
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||||
|
var total = await reader.ReadFirstAsync<int>();
|
||||||
|
return (items, total);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
@@ -130,19 +156,34 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
|
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
|
||||||
seo_title = @SeoTitle, seo_description = @SeoDescription,
|
seo_title = @SeoTitle, seo_description = @SeoDescription,
|
||||||
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
|
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
|
||||||
WHERE id = @Id",
|
WHERE id = @Id AND deleted_at IS NULL",
|
||||||
post);
|
post);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await ArchiveAsync(id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ArchiveAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
await conn.ExecuteAsync("DELETE FROM blog_posts WHERE id = @Id", new { Id = id });
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE blog_posts SET deleted_at = NOW(), updated_at = NOW() WHERE id = @Id AND deleted_at IS NULL",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE blog_posts SET deleted_at = NULL, updated_at = NOW() WHERE id = @Id AND deleted_at IS NOT NULL",
|
||||||
|
new { Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
|
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id", new { Id = id });
|
await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id AND deleted_at IS NULL", new { Id = id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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<string>> GetAllGroupsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<string>(
|
||||||
|
"SELECT DISTINCT code_group FROM common_codes WHERE is_active = TRUE ORDER BY code_group");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<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 code_value = @CodeValue",
|
||||||
|
new { CodeGroup = codeGroup, CodeValue = codeValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO common_codes (code_group, code_value, code_name, sort_order, is_active)
|
||||||
|
VALUES (@CodeGroup, @CodeValue, @CodeName, @SortOrder, @IsActive)
|
||||||
|
ON CONFLICT (code_group, code_value) DO UPDATE
|
||||||
|
SET code_name = EXCLUDED.code_name,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
is_active = EXCLUDED.is_active",
|
||||||
|
code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"DELETE FROM common_codes
|
||||||
|
WHERE code_group = @CodeGroup AND code_value = @CodeValue",
|
||||||
|
new { CodeGroup = codeGroup, CodeValue = codeValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,23 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
|||||||
new { Id = id, AdminMemo = adminMemo });
|
new { Id = id, AdminMemo = adminMemo });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE inquiries
|
||||||
|
SET name = @Name,
|
||||||
|
phone = @Phone,
|
||||||
|
email = @Email,
|
||||||
|
service_type = @ServiceType,
|
||||||
|
message = @Message,
|
||||||
|
status = @Status,
|
||||||
|
admin_memo = @AdminMemo,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
inquiry);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
|
|||||||
@@ -24,15 +24,6 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
|
|||||||
FROM revenue_tracking ORDER BY invoice_date DESC");
|
FROM revenue_tracking ORDER BY invoice_date DESC");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
using var conn = Conn();
|
|
||||||
return await conn.QueryFirstOrDefaultAsync<RevenueTracking>(
|
|
||||||
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
|
||||||
FROM revenue_tracking WHERE id = @Id",
|
|
||||||
new { Id = id });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
|
|||||||
profile);
|
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)
|
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
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>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"runtimeOptions": {
|
||||||
|
"tfm": "net10.0",
|
||||||
|
"includedFrameworks": [
|
||||||
|
{
|
||||||
|
"name": "Microsoft.NETCore.App",
|
||||||
|
"version": "10.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wasmHostProperties": {
|
||||||
|
"perHostConfig": [
|
||||||
|
{
|
||||||
|
"name": "browser",
|
||||||
|
"host": "browser"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configProperties": {
|
||||||
|
"Microsoft.AspNetCore.Components.Routing.RegexConstraintSupport": false,
|
||||||
|
"Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true,
|
||||||
|
"System.ComponentModel.DefaultValueAttribute.IsSupported": false,
|
||||||
|
"System.ComponentModel.Design.IDesignerHost.IsSupported": false,
|
||||||
|
"System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false,
|
||||||
|
"System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false,
|
||||||
|
"System.Data.DataSet.XmlSerializationIsSupported": false,
|
||||||
|
"System.Diagnostics.Debugger.IsSupported": false,
|
||||||
|
"System.Diagnostics.Metrics.Meter.IsSupported": false,
|
||||||
|
"System.Diagnostics.Tracing.EventSource.IsSupported": false,
|
||||||
|
"System.GC.Server": true,
|
||||||
|
"System.Globalization.Invariant": false,
|
||||||
|
"System.TimeZoneInfo.Invariant": false,
|
||||||
|
"System.Linq.Enumerable.IsSizeOptimized": true,
|
||||||
|
"System.Net.Http.EnableActivityPropagation": false,
|
||||||
|
"System.Net.Http.WasmEnableStreamingResponse": true,
|
||||||
|
"System.Net.SocketsHttpHandler.Http3Support": false,
|
||||||
|
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
|
||||||
|
"System.Resources.ResourceManager.AllowCustomResourceTypes": false,
|
||||||
|
"System.Resources.UseSystemResourceKeys": true,
|
||||||
|
"System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": true,
|
||||||
|
"System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false,
|
||||||
|
"System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false,
|
||||||
|
"System.Runtime.InteropServices.EnableCppCLIHostActivation": false,
|
||||||
|
"System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false,
|
||||||
|
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false,
|
||||||
|
"System.StartupHookProvider.IsSupported": false,
|
||||||
|
"System.Text.Encoding.EnableUnsafeUTF7Encoding": false,
|
||||||
|
"System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": true,
|
||||||
|
"System.Threading.Thread.EnableAutoreleasePool": false,
|
||||||
|
"Microsoft.AspNetCore.Components.Endpoints.NavigationManager.DisableThrowNavigationException": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
global using System.Net.Http;
|
||||||
|
global using System.Net.Http.Json;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@
|
||||||
|
@rendermode InteractiveWebAssembly
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6 ma-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h5" GutterBottom="true">WebAssembly 렌더 모드 점검</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-4">이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다.</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Increment">카운트: @count</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private int count;
|
||||||
|
private void Increment() => count++;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using MudBlazor.Services;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
using TaxBaik.Web.Services.AdminClients;
|
||||||
|
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
|
// MudBlazor (WASM 측 인터랙티브 컴포넌트용)
|
||||||
|
builder.Services.AddMudServices(config =>
|
||||||
|
{
|
||||||
|
config.SnackbarConfiguration.HideTransitionDuration = 400;
|
||||||
|
config.SnackbarConfiguration.ShowTransitionDuration = 300;
|
||||||
|
config.PopoverOptions.ThrowOnDuplicateProvider = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Base Url 동적 구성 (호스트 기준 /taxbaik/api/)
|
||||||
|
var apiBaseUrl = builder.HostEnvironment.BaseAddress.TrimEnd('/') + "/taxbaik/api/";
|
||||||
|
|
||||||
|
// HTTP Client for API (with automatic token refresh)
|
||||||
|
builder.Services.AddScoped<ITokenStore, TokenStore>();
|
||||||
|
builder.Services.AddScoped<TokenRefreshHandler>();
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
|
}).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
|
|
||||||
|
// 각 Browser API Client 등록
|
||||||
|
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
|
builder.Services.AddHttpClient<IBlogBrowserClient, BlogBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
|
builder.Services.AddHttpClient<ICategoryBrowserClient, CategoryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl));
|
||||||
|
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>();
|
||||||
|
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)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
|
|
||||||
|
// Blazor 인증 (WASM 측 클라이언트)
|
||||||
|
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
|
||||||
|
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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<string>> GetGroupsAsync(CancellationToken ct = default);
|
||||||
|
Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
|
||||||
|
Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default);
|
||||||
|
Task<CommonCode?> GetAsync(string group, string value, CancellationToken ct = default);
|
||||||
|
Task<bool> UpsertAsync(CommonCode code, CancellationToken ct = default);
|
||||||
|
Task<bool> DeleteAsync(string group, string value, 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetGroupsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
return await httpClient.GetFromJsonAsync<List<string>>($"{BaseUrl}/groups", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get common code groups");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CommonCode?> GetAsync(string group, string value, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
return await httpClient.GetFromJsonAsync<CommonCode>($"{BaseUrl}/{group}/{value}", ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get common code {Group}/{Value}", group, value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await httpClient.PostAsJsonAsync(BaseUrl, code, ct);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to upsert common code {Group}/{Value}", code.CodeGroup, code.CodeValue);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(string group, string value, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await httpClient.DeleteAsync($"{BaseUrl}/{group}/{value}", ct);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to delete common code {Group}/{Value}", group, value);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
|
||||||
|
public interface IBlogBrowserClient
|
||||||
|
{
|
||||||
|
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
|
||||||
|
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
|
||||||
|
Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default);
|
||||||
|
Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
|
||||||
|
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<bool> RestoreAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BlogBrowserClient : IBlogBrowserClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ILogger<BlogBrowserClient> _logger;
|
||||||
|
private readonly ITokenStore _tokenStore;
|
||||||
|
|
||||||
|
public BlogBrowserClient(HttpClient http, ILogger<BlogBrowserClient> logger, ITokenStore tokenStore)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_logger = logger;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAuthHeader()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||||
|
else
|
||||||
|
_http.DefaultRequestHeaders.Authorization = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin?page={page}&pageSize={pageSize}", ct);
|
||||||
|
return result != null ? (result.Data, result.Total) : ([], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin/archived?page={page}&pageSize={pageSize}", ct);
|
||||||
|
return result != null ? (result.Data, result.Total) : ([], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
return await _http.GetFromJsonAsync<BlogPostResponseDto>($"blog/{id}", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await _http.PostAsJsonAsync("blog", dto, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
var content = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await _http.PutAsJsonAsync($"blog/{id}", dto, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
var content = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await _http.DeleteAsync($"blog/{id}", ct);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RestoreAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await _http.PostAsync($"blog/{id}/restore", null, ct);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await UpdateAsync(id, dto, ct);
|
||||||
|
return result != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PagedResponse
|
||||||
|
{
|
||||||
|
public List<BlogPostResponseDto> Data { get; set; } = [];
|
||||||
|
public int Total { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ICategoryBrowserClient
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CategoryBrowserClient : ICategoryBrowserClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ILogger<CategoryBrowserClient> _logger;
|
||||||
|
|
||||||
|
public CategoryBrowserClient(HttpClient http, ILogger<CategoryBrowserClient> logger)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _http.GetFromJsonAsync<List<Category>>("category", cancellationToken: ct);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to fetch categories");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-10
@@ -1,6 +1,7 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
namespace TaxBaik.Web.Services;
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
@@ -8,18 +9,18 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
{
|
{
|
||||||
private readonly ILocalStorageService _localStorage;
|
private readonly ILocalStorageService _localStorage;
|
||||||
private readonly ITokenStore _tokenStore;
|
private readonly ITokenStore _tokenStore;
|
||||||
private readonly AuthService _authService;
|
private readonly IApiClient _apiClient;
|
||||||
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
|
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
|
||||||
|
|
||||||
public CustomAuthenticationStateProvider(
|
public CustomAuthenticationStateProvider(
|
||||||
ILocalStorageService localStorage,
|
ILocalStorageService localStorage,
|
||||||
ITokenStore tokenStore,
|
ITokenStore tokenStore,
|
||||||
AuthService authService,
|
IApiClient apiClient,
|
||||||
ILogger<CustomAuthenticationStateProvider> logger)
|
ILogger<CustomAuthenticationStateProvider> logger)
|
||||||
{
|
{
|
||||||
_localStorage = localStorage;
|
_localStorage = localStorage;
|
||||||
_tokenStore = tokenStore;
|
_tokenStore = tokenStore;
|
||||||
_authService = authService;
|
_apiClient = apiClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
{
|
{
|
||||||
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
|
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
|
||||||
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
|
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
|
||||||
if (long.TryParse(ticksStr, out var ticks))
|
if (TryNormalizeExpiryTicks(ticksStr, out var ticks))
|
||||||
{
|
{
|
||||||
_tokenStore.AccessToken = storedToken;
|
_tokenStore.AccessToken = storedToken;
|
||||||
_tokenStore.RefreshToken = refreshToken;
|
_tokenStore.RefreshToken = refreshToken;
|
||||||
@@ -64,8 +65,9 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
|
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
|
||||||
{
|
{
|
||||||
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
|
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
|
||||||
var newTokenPair = await _authService.RefreshAccessTokenAsync(_tokenStore.RefreshToken);
|
var request = new { RefreshToken = _tokenStore.RefreshToken };
|
||||||
if (newTokenPair != null)
|
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
|
||||||
|
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
|
||||||
{
|
{
|
||||||
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
|
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
|
||||||
_logger.LogInformation("토큰 자동 갱신 성공");
|
_logger.LogInformation("토큰 자동 갱신 성공");
|
||||||
@@ -79,7 +81,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var principal = _authService.ValidateToken(accessToken!);
|
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
|
||||||
if (principal == null)
|
if (principal == null)
|
||||||
{
|
{
|
||||||
await LogoutAsync();
|
await LogoutAsync();
|
||||||
@@ -95,6 +97,22 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwtToken = handler.ReadJwtToken(token);
|
||||||
|
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
|
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
|
||||||
{
|
{
|
||||||
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
|
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
|
||||||
@@ -112,17 +130,40 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeExpiryTicks(string? rawValue, out long ticks)
|
||||||
|
{
|
||||||
|
ticks = 0;
|
||||||
|
if (!long.TryParse(rawValue, out var parsed))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support both legacy Unix-millisecond storage and .NET ticks.
|
||||||
|
if (parsed > 10_000_000_000_000L && parsed < 100_000_000_000_000_000L)
|
||||||
|
{
|
||||||
|
ticks = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed > 1_000_000_000_000L && parsed < 100_000_000_000_000L)
|
||||||
|
{
|
||||||
|
ticks = DateTimeOffset.FromUnixTimeMilliseconds(parsed).UtcDateTime.Ticks;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private bool ShouldRefreshToken()
|
private bool ShouldRefreshToken()
|
||||||
{
|
{
|
||||||
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
|
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
|
||||||
var tokenExpiryTicks = _tokenStore.TokenExpiryTicks;
|
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
|
||||||
if (tokenExpiryTicks is null || tokenExpiryTicks <= 0)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const int refreshThresholdSeconds = 300;
|
const int refreshThresholdSeconds = 300;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var expiryTime = new DateTime(tokenExpiryTicks.Value, DateTimeKind.Utc);
|
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
|
||||||
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
|
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
|
||||||
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
|
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
|
||||||
}
|
}
|
||||||
@@ -145,4 +186,31 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsTokenExpired(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwtToken = handler.ReadJwtToken(token);
|
||||||
|
return jwtToken.ValidTo < DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WasmAuthTokenPair
|
||||||
|
{
|
||||||
|
public WasmAuthTokenPair() { }
|
||||||
|
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
|
||||||
|
{
|
||||||
|
AccessToken = accessToken;
|
||||||
|
RefreshToken = refreshToken;
|
||||||
|
ExpiresIn = expiresIn;
|
||||||
|
}
|
||||||
|
public string AccessToken { get; set; } = "";
|
||||||
|
public string RefreshToken { get; set; } = "";
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
}
|
}
|
||||||
+61
@@ -2,6 +2,7 @@ namespace TaxBaik.Web.Services;
|
|||||||
|
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -15,7 +16,10 @@ public interface IInquiryBrowserClient
|
|||||||
Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default);
|
Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default);
|
Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default);
|
||||||
Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default);
|
Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default);
|
||||||
|
Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default);
|
||||||
Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default);
|
Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default);
|
||||||
|
Task<Inquiry?> CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default);
|
||||||
|
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InquiryBrowserClient : IInquiryBrowserClient
|
public class InquiryBrowserClient : IInquiryBrowserClient
|
||||||
@@ -116,6 +120,27 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await _http.PutAsJsonAsync($"inquiry/{id}", dto, cancellationToken: ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<Inquiry>(
|
||||||
|
content,
|
||||||
|
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update inquiry {InquiryId}", id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default)
|
public async Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -143,6 +168,42 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Inquiry?> CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await _http.PostAsJsonAsync("inquiry", dto, cancellationToken: ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<Inquiry>(
|
||||||
|
content,
|
||||||
|
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create inquiry");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
|
var response = await _http.DeleteAsync($"inquiry/{id}", ct);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete inquiry {InquiryId}", id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class InquiryPagedResponse
|
private class InquiryPagedResponse
|
||||||
{
|
{
|
||||||
public List<Inquiry> Data { get; set; } = [];
|
public List<Inquiry> Data { get; set; } = [];
|
||||||
+2
-2
@@ -62,7 +62,7 @@ public class TokenRefreshHandler : DelegatingHandler
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
|
private async Task<WasmAuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -87,7 +87,7 @@ public class TokenRefreshHandler : DelegatingHandler
|
|||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
return result != null
|
return result != null
|
||||||
? new AuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
|
? new WasmAuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>TaxBaik.WasmClient</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
|
||||||
|
<PackageReference Include="MudBlazor" Version="6.10.0" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using MudBlazor
|
||||||
|
@using TaxBaik.WasmClient
|
||||||
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
{
|
||||||
|
"runtimeTarget": {
|
||||||
|
"name": ".NETCoreApp,Version=v10.0",
|
||||||
|
"signature": ""
|
||||||
|
},
|
||||||
|
"compilationOptions": {},
|
||||||
|
"targets": {
|
||||||
|
".NETCoreApp,Version=v10.0": {
|
||||||
|
"TaxBaik.Web/1.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"BCrypt.Net-Next": "4.0.3",
|
||||||
|
"Microsoft.AspNetCore.Authentication.Google": "10.0.9",
|
||||||
|
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.9",
|
||||||
|
"Microsoft.AspNetCore.Components.WebAssembly.Server": "10.0.9",
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.1",
|
||||||
|
"MudBlazor": "6.10.0",
|
||||||
|
"Serilog.AspNetCore": "8.0.1",
|
||||||
|
"Serilog.Sinks.Console": "6.0.0",
|
||||||
|
"Serilog.Sinks.File": "5.0.0",
|
||||||
|
"System.IdentityModel.Tokens.Jwt": "8.19.1",
|
||||||
|
"TaxBaik.Application": "1.0.0",
|
||||||
|
"TaxBaik.Infrastructure": "1.0.0",
|
||||||
|
"TaxBaik.Web.Client": "1.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"TaxBaik.Web.dll": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BCrypt.Net-Next/4.0.3": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net6.0/BCrypt.Net-Next.dll": {
|
||||||
|
"assemblyVersion": "4.0.3.0",
|
||||||
|
"fileVersion": "4.0.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Dapper/2.1.15": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net5.0/Dapper.dll": {
|
||||||
|
"assemblyVersion": "2.0.0.0",
|
||||||
|
"fileVersion": "2.1.15.52653"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Authentication.Google/10.0.9": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.AspNetCore.Authentication.Google.dll": {
|
||||||
|
"assemblyVersion": "10.0.9.0",
|
||||||
|
"fileVersion": "10.0.926.27113"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.9": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
|
||||||
|
"assemblyVersion": "10.0.9.0",
|
||||||
|
"fileVersion": "10.0.926.27113"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Components.WebAssembly/10.0.9": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.JSInterop.WebAssembly": "10.0.9"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.AspNetCore.Components.WebAssembly.dll": {
|
||||||
|
"assemblyVersion": "10.0.9.0",
|
||||||
|
"fileVersion": "10.0.926.27113"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Components.WebAssembly.Server/10.0.9": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.AspNetCore.Components.WebAssembly": "10.0.9"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.AspNetCore.Components.WebAssembly.Server.dll": {
|
||||||
|
"assemblyVersion": "10.0.9.0",
|
||||||
|
"fileVersion": "10.0.926.27113"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Bcl.Cryptography/10.0.2": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.Bcl.Cryptography.dll": {
|
||||||
|
"assemblyVersion": "10.0.0.2",
|
||||||
|
"fileVersion": "10.0.225.61305"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.DependencyModel/8.0.0": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Microsoft.Extensions.DependencyModel.dll": {
|
||||||
|
"assemblyVersion": "8.0.0.0",
|
||||||
|
"fileVersion": "8.0.23.53103"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Abstractions/8.19.1": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||||
|
"assemblyVersion": "8.19.1.0",
|
||||||
|
"fileVersion": "8.19.1.26153"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.1"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||||
|
"assemblyVersion": "8.19.1.0",
|
||||||
|
"fileVersion": "8.19.1.26153"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Logging/8.19.1": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Abstractions": "8.19.1"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.IdentityModel.Logging.dll": {
|
||||||
|
"assemblyVersion": "8.19.1.0",
|
||||||
|
"fileVersion": "8.19.1.26153"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.1"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||||
|
"assemblyVersion": "8.0.1.0",
|
||||||
|
"fileVersion": "8.0.1.50722"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Protocols": "8.0.1",
|
||||||
|
"System.IdentityModel.Tokens.Jwt": "8.19.1"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||||
|
"assemblyVersion": "8.0.1.0",
|
||||||
|
"fileVersion": "8.0.1.50722"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Tokens/8.19.1": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Bcl.Cryptography": "10.0.2",
|
||||||
|
"Microsoft.IdentityModel.Logging": "8.19.1"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||||
|
"assemblyVersion": "8.19.1.0",
|
||||||
|
"fileVersion": "8.19.1.26153"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.JSInterop.WebAssembly/10.0.9": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Microsoft.JSInterop.WebAssembly.dll": {
|
||||||
|
"assemblyVersion": "10.0.9.0",
|
||||||
|
"fileVersion": "10.0.926.27113"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MudBlazor/6.10.0": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net7.0/MudBlazor.dll": {
|
||||||
|
"assemblyVersion": "6.10.0.0",
|
||||||
|
"fileVersion": "6.10.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Npgsql/10.0.3": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/Npgsql.dll": {
|
||||||
|
"assemblyVersion": "10.0.3.0",
|
||||||
|
"fileVersion": "10.0.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog/4.0.0": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Serilog.dll": {
|
||||||
|
"assemblyVersion": "4.0.0.0",
|
||||||
|
"fileVersion": "4.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.AspNetCore/8.0.1": {
|
||||||
|
"dependencies": {
|
||||||
|
"Serilog": "4.0.0",
|
||||||
|
"Serilog.Extensions.Hosting": "8.0.0",
|
||||||
|
"Serilog.Extensions.Logging": "8.0.0",
|
||||||
|
"Serilog.Formatting.Compact": "2.0.0",
|
||||||
|
"Serilog.Settings.Configuration": "8.0.0",
|
||||||
|
"Serilog.Sinks.Console": "6.0.0",
|
||||||
|
"Serilog.Sinks.Debug": "2.0.0",
|
||||||
|
"Serilog.Sinks.File": "5.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Serilog.AspNetCore.dll": {
|
||||||
|
"assemblyVersion": "8.0.1.0",
|
||||||
|
"fileVersion": "8.0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.Extensions.Hosting/8.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Serilog": "4.0.0",
|
||||||
|
"Serilog.Extensions.Logging": "8.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Serilog.Extensions.Hosting.dll": {
|
||||||
|
"assemblyVersion": "7.0.0.0",
|
||||||
|
"fileVersion": "8.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.Extensions.Logging/8.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Serilog": "4.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Serilog.Extensions.Logging.dll": {
|
||||||
|
"assemblyVersion": "7.0.0.0",
|
||||||
|
"fileVersion": "8.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.Formatting.Compact/2.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Serilog": "4.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net7.0/Serilog.Formatting.Compact.dll": {
|
||||||
|
"assemblyVersion": "2.0.0.0",
|
||||||
|
"fileVersion": "2.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.Settings.Configuration/8.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.DependencyModel": "8.0.0",
|
||||||
|
"Serilog": "4.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Serilog.Settings.Configuration.dll": {
|
||||||
|
"assemblyVersion": "8.0.0.0",
|
||||||
|
"fileVersion": "8.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.Sinks.Console/6.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Serilog": "4.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Serilog.Sinks.Console.dll": {
|
||||||
|
"assemblyVersion": "6.0.0.0",
|
||||||
|
"fileVersion": "6.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.Sinks.Debug/2.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Serilog": "4.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/netstandard2.1/Serilog.Sinks.Debug.dll": {
|
||||||
|
"assemblyVersion": "2.0.0.0",
|
||||||
|
"fileVersion": "2.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog.Sinks.File/5.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Serilog": "4.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net5.0/Serilog.Sinks.File.dll": {
|
||||||
|
"assemblyVersion": "5.0.0.0",
|
||||||
|
"fileVersion": "5.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"System.IdentityModel.Tokens.Jwt/8.19.1": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.JsonWebTokens": "8.19.1",
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.1"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net10.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||||
|
"assemblyVersion": "8.19.1.0",
|
||||||
|
"fileVersion": "8.19.1.26153"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TaxBaik.Application/1.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"TaxBaik.Domain": "1.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"TaxBaik.Application.dll": {
|
||||||
|
"assemblyVersion": "1.0.0.0",
|
||||||
|
"fileVersion": "1.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TaxBaik.Domain/1.0.0": {
|
||||||
|
"runtime": {
|
||||||
|
"TaxBaik.Domain.dll": {
|
||||||
|
"assemblyVersion": "1.0.0.0",
|
||||||
|
"fileVersion": "1.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TaxBaik.Infrastructure/1.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Dapper": "2.1.15",
|
||||||
|
"Npgsql": "10.0.3",
|
||||||
|
"TaxBaik.Domain": "1.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"TaxBaik.Infrastructure.dll": {
|
||||||
|
"assemblyVersion": "1.0.0.0",
|
||||||
|
"fileVersion": "1.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TaxBaik.Web.Client/1.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.AspNetCore.Components.WebAssembly": "10.0.9",
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.1",
|
||||||
|
"MudBlazor": "6.10.0",
|
||||||
|
"System.IdentityModel.Tokens.Jwt": "8.19.1",
|
||||||
|
"TaxBaik.Application": "1.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"TaxBaik.Web.Client.dll": {
|
||||||
|
"assemblyVersion": "1.0.0.0",
|
||||||
|
"fileVersion": "1.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraries": {
|
||||||
|
"TaxBaik.Web/1.0.0": {
|
||||||
|
"type": "project",
|
||||||
|
"serviceable": false,
|
||||||
|
"sha512": ""
|
||||||
|
},
|
||||||
|
"BCrypt.Net-Next/4.0.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-W+U9WvmZQgi5cX6FS5GDtDoPzUCV4LkBLkywq/kRZhuDwcbavOzcDAr3LXJFqHUi952Yj3LEYoWW0jbEUQChsA==",
|
||||||
|
"path": "bcrypt.net-next/4.0.3",
|
||||||
|
"hashPath": "bcrypt.net-next.4.0.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Dapper/2.1.15": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-1aWSAosZymEM+mRwfrXteRIN74/JTUjqj9B/KqEbanH6vfUKy9D9cemRN0q1ZOEfSB7d1PpFTpVOCbf2Uv70Og==",
|
||||||
|
"path": "dapper/2.1.15",
|
||||||
|
"hashPath": "dapper.2.1.15.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Authentication.Google/10.0.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-xqjTc8/ap0dwKmdaqSlV8RxjXb02uQ8rynDtTuHRU2gmOYaNm6O+uUjobp4Ararzq0ndKNXiWnQErxjWEGFGiA==",
|
||||||
|
"path": "microsoft.aspnetcore.authentication.google/10.0.9",
|
||||||
|
"hashPath": "microsoft.aspnetcore.authentication.google.10.0.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-Hs5NDsGm8YicDDNx5RoBIT+H2AB9R27MvZ2gHoupTiHr+nnH3VxzY7DcmlbJ3b5DvvOhK35lWt/9Odtrq9sjtA==",
|
||||||
|
"path": "microsoft.aspnetcore.authentication.jwtbearer/10.0.9",
|
||||||
|
"hashPath": "microsoft.aspnetcore.authentication.jwtbearer.10.0.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Components.WebAssembly/10.0.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-tBv68AsZ3r6z2QdV2m3cSSKUCbvEscN8REpHxcUs22vlR6UjTz6IKdInKNREkJ/3G1AQrBKrRTdrfrHVffE8Iw==",
|
||||||
|
"path": "microsoft.aspnetcore.components.webassembly/10.0.9",
|
||||||
|
"hashPath": "microsoft.aspnetcore.components.webassembly.10.0.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.Components.WebAssembly.Server/10.0.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-ZTtYvBILwGxhIiXi1L03ETBBOgMmizStu7dO/YblK6rPTa27wpEgYKp5Z9bUfr+wsFvHIDWd/ZMGb9on41f6yw==",
|
||||||
|
"path": "microsoft.aspnetcore.components.webassembly.server/10.0.9",
|
||||||
|
"hashPath": "microsoft.aspnetcore.components.webassembly.server.10.0.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.Bcl.Cryptography/10.0.2": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==",
|
||||||
|
"path": "microsoft.bcl.cryptography/10.0.2",
|
||||||
|
"hashPath": "microsoft.bcl.cryptography.10.0.2.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.DependencyModel/8.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==",
|
||||||
|
"path": "microsoft.extensions.dependencymodel/8.0.0",
|
||||||
|
"hashPath": "microsoft.extensions.dependencymodel.8.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Abstractions/8.19.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-gFA8THIk23uNF/vMdOHnjIdXD1LyA2g12cHzMJ+Xag6WpgWLw6E/6uCXxvA0gp9d2yAvkRt3xzFzMUiO/hofnQ==",
|
||||||
|
"path": "microsoft.identitymodel.abstractions/8.19.1",
|
||||||
|
"hashPath": "microsoft.identitymodel.abstractions.8.19.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-6eeY+y2QFyjj3XnCz/8gJdoP5smYHTS9ow1bw2nsZzDIPjPhBZlackYTIduSMipVpxnoT/B62LkrXX2jPggOXg==",
|
||||||
|
"path": "microsoft.identitymodel.jsonwebtokens/8.19.1",
|
||||||
|
"hashPath": "microsoft.identitymodel.jsonwebtokens.8.19.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Logging/8.19.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-H+sMrMpdbWnwkQnpb/ESkQovtOgdefmj0ecGCcP40mDKzE5i4dUYkH6599M9mWYFNGNJnTp92l/9wLubYXWimw==",
|
||||||
|
"path": "microsoft.identitymodel.logging/8.19.1",
|
||||||
|
"hashPath": "microsoft.identitymodel.logging.8.19.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
|
||||||
|
"path": "microsoft.identitymodel.protocols/8.0.1",
|
||||||
|
"hashPath": "microsoft.identitymodel.protocols.8.0.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
|
||||||
|
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
|
||||||
|
"hashPath": "microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Tokens/8.19.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-KDiuSLXud2AFVNAOottd8ztVysfPeHyr4r8gofU3/VKUXlI7oytzGTnPsNJ/B3nui17rgz8wAdWNJOtzPjkUxw==",
|
||||||
|
"path": "microsoft.identitymodel.tokens/8.19.1",
|
||||||
|
"hashPath": "microsoft.identitymodel.tokens.8.19.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Microsoft.JSInterop.WebAssembly/10.0.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-4G0A7GuQrtCAes8PuJPTDUcy+lCrxHWjr8ZlkDOa4h8a2Txj1XdhbXKLnld2vMY5EyZNC5jZXxa1xTD/AOCUlw==",
|
||||||
|
"path": "microsoft.jsinterop.webassembly/10.0.9",
|
||||||
|
"hashPath": "microsoft.jsinterop.webassembly.10.0.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"MudBlazor/6.10.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-Dpjouo3MVva4p8Nh2VCzHzvzReWhnzmCBNlrhymeXjn6oBEtT3Oi9z/R2sHOg/jYrW/hIPKMhfZHnptilHScsw==",
|
||||||
|
"path": "mudblazor/6.10.0",
|
||||||
|
"hashPath": "mudblazor.6.10.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Npgsql/10.0.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
|
||||||
|
"path": "npgsql/10.0.3",
|
||||||
|
"hashPath": "npgsql.10.0.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog/4.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-2jDkUrSh5EofOp7Lx5Zgy0EB+7hXjjxE2ktTb1WVQmU00lDACR2TdROGKU0K1pDTBSJBN1PqgYpgOZF8mL7NJw==",
|
||||||
|
"path": "serilog/4.0.0",
|
||||||
|
"hashPath": "serilog.4.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.AspNetCore/8.0.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-B/X+wAfS7yWLVOTD83B+Ip9yl4MkhioaXj90JSoWi1Ayi8XHepEnsBdrkojg08eodCnmOKmShFUN2GgEc6c0CQ==",
|
||||||
|
"path": "serilog.aspnetcore/8.0.1",
|
||||||
|
"hashPath": "serilog.aspnetcore.8.0.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.Extensions.Hosting/8.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-db0OcbWeSCvYQkHWu6n0v40N4kKaTAXNjlM3BKvcbwvNzYphQFcBR+36eQ/7hMMwOkJvAyLC2a9/jNdUL5NjtQ==",
|
||||||
|
"path": "serilog.extensions.hosting/8.0.0",
|
||||||
|
"hashPath": "serilog.extensions.hosting.8.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.Extensions.Logging/8.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==",
|
||||||
|
"path": "serilog.extensions.logging/8.0.0",
|
||||||
|
"hashPath": "serilog.extensions.logging.8.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.Formatting.Compact/2.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-ob6z3ikzFM3D1xalhFuBIK1IOWf+XrQq+H4KeH4VqBcPpNcmUgZlRQ2h3Q7wvthpdZBBoY86qZOI2LCXNaLlNA==",
|
||||||
|
"path": "serilog.formatting.compact/2.0.0",
|
||||||
|
"hashPath": "serilog.formatting.compact.2.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.Settings.Configuration/8.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-nR0iL5HwKj5v6ULo3/zpP8NMcq9E2pxYA6XKTSWCbugVs4YqPyvaqaKOY+OMpPivKp7zMEpax2UKHnDodbRB0Q==",
|
||||||
|
"path": "serilog.settings.configuration/8.0.0",
|
||||||
|
"hashPath": "serilog.settings.configuration.8.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.Sinks.Console/6.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==",
|
||||||
|
"path": "serilog.sinks.console/6.0.0",
|
||||||
|
"hashPath": "serilog.sinks.console.6.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.Sinks.Debug/2.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-Y6g3OBJ4JzTyyw16fDqtFcQ41qQAydnEvEqmXjhwhgjsnG/FaJ8GUqF5ldsC/bVkK8KYmqrPhDO+tm4dF6xx4A==",
|
||||||
|
"path": "serilog.sinks.debug/2.0.0",
|
||||||
|
"hashPath": "serilog.sinks.debug.2.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Serilog.Sinks.File/5.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-uwV5hdhWPwUH1szhO8PJpFiahqXmzPzJT/sOijH/kFgUx+cyoDTMM8MHD0adw9+Iem6itoibbUXHYslzXsLEAg==",
|
||||||
|
"path": "serilog.sinks.file/5.0.0",
|
||||||
|
"hashPath": "serilog.sinks.file.5.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"System.IdentityModel.Tokens.Jwt/8.19.1": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-2VHcRtT95GAcW1E3aVBLvL2rAAMxKHXKMXKXFyWzwgkdFXZPMMvP8tVOfnRydL4vTr1RirNuGC6T8VSEF2YsPQ==",
|
||||||
|
"path": "system.identitymodel.tokens.jwt/8.19.1",
|
||||||
|
"hashPath": "system.identitymodel.tokens.jwt.8.19.1.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"TaxBaik.Application/1.0.0": {
|
||||||
|
"type": "project",
|
||||||
|
"serviceable": false,
|
||||||
|
"sha512": ""
|
||||||
|
},
|
||||||
|
"TaxBaik.Domain/1.0.0": {
|
||||||
|
"type": "project",
|
||||||
|
"serviceable": false,
|
||||||
|
"sha512": ""
|
||||||
|
},
|
||||||
|
"TaxBaik.Infrastructure/1.0.0": {
|
||||||
|
"type": "project",
|
||||||
|
"serviceable": false,
|
||||||
|
"sha512": ""
|
||||||
|
},
|
||||||
|
"TaxBaik.Web.Client/1.0.0": {
|
||||||
|
"type": "project",
|
||||||
|
"serviceable": false,
|
||||||
|
"sha512": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"runtimeOptions": {
|
||||||
|
"tfm": "net10.0",
|
||||||
|
"frameworks": [
|
||||||
|
{
|
||||||
|
"name": "Microsoft.NETCore.App",
|
||||||
|
"version": "10.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Microsoft.AspNetCore.App",
|
||||||
|
"version": "10.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configProperties": {
|
||||||
|
"System.GC.Server": true,
|
||||||
|
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
|
||||||
|
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
@inject VersionInfo VersionInfo
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
@@ -7,12 +7,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>백원숙 세무회계 - 관리자</title>
|
<title>백원숙 세무회계 - 관리자</title>
|
||||||
<base href="/taxbaik/" />
|
<base href="/taxbaik/" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap" rel="stylesheet" />
|
<link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" />
|
||||||
|
<link rel="alternate icon" href="/taxbaik/favicon.ico" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="css/design-tokens.css" />
|
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="css/ui-primitives.css" />
|
|
||||||
<link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
|
|
||||||
<script>
|
<script>
|
||||||
|
window.taxbaikAdminBuildVersion = '@VersionInfo.Version';
|
||||||
|
window.taxbaikAdminComponent = 'AdminApp';
|
||||||
document.documentElement.classList.toggle(
|
document.documentElement.classList.toggle(
|
||||||
'admin-login-route',
|
'admin-login-route',
|
||||||
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
||||||
@@ -28,21 +30,114 @@
|
|||||||
<span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span>
|
<span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="blazor-loading" class="blazor-loading-overlay show">
|
<div id="blazor-loading" class="blazor-loading-overlay">
|
||||||
<div class="blazor-loading-spinner">
|
<div class="blazor-loading-spinner">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>로드 중...</p>
|
<p>로드 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
||||||
<FluentProviders />
|
<Routes @rendermode="new InteractiveWebAssemblyRenderMode(prerender: true)" />
|
||||||
<FluentDialogProvider />
|
<script src="/taxbaik/_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
<FluentTooltipProvider />
|
<script src="/taxbaik/js/admin-session.js"></script>
|
||||||
|
<script src="/taxbaik/_framework/blazor.web.js"></script>
|
||||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
<script>
|
||||||
|
if (window.taxbaikAdminSession && typeof window.taxbaikAdminSession.initErrorLogging === 'function') {
|
||||||
<script src="js/admin-session.js"></script>
|
window.taxbaikAdminSession.initErrorLogging();
|
||||||
<script src="_framework/blazor.web.js"></script>
|
}
|
||||||
<script>window.taxbaikAdminSession?.watchReconnect();</script>
|
if (window.taxbaikAdminSession && typeof window.taxbaikAdminSession.bindLoginForm === 'function') {
|
||||||
|
window.taxbaikAdminSession.bindLoginForm();
|
||||||
|
}
|
||||||
|
if (window.taxbaikAdminSession && typeof window.taxbaikAdminSession.watchReconnect === 'function') {
|
||||||
|
window.taxbaikAdminSession.watchReconnect();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool isDarkMode = false;
|
||||||
|
private MudTheme mudTheme = new()
|
||||||
|
{
|
||||||
|
Palette = new PaletteLight()
|
||||||
|
{
|
||||||
|
Primary = "#1976D2",
|
||||||
|
PrimaryContrastText = "#FFFFFF",
|
||||||
|
Secondary = "#2D9F7E",
|
||||||
|
SecondaryContrastText = "#FFFFFF",
|
||||||
|
Tertiary = "#FF8A50",
|
||||||
|
TertiaryContrastText = "#FFFFFF",
|
||||||
|
Surface = "#F5F7FA",
|
||||||
|
Background = "#FFFFFF",
|
||||||
|
BackgroundGrey = "#F8F9FB",
|
||||||
|
DrawerBackground = "#FFFFFF",
|
||||||
|
DrawerText = "#424242",
|
||||||
|
AppbarBackground = "#FFFFFF",
|
||||||
|
AppbarText = "#424242",
|
||||||
|
TextPrimary = "#1A1A1A",
|
||||||
|
TextSecondary = "#64748B",
|
||||||
|
TextDisabled = "#94A3B8",
|
||||||
|
ActionDefault = "#1976D2",
|
||||||
|
ActionDisabled = "#BDBDBD",
|
||||||
|
Divider = "#E2E8F0",
|
||||||
|
DividerLight = "#F1F5F9",
|
||||||
|
Error = "#DC2626",
|
||||||
|
ErrorContrastText = "#FFFFFF",
|
||||||
|
Warning = "#F59E0B",
|
||||||
|
WarningContrastText = "#FFFFFF",
|
||||||
|
Info = "#06B6D4",
|
||||||
|
InfoContrastText = "#FFFFFF",
|
||||||
|
Success = "#16A34A",
|
||||||
|
SuccessContrastText = "#FFFFFF",
|
||||||
|
},
|
||||||
|
LayoutProperties = new LayoutProperties()
|
||||||
|
{
|
||||||
|
DefaultBorderRadius = "6px"
|
||||||
|
},
|
||||||
|
Typography = new Typography()
|
||||||
|
{
|
||||||
|
Default = new Default()
|
||||||
|
{
|
||||||
|
FontSize = ".8125rem",
|
||||||
|
FontWeight = 400,
|
||||||
|
LineHeight = 1.5
|
||||||
|
},
|
||||||
|
H1 = new H1()
|
||||||
|
{
|
||||||
|
FontSize = "1.75rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.2
|
||||||
|
},
|
||||||
|
H2 = new H2()
|
||||||
|
{
|
||||||
|
FontSize = "1.5rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.3
|
||||||
|
},
|
||||||
|
H3 = new H3()
|
||||||
|
{
|
||||||
|
FontSize = "1.25rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.3
|
||||||
|
},
|
||||||
|
H4 = new H4()
|
||||||
|
{
|
||||||
|
FontSize = "1.1rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.4
|
||||||
|
},
|
||||||
|
H5 = new H5()
|
||||||
|
{
|
||||||
|
FontSize = "0.95rem",
|
||||||
|
FontWeight = 500,
|
||||||
|
LineHeight = 1.4
|
||||||
|
},
|
||||||
|
H6 = new H6()
|
||||||
|
{
|
||||||
|
FontSize = "0.85rem",
|
||||||
|
FontWeight = 500,
|
||||||
|
LineHeight = 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
@using MudBlazor
|
||||||
<div class="admin-dialog">
|
|
||||||
<div class="admin-dialog-title">삭제 확인</div>
|
<MudDialog>
|
||||||
<p class="admin-dialog-message">정말로 삭제하시겠습니까?</p>
|
<DialogContent>
|
||||||
<div class="admin-dialog-actions">
|
<MudText>정말로 삭제하시겠습니까?</MudText>
|
||||||
<FluentButton Appearance="ButtonAppearance.Transparent" @onclick="Cancel">취소</FluentButton>
|
</DialogContent>
|
||||||
<FluentButton Appearance="ButtonAppearance.Primary" @onclick="Confirm">삭제</FluentButton>
|
<DialogActions>
|
||||||
</div>
|
<MudButton OnClick="@Cancel">취소</MudButton>
|
||||||
</div>
|
<MudButton Color="Color.Error" OnClick="@Confirm">삭제</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public EventCallback OnCancel { get; set; }
|
[CascadingParameter] MudDialogInstance? MudDialog { get; set; }
|
||||||
[Parameter] public EventCallback OnConfirm { get; set; }
|
|
||||||
|
|
||||||
Task Cancel() => OnCancel.InvokeAsync();
|
void Cancel() => MudDialog?.Cancel();
|
||||||
Task Confirm() => OnConfirm.InvokeAsync();
|
void Confirm() => MudDialog?.Close(DialogResult.Ok(true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,49 @@
|
|||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Application.Services
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
|
||||||
|
|
||||||
<form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
|
<MudForm @ref="form">
|
||||||
<FluentTextInput Label="회사 코드" @bind-CurrentValue="model.CompanyCode" />
|
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
|
||||||
<FluentTextInput Label="회사명" @bind-CurrentValue="model.CompanyName" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true"
|
||||||
<FluentTextInput Label="담당자명" @bind-CurrentValue="model.ContactPerson" />
|
HelperText="영문/숫자, 최대 50자" />
|
||||||
<FluentTextInput Label="전화번호" @bind-CurrentValue="model.Phone" />
|
|
||||||
<FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
|
<MudTextField @bind-Value="model.CompanyName" Label="회사명"
|
||||||
<FluentTextArea Label="메모" @bind-CurrentValue="model.Memo" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
<label class="admin-checkbox-row">
|
|
||||||
<input type="checkbox" @bind="model.IsActive" />
|
<MudTextField @bind-Value="model.ContactPerson" Label="담당자명"
|
||||||
<span>활성</span>
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
</label>
|
|
||||||
<div class="admin-form-actions">
|
<MudTextField @bind-Value="model.Phone" Label="전화번호"
|
||||||
<button type="submit" class="admin-login-submit">@ButtonText</button>
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
|
|
||||||
|
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Memo" Label="메모"
|
||||||
|
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
|
||||||
|
@ButtonText
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</MudForm>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
|
[Parameter, EditorRequired]
|
||||||
[Parameter] public EventCallback<CompanyFormModel> OnSubmit { get; set; }
|
public string ButtonText { get; set; } = "저장";
|
||||||
[Parameter] public EventCallback OnCancel { get; set; }
|
|
||||||
[Parameter] public CompanyFormModel? InitialData { get; set; }
|
[Parameter]
|
||||||
|
public EventCallback<CompanyFormModel> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnCancel { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public CompanyFormModel? InitialData { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
private CompanyFormModel model = new();
|
private CompanyFormModel model = new();
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
@@ -42,7 +63,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
|
private async Task HandleSubmit()
|
||||||
|
{
|
||||||
|
if (form == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await OnSubmit.InvokeAsync(model);
|
||||||
|
}
|
||||||
|
|
||||||
public class CompanyFormModel
|
public class CompanyFormModel
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,41 +1,56 @@
|
|||||||
@using TaxBaik.Application.DTOs
|
@using TaxBaik.Application.DTOs
|
||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Application.Services
|
||||||
@using Microsoft.FluentUI.AspNetCore.Components
|
@using TaxBaik.Web.Components.Admin.Shared
|
||||||
|
|
||||||
<form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
|
<MudForm @ref="form">
|
||||||
<FluentTextInput Label="이름" @bind-CurrentValue="model.Name" />
|
<AdminFormSection Title="연락처" Description="고객 식별과 기본 회신 정보입니다." CssClass="mb-4">
|
||||||
<FluentTextInput Label="전화번호 (예: 010-1234-5678)" @bind-CurrentValue="model.Phone" />
|
<MudTextField @bind-Value="model.Name" Label="이름"
|
||||||
<FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
<FluentSelect TValue="string" TOption="string" Label="문의 유형" @bind-CurrentValue="model.ServiceType">
|
|
||||||
<FluentOption Value="@("사업자세무")">사업자세무</FluentOption>
|
|
||||||
<FluentOption Value="@("부동산세금")">부동산세금</FluentOption>
|
|
||||||
<FluentOption Value="@("가족자산")">가족자산</FluentOption>
|
|
||||||
<FluentOption Value="@("기타")">기타</FluentOption>
|
|
||||||
</FluentSelect>
|
|
||||||
<FluentTextArea Label="문의 내용" @bind-CurrentValue="model.Message" />
|
|
||||||
<FluentSelect TValue="string" TOption="string" Label="상태" @bind-CurrentValue="model.Status">
|
|
||||||
<FluentOption Value="@("new")">신규</FluentOption>
|
|
||||||
<FluentOption Value="@("consulting")">상담중</FluentOption>
|
|
||||||
<FluentOption Value="@("contracted")">계약완료</FluentOption>
|
|
||||||
<FluentOption Value="@("rejected")">거절</FluentOption>
|
|
||||||
<FluentOption Value="@("closed")">종결</FluentOption>
|
|
||||||
</FluentSelect>
|
|
||||||
<FluentTextArea Label="관리 메모" @bind-CurrentValue="model.AdminMemo" />
|
|
||||||
|
|
||||||
<div class="admin-form-actions">
|
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
|
||||||
<button type="submit" class="admin-login-submit">@ButtonText</button>
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
|
|
||||||
</div>
|
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||||
</form>
|
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
|
||||||
|
</AdminFormSection>
|
||||||
|
|
||||||
|
<AdminFormSection Title="문의 내용" Description="운영 분류와 처리 메모를 함께 관리합니다." CssClass="mb-4">
|
||||||
|
<CommonCodeSelect @bind-Value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Message" Label="문의 내용"
|
||||||
|
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<CommonCodeSelect @bind-Value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
|
||||||
|
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||||
|
</AdminFormSection>
|
||||||
|
|
||||||
|
<AdminFormActions SubmitText="@ButtonText"
|
||||||
|
LoadingText="저장 중..."
|
||||||
|
CancelText="취소"
|
||||||
|
SubmitIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnSubmit="@HandleSubmit"
|
||||||
|
OnCancel="@OnCancel"
|
||||||
|
IsSubmitting="false" />
|
||||||
|
</MudForm>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
|
[Parameter, EditorRequired]
|
||||||
[Parameter] public EventCallback<InquiryFormModel> OnSubmit { get; set; }
|
public string ButtonText { get; set; } = "저장";
|
||||||
[Parameter] public EventCallback OnCancel { get; set; }
|
|
||||||
[Parameter] public InquiryFormModel? InitialData { get; set; }
|
|
||||||
private InquiryFormModel model = new();
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
[Parameter]
|
||||||
|
public EventCallback<InquiryFormModel> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnCancel { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public InquiryFormModel? InitialData { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
|
private InquiryFormModel model = new();
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
if (InitialData != null)
|
if (InitialData != null)
|
||||||
{
|
{
|
||||||
@@ -50,9 +65,20 @@
|
|||||||
AdminMemo = InitialData.AdminMemo
|
AdminMemo = InitialData.AdminMemo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
|
private async Task HandleSubmit()
|
||||||
|
{
|
||||||
|
if (form == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await OnSubmit.InvokeAsync(model);
|
||||||
|
}
|
||||||
|
|
||||||
public class InquiryFormModel
|
public class InquiryFormModel
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<div class="admin-table-wrap">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
|
||||||
<table class="admin-table mt-4">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
@@ -19,19 +18,22 @@
|
|||||||
<td>@inquiry.Phone</td>
|
<td>@inquiry.Phone</td>
|
||||||
<td>@inquiry.ServiceType</td>
|
<td>@inquiry.ServiceType</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span>
|
<MudChip Size="Size.Small" Color="@GetStatusColor(inquiry.Status)">
|
||||||
|
@GetStatusLabel(inquiry.Status)
|
||||||
|
</MudChip>
|
||||||
</td>
|
</td>
|
||||||
<td>@GetPreview(inquiry.Message)</td>
|
<td>@GetPreview(inquiry.Message)</td>
|
||||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</a>
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
<a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</a>
|
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
|
||||||
|
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</MudButton>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</MudSimpleTable>
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired]
|
[Parameter, EditorRequired]
|
||||||
@@ -64,14 +66,14 @@
|
|||||||
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
|
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetStatusClass(string status) => status switch
|
private static Color GetStatusColor(string status) => status switch
|
||||||
{
|
{
|
||||||
"new" => "warning",
|
"new" => Color.Warning,
|
||||||
"consulting" => "info",
|
"consulting" => Color.Info,
|
||||||
"contracted" => "success",
|
"contracted" => Color.Success,
|
||||||
"rejected" => "danger",
|
"rejected" => Color.Error,
|
||||||
"closed" => "muted",
|
"closed" => Color.Dark,
|
||||||
_ => "muted"
|
_ => Color.Default
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||||
|
|||||||
@@ -1,115 +1,6 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
@implements IDisposable
|
|
||||||
|
|
||||||
<div class="admin-shell">
|
<AdminShell>
|
||||||
<header class="admin-topbar">
|
<AdminTelemetryContext />
|
||||||
<button type="button" class="admin-icon-button admin-menu-button" @onclick="ToggleDrawer" aria-label="메뉴 열기">
|
@Body
|
||||||
<span class="material-icons">menu</span>
|
</AdminShell>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="admin-topbar-title">
|
|
||||||
<span class="admin-topbar-kicker">TaxBaik Admin</span>
|
|
||||||
<h1>세무회계 관리 대시보드</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-topbar-actions">
|
|
||||||
<a class="admin-topbar-action" href="/taxbaik" target="_blank" rel="noreferrer">
|
|
||||||
<span class="material-icons">open_in_new</span>
|
|
||||||
공개 사이트
|
|
||||||
</a>
|
|
||||||
<a class="admin-topbar-action danger" href="/taxbaik/admin/logout">
|
|
||||||
<span class="material-icons">logout</span>
|
|
||||||
로그아웃
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<aside class="@DrawerClass">
|
|
||||||
<div class="admin-drawer-brand">
|
|
||||||
<div class="admin-brand-mark">T</div>
|
|
||||||
<div>
|
|
||||||
<div class="admin-brand-title">TaxBaik</div>
|
|
||||||
<div class="admin-brand-subtitle">세무 운영 콘솔</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="admin-nav">
|
|
||||||
<a href="/taxbaik/admin/dashboard" class="admin-nav-link">대시보드</a>
|
|
||||||
|
|
||||||
<details open>
|
|
||||||
<summary>CRM & 세무관리</summary>
|
|
||||||
<a href="/taxbaik/admin/tax-profiles" class="admin-nav-link">세무 프로필</a>
|
|
||||||
<a href="/taxbaik/admin/tax-filing-schedules" class="admin-nav-link">신고 일정</a>
|
|
||||||
<a href="/taxbaik/admin/contracts" class="admin-nav-link">계약 관리</a>
|
|
||||||
<a href="/taxbaik/admin/consulting-activities" class="admin-nav-link">상담 활동</a>
|
|
||||||
<a href="/taxbaik/admin/revenue-trackings" class="admin-nav-link">수익 추적</a>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>고객 관리</summary>
|
|
||||||
<a href="/taxbaik/admin/clients" class="admin-nav-link">고객 카드</a>
|
|
||||||
<a href="/taxbaik/admin/tax-filings" class="admin-nav-link">세무신고</a>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>홈페이지</summary>
|
|
||||||
<a href="/taxbaik/admin/announcements" class="admin-nav-link">공지사항</a>
|
|
||||||
<a href="/taxbaik/admin/faqs" class="admin-nav-link">FAQ 관리</a>
|
|
||||||
<a href="/taxbaik/admin/blog" class="admin-nav-link">블로그 관리</a>
|
|
||||||
<a href="/taxbaik/admin/season-simulator" class="admin-nav-link">시즌 시뮬레이터</a>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<a href="/taxbaik/admin/inquiries" class="admin-nav-link">문의 관리</a>
|
|
||||||
<a href="/taxbaik/admin/settings" class="admin-nav-link">설정</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="admin-drawer-footer">
|
|
||||||
<div class="admin-footer-item">
|
|
||||||
<span class="material-icons">shield</span>
|
|
||||||
<span>보안 모드</span>
|
|
||||||
</div>
|
|
||||||
<div class="admin-footer-meta">Fluent UI Blazor 기반 관리자 콘솔</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="admin-content">
|
|
||||||
<div class="admin-content-inner">
|
|
||||||
@Body
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool drawerOpen = true;
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged += OnLocationChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (!firstRender)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var viewportWidth = await JS.InvokeAsync<int>("taxbaikAdminSession.getViewportWidth");
|
|
||||||
drawerOpen = viewportWidth >= 960;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string DrawerClass => drawerOpen ? "admin-drawer open" : "admin-drawer";
|
|
||||||
|
|
||||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
|
||||||
{
|
|
||||||
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleDrawer() => drawerOpen = !drawerOpen;
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged -= OnLocationChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,49 +3,101 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Application.DTOs
|
@using TaxBaik.Application.DTOs
|
||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Web.Components.Admin.Shared
|
||||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
|
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Homepage</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
|
||||||
<h1 class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</MudText>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface" style="max-width:720px;">
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
|
<MudForm @ref="form">
|
||||||
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
|
<MudGrid>
|
||||||
<label>상세 내용 (선택) <textarea class="admin-input" rows="3" @bind="model.Content"></textarea></label>
|
<MudItem xs="12">
|
||||||
<label>유형
|
<MudTextField @bind-Value="model.Title"
|
||||||
<select class="admin-input" @bind="model.DisplayType">
|
Label="제목"
|
||||||
<option value="info">일반 (파란색)</option>
|
Variant="Variant.Outlined"
|
||||||
<option value="banner">배너 (주황색)</option>
|
Required="true"
|
||||||
<option value="urgent">긴급 (빨간색)</option>
|
RequiredError="제목을 입력하세요."
|
||||||
</select>
|
HelperText="홈페이지 상단 공지 바에 표시되는 텍스트입니다." />
|
||||||
</label>
|
</MudItem>
|
||||||
<label>노출 순서 <input class="admin-input" type="number" @bind="model.SortOrder" /></label>
|
|
||||||
<label>게시 시작일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="StartsAtText" /></label>
|
<MudItem xs="12">
|
||||||
<label>게시 종료일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="EndsAtText" /></label>
|
<MudTextField @bind-Value="model.Content"
|
||||||
<label><input type="checkbox" @bind="model.IsActive" /> @(model.IsActive ? "활성화" : "비활성화")</label>
|
Label="상세 내용 (선택)"
|
||||||
<div class="admin-dialog-actions">
|
Variant="Variant.Outlined"
|
||||||
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
|
Lines="3"
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/announcements")'>취소</button>
|
HelperText="부가 설명이 있을 경우 입력합니다. 없으면 제목만 표시됩니다." />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<CommonCodeSelect @bind-Value="model.DisplayType"
|
||||||
|
Group="ANNOUNCEMENT_DISPLAY_TYPE"
|
||||||
|
Label="유형"
|
||||||
|
Class="mb-0" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudNumericField @bind-Value="model.SortOrder"
|
||||||
|
Label="노출 순서"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
HelperText="숫자가 클수록 먼저 표시됩니다." />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudDatePicker @bind-Date="startsAtDate"
|
||||||
|
Label="게시 시작일 (비우면 즉시)"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
DateFormat="yyyy-MM-dd"
|
||||||
|
Clearable="true" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudDatePicker @bind-Date="endsAtDate"
|
||||||
|
Label="게시 종료일 (비우면 무기한)"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
DateFormat="yyyy-MM-dd"
|
||||||
|
Clearable="true" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudSwitch @bind-Checked="model.IsActive"
|
||||||
|
Label="@(model.IsActive ? "활성화 (홈페이지에 노출)" : "비활성화 (홈페이지 미노출)")"
|
||||||
|
Color="Color.Primary" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
Disabled="isSaving"
|
||||||
|
@onclick="SaveAsync">
|
||||||
|
@(isSaving ? "저장 중..." : "저장")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/announcements"))">
|
||||||
|
취소
|
||||||
|
</MudButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</MudForm>
|
||||||
</div>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int? Id { get; set; }
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
private bool isSaving;
|
private bool isSaving;
|
||||||
private DateTime? startsAtDate;
|
private DateTime? startsAtDate;
|
||||||
private DateTime? endsAtDate;
|
private DateTime? endsAtDate;
|
||||||
|
|
||||||
private AnnouncementDto model = new();
|
private AnnouncementDto model = new();
|
||||||
private string StartsAtText { get => startsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => startsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
|
||||||
private string EndsAtText { get => endsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => endsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -61,15 +113,15 @@
|
|||||||
}
|
}
|
||||||
model = new AnnouncementDto
|
model = new AnnouncementDto
|
||||||
{
|
{
|
||||||
Id = entity.Id,
|
Id = entity.Id,
|
||||||
Title = entity.Title,
|
Title = entity.Title,
|
||||||
Content = entity.Content,
|
Content = entity.Content,
|
||||||
DisplayType = entity.DisplayType,
|
DisplayType = entity.DisplayType,
|
||||||
IsActive = entity.IsActive,
|
IsActive = entity.IsActive,
|
||||||
SortOrder = entity.SortOrder
|
SortOrder = entity.SortOrder
|
||||||
};
|
};
|
||||||
startsAtDate = entity.StartsAt?.ToLocalTime();
|
startsAtDate = entity.StartsAt?.ToLocalTime();
|
||||||
endsAtDate = entity.EndsAt?.ToLocalTime();
|
endsAtDate = entity.EndsAt?.ToLocalTime();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -80,18 +132,41 @@
|
|||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
|
if (form is null) return;
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid) return;
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
model.StartsAt = startsAtDate.HasValue ? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() : null;
|
model.StartsAt = startsAtDate.HasValue
|
||||||
model.EndsAt = endsAtDate.HasValue ? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime() : null;
|
? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime()
|
||||||
var result = Id.HasValue ? await AnnouncementClient.UpdateAsync(Id.Value, model) : await AnnouncementClient.CreateAsync(model);
|
: null;
|
||||||
await JS.InvokeVoidAsync("alert", result != null ? "공지사항이 저장되었습니다." : "저장 실패");
|
model.EndsAt = endsAtDate.HasValue
|
||||||
|
? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
var result = await AnnouncementClient.UpdateAsync(Id.Value, model);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("저장 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = await AnnouncementClient.CreateAsync(model);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("저장 실패", Severity.Error);
|
||||||
|
}
|
||||||
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,93 +4,129 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>공지사항 관리</PageTitle>
|
<PageTitle>공지사항 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Homepage</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
|
||||||
<h1 class="admin-page-title">공지사항 관리</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">공지사항 관리</MudText>
|
||||||
<p class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
<a class="site-button primary" href="/taxbaik/admin/announcements/create">공지 등록</a>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/announcements/create">
|
||||||
|
공지 등록
|
||||||
|
</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface">
|
<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>
|
||||||
|
|
||||||
|
<AdminDataPanel Loading="@(announcements is null)" SkeletonContent="@AnnouncementSkeleton">
|
||||||
@if (announcements is null)
|
@if (announcements is null)
|
||||||
{
|
{
|
||||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
|
||||||
}
|
}
|
||||||
else if (!announcements.Any())
|
else if (!FilteredAnnouncements.Any())
|
||||||
{
|
{
|
||||||
<div class="muted">등록된 공지사항이 없습니다.</div>
|
<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
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-table-wrap">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
<table class="admin-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
|
<th>제목</th>
|
||||||
|
<th>유형</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>게시 기간</th>
|
||||||
|
<th>순서</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in FilteredAnnouncements)
|
||||||
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<th>제목</th>
|
<td>@item.Title</td>
|
||||||
<th>유형</th>
|
<td>
|
||||||
<th>상태</th>
|
<MudChip Size="Size.Small" Color="@GetTypeColor(item.DisplayType)">
|
||||||
<th>게시 기간</th>
|
@GetTypeLabel(item.DisplayType)
|
||||||
<th>순서</th>
|
</MudChip>
|
||||||
<th></th>
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (IsCurrentlyActive(item))
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
|
||||||
|
}
|
||||||
|
else if (!item.IsActive)
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Warning">기간 외</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
@FormatPeriod(item)
|
||||||
|
</td>
|
||||||
|
<td>@item.SortOrder</td>
|
||||||
|
<td>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
||||||
|
삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudButtonGroup>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
</tbody>
|
||||||
@foreach (var item in announcements)
|
</MudSimpleTable>
|
||||||
{
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||||
<tr>
|
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
|
||||||
<td>@item.Title</td>
|
</MudText>
|
||||||
<td><span class="status-pill info">@GetTypeLabel(item.DisplayType)</span></td>
|
|
||||||
<td>
|
|
||||||
@if (IsCurrentlyActive(item))
|
|
||||||
{
|
|
||||||
<span class="status-pill success">노출 중</span>
|
|
||||||
}
|
|
||||||
else if (!item.IsActive)
|
|
||||||
{
|
|
||||||
<span class="status-pill default">비활성</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="status-pill warning">기간 외</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="small">@FormatPeriod(item)</td>
|
|
||||||
<td>@item.SortOrder</td>
|
|
||||||
<td>
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">✎</button>
|
|
||||||
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</AdminDataPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Announcement>? announcements;
|
private List<Announcement>? announcements;
|
||||||
|
private string searchQuery = "";
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private RenderFragment AnnouncementSkeleton => builder =>
|
||||||
{
|
{
|
||||||
if (firstRender && AuthStateTask != null)
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 5);
|
||||||
|
builder.AddAttribute(2, "Columns", 4);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (AuthStateTask != null)
|
||||||
{
|
{
|
||||||
var authState = await AuthStateTask;
|
var authState = await AuthStateTask;
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,32 +139,36 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
announcements = [];
|
announcements = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(Announcement item)
|
private async Task DeleteAsync(Announcement item)
|
||||||
{
|
{
|
||||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Title}' 공지를 삭제하시겠습니까?");
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
if (!confirmed) return;
|
"공지 삭제",
|
||||||
|
$"'{item.Title}' 공지를 삭제하시겠습니까?",
|
||||||
|
yesText: "삭제", cancelText: "취소");
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await AnnouncementClient.DeleteAsync(item.Id);
|
var success = await AnnouncementClient.DeleteAsync(item.Id);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "공지사항이 삭제되었습니다.");
|
Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
Snackbar.Add("삭제 실패", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,21 +177,28 @@
|
|||||||
if (!a.IsActive) return false;
|
if (!a.IsActive) return false;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
if (a.StartsAt.HasValue && a.StartsAt > now) return false;
|
if (a.StartsAt.HasValue && a.StartsAt > now) return false;
|
||||||
if (a.EndsAt.HasValue && a.EndsAt < now) return false;
|
if (a.EndsAt.HasValue && a.EndsAt < now) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatPeriod(Announcement a)
|
private static string FormatPeriod(Announcement a)
|
||||||
{
|
{
|
||||||
var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시";
|
var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시";
|
||||||
var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한";
|
var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한";
|
||||||
return $"{start} ~ {end}";
|
return $"{start} ~ {end}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Color GetTypeColor(string type) => type switch
|
||||||
|
{
|
||||||
|
"urgent" => Color.Error,
|
||||||
|
"banner" => Color.Warning,
|
||||||
|
_ => Color.Info
|
||||||
|
};
|
||||||
|
|
||||||
private static string GetTypeLabel(string type) => type switch
|
private static string GetTypeLabel(string type) => type switch
|
||||||
{
|
{
|
||||||
"urgent" => "긴급",
|
"urgent" => "긴급",
|
||||||
"banner" => "배너",
|
"banner" => "배너",
|
||||||
_ => "일반"
|
_ => "일반"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,43 @@
|
|||||||
@page "/admin/blog/create"
|
@page "/admin/blog/create"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Application.DTOs
|
@using TaxBaik.Application.DTOs
|
||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Web.Components.Admin.Pages.Blog
|
||||||
@using TaxBaik.Domain.Interfaces
|
@inject IBlogBrowserClient BlogClient
|
||||||
@inject BlogService BlogService
|
@inject ICategoryBrowserClient CategoryClient
|
||||||
@inject ICategoryRepository CategoryRepository
|
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>새 포스트 작성</PageTitle>
|
<PageTitle>새 포스트 작성</PageTitle>
|
||||||
<section class="admin-page-hero">
|
|
||||||
<div>
|
|
||||||
<div class="admin-eyebrow">Content</div>
|
|
||||||
<h1 class="admin-page-title">새 포스트 작성</h1>
|
|
||||||
<p class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="admin-surface mt-4">
|
<AdminCrudPageShell Title="새 포스트 작성"
|
||||||
<form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
|
Eyebrow="Content"
|
||||||
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
|
Subtitle="새로운 블로그 포스트를 작성합니다."
|
||||||
<label>카테고리
|
Loading="@false"
|
||||||
<select class="admin-input" @bind="CategoryIdText">
|
OnCancel="@GoBack">
|
||||||
<option value="">선택하세요</option>
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
@foreach (var category in categories)
|
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" OnCancel="GoBack" />
|
||||||
{
|
</MudPaper>
|
||||||
<option value="@category.Id.ToString()">@category.Name</option>
|
</AdminCrudPageShell>
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
|
|
||||||
<label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
|
|
||||||
<label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
|
|
||||||
<label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
|
|
||||||
<label><input type="checkbox" @bind="model.IsPublished" /> 즉시 발행</label>
|
|
||||||
<div class="admin-dialog-actions">
|
|
||||||
<button type="submit" class="site-button primary">저장</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Domain.Entities.Category> categories = [];
|
private IReadOnlyList<Domain.Entities.Category> categories = [];
|
||||||
private CreatePostModel model = new();
|
private BlogForm.BlogFormModel model = new();
|
||||||
private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
categories = await CategoryClient.GetAllAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SavePost()
|
private async Task SavePost()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await BlogService.CreateAsync(new CreateBlogPostDto
|
var result = await BlogClient.CreateAsync(new CreateBlogPostDto
|
||||||
{
|
{
|
||||||
Title = model.Title,
|
Title = model.Title,
|
||||||
Content = model.Content,
|
Content = model.Content,
|
||||||
@@ -66,23 +48,18 @@
|
|||||||
IsPublished = model.IsPublished
|
IsPublished = model.IsPublished
|
||||||
});
|
});
|
||||||
|
|
||||||
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
|
if (result == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("포스트 저장에 실패했습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CreatePostModel
|
|
||||||
{
|
|
||||||
public string Title { get; set; } = "";
|
|
||||||
public string Content { get; set; } = "";
|
|
||||||
public int? CategoryId { get; set; }
|
|
||||||
public string? Tags { get; set; }
|
|
||||||
public string? SeoTitle { get; set; }
|
|
||||||
public string? SeoDescription { get; set; }
|
|
||||||
public bool IsPublished { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,67 @@
|
|||||||
@page "/admin/blog/{id:int}/edit"
|
@page "/admin/blog/{id:int}/edit"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Application.DTOs
|
@using TaxBaik.Application.DTOs
|
||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Web.Components.Admin.Pages.Blog
|
||||||
@using TaxBaik.Domain.Interfaces
|
@inject IBlogBrowserClient BlogClient
|
||||||
@inject BlogService BlogService
|
@inject ICategoryBrowserClient CategoryClient
|
||||||
@inject ICategoryRepository CategoryRepository
|
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
<PageTitle>포스트 수정</PageTitle>
|
<PageTitle>포스트 수정</PageTitle>
|
||||||
<section class="admin-page-hero">
|
|
||||||
<div>
|
|
||||||
<div class="admin-eyebrow">Content</div>
|
|
||||||
<h1 class="admin-page-title">포스트 수정</h1>
|
|
||||||
<p class="admin-page-subtitle">블로그 포스트를 수정합니다.</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if (isLoading)
|
<AdminCrudPageShell Title="포스트 수정"
|
||||||
|
Eyebrow="Content"
|
||||||
|
Subtitle="블로그 포스트를 수정합니다."
|
||||||
|
Loading="@isLoading"
|
||||||
|
SkeletonContent="@EditorSkeleton"
|
||||||
|
OnCancel="@GoBack">
|
||||||
|
@if (post == null)
|
||||||
{
|
{
|
||||||
<div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
|
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
|
||||||
}
|
|
||||||
else if (post == null)
|
|
||||||
{
|
|
||||||
<div class="admin-surface mt-4">포스트를 찾을 수 없습니다.</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-surface mt-4">
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
|
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" />
|
||||||
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
|
<div class="mt-4">
|
||||||
<label>카테고리
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeletePost">삭제</MudButton>
|
||||||
<select class="admin-input" @bind="CategoryIdText">
|
</div>
|
||||||
<option value="">선택하세요</option>
|
</MudPaper>
|
||||||
@foreach (var category in categories)
|
|
||||||
{
|
|
||||||
<option value="@category.Id.ToString()">@category.Name</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
|
|
||||||
<label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
|
|
||||||
<label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
|
|
||||||
<label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
|
|
||||||
<label><input type="checkbox" @bind="model.IsPublished" /> 발행</label>
|
|
||||||
<div class="admin-dialog-actions">
|
|
||||||
<button type="submit" class="site-button primary">저장</button>
|
|
||||||
<button type="button" class="site-button secondary" @onclick="DeletePost">삭제</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
</AdminCrudPageShell>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int Id { get; set; }
|
[Parameter]
|
||||||
private Domain.Entities.BlogPost? post;
|
public int Id { get; set; }
|
||||||
private List<Domain.Entities.Category> categories = [];
|
|
||||||
private EditPostModel model = new();
|
private TaxBaik.Application.DTOs.BlogPostResponseDto? post;
|
||||||
|
private IReadOnlyList<Domain.Entities.Category> categories = [];
|
||||||
|
private BlogForm.BlogFormModel model = new();
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
|
|
||||||
|
private RenderFragment EditorSkeleton => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 5);
|
||||||
|
builder.AddAttribute(2, "Columns", 3);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
post = await BlogService.GetByIdAsync(Id);
|
post = await BlogClient.GetByIdAsync(Id);
|
||||||
if (post != null)
|
if (post != null)
|
||||||
{
|
{
|
||||||
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
categories = await CategoryClient.GetAllAsync();
|
||||||
MapPostToModel(post);
|
MapPostToModel(post);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"포스트 로드 실패: {ex.Message}");
|
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -82,7 +69,7 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MapPostToModel(Domain.Entities.BlogPost post)
|
private void MapPostToModel(TaxBaik.Application.DTOs.BlogPostResponseDto post)
|
||||||
{
|
{
|
||||||
model.Title = post.Title;
|
model.Title = post.Title;
|
||||||
model.Content = post.Content;
|
model.Content = post.Content;
|
||||||
@@ -93,12 +80,19 @@ else
|
|||||||
model.IsPublished = post.IsPublished;
|
model.IsPublished = post.IsPublished;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SavePost()
|
private async Task SavePost()
|
||||||
{
|
{
|
||||||
if (post == null) return;
|
if (post == null)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
|
var result = await BlogClient.UpdateAsync(post.Id, new CreateBlogPostDto
|
||||||
{
|
{
|
||||||
Title = model.Title,
|
Title = model.Title,
|
||||||
Content = model.Content,
|
Content = model.Content,
|
||||||
@@ -108,32 +102,53 @@ else
|
|||||||
SeoDescription = model.SeoDescription,
|
SeoDescription = model.SeoDescription,
|
||||||
IsPublished = model.IsPublished
|
IsPublished = model.IsPublished
|
||||||
});
|
});
|
||||||
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
|
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("저장 실패: 포스트를 저장하지 못했습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeletePost()
|
private async Task DeletePost()
|
||||||
{
|
{
|
||||||
if (post == null) return;
|
if (post == null)
|
||||||
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
|
return;
|
||||||
await BlogService.DeleteAsync(post.Id);
|
|
||||||
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
|
|
||||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
|
||||||
}
|
|
||||||
|
|
||||||
private class EditPostModel
|
var result = await DialogService.ShowMessageBox(
|
||||||
{
|
"포스트 삭제",
|
||||||
public string Title { get; set; } = "";
|
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
public string Content { get; set; } = "";
|
"삭제", "취소");
|
||||||
public int? CategoryId { get; set; }
|
|
||||||
public string? Tags { get; set; }
|
if (result != true)
|
||||||
public string? SeoTitle { get; set; }
|
return;
|
||||||
public string? SeoDescription { get; set; }
|
|
||||||
public bool IsPublished { get; set; }
|
try
|
||||||
|
{
|
||||||
|
var deleted = await BlogClient.DeleteAsync(post.Id);
|
||||||
|
if (!deleted)
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<AdminFormSection Title="기본 정보" Description="제목과 카테고리, 발행 여부를 먼저 설정합니다." CssClass="mb-4">
|
||||||
|
<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">
|
||||||
|
@foreach (var category in Categories)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
|
||||||
|
<MudCheckBox @bind-Checked="Model.IsPublished" Label="즉시 발행" Class="mb-4" />
|
||||||
|
</AdminFormSection>
|
||||||
|
|
||||||
|
<AdminFormSection Title="본문" Description="SEO와 실제 노출 본문을 함께 관리합니다." CssClass="mb-4">
|
||||||
|
<MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
|
||||||
|
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
|
||||||
|
Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
|
||||||
|
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||||
|
</AdminFormSection>
|
||||||
|
|
||||||
|
<AdminFormActions SubmitText="@SubmitText"
|
||||||
|
LoadingText="저장 중..."
|
||||||
|
CancelText="취소"
|
||||||
|
SubmitIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnSubmit="@HandleSubmit"
|
||||||
|
OnCancel="@OnCancel"
|
||||||
|
IsSubmitting="false" />
|
||||||
|
</MudForm>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public BlogFormModel Model { get; set; } = new();
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<Category> Categories { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string SubmitText { get; set; } = "저장";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnSubmit { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnCancel { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
|
|
||||||
|
private async Task HandleSubmit()
|
||||||
|
{
|
||||||
|
if (form == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await OnSubmit.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BlogFormModel
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Content { get; set; } = "";
|
||||||
|
public int? CategoryId { get; set; }
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? SeoTitle { get; set; }
|
||||||
|
public string? SeoDescription { get; set; }
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,102 +1,112 @@
|
|||||||
@page "/admin/blog"
|
@page "/admin/blog"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IApiClient ApiClient
|
@inject IBlogBrowserClient BlogClient
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>블로그 관리</PageTitle>
|
<PageTitle>블로그 관리</PageTitle>
|
||||||
<section class="admin-page-hero">
|
|
||||||
<div>
|
|
||||||
<div class="admin-eyebrow">Content</div>
|
|
||||||
<h1 class="admin-page-title">블로그 관리</h1>
|
|
||||||
<p class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="admin-surface mb-4">
|
<AdminPageHeader Title="블로그 관리" Eyebrow="Content" Subtitle="검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.">
|
||||||
<div class="admin-summary-bar">
|
<ChildContent>
|
||||||
<span>전체 포스트: @($"{totalPosts}개")</span>
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore"
|
||||||
<span>페이지 @currentPage / @totalPages</span>
|
OnClick="ToggleArchiveView">
|
||||||
</div>
|
@(showArchived ? "전체 글 보기" : "숨김 글 보기")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Refresh"
|
||||||
|
OnClick="Reload">
|
||||||
|
새로고침
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote"
|
||||||
|
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
||||||
|
</ChildContent>
|
||||||
|
</AdminPageHeader>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="admin-surface">
|
<AdminDataPanel Loading="@isLoading">
|
||||||
@if (isLoading)
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
|
||||||
{
|
<MudText Typo="Typo.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
|
||||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||||
}
|
</MudStack>
|
||||||
else
|
|
||||||
{
|
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Class="admin-grid">
|
||||||
<div class="admin-table-wrap">
|
<Columns>
|
||||||
<table class="admin-table">
|
<PropertyColumn Property="x => x.Title" Title="제목" />
|
||||||
<thead>
|
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
||||||
<tr>
|
<CellTemplate Context="cell">
|
||||||
<th>제목</th>
|
<MudCheckBox T="bool" Value="@cell.Item.IsPublished"
|
||||||
<th>발행</th>
|
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
|
||||||
<th>조회수</th>
|
</CellTemplate>
|
||||||
<th>작성일</th>
|
</PropertyColumn>
|
||||||
<th></th>
|
<PropertyColumn Property="x => x.ViewCount" Title="조회수" />
|
||||||
</tr>
|
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
|
||||||
</thead>
|
<TemplateColumn>
|
||||||
<tbody>
|
<CellTemplate Context="cell">
|
||||||
@foreach (var post in posts)
|
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
|
||||||
|
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
|
||||||
|
@if (showArchived)
|
||||||
{
|
{
|
||||||
<tr>
|
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Success"
|
||||||
<td>@post.Title</td>
|
@onclick="@(async () => await RestorePost(cell.Item.Id))">복원</MudButton>
|
||||||
<td><label><input type="checkbox" checked="@post.IsPublished" @onchange="@(async e => await TogglePublish(post, (bool)e.Value!))" /> 발행</label></td>
|
|
||||||
<td>@post.ViewCount</td>
|
|
||||||
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
|
|
||||||
<td>
|
|
||||||
<div class="admin-row-actions">
|
|
||||||
<a class="site-button secondary" href="@($"/taxbaik/admin/blog/{post.Id}/edit")">수정</a>
|
|
||||||
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeletePost(post.Id))">✕</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
else
|
||||||
</table>
|
{
|
||||||
</div>
|
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
|
||||||
}
|
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
|
||||||
</div>
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
</Columns>
|
||||||
|
</MudDataGrid>
|
||||||
|
|
||||||
<div class="admin-pagination">
|
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
||||||
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
|
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
||||||
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
|
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
||||||
</div>
|
</MudStack>
|
||||||
|
</AdminDataPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
[CascadingParameter]
|
||||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
|
private List<TaxBaik.Application.DTOs.BlogPostResponseDto> posts = [];
|
||||||
|
private string searchQuery = "";
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private int currentPage = 1;
|
private int currentPage = 1;
|
||||||
private int totalPages = 1;
|
private int totalPages = 1;
|
||||||
private int totalPosts = 0;
|
private int totalPosts = 0;
|
||||||
|
private bool showArchived;
|
||||||
private const int PageSize = 20;
|
private const int PageSize = 20;
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private IEnumerable<TaxBaik.Application.DTOs.BlogPostResponseDto> FilteredPosts => posts
|
||||||
|
.Where(p => string.IsNullOrEmpty(searchQuery) ||
|
||||||
|
p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
if (firstRender && AuthStateTask != null)
|
if (AuthStateTask != null)
|
||||||
{
|
{
|
||||||
var authState = await AuthStateTask;
|
var authState = await AuthStateTask;
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadPosts();
|
await LoadPosts();
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string NavTo(string url) => url;
|
|
||||||
|
|
||||||
private async Task LoadPosts()
|
private async Task LoadPosts()
|
||||||
{
|
{
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await ApiClient.GetAsync<PagedBlogResponse>($"blog/admin?page={currentPage}&pageSize={PageSize}");
|
var result = showArchived
|
||||||
posts = result?.Data ?? [];
|
? await BlogClient.GetArchivedPagedAsync(currentPage, PageSize)
|
||||||
totalPosts = result?.Total ?? 0;
|
: await BlogClient.GetAdminPagedAsync(currentPage, PageSize);
|
||||||
|
posts = result.Items.ToList();
|
||||||
|
totalPosts = result.Total;
|
||||||
totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize));
|
totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -105,39 +115,85 @@
|
|||||||
totalPosts = 0;
|
totalPosts = 0;
|
||||||
totalPages = 1;
|
totalPages = 1;
|
||||||
}
|
}
|
||||||
finally
|
isLoading = false;
|
||||||
{
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadPosts(); } }
|
private async Task PreviousPage()
|
||||||
private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadPosts(); } }
|
{
|
||||||
|
if (currentPage <= 1)
|
||||||
|
return;
|
||||||
|
|
||||||
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
|
currentPage--;
|
||||||
|
await LoadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NextPage()
|
||||||
|
{
|
||||||
|
if (currentPage >= totalPages)
|
||||||
|
return;
|
||||||
|
|
||||||
|
currentPage++;
|
||||||
|
await LoadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TogglePublish(TaxBaik.Application.DTOs.BlogPostResponseDto post, bool isPublished)
|
||||||
{
|
{
|
||||||
var previous = post.IsPublished;
|
var previous = post.IsPublished;
|
||||||
post.IsPublished = isPublished;
|
post.IsPublished = isPublished;
|
||||||
var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new { post.Title, post.Content, post.CategoryId, post.Tags, post.SeoTitle, post.SeoDescription, post.ThumbnailUrl, IsPublished = isPublished, post.AuthorId });
|
var result = await BlogClient.UpdateAsync(post.Id, new TaxBaik.Application.DTOs.CreateBlogPostDto
|
||||||
|
{
|
||||||
|
Title = post.Title,
|
||||||
|
Content = post.Content,
|
||||||
|
CategoryId = post.CategoryId,
|
||||||
|
Tags = post.Tags,
|
||||||
|
SeoTitle = post.SeoTitle,
|
||||||
|
SeoDescription = post.SeoDescription,
|
||||||
|
ThumbnailUrl = post.ThumbnailUrl,
|
||||||
|
IsPublished = isPublished,
|
||||||
|
AuthorId = post.AuthorId
|
||||||
|
});
|
||||||
|
|
||||||
if (result == null)
|
if (result == null)
|
||||||
{
|
{
|
||||||
post.IsPublished = previous;
|
post.IsPublished = previous;
|
||||||
await JS.InvokeVoidAsync("alert", "발행 상태 변경에 실패했습니다.");
|
Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await JS.InvokeVoidAsync("alert", "발행 상태가 변경되었습니다.");
|
|
||||||
|
Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeletePost(int postId)
|
private async Task DeletePost(int postId)
|
||||||
{
|
{
|
||||||
await ApiClient.DeleteAsync($"blog/{postId}");
|
var deleted = await BlogClient.DeleteAsync(postId);
|
||||||
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
|
if (!deleted)
|
||||||
|
{
|
||||||
|
Snackbar.Add("포스트 삭제에 실패했습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||||
await LoadPosts();
|
await LoadPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
private class PagedBlogResponse
|
private async Task RestorePost(int postId)
|
||||||
{
|
{
|
||||||
public List<TaxBaik.Domain.Entities.BlogPost> Data { get; set; } = [];
|
var restored = await BlogClient.RestoreAsync(postId);
|
||||||
public int Total { get; set; }
|
if (!restored)
|
||||||
|
{
|
||||||
|
Snackbar.Add("포스트 복원에 실패했습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add("포스트가 복원되었습니다.", Severity.Success);
|
||||||
|
await LoadPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ToggleArchiveView()
|
||||||
|
{
|
||||||
|
showArchived = !showArchived;
|
||||||
|
currentPage = 1;
|
||||||
|
await LoadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Reload() => await LoadPosts();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,186 @@
|
|||||||
@page "/admin/clients/{ClientId:int}"
|
@page "/admin/clients/{ClientId:int}"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Web.Services
|
||||||
@inject ClientService ClientService
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
@inject ConsultationService ConsultationService
|
@using TaxBaik.Web.Components.Admin.Shared
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject IConsultingActivityBrowserClient ConsultingClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>고객 상세</PageTitle>
|
<PageTitle>고객 상세</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Client Details</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Client Details</MudText>
|
||||||
<h1 class="admin-page-title">고객 상세</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객 상세</MudText>
|
||||||
<p class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (client == null)
|
@if (client == null)
|
||||||
{
|
{
|
||||||
<div class="admin-surface mt-4">고객을 찾을 수 없습니다.</div>
|
<MudText>고객을 찾을 수 없습니다.</MudText>
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="admin-page-actions">
|
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
|
|
||||||
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{ClientId}/edit")">수정</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-detail-grid">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4" Spacing="2">
|
||||||
<section class="admin-surface">
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
<h3 class="admin-section-title">고객 정보</h3>
|
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
<div class="admin-kv-grid">
|
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/clients"))">
|
||||||
<div><span>이름</span><strong>@client.Name</strong></div>
|
목록으로
|
||||||
<div><span>상호</span><strong>@(client.CompanyName ?? "-")</strong></div>
|
</MudButton>
|
||||||
<div><span>연락처</span><strong>@(client.Phone ?? "-")</strong></div>
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning"
|
||||||
<div><span>이메일</span><strong>@(client.Email ?? "-")</strong></div>
|
StartIcon="@Icons.Material.Filled.Edit"
|
||||||
<div><span>서비스</span><strong>@(client.ServiceType ?? "-")</strong></div>
|
Href="@($"/taxbaik/admin/clients/{ClientId}/edit")">
|
||||||
<div><span>사업자 유형</span><strong>@(client.TaxType ?? "-")</strong></div>
|
수정
|
||||||
<div><span>유입 경로</span><strong>@(client.Source ?? "-")</strong></div>
|
</MudButton>
|
||||||
<div><span>등록일</span><strong>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</strong></div>
|
</MudStack>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">고객 정보</MudText>
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
|
||||||
|
<MudText>@client.Name</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">상호</MudText>
|
||||||
|
<MudText>@(client.CompanyName ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
|
||||||
|
<MudText>@(client.Phone ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
|
||||||
|
<MudText>@(client.Email ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">서비스</MudText>
|
||||||
|
<MudText>@(client.ServiceType ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">사업자 유형</MudText>
|
||||||
|
<MudText>@(client.TaxType ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">유입 경로</MudText>
|
||||||
|
<MudText>@(client.Source ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">등록일</MudText>
|
||||||
|
<MudText>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</MudText>
|
||||||
|
</MudItem>
|
||||||
@if (!string.IsNullOrWhiteSpace(client.Memo))
|
@if (!string.IsNullOrWhiteSpace(client.Memo))
|
||||||
{
|
{
|
||||||
<div class="span-2"><span>메모</span><strong style="white-space: pre-wrap;">@client.Memo</strong></div>
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">메모</MudText>
|
||||||
|
<MudText Style="white-space: pre-wrap;">@client.Memo</MudText>
|
||||||
|
</MudItem>
|
||||||
}
|
}
|
||||||
</div>
|
</MudGrid>
|
||||||
</section>
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
<section class="admin-surface">
|
<MudItem xs="12" md="7">
|
||||||
<div class="admin-section-header compact">
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
<div>
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
|
||||||
<h3 class="admin-section-title">상담 이력</h3>
|
<MudText Typo="Typo.h6">상담 이력</MudText>
|
||||||
</div>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
<button type="button" class="site-button primary" @onclick="OpenAddConsultation">+ 상담 추가</button>
|
Size="Size.Small"
|
||||||
</div>
|
OnClick="OpenAddConsultation">
|
||||||
|
+ 상담 추가
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
@if (showAddForm)
|
@if (showAddForm)
|
||||||
{
|
{
|
||||||
<form class="admin-dialog-card mb-4" @onsubmit="AddConsultation" @onsubmit:preventDefault="true">
|
<MudPaper Class="pa-3 mb-3" Outlined="true">
|
||||||
<label>상담일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="ConsultationDateText" /></label>
|
<MudGrid Spacing="2">
|
||||||
<label>서비스 분야
|
<MudItem xs="12" sm="6">
|
||||||
<select class="admin-input" @bind="newServiceType">
|
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
|
||||||
<option value="">선택하세요</option>
|
</MudItem>
|
||||||
@foreach (var t in ClientService.ServiceTypes)
|
<MudItem xs="12" sm="6">
|
||||||
{
|
<CommonCodeSelect @bind-Value="newServiceType" Group="CONSULTING_ACTIVITY_TYPE" Label="서비스 분야" Placeholder="선택" Clearable="true" />
|
||||||
<option value="@t">@t</option>
|
</MudItem>
|
||||||
}
|
<MudItem xs="12">
|
||||||
</select>
|
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
|
||||||
</label>
|
Lines="3" Variant="Variant.Outlined" Required="true" />
|
||||||
<label>상담 내용 * <textarea class="admin-input" rows="3" @bind="newSummary"></textarea></label>
|
</MudItem>
|
||||||
<label>결과
|
<MudItem xs="12" sm="6">
|
||||||
<select class="admin-input" @bind="newResult">
|
<MudSelect T="string" @bind-Value="newResult" Label="결과">
|
||||||
<option value="">-</option>
|
<MudSelectItem Value="@("")">-</MudSelectItem>
|
||||||
@foreach (var r in ConsultationService.Results)
|
@foreach (var r in results)
|
||||||
{
|
{
|
||||||
<option value="@r">@r</option>
|
<MudSelectItem Value="@r">@r</MudSelectItem>
|
||||||
}
|
}
|
||||||
</select>
|
</MudSelect>
|
||||||
</label>
|
</MudItem>
|
||||||
<label>수임료 (원) <input class="admin-input" type="text" placeholder="100000" @bind="FeeText" /></label>
|
<MudItem xs="12" sm="6">
|
||||||
<div class="admin-dialog-actions">
|
<MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
|
||||||
<button type="submit" class="site-button primary">저장</button>
|
Format="N0" />
|
||||||
<button type="button" class="site-button secondary" @onclick='() => showAddForm = false'>취소</button>
|
</MudItem>
|
||||||
</div>
|
</MudGrid>
|
||||||
</form>
|
<MudStack Row="true" Class="mt-2" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddConsultation">저장</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (consultations.Count == 0)
|
@if (consultations.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="muted">상담 이력이 없습니다.</p>
|
<MudText Color="Color.Secondary">상담 이력이 없습니다.</MudText>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-activity-list">
|
<MudList T="string" Dense="true">
|
||||||
@foreach (var c in consultations)
|
@foreach (var c in consultations)
|
||||||
{
|
{
|
||||||
<article class="admin-activity-card">
|
<MudListItem>
|
||||||
<div class="admin-activity-head">
|
<MudPaper Class="pa-3" Outlined="true" Style="width:100%">
|
||||||
<div>
|
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween">
|
||||||
<span class="muted">@c.ConsultationDate.ToString("yyyy-MM-dd") @(string.IsNullOrEmpty(c.ServiceType) ? "" : $"· {c.ServiceType}")</span>
|
<div>
|
||||||
</div>
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteConsultation(c.Id))">✕</button>
|
@c.ConsultationDate.ToString("yyyy-MM-dd")
|
||||||
</div>
|
@if (!string.IsNullOrEmpty(c.ServiceType)) { <text> · @c.ServiceType</text> }
|
||||||
<p style="white-space: pre-wrap;">@c.Summary</p>
|
</MudText>
|
||||||
@if (!string.IsNullOrEmpty(c.Result))
|
<MudText Style="white-space: pre-wrap;" Class="mt-1">@c.Summary</MudText>
|
||||||
{
|
@if (!string.IsNullOrEmpty(c.Result))
|
||||||
<span class="status-pill info">@c.Result</span>
|
{
|
||||||
}
|
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="mt-1">@c.Result</MudChip>
|
||||||
@if (c.Fee.HasValue)
|
}
|
||||||
{
|
@if (c.Fee.HasValue)
|
||||||
<div class="muted">수임료: @c.Fee.Value.ToString("N0")원</div>
|
{
|
||||||
}
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
|
||||||
</article>
|
수임료: @c.Fee.Value.ToString("N0")원
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
|
Size="Size.Small" Color="Color.Error"
|
||||||
|
OnClick="@(() => DeleteConsultation(c.Id))" />
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudListItem>
|
||||||
}
|
}
|
||||||
</div>
|
</MudList>
|
||||||
}
|
}
|
||||||
</section>
|
</MudPaper>
|
||||||
</div>
|
</MudItem>
|
||||||
}
|
</MudGrid>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int ClientId { get; set; }
|
[Parameter]
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
|
||||||
private Domain.Entities.Client? client;
|
private Domain.Entities.Client? client;
|
||||||
private List<Domain.Entities.Consultation> consultations = [];
|
private List<Domain.Entities.Consultation> consultations = [];
|
||||||
|
private static readonly string[] results = ["", "상담완료", "추가자료 요청", "견적발송", "계약전환", "보류"];
|
||||||
|
|
||||||
private bool showAddForm;
|
private bool showAddForm;
|
||||||
private DateTime? newDate = DateTime.Today;
|
private DateTime? newDate = DateTime.Today;
|
||||||
private string newServiceType = "";
|
private string newServiceType = "";
|
||||||
@@ -128,15 +188,26 @@ else
|
|||||||
private string newResult = "";
|
private string newResult = "";
|
||||||
private decimal? newFee;
|
private decimal? newFee;
|
||||||
|
|
||||||
private string ConsultationDateText { get => newDate?.ToString("yyyy-MM-dd") ?? ""; set => newDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
protected override async Task OnInitializedAsync()
|
||||||
private string FeeText { get => newFee?.ToString() ?? ""; set => newFee = decimal.TryParse(value, out var d) ? d : null; }
|
{
|
||||||
|
await LoadAll();
|
||||||
protected override async Task OnInitializedAsync() => await LoadAll();
|
}
|
||||||
|
|
||||||
private async Task LoadAll()
|
private async Task LoadAll()
|
||||||
{
|
{
|
||||||
client = await ClientService.GetByIdAsync(ClientId);
|
client = await ClientClient.GetByIdAsync(ClientId);
|
||||||
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
consultations = (await ConsultingClient.GetByClientIdAsync(ClientId))
|
||||||
|
.Select(c => new Domain.Entities.Consultation
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
ClientId = c.ClientId,
|
||||||
|
ConsultationDate = c.ActivityDate,
|
||||||
|
ServiceType = c.ActivityType,
|
||||||
|
Summary = c.Description,
|
||||||
|
Result = null,
|
||||||
|
Fee = null
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenAddConsultation()
|
private void OpenAddConsultation()
|
||||||
@@ -153,38 +224,35 @@ else
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(newSummary))
|
var newId = await ConsultingClient.CreateAsync(
|
||||||
{
|
ClientId,
|
||||||
await JS.InvokeVoidAsync("alert", "상담 내용을 입력하세요.");
|
string.IsNullOrWhiteSpace(newServiceType) ? "기타" : newServiceType,
|
||||||
return;
|
newDate?.ToUniversalTime() ?? DateTime.UtcNow,
|
||||||
}
|
newSummary,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
var c = new Domain.Entities.Consultation
|
if (newId <= 0)
|
||||||
{
|
throw new Exception("상담 생성 실패");
|
||||||
ClientId = ClientId,
|
|
||||||
ConsultationDate = newDate?.ToUniversalTime() ?? DateTime.UtcNow,
|
|
||||||
ServiceType = string.IsNullOrWhiteSpace(newServiceType) ? null : newServiceType,
|
|
||||||
Summary = newSummary,
|
|
||||||
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
|
|
||||||
Fee = newFee
|
|
||||||
};
|
|
||||||
|
|
||||||
await ConsultationService.CreateAsync(c);
|
|
||||||
showAddForm = false;
|
showAddForm = false;
|
||||||
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
await LoadAll();
|
||||||
await JS.InvokeVoidAsync("alert", "상담이 추가되었습니다.");
|
Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteConsultation(int id)
|
private async Task DeleteConsultation(int id)
|
||||||
{
|
{
|
||||||
if (!await JS.InvokeAsync<bool>("confirm", "이 상담을 삭제하시겠습니까?")) return;
|
await ConsultingClient.DeleteAsync(id);
|
||||||
await ConsultationService.DeleteAsync(id);
|
await LoadAll();
|
||||||
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
Snackbar.Add("삭제되었습니다.", Severity.Info);
|
||||||
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,79 +4,111 @@
|
|||||||
@using TaxBaik.Application.DTOs
|
@using TaxBaik.Application.DTOs
|
||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
|
@using TaxBaik.Web.Components.Admin.Shared
|
||||||
@inject IClientBrowserClient ClientClient
|
@inject IClientBrowserClient ClientClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
|
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">CRM</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
||||||
<h1 class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface" style="max-width:720px;">
|
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@ClientEditSkeleton">
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
|
<MudForm @ref="form" @bind-IsValid="isValid">
|
||||||
<label>고객명 * <input class="admin-input" @bind="dto.Name" /></label>
|
<MudGrid Spacing="3">
|
||||||
<label>회사명 <input class="admin-input" @bind="dto.CompanyName" /></label>
|
@* 기본 정보 *@
|
||||||
<label>연락처 <input class="admin-input" @bind="dto.Phone" /></label>
|
<MudItem xs="12">
|
||||||
<label>이메일 <input class="admin-input" type="email" @bind="dto.Email" /></label>
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">기본 정보</MudText>
|
||||||
<label>서비스 유형
|
<MudDivider />
|
||||||
<select class="admin-input" @bind="dto.ServiceType">
|
</MudItem>
|
||||||
<option value="">선택하세요</option>
|
<MudItem xs="12" md="6">
|
||||||
@foreach (var t in ClientService.ServiceTypes)
|
<MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true"
|
||||||
{
|
RequiredError="고객명을 입력하세요." />
|
||||||
<option value="@t">@t</option>
|
</MudItem>
|
||||||
}
|
<MudItem xs="12" md="6">
|
||||||
</select>
|
<MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" />
|
||||||
</label>
|
</MudItem>
|
||||||
<label>세금 유형
|
<MudItem xs="12" md="6">
|
||||||
<select class="admin-input" @bind="dto.TaxType">
|
<MudTextField @bind-Value="dto.Phone" Label="연락처"
|
||||||
<option value="">선택하세요</option>
|
Placeholder="010-0000-0000" />
|
||||||
@foreach (var t in ClientService.TaxTypes)
|
</MudItem>
|
||||||
{
|
<MudItem xs="12" md="6">
|
||||||
<option value="@t">@t</option>
|
<MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" />
|
||||||
}
|
</MudItem>
|
||||||
</select>
|
|
||||||
</label>
|
@* 세무 정보 *@
|
||||||
<label>상태
|
<MudItem xs="12" Class="mt-2">
|
||||||
<select class="admin-input" @bind="dto.Status">
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">세무 정보</MudText>
|
||||||
<option value="active">활성</option>
|
<MudDivider />
|
||||||
<option value="inactive">비활성</option>
|
</MudItem>
|
||||||
</select>
|
<MudItem xs="12" md="6">
|
||||||
</label>
|
<CommonCodeSelect @bind-Value="dto.ServiceType" Group="CLIENT_SERVICE_TYPE" Label="서비스 유형" Clearable="true" />
|
||||||
<label>유입 경로
|
</MudItem>
|
||||||
<select class="admin-input" @bind="dto.Source">
|
<MudItem xs="12" md="6">
|
||||||
<option value="">선택하세요</option>
|
<CommonCodeSelect @bind-Value="dto.TaxType" Group="CLIENT_TAX_TYPE" Label="세금 유형" Clearable="true" />
|
||||||
@foreach (var s in ClientService.Sources)
|
</MudItem>
|
||||||
{
|
|
||||||
<option value="@s">@s</option>
|
@* 관리 정보 *@
|
||||||
}
|
<MudItem xs="12" Class="mt-2">
|
||||||
</select>
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">관리 정보</MudText>
|
||||||
</label>
|
<MudDivider />
|
||||||
<label>메모 <textarea class="admin-input" rows="4" @bind="dto.Memo"></textarea></label>
|
</MudItem>
|
||||||
<div class="admin-dialog-actions">
|
<MudItem xs="12" md="6">
|
||||||
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
|
<CommonCodeSelect @bind-Value="dto.Status" Group="CLIENT_STATUS" Label="상태 *" Required="true" />
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>취소</button>
|
</MudItem>
|
||||||
</div>
|
<MudItem xs="12" md="6">
|
||||||
</form>
|
<CommonCodeSelect @bind-Value="dto.Source" Group="CLIENT_SOURCE" Label="유입 경로" Clearable="true" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="dto.Memo" Label="메모"
|
||||||
|
Lines="4" AutoGrow="true"
|
||||||
|
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* 저장 버튼 *@
|
||||||
|
<MudItem xs="12" Class="d-flex gap-2 mt-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="@SaveAsync" Disabled="@isSaving">
|
||||||
|
@(isSaving ? "저장 중..." : "저장")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients">
|
||||||
|
취소
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudForm>
|
||||||
}
|
}
|
||||||
</div>
|
</AdminEditorPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int? Id { get; set; }
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm form = null!;
|
||||||
private CreateClientDto dto = new() { Status = "active" };
|
private CreateClientDto dto = new() { Status = "active" };
|
||||||
|
private bool isValid;
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private bool isSaving;
|
private bool isSaving;
|
||||||
|
|
||||||
|
private RenderFragment ClientEditSkeleton => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 6);
|
||||||
|
builder.AddAttribute(2, "Columns", 3);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
if (Id.HasValue)
|
if (Id.HasValue)
|
||||||
@@ -86,7 +118,7 @@
|
|||||||
var client = await ClientClient.GetByIdAsync(Id.Value);
|
var client = await ClientClient.GetByIdAsync(Id.Value);
|
||||||
if (client is null)
|
if (client is null)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "고객을 찾을 수 없습니다.");
|
Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -102,42 +134,46 @@
|
|||||||
Source = client.Source,
|
Source = client.Source,
|
||||||
Memo = client.Memo
|
Memo = client.Memo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
|
await form.Validate();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("alert", "고객명을 입력하세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Id.HasValue)
|
if (Id.HasValue)
|
||||||
{
|
{
|
||||||
var result = await ClientClient.UpdateAsync(Id.Value, dto);
|
var result = await ClientClient.UpdateAsync(Id.Value, dto);
|
||||||
await JS.InvokeVoidAsync("alert", result != null ? "고객 정보가 수정되었습니다." : "수정에 실패했습니다.");
|
if (result != null)
|
||||||
|
Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("수정에 실패했습니다.", Severity.Error);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var result = await ClientClient.CreateAsync(dto);
|
var result = await ClientClient.CreateAsync(dto);
|
||||||
await JS.InvokeVoidAsync("alert", result != null ? "고객이 등록되었습니다." : "등록에 실패했습니다.");
|
if (result != null)
|
||||||
|
Snackbar.Add("고객이 등록되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
||||||
}
|
}
|
||||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,94 +4,123 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IClientBrowserClient ClientClient
|
@inject IClientBrowserClient ClientClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>고객 관리</PageTitle>
|
<PageTitle>고객 관리</PageTitle>
|
||||||
<section class="admin-page-hero">
|
|
||||||
<div>
|
|
||||||
<div class="admin-eyebrow">CRM</div>
|
|
||||||
<h1 class="admin-page-title">고객 관리</h1>
|
|
||||||
<p class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients/create")'>고객 등록</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="admin-surface mb-3 pa-3">
|
<AdminPageHeader Title="고객 관리" Eyebrow="CRM" Subtitle="고객 카드를 등록하고 상담 이력을 관리합니다.">
|
||||||
<div class="admin-filter-grid">
|
<ChildContent>
|
||||||
<input class="admin-input" placeholder="검색 (이름·연락처·회사명)" @bind="searchText" @onkeyup="OnSearchKeyUp" />
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
<select class="admin-input" @bind="statusFilter">
|
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||||
<option value="">전체</option>
|
Href="/taxbaik/admin/clients/create">
|
||||||
<option value="active">활성</option>
|
고객 등록
|
||||||
<option value="inactive">비활성</option>
|
</MudButton>
|
||||||
</select>
|
</ChildContent>
|
||||||
<button type="button" class="site-button secondary" @onclick="SearchAsync">검색</button>
|
</AdminPageHeader>
|
||||||
<button type="button" class="site-button secondary" @onclick="ResetAsync">초기화</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-surface">
|
@* 검색/필터 바 *@
|
||||||
|
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
|
||||||
|
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<CommonCodeSelect @bind-Value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||||
|
<MudButton Variant="Variant.Text" OnClick="@ResetAsync" FullWidth="true">초기화</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<AdminDataPanel Loading="@(clients is null)" SkeletonContent="@ClientListSkeleton">
|
||||||
@if (clients is null)
|
@if (clients is null)
|
||||||
{
|
{
|
||||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
|
||||||
}
|
}
|
||||||
else if (!clients.Any())
|
else if (!clients.Any())
|
||||||
{
|
{
|
||||||
<div class="muted mt-4">등록된 고객이 없습니다.</div>
|
<AdminEmptyState Icon="@Icons.Material.Filled.PeopleAlt" Message="등록된 고객이 없습니다." />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-table-wrap">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
<table class="admin-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>회사명</th>
|
||||||
|
<th>연락처</th>
|
||||||
|
<th>서비스</th>
|
||||||
|
<th>세금 유형</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>유입 경로</th>
|
||||||
|
<th>등록일</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in clients)
|
||||||
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<td><strong>@c.Name</strong></td>
|
||||||
<th>회사명</th>
|
<td>@(c.CompanyName ?? "—")</td>
|
||||||
<th>연락처</th>
|
<td>@(c.Phone ?? "—")</td>
|
||||||
<th>서비스</th>
|
<td>
|
||||||
<th>세금 유형</th>
|
@if (!string.IsNullOrEmpty(c.ServiceType))
|
||||||
<th>상태</th>
|
{
|
||||||
<th>유입 경로</th>
|
<MudChip Size="Size.Small" Color="Color.Primary">@c.ServiceType</MudChip>
|
||||||
<th>등록일</th>
|
}
|
||||||
<th></th>
|
</td>
|
||||||
|
<td>@(c.TaxType ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
@if (c.Status == "active")
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success">활성</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@(c.Source ?? "—")</td>
|
||||||
|
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
|
||||||
|
<td>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(c))">
|
||||||
|
삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudButtonGroup>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
</tbody>
|
||||||
@foreach (var c in clients)
|
</MudSimpleTable>
|
||||||
{
|
|
||||||
<tr>
|
@* 페이징 *@
|
||||||
<td><strong>@c.Name</strong></td>
|
|
||||||
<td>@(c.CompanyName ?? "—")</td>
|
|
||||||
<td>@(c.Phone ?? "—")</td>
|
|
||||||
<td>@(c.ServiceType ?? "—")</td>
|
|
||||||
<td>@(c.TaxType ?? "—")</td>
|
|
||||||
<td>@(c.Status == "active" ? "활성" : "비활성")</td>
|
|
||||||
<td>@(c.Source ?? "—")</td>
|
|
||||||
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
|
|
||||||
<td>
|
|
||||||
<div class="admin-row-actions">
|
|
||||||
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">✎</button>
|
|
||||||
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(c))">✕</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@if (totalPages > 1)
|
@if (totalPages > 1)
|
||||||
{
|
{
|
||||||
<div class="admin-pagination">
|
<div class="d-flex justify-center pa-3">
|
||||||
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1)" @onclick="PreviousPage">이전</button>
|
<MudPagination BoundaryCount="1" MiddleCount="3"
|
||||||
<span>@currentPage / @totalPages</span>
|
Count="@totalPages" Selected="@currentPage"
|
||||||
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages)" @onclick="NextPage">다음</button>
|
SelectedChanged="@OnPageChanged" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="admin-table-footer">총 @(totalCount)명</div>
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText>
|
||||||
}
|
}
|
||||||
</div>
|
</AdminDataPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Client>? clients;
|
private List<Client>? clients;
|
||||||
private string searchText = "";
|
private string searchText = "";
|
||||||
private string statusFilter = "";
|
private string statusFilter = "";
|
||||||
@@ -100,15 +129,22 @@
|
|||||||
private int totalPages;
|
private int totalPages;
|
||||||
private const int PageSize = 20;
|
private const int PageSize = 20;
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private RenderFragment ClientListSkeleton => builder =>
|
||||||
{
|
{
|
||||||
if (firstRender && AuthStateTask != null)
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 5);
|
||||||
|
builder.AddAttribute(2, "Columns", 5);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (AuthStateTask != null)
|
||||||
{
|
{
|
||||||
var authState = await AuthStateTask;
|
var authState = await AuthStateTask;
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,39 +153,75 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (items, total) = await ClientClient.GetPagedAsync(currentPage, PageSize, string.IsNullOrEmpty(statusFilter) ? null : statusFilter, string.IsNullOrEmpty(searchText) ? null : searchText);
|
var (items, total) = await ClientClient.GetPagedAsync(
|
||||||
|
currentPage, PageSize,
|
||||||
|
string.IsNullOrEmpty(statusFilter) ? null : statusFilter,
|
||||||
|
string.IsNullOrEmpty(searchText) ? null : searchText);
|
||||||
|
|
||||||
clients = items.ToList();
|
clients = items.ToList();
|
||||||
totalCount = total;
|
totalCount = total;
|
||||||
totalPages = (int)Math.Ceiling((double)total / PageSize);
|
totalPages = (int)Math.Ceiling((double)total / PageSize);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
clients = [];
|
clients = [];
|
||||||
|
totalCount = 0;
|
||||||
|
totalPages = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SearchAsync() { currentPage = 1; await LoadAsync(); }
|
private async Task SearchAsync()
|
||||||
private async Task ResetAsync() { searchText = ""; statusFilter = ""; currentPage = 1; await LoadAsync(); }
|
{
|
||||||
private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadAsync(); } }
|
currentPage = 1;
|
||||||
private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadAsync(); } }
|
await LoadAsync();
|
||||||
private async Task OnSearchKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") await SearchAsync(); }
|
}
|
||||||
|
|
||||||
|
private async Task ResetAsync()
|
||||||
|
{
|
||||||
|
searchText = "";
|
||||||
|
statusFilter = "";
|
||||||
|
currentPage = 1;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPageChanged(int page)
|
||||||
|
{
|
||||||
|
currentPage = page;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSearchKeyUp(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter") await SearchAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(Client client)
|
private async Task DeleteAsync(Client client)
|
||||||
{
|
{
|
||||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.");
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
if (!confirmed) return;
|
"고객 삭제",
|
||||||
|
$"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.",
|
||||||
|
yesText: "삭제", cancelText: "취소");
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await ClientClient.DeleteAsync(client.Id);
|
var success = await ClientClient.DeleteAsync(client.Id);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"{client.Name} 고객이 삭제되었습니다.");
|
Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제에 실패했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
@page "/admin/common-codes"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject ICommonCodeBrowserClient CommonCodeClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>공통관리</PageTitle>
|
||||||
|
|
||||||
|
<AdminPageHeader Title="공통관리" Eyebrow="System" Subtitle="공통코드 그룹과 항목을 일관된 기준으로 관리합니다." />
|
||||||
|
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem XS="12" MD="4">
|
||||||
|
<CommonCodeGroupPanel Groups="groups"
|
||||||
|
SelectedGroup="selectedGroup"
|
||||||
|
SelectedGroupChanged="OnGroupChanged"
|
||||||
|
OnCreateRequested="PrepareCreate" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem XS="12" MD="8">
|
||||||
|
<CommonCodeListPanel Loading="@isLoading"
|
||||||
|
Codes="codes"
|
||||||
|
EditModel="editModel"
|
||||||
|
IsCreateMode="isCreateMode"
|
||||||
|
Form="form"
|
||||||
|
EditRequested="EditCode"
|
||||||
|
DeleteRequested="DeleteCode"
|
||||||
|
SaveRequested="SaveCode"
|
||||||
|
ResetRequested="PrepareCreate" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<string> groups = [];
|
||||||
|
private List<CommonCode> codes = [];
|
||||||
|
private string selectedGroup = "";
|
||||||
|
private bool isLoading = true;
|
||||||
|
private CommonCode editModel = new();
|
||||||
|
private bool isCreateMode = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
groups = await CommonCodeClient.GetGroupsAsync();
|
||||||
|
selectedGroup = groups.FirstOrDefault() ?? "";
|
||||||
|
await LoadCodes();
|
||||||
|
PrepareCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGroupChanged(string value)
|
||||||
|
{
|
||||||
|
selectedGroup = value;
|
||||||
|
await LoadCodes();
|
||||||
|
PrepareCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCodes()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
codes = string.IsNullOrWhiteSpace(selectedGroup)
|
||||||
|
? []
|
||||||
|
: await CommonCodeClient.GetByGroupAsync(selectedGroup);
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrepareCreate()
|
||||||
|
{
|
||||||
|
isCreateMode = true;
|
||||||
|
editModel = new CommonCode
|
||||||
|
{
|
||||||
|
CodeGroup = selectedGroup,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditCode(CommonCode code)
|
||||||
|
{
|
||||||
|
isCreateMode = false;
|
||||||
|
editModel = new CommonCode
|
||||||
|
{
|
||||||
|
CodeGroup = code.CodeGroup,
|
||||||
|
CodeValue = code.CodeValue,
|
||||||
|
CodeName = code.CodeName,
|
||||||
|
SortOrder = code.SortOrder,
|
||||||
|
IsActive = code.IsActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveCode()
|
||||||
|
{
|
||||||
|
editModel.CodeGroup = editModel.CodeGroup?.Trim() ?? string.Empty;
|
||||||
|
editModel.CodeValue = editModel.CodeValue?.Trim() ?? string.Empty;
|
||||||
|
editModel.CodeName = editModel.CodeName?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(editModel.CodeGroup) ||
|
||||||
|
string.IsNullOrWhiteSpace(editModel.CodeValue) ||
|
||||||
|
string.IsNullOrWhiteSpace(editModel.CodeName))
|
||||||
|
{
|
||||||
|
Snackbar.Add("그룹, 값, 이름은 모두 입력해야 합니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editModel.CodeGroup.Any(char.IsWhiteSpace))
|
||||||
|
{
|
||||||
|
Snackbar.Add("code_group에는 공백을 넣을 수 없습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editModel.CodeValue.Any(char.IsWhiteSpace))
|
||||||
|
{
|
||||||
|
Snackbar.Add("code_value에는 공백을 넣을 수 없습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await CommonCodeClient.UpsertAsync(editModel))
|
||||||
|
{
|
||||||
|
Snackbar.Add("저장 실패", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add("저장되었습니다.", Severity.Success);
|
||||||
|
await LoadCodes();
|
||||||
|
PrepareCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteCode(CommonCode code)
|
||||||
|
{
|
||||||
|
if (!await CommonCodeClient.DeleteAsync(code.CodeGroup, code.CodeValue))
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제 실패", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add("삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadCodes();
|
||||||
|
PrepareCreate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,22 @@
|
|||||||
@using TaxBaik.Web.Components.Admin.Forms
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
@inject IApiClient ApiClient
|
@inject IApiClient ApiClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>고객사 등록</PageTitle>
|
<PageTitle>고객사 등록</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Settings</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||||
<h1 class="admin-page-title">새 고객사 등록</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">새 고객사 등록</MudText>
|
||||||
<p class="admin-page-subtitle">새로운 고객사를 추가합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 고객사를 추가합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface mt-4">
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||||
</div>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private void GoBack()
|
private void GoBack()
|
||||||
@@ -40,12 +40,12 @@
|
|||||||
memo = model.Memo
|
memo = model.Memo
|
||||||
});
|
});
|
||||||
|
|
||||||
await JS.InvokeVoidAsync("alert", "고객사가 등록되었습니다.");
|
Snackbar.Add("고객사가 등록되었습니다.", Severity.Success);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
|
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,38 +3,38 @@
|
|||||||
@using TaxBaik.Web.Components.Admin.Forms
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
@inject IApiClient ApiClient
|
@inject IApiClient ApiClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
<PageTitle>고객사 수정</PageTitle>
|
<PageTitle>고객사 수정</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Settings</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||||
<h1 class="admin-page-title">고객사 수정</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 수정</MudText>
|
||||||
<p class="admin-page-subtitle">고객사 정보를 수정합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객사 정보를 수정합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (isLoading)
|
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@CompanySkeleton">
|
||||||
{
|
@if (formModel == null)
|
||||||
<div class="admin-surface mt-4">
|
{
|
||||||
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
|
||||||
</div>
|
}
|
||||||
}
|
else
|
||||||
else if (formModel == null)
|
{
|
||||||
{
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<div class="admin-surface mt-4">고객사를 찾을 수 없습니다.</div>
|
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||||
}
|
|
||||||
else
|
<MudDivider Class="my-4" />
|
||||||
{
|
|
||||||
<div class="admin-surface mt-4">
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
|
||||||
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
고객사 삭제
|
||||||
<div class="mt-4">
|
</MudButton>
|
||||||
<button type="button" class="site-button secondary danger" @onclick="DeleteCompany">고객사 삭제</button>
|
</MudPaper>
|
||||||
</div>
|
}
|
||||||
</div>
|
</AdminEditorPanel>
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
@@ -43,6 +43,14 @@ else
|
|||||||
private CompanyForm.CompanyFormModel? formModel;
|
private CompanyForm.CompanyFormModel? formModel;
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
private RenderFragment CompanySkeleton => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 6);
|
||||||
|
builder.AddAttribute(2, "Columns", 3);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -65,7 +73,7 @@ else
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
|
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -93,29 +101,34 @@ else
|
|||||||
isActive = model.IsActive
|
isActive = model.IsActive
|
||||||
});
|
});
|
||||||
|
|
||||||
await JS.InvokeVoidAsync("alert", "고객사가 수정되었습니다.");
|
Snackbar.Add("고객사가 수정되었습니다.", Severity.Success);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
|
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteCompany()
|
private async Task DeleteCompany()
|
||||||
{
|
{
|
||||||
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."))
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"고객사 삭제",
|
||||||
|
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"삭제", "취소");
|
||||||
|
|
||||||
|
if (result != true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ApiClient.DeleteAsync($"company/{Id}");
|
await ApiClient.DeleteAsync($"company/{Id}");
|
||||||
await JS.InvokeVoidAsync("alert", "고객사가 삭제되었습니다.");
|
Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/companies");
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,53 @@
|
|||||||
@page "/admin/companies"
|
@page "/admin/companies"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IApiClient ApiClient
|
@inject IApiClient ApiClient
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>고객사 관리</PageTitle>
|
<PageTitle>고객사 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Settings</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||||
<h1 class="admin-page-title">고객사 관리</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 관리</MudText>
|
||||||
<p class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/companies/create")'>새 고객사 등록</button>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface mb-4 mt-4">
|
<MudPaper Class="admin-surface mb-4 mt-4" Elevation="0">
|
||||||
<div class="admin-summary-bar">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||||
<span>@($"전체 고객사 {totalCompanies}개")</span>
|
<MudText Typo="Typo.subtitle1">@($"전체 고객사 {totalCompanies}개")</MudText>
|
||||||
<span>페이지 @currentPage / @totalPages</span>
|
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||||
</div>
|
</MudStack>
|
||||||
</div>
|
</MudPaper>
|
||||||
|
|
||||||
<div class="admin-surface">
|
<MudDataGrid Items="@companies" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||||
@if (isLoading)
|
<Columns>
|
||||||
{
|
<PropertyColumn Property="x => x.CompanyCode" Title="회사코드" />
|
||||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
<PropertyColumn Property="x => x.CompanyName" Title="회사명" />
|
||||||
}
|
<PropertyColumn Property="x => x.ContactPerson" Title="담당자" />
|
||||||
else
|
<PropertyColumn Property="x => x.Phone" Title="전화" />
|
||||||
{
|
<PropertyColumn Property="x => x.Email" Title="이메일" />
|
||||||
<div class="admin-table-wrap">
|
<PropertyColumn Property="x => x.IsActive" Title="활성">
|
||||||
<table class="admin-table">
|
<CellTemplate Context="cell">
|
||||||
<thead>
|
<MudCheckBox T="bool" Value="@cell.Item.IsActive" Disabled="true" />
|
||||||
<tr>
|
</CellTemplate>
|
||||||
<th>회사코드</th>
|
</PropertyColumn>
|
||||||
<th>회사명</th>
|
<PropertyColumn Property="x => x.CreatedAt" Title="등록일" Format="yyyy-MM-dd" />
|
||||||
<th>담당자</th>
|
<TemplateColumn>
|
||||||
<th>전화</th>
|
<CellTemplate Context="cell">
|
||||||
<th>이메일</th>
|
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
|
||||||
<th>활성</th>
|
Href="@($"/taxbaik/admin/companies/{cell.Item.Id}/edit")">수정</MudButton>
|
||||||
<th>등록일</th>
|
</CellTemplate>
|
||||||
<th></th>
|
</TemplateColumn>
|
||||||
</tr>
|
</Columns>
|
||||||
</thead>
|
</MudDataGrid>
|
||||||
<tbody>
|
|
||||||
@foreach (var item in companies)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@item.CompanyCode</td>
|
|
||||||
<td>@item.CompanyName</td>
|
|
||||||
<td>@(item.ContactPerson ?? "—")</td>
|
|
||||||
<td>@(item.Phone ?? "—")</td>
|
|
||||||
<td>@(item.Email ?? "—")</td>
|
|
||||||
<td>@(item.IsActive ? "활성" : "비활성")</td>
|
|
||||||
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
|
|
||||||
<td><a class="site-button secondary" href="@($"/taxbaik/admin/companies/{item.Id}/edit")">수정</a></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-pagination">
|
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
||||||
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
|
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
||||||
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
|
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
||||||
</div>
|
</MudStack>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<CompanyDto> companies = [];
|
private List<CompanyDto> companies = [];
|
||||||
@@ -118,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
|
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -149,6 +131,4 @@
|
|||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private string NavTo(string url) => url;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,148 @@
|
|||||||
@page "/admin/consulting-activities"
|
@page "/admin/consulting-activities"
|
||||||
@using TaxBaik.Web.Services.AdminClients
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@using TaxBaik.Web.Components.Admin.Shared
|
||||||
@inject IConsultingActivityBrowserClient ActivityClient
|
@inject IConsultingActivityBrowserClient ActivityClient
|
||||||
@inject IClientBrowserClient ClientClient
|
@inject IClientBrowserClient ClientClient
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>상담 활동 관리</PageTitle>
|
<PageTitle>상담 활동 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">CRM & 세무관리</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
<h1 class="admin-page-title">상담 활동 관리</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">상담 활동 관리</MudText>
|
||||||
<p class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 활동 기록</button>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
새 활동 기록
|
||||||
|
</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface">
|
<AdminDataPanel Loading="@(activities is null)" SkeletonContent="@ActivitySkeleton">
|
||||||
@if (activities is null)
|
@if (activities is null)
|
||||||
{
|
{
|
||||||
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
|
||||||
}
|
}
|
||||||
else if (activities.Count == 0)
|
else if (activities.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="muted">상담 활동이 없습니다.</div>
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="me-2" />
|
||||||
|
상담 활동이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-table-wrap">
|
<MudDataGrid T="ConsultingActivity"
|
||||||
<table class="admin-table">
|
Items="@activities"
|
||||||
<thead>
|
Dense="true"
|
||||||
<tr>
|
Hover="true"
|
||||||
<th>ID</th>
|
Striped="true"
|
||||||
<th>고객</th>
|
Virtualize="true"
|
||||||
<th>활동 유형</th>
|
RowsPerPage="30"
|
||||||
<th>활동일시</th>
|
Class="admin-grid">
|
||||||
<th>설명</th>
|
<Columns>
|
||||||
<th>다음 팔로업</th>
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
<th>작업</th>
|
<TemplateColumn Title="고객">
|
||||||
</tr>
|
<CellTemplate>
|
||||||
</thead>
|
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||||
<tbody>
|
{
|
||||||
@foreach (var item in activities)
|
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||||
{
|
@clientName
|
||||||
<tr>
|
</MudLink>
|
||||||
<td>@item.Id</td>
|
}
|
||||||
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
|
</CellTemplate>
|
||||||
<td>@item.ActivityType</td>
|
</TemplateColumn>
|
||||||
<td>@item.ActivityDate.ToString("g")</td>
|
<PropertyColumn Property="x => x.ActivityType" Title="활동 유형" />
|
||||||
<td>@Truncate(item.Description)</td>
|
<PropertyColumn Property="x => x.ActivityDate" Title="활동일시" Format="g" />
|
||||||
<td>@(item.NextFollowupDate?.ToString("yyyy-MM-dd") ?? "—")</td>
|
<TemplateColumn Title="설명">
|
||||||
<td>
|
<CellTemplate>
|
||||||
<div class="admin-row-actions">
|
@{
|
||||||
<button type="button" class="admin-icon-button" @onclick="@(async () => await OpenEditDialog(item))">✎</button>
|
var desc = context.Item.Description ?? "";
|
||||||
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteActivity(item.Id))">✕</button>
|
if (desc.Length > 30) desc = desc.Substring(0, 30) + "...";
|
||||||
</div>
|
}
|
||||||
</td>
|
<span>@desc</span>
|
||||||
</tr>
|
</CellTemplate>
|
||||||
}
|
</TemplateColumn>
|
||||||
</tbody>
|
<TemplateColumn Title="다음 팔로업">
|
||||||
</table>
|
<CellTemplate>
|
||||||
</div>
|
@if (context.Item.NextFollowupDate.HasValue)
|
||||||
|
{
|
||||||
|
var daysLeft = (context.Item.NextFollowupDate.Value.Date - DateTime.Today).Days;
|
||||||
|
<MudChip Size="Size.Small"
|
||||||
|
Color="@(daysLeft < 0 ? Color.Error : daysLeft <= 3 ? Color.Warning : Color.Success)"
|
||||||
|
Variant="Variant.Filled">
|
||||||
|
@context.Item.NextFollowupDate.Value.ToString("yyyy-MM-dd")
|
||||||
|
</MudChip>
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<TemplateColumn Title="작업" Sortable="false">
|
||||||
|
<CellTemplate>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||||
|
OnClick="@(async () => await DeleteActivity(context.Item.Id))" />
|
||||||
|
</MudButtonGroup>
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
</Columns>
|
||||||
|
</MudDataGrid>
|
||||||
}
|
}
|
||||||
</div>
|
</AdminDataPanel>
|
||||||
|
|
||||||
<dialog class="admin-dialog" open="@isDialogOpen">
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
<form class="admin-dialog-card" @onsubmit="SaveActivity" @onsubmit:preventDefault="true">
|
<TitleContent>
|
||||||
<h3>@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</h3>
|
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText>
|
||||||
<label>고객
|
</TitleContent>
|
||||||
<select class="admin-input" @bind="ClientIdText">
|
<DialogContent>
|
||||||
<option value="">선택하세요</option>
|
<MudForm @ref="form">
|
||||||
|
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||||
@foreach (var client in clients)
|
@foreach (var client in clients)
|
||||||
{
|
{
|
||||||
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||||
}
|
}
|
||||||
</select>
|
</MudSelect>
|
||||||
</label>
|
<CommonCodeSelect @bind-Value="activityForm.ActivityType" Group="CONSULTING_ACTIVITY_TYPE" Label="활동 유형" Class="mb-4" Required="true" />
|
||||||
<label>활동 유형
|
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
<select class="admin-input" @bind="activityForm.ActivityType">
|
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
|
||||||
<option value="">선택하세요</option>
|
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||||
<option value="방문 상담">방문 상담</option>
|
</MudForm>
|
||||||
<option value="전화 상담">전화 상담</option>
|
</DialogContent>
|
||||||
<option value="세무조사 대응 미팅">세무조사 대응 미팅</option>
|
<DialogActions>
|
||||||
<option value="카카오톡 상담">카카오톡 상담</option>
|
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||||
<option value="이메일 자료 접수">이메일 자료 접수</option>
|
<MudButton Color="Color.Primary" OnClick="SaveActivity">저장</MudButton>
|
||||||
<option value="기타">기타</option>
|
</DialogActions>
|
||||||
</select>
|
</MudDialog>
|
||||||
</label>
|
|
||||||
<label>활동일 <input class="admin-input" type="text" placeholder="2026-06-29 14:00" @bind="ActivityDateText" /></label>
|
|
||||||
<label>설명 <textarea class="admin-input" rows="4" @bind="activityForm.Description"></textarea></label>
|
|
||||||
<label>다음 팔로업일 <input class="admin-input" type="text" placeholder="2026-07-10" @bind="NextFollowupText" /></label>
|
|
||||||
<div class="admin-dialog-actions">
|
|
||||||
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
|
|
||||||
<button type="submit" class="site-button primary">저장</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<ConsultingActivity>? activities;
|
private List<ConsultingActivity>? activities;
|
||||||
private List<Client> clients = [];
|
private List<Client> clients = [];
|
||||||
private Dictionary<int, string> clientMap = new();
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private MudForm? form;
|
||||||
private bool isDialogOpen;
|
private bool isDialogOpen;
|
||||||
private ConsultingActivity? editingActivity;
|
private ConsultingActivity? editingActivity;
|
||||||
private ConsultingActivityForm activityForm = new();
|
private ConsultingActivityForm activityForm = new();
|
||||||
|
|
||||||
private string ClientIdText { get => activityForm.ClientId > 0 ? activityForm.ClientId.ToString() : ""; set => activityForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
|
private RenderFragment ActivitySkeleton => builder =>
|
||||||
private string ActivityDateText { get => activityForm.ActivityDate?.ToString("yyyy-MM-dd HH:mm") ?? ""; set => activityForm.ActivityDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
|
||||||
private string NextFollowupText { get => activityForm.NextFollowupDate?.ToString("yyyy-MM-dd") ?? ""; set => activityForm.NextFollowupDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
{
|
||||||
if (firstRender && AuthStateTask != null)
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 5);
|
||||||
|
builder.AddAttribute(2, "Columns", 4);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (AuthStateTask != null)
|
||||||
{
|
{
|
||||||
var authState = await AuthStateTask;
|
var authState = await AuthStateTask;
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadData();
|
await LoadData();
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,14 +158,18 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenCreateDialog()
|
private void OpenCreateDialog()
|
||||||
{
|
{
|
||||||
editingActivity = null;
|
editingActivity = null;
|
||||||
activityForm = new ConsultingActivityForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, ActivityDate = DateTime.Now };
|
activityForm = new ConsultingActivityForm
|
||||||
|
{
|
||||||
|
ActivityDate = DateTime.Now,
|
||||||
|
ClientId = clients.FirstOrDefault()?.Id ?? 0
|
||||||
|
};
|
||||||
isDialogOpen = true;
|
isDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,60 +185,103 @@
|
|||||||
NextFollowupDate = activity.NextFollowupDate
|
NextFollowupDate = activity.NextFollowupDate
|
||||||
};
|
};
|
||||||
isDialogOpen = true;
|
isDialogOpen = true;
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveActivity()
|
private async Task SaveActivity()
|
||||||
{
|
{
|
||||||
if (activityForm.ClientId <= 0 || string.IsNullOrWhiteSpace(activityForm.ActivityType) || string.IsNullOrWhiteSpace(activityForm.Description))
|
if (form != null)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
|
await form.Validate();
|
||||||
return;
|
if (!form.IsValid)
|
||||||
|
{
|
||||||
|
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (editingActivity == null)
|
if (editingActivity == null)
|
||||||
{
|
{
|
||||||
var newId = await ActivityClient.CreateAsync(activityForm.ClientId, activityForm.ActivityType, activityForm.ActivityDate ?? DateTime.Now, activityForm.Description, null, activityForm.NextFollowupDate);
|
var actDate = activityForm.ActivityDate ?? DateTime.Now;
|
||||||
|
var newId = await ActivityClient.CreateAsync(
|
||||||
|
activityForm.ClientId,
|
||||||
|
activityForm.ActivityType,
|
||||||
|
actDate,
|
||||||
|
activityForm.Description,
|
||||||
|
null,
|
||||||
|
activityForm.NextFollowupDate);
|
||||||
|
|
||||||
if (newId > 0)
|
if (newId > 0)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "활동이 기록되었습니다.");
|
Snackbar.Add("활동이 기록되었습니다.", Severity.Success);
|
||||||
CloseDialog();
|
CloseDialog();
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await ActivityClient.UpdateAsync(editingActivity.Id, null, activityForm.NextFollowupDate);
|
await ActivityClient.UpdateAsync(
|
||||||
await JS.InvokeVoidAsync("alert", "활동이 업데이트되었습니다.");
|
editingActivity.Id,
|
||||||
|
null,
|
||||||
|
activityForm.NextFollowupDate);
|
||||||
|
|
||||||
|
Snackbar.Add("활동이 업데이트되었습니다.", Severity.Success);
|
||||||
CloseDialog();
|
CloseDialog();
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteActivity(int id)
|
private async Task DeleteActivity(int id)
|
||||||
{
|
{
|
||||||
if (!await JS.InvokeAsync<bool>("confirm", "이 활동을 삭제하시겠습니까?")) return;
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 활동을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (result?.Canceled ?? true)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ActivityClient.DeleteAsync(id);
|
await ActivityClient.DeleteAsync(id);
|
||||||
await JS.InvokeVoidAsync("alert", "활동이 삭제되었습니다.");
|
Snackbar.Add("활동이 삭제되었습니다.", Severity.Success);
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CloseDialog() { isDialogOpen = false; editingActivity = null; activityForm = new(); }
|
private void CloseDialog()
|
||||||
private static string Truncate(string? text) => string.IsNullOrWhiteSpace(text) ? "—" : text.Length > 30 ? text[..30] + "..." : text;
|
{
|
||||||
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
isDialogOpen = false;
|
||||||
private sealed class ConsultingActivityForm { public int ClientId { get; set; } public string ActivityType { get; set; } = ""; public DateTime? ActivityDate { get; set; } = DateTime.Now; public string Description { get; set; } = ""; public DateTime? NextFollowupDate { get; set; } }
|
editingActivity = null;
|
||||||
|
activityForm = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetClientDisplayName(Client client)
|
||||||
|
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||||
|
? client.CompanyName
|
||||||
|
: !string.IsNullOrWhiteSpace(client.Name)
|
||||||
|
? client.Name
|
||||||
|
: $"Client #{client.Id}";
|
||||||
|
private class ConsultingActivityForm
|
||||||
|
{
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public string ActivityType { get; set; } = "";
|
||||||
|
public DateTime? ActivityDate { get; set; } = DateTime.Now;
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public DateTime? NextFollowupDate { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +1,179 @@
|
|||||||
@page "/admin/contracts"
|
@page "/admin/contracts"
|
||||||
@using TaxBaik.Web.Services.AdminClients
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@using TaxBaik.Web.Components.Admin.Shared
|
||||||
@inject IContractBrowserClient ContractClient
|
@inject IContractBrowserClient ContractClient
|
||||||
@inject IClientBrowserClient ClientClient
|
@inject IClientBrowserClient ClientClient
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>계약 관리</PageTitle>
|
<PageTitle>계약 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">CRM & 세무관리</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
<h1 class="admin-page-title">계약 관리</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">계약 관리</MudText>
|
||||||
<p class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</MudText>
|
||||||
@if (mrr > 0)
|
@if (mrr > 0)
|
||||||
{
|
{
|
||||||
<p class="admin-page-subtitle mt-2">월 정기수익: <strong>₩@mrr.ToString("N0")</strong></p>
|
<MudText Typo="Typo.body2" Class="mt-2">
|
||||||
|
월 정기수익:
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
|
||||||
|
</MudText>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 계약 추가</button>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
|
||||||
|
새 계약 추가
|
||||||
|
</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface">
|
<AdminEditorPanel Loading="@(contracts is null)" SkeletonContent="@ContractSkeleton">
|
||||||
@if (contracts is null)
|
@if (contracts is null)
|
||||||
{
|
{
|
||||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
}
|
||||||
}
|
else
|
||||||
else if (contracts.Count == 0)
|
{
|
||||||
{
|
<MudGrid Spacing="2" Class="mt-2">
|
||||||
<div class="muted">계약이 없습니다.</div>
|
<!-- Left: Dense Grid List -->
|
||||||
}
|
<MudItem XS="12" MD="8">
|
||||||
else
|
@if (contracts.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="admin-table-wrap">
|
<MudAlert Severity="Severity.Info">
|
||||||
<table class="admin-table">
|
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
|
||||||
<thead>
|
계약이 없습니다.
|
||||||
<tr>
|
</MudAlert>
|
||||||
<th>ID</th>
|
}
|
||||||
<th>고객</th>
|
else
|
||||||
<th>계약번호</th>
|
{
|
||||||
<th>서비스 유형</th>
|
<MudDataGrid T="Contract"
|
||||||
<th>월 수수료</th>
|
Items="@contracts"
|
||||||
<th>계약기간</th>
|
Dense="true"
|
||||||
<th>상태</th>
|
Hover="true"
|
||||||
<th>작업</th>
|
Striped="true"
|
||||||
</tr>
|
Virtualize="true"
|
||||||
</thead>
|
RowsPerPage="30"
|
||||||
<tbody>
|
SelectedItem="@selectedContract"
|
||||||
@foreach (var item in contracts)
|
SelectedItemChanged="OnRowSelected"
|
||||||
{
|
Class="admin-grid">
|
||||||
var isActive = !item.EndDate.HasValue || item.EndDate.Value >= DateTime.Today;
|
<Columns>
|
||||||
<tr>
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
<td>@item.Id</td>
|
<TemplateColumn Title="고객">
|
||||||
<td>@(clientMap.TryGetValue(item.ClientId, out var clientName) ? clientName : "")</td>
|
<CellTemplate>
|
||||||
<td>@item.ContractNumber</td>
|
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||||
<td>@item.ServiceType</td>
|
{
|
||||||
<td>@(item.MonthlyFee?.ToString("C") ?? "—")</td>
|
@clientName
|
||||||
<td>@item.StartDate@if (item.EndDate.HasValue){<span>~ @item.EndDate.Value</span>}</td>
|
}
|
||||||
<td><span class="status-pill @(isActive ? "success" : "muted")">@(isActive ? "활성" : "만료")</span></td>
|
</CellTemplate>
|
||||||
<td><button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteContract(item.Id))">✕</button></td>
|
</TemplateColumn>
|
||||||
</tr>
|
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
|
||||||
}
|
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
|
||||||
</tbody>
|
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
|
||||||
</table>
|
<TemplateColumn Title="계약기간">
|
||||||
</div>
|
<CellTemplate>
|
||||||
}
|
@context.Item.StartDate.ToString("yyyy-MM-dd")
|
||||||
</div>
|
@if (context.Item.EndDate.HasValue)
|
||||||
|
{
|
||||||
|
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<TemplateColumn Title="상태">
|
||||||
|
<CellTemplate>
|
||||||
|
@{
|
||||||
|
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
|
||||||
|
}
|
||||||
|
@if (isActive)
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<TemplateColumn Title="작업" Sortable="false">
|
||||||
|
<CellTemplate>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||||
|
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
</Columns>
|
||||||
|
</MudDataGrid>
|
||||||
|
}
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
<dialog class="admin-dialog" open="@isDialogOpen">
|
<!-- Right: Detail Form Panel (Inline Editor) -->
|
||||||
<form class="admin-dialog-card" @onsubmit="SaveContract" @onsubmit:preventDefault="true">
|
<MudItem XS="12" MD="4">
|
||||||
<h3>새 계약 추가</h3>
|
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
|
||||||
<label>고객
|
<div class="d-flex align-center justify-space-between mb-4">
|
||||||
<select class="admin-input" @bind="ClientIdText">
|
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "계약 상세 정보" : "새 계약 추가")</MudText>
|
||||||
<option value="">선택하세요</option>
|
@if (isEditMode)
|
||||||
@foreach (var client in clients)
|
{
|
||||||
{
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
|
||||||
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
새로 작성
|
||||||
}
|
</MudButton>
|
||||||
</select>
|
}
|
||||||
</label>
|
</div>
|
||||||
<label>계약번호 <input class="admin-input" @bind="contractForm.ContractNumber" /></label>
|
<MudForm @ref="form">
|
||||||
<label>서비스 유형
|
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
|
||||||
<select class="admin-input" @bind="contractForm.ServiceType">
|
@foreach (var client in clients)
|
||||||
<option value="개인 기장대리">개인 기장대리</option>
|
{
|
||||||
<option value="법인 기장대리">법인 기장대리</option>
|
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
|
||||||
<option value="세무조정 대행">세무조정 대행</option>
|
}
|
||||||
<option value="양도세 신고대리">양도세 신고대리</option>
|
</MudSelect>
|
||||||
<option value="상속·증여 자문">상속·증여 자문</option>
|
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
|
||||||
<option value="세무조사 대응">세무조사 대응</option>
|
<CommonCodeSelect @bind-Value="contractForm.ServiceType" Group="CONTRACT_SERVICE_TYPE" Label="서비스 유형" Class="mb-3" Required="true" />
|
||||||
</select>
|
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
|
||||||
</label>
|
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" />
|
||||||
<label>계약 시작일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="StartDateText" /></label>
|
|
||||||
<label>월 수수료 <input class="admin-input" type="text" placeholder="100000" @bind="MonthlyFeeText" /></label>
|
<div class="d-flex justify-end gap-2">
|
||||||
<div class="admin-dialog-actions">
|
@if (isEditMode)
|
||||||
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
|
{
|
||||||
<button type="submit" class="site-button primary">저장</button>
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteContract(selectedContract?.Id ?? 0))">삭제</MudButton>
|
||||||
</div>
|
}
|
||||||
</form>
|
else
|
||||||
</dialog>
|
{
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveContract" id="btn-save-contract">저장</MudButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudForm>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
}
|
||||||
|
</AdminEditorPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Contract>? contracts;
|
private List<Contract>? contracts;
|
||||||
private List<Client> clients = [];
|
private List<Client> clients = [];
|
||||||
private Dictionary<int, string> clientMap = new();
|
private Dictionary<int, string> clientMap = new();
|
||||||
private decimal mrr = 0;
|
private decimal mrr = 0;
|
||||||
private bool isDialogOpen;
|
private MudForm? form;
|
||||||
|
private bool isEditMode;
|
||||||
|
private Contract? selectedContract;
|
||||||
private ContractForm contractForm = new();
|
private ContractForm contractForm = new();
|
||||||
private string ClientIdText { get => contractForm.ClientId > 0 ? contractForm.ClientId.ToString() : ""; set => contractForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
|
|
||||||
private string StartDateText { get => contractForm.StartDate?.ToString("yyyy-MM-dd") ?? ""; set => contractForm.StartDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
|
||||||
private string MonthlyFeeText { get => contractForm.MonthlyFee?.ToString() ?? ""; set => contractForm.MonthlyFee = decimal.TryParse(value, out var amount) ? amount : null; }
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private RenderFragment ContractSkeleton => builder =>
|
||||||
{
|
{
|
||||||
if (firstRender && AuthStateTask != null)
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 6);
|
||||||
|
builder.AddAttribute(2, "Columns", 4);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (AuthStateTask != null)
|
||||||
{
|
{
|
||||||
var authState = await AuthStateTask;
|
var authState = await AuthStateTask;
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadData();
|
await LoadData();
|
||||||
StateHasChanged();
|
PrepareCreate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,56 +190,114 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenCreateDialog()
|
private void PrepareCreate()
|
||||||
{
|
{
|
||||||
contractForm = new ContractForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, StartDate = DateTime.Today };
|
selectedContract = null;
|
||||||
isDialogOpen = true;
|
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()
|
private async Task SaveContract()
|
||||||
{
|
{
|
||||||
try
|
if (form != null)
|
||||||
{
|
{
|
||||||
if (contractForm.ClientId <= 0)
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
|
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (contractForm.ClientId == null) return;
|
||||||
|
var newId = await ContractClient.CreateAsync(
|
||||||
|
contractForm.ClientId.Value,
|
||||||
|
contractForm.ContractNumber,
|
||||||
|
contractForm.ServiceType,
|
||||||
|
contractForm.StartDate ?? DateTime.Now,
|
||||||
|
contractForm.MonthlyFee);
|
||||||
|
|
||||||
var newId = await ContractClient.CreateAsync(contractForm.ClientId, contractForm.ContractNumber, contractForm.ServiceType, contractForm.StartDate ?? DateTime.Today, contractForm.MonthlyFee);
|
|
||||||
if (newId > 0)
|
if (newId > 0)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "계약이 추가되었습니다.");
|
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
|
||||||
CloseDialog();
|
PrepareCreate();
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteContract(int id)
|
private async Task DeleteContract(int id)
|
||||||
{
|
{
|
||||||
if (!await JS.InvokeAsync<bool>("confirm", "이 계약을 삭제하시겠습니까?")) return;
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 계약을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (result?.Canceled ?? true)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ContractClient.DeleteAsync(id);
|
await ContractClient.DeleteAsync(id);
|
||||||
await JS.InvokeVoidAsync("alert", "계약이 삭제되었습니다.");
|
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
|
||||||
|
if (selectedContract?.Id == id)
|
||||||
|
{
|
||||||
|
PrepareCreate();
|
||||||
|
}
|
||||||
await LoadData();
|
await LoadData();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CloseDialog() { isDialogOpen = false; contractForm = new(); }
|
private static string GetClientDisplayName(Client client)
|
||||||
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
=> !string.IsNullOrWhiteSpace(client.CompanyName)
|
||||||
private sealed class ContractForm { public int ClientId { get; set; } public string ContractNumber { get; set; } = ""; public string ServiceType { get; set; } = ""; public DateTime? StartDate { get; set; } public decimal? MonthlyFee { get; set; } }
|
? client.CompanyName
|
||||||
|
: !string.IsNullOrWhiteSpace(client.Name)
|
||||||
|
? client.Name
|
||||||
|
: $"Client #{client.Id}";
|
||||||
|
|
||||||
|
private class ContractForm
|
||||||
|
{
|
||||||
|
public int? ClientId { get; set; }
|
||||||
|
public string ContractNumber { get; set; } = "";
|
||||||
|
public string ServiceType { get; set; } = "";
|
||||||
|
public DateTime? StartDate { get; set; }
|
||||||
|
public decimal? MonthlyFee { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@page "/admin/dashboard"
|
@page "/admin/dashboard"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Web.Components.Admin.Shared
|
||||||
@inject IAdminDashboardClient DashboardClient
|
@inject IAdminDashboardClient DashboardClient
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
@@ -8,90 +9,39 @@
|
|||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Overview</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Overview</MudText>
|
||||||
<h1 class="admin-page-title">대시보드</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">대시보드</MudText>
|
||||||
<p class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button primary" @onclick='() => Nav.NavigateTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="/taxbaik/admin/blog/create">
|
||||||
|
새 포스트 작성
|
||||||
|
</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (summary is null)
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<AdminDataPanel Loading="@isLoading" SkeletonContent="@DashboardSkeleton">
|
||||||
<div class="admin-metric-grid">
|
<div class="admin-metric-grid">
|
||||||
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
<AdminMetricCard Label="이번달 문의" Value="@summary.ThisMonthInquiries" Caption="월간 상담 유입 (클릭 시 이동)" Accent="accent-blue" Icon="💬" ValueColor="var(--primary-dark)" IconColor="var(--primary-color)" OnClick="@GoInquiries" />
|
||||||
|
<AdminMetricCard Label="신규 문의" Value="@summary.NewInquiries" Caption="처리 대기 (클릭 시 이동)" Accent="accent-amber" Icon="⚠️" ValueColor="var(--tertiary-dark)" IconColor="var(--tertiary-color)" OnClick="@GoNewInquiries" />
|
||||||
|
<AdminMetricCard Label="전체 포스트" Value="@summary.TotalPosts" Caption="콘텐츠 자산 (클릭 시 이동)" Accent="accent-slate" Icon="📄" ValueColor="#455a64" IconColor="#607d8b" OnClick="@GoBlog" />
|
||||||
|
<AdminMetricCard Label="발행된 포스트" Value="@summary.PublishedPosts" Caption="검색 노출 대상 (클릭 시 이동)" Accent="accent-green" Icon="🌐" ValueColor="var(--secondary-dark)" IconColor="var(--secondary-color)" OnClick="@GoBlog" />
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-surface mt-4">
|
|
||||||
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
|
||||||
</div>
|
|
||||||
<div class="admin-surface mt-4">
|
|
||||||
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="admin-metric-grid">
|
|
||||||
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
|
|
||||||
<div class="metric-card-inner">
|
|
||||||
<span class="metric-label">이번달 문의</span>
|
|
||||||
<div class="metric-value-row">
|
|
||||||
<span class="metric-value blue">@summary.ThisMonthInquiries</span>
|
|
||||||
<span class="metric-icon">💬</span>
|
|
||||||
</div>
|
|
||||||
<span class="metric-hint">월간 상담 유입</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
|
@if (upcomingFilings.Count > 0)
|
||||||
<div class="metric-card-inner">
|
{
|
||||||
<span class="metric-label">신규 문의</span>
|
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
||||||
<div class="metric-value-row">
|
<div class="admin-section-header">
|
||||||
<span class="metric-value amber">@summary.NewInquiries</span>
|
<div>
|
||||||
<span class="metric-icon">⚠️</span>
|
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
|
||||||
|
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)</MudText>
|
||||||
</div>
|
</div>
|
||||||
<span class="metric-hint">처리 대기</span>
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
|
|
||||||
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
|
|
||||||
<div class="metric-card-inner">
|
|
||||||
<span class="metric-label">전체 포스트</span>
|
|
||||||
<div class="metric-value-row">
|
|
||||||
<span class="metric-value slate">@summary.TotalPosts</span>
|
|
||||||
<span class="metric-icon">📄</span>
|
|
||||||
</div>
|
|
||||||
<span class="metric-hint">콘텐츠 자산</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
|
|
||||||
<div class="metric-card-inner">
|
|
||||||
<span class="metric-label">발행된 포스트</span>
|
|
||||||
<div class="metric-value-row">
|
|
||||||
<span class="metric-value green">@summary.PublishedPosts</span>
|
|
||||||
<span class="metric-icon">🌐</span>
|
|
||||||
</div>
|
|
||||||
<span class="metric-hint">검색 노출 대상</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (upcomingFilings.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="admin-surface mt-4">이번 달 마감 임박 신고가 없습니다.</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="admin-surface mt-4">
|
|
||||||
<div class="admin-section-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="admin-section-title">이번 달 마감 임박 신고</h3>
|
|
||||||
<p class="muted">30일 이내 신고 예정 건</p>
|
|
||||||
</div>
|
|
||||||
<a class="site-button secondary" href="/taxbaik/admin/tax-filings">전체 일정 보기</a>
|
|
||||||
</div>
|
|
||||||
<div class="admin-table-wrap">
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>고객</th>
|
<th>고객</th>
|
||||||
@@ -103,19 +53,24 @@ else
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var f in upcomingFilings)
|
@foreach (var f in upcomingFilings)
|
||||||
{
|
{
|
||||||
var dday = (f.DueDate.Date - DateTime.Today).Days;
|
var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(f.DueDate));
|
||||||
|
var effectiveDueDate = BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(f.DueDate));
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="@($"/taxbaik/admin/clients/{f.ClientId}")">@f.ClientName</a></td>
|
<td>
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
|
||||||
|
@f.ClientName
|
||||||
|
</MudLink>
|
||||||
|
</td>
|
||||||
<td>@f.FilingType</td>
|
<td>@f.FilingType</td>
|
||||||
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
|
<td>@effectiveDueDate.ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (dday < 0)
|
@if (dday < 0)
|
||||||
{
|
{
|
||||||
<span class="status-pill dark">기한 초과 (@(-dday)일)</span>
|
<MudChip T="string" Size="Size.Small" Color="Color.Dark">기한 초과 (@(-dday)일)</MudChip>
|
||||||
}
|
}
|
||||||
else if (dday <= 7)
|
else if (dday <= 7)
|
||||||
{
|
{
|
||||||
<span class="status-pill danger">D-@dday</span>
|
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -125,59 +80,87 @@ else
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</MudSimpleTable>
|
||||||
</div>
|
</MudPaper>
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@if (summary is not null)
|
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
||||||
{
|
|
||||||
<div class="admin-surface mt-4">
|
|
||||||
<div class="admin-section-header">
|
<div class="admin-section-header">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="admin-section-title">최근 문의</h3>
|
<MudText Typo="Typo.h6">최근 문의</MudText>
|
||||||
<p class="muted">최근 유입된 상담 요청을 빠르게 확인합니다.</p>
|
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)</MudText>
|
||||||
</div>
|
</div>
|
||||||
<a class="site-button secondary" href="/taxbaik/admin/inquiries">문의 전체 보기</a>
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-table-wrap">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
<table class="admin-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>전화</th>
|
||||||
|
<th>분야</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>날짜</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var inquiry in summary.RecentInquiries)
|
||||||
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<td>
|
||||||
<th>전화</th>
|
<MudLink Href="@($"/taxbaik/admin/inquiries?id={inquiry.Id}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
|
||||||
<th>분야</th>
|
@inquiry.Name
|
||||||
<th>상태</th>
|
</MudLink>
|
||||||
<th>날짜</th>
|
</td>
|
||||||
|
<td>@inquiry.Phone</td>
|
||||||
|
<td>@inquiry.ServiceType</td>
|
||||||
|
<td>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
|
||||||
|
@GetStatusLabel(inquiry.Status)
|
||||||
|
</MudChip>
|
||||||
|
</td>
|
||||||
|
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
</tbody>
|
||||||
@foreach (var inquiry in summary.RecentInquiries)
|
</MudSimpleTable>
|
||||||
{
|
</MudPaper>
|
||||||
<tr>
|
</AdminDataPanel>
|
||||||
<td><a href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">@inquiry.Name</a></td>
|
|
||||||
<td>@inquiry.Phone</td>
|
|
||||||
<td>@inquiry.ServiceType</td>
|
|
||||||
<td><span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span></td>
|
|
||||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private AdminDashboardSummary? summary;
|
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
||||||
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
||||||
|
private string? errorMessage;
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private RenderFragment DashboardSkeleton => builder =>
|
||||||
{
|
{
|
||||||
if (firstRender && AuthStateTask != null)
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 6);
|
||||||
|
builder.AddAttribute(2, "Columns", 4);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
|
private void GoInquiries()
|
||||||
|
{
|
||||||
|
Nav.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoNewInquiries()
|
||||||
|
{
|
||||||
|
Nav.NavigateTo("/taxbaik/admin/inquiries?status=new");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoBlog()
|
||||||
|
{
|
||||||
|
Nav.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (AuthStateTask != null)
|
||||||
{
|
{
|
||||||
var authState = await AuthStateTask;
|
var authState = await AuthStateTask;
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
@@ -186,27 +169,33 @@ else
|
|||||||
{
|
{
|
||||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||||
|
|
||||||
await Task.WhenAll(summaryTask, filingsTask);
|
await Task.WhenAll(summaryTask, filingsTask);
|
||||||
summary = await summaryTask;
|
summary = await summaryTask;
|
||||||
upcomingFilings = (await filingsTask).ToList();
|
upcomingFilings = (await filingsTask).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||||
}
|
}
|
||||||
StateHasChanged();
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||||
private static string GetStatusClass(string status) => status switch
|
|
||||||
|
private static Color StatusColor(string status) => status switch
|
||||||
{
|
{
|
||||||
"new" => "warning",
|
"new" => Color.Warning,
|
||||||
"consulting" => "info",
|
"consulting" => Color.Info,
|
||||||
"contracted" => "success",
|
"contracted" => Color.Success,
|
||||||
"rejected" => "danger",
|
"rejected" => Color.Error,
|
||||||
"closed" => "dark",
|
"closed" => Color.Dark,
|
||||||
_ => "default"
|
_ => Color.Default
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,52 +5,83 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IFaqBrowserClient FaqClient
|
@inject IFaqBrowserClient FaqClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
|
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">홈페이지</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
|
||||||
<h1 class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>목록으로</button>
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface" style="max-width:720px;">
|
<AdminEditorPanel Loading="@isLoading" SkeletonContent="@FaqSkeleton">
|
||||||
@if (isLoading)
|
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
||||||
{
|
<MudForm @ref="form" @bind-IsValid="isValid">
|
||||||
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
<MudGrid Spacing="3">
|
||||||
}
|
<MudItem xs="12">
|
||||||
else
|
<MudTextField @bind-Value="faq.Question"
|
||||||
{
|
Label="질문 *" Required="true"
|
||||||
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
|
RequiredError="질문을 입력하세요."
|
||||||
<label>질문 * <textarea class="admin-input" rows="3" @bind="faq.Question"></textarea></label>
|
Counter="300" MaxLength="300"
|
||||||
<label>답변 * <textarea class="admin-input" rows="6" @bind="faq.Answer"></textarea></label>
|
Lines="2" AutoGrow="true"
|
||||||
<label>카테고리
|
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
|
||||||
<select class="admin-input" @bind="faq.Category">
|
</MudItem>
|
||||||
<option value="">선택하세요</option>
|
<MudItem xs="12">
|
||||||
@foreach (var cat in FaqService.Categories)
|
<MudTextField @bind-Value="faq.Answer"
|
||||||
{
|
Label="답변 *" Required="true"
|
||||||
<option value="@cat">@cat</option>
|
RequiredError="답변을 입력하세요."
|
||||||
}
|
Lines="5" AutoGrow="true"
|
||||||
</select>
|
Placeholder="방문자에게 보여질 답변을 입력하세요." />
|
||||||
</label>
|
</MudItem>
|
||||||
<label>정렬 순서 <input class="admin-input" type="number" min="0" max="9999" @bind="SortOrderText" /></label>
|
<MudItem xs="12" md="6">
|
||||||
<label><input type="checkbox" @bind="faq.IsActive" /> 노출 중</label>
|
<CommonCodeSelect @bind-Value="faq.Category" Group="FAQ_CATEGORY" Label="카테고리" Clearable="true" Placeholder="전체" />
|
||||||
<div class="admin-dialog-actions">
|
</MudItem>
|
||||||
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
|
<MudItem xs="12" md="3">
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>취소</button>
|
<MudNumericField @bind-Value="faq.SortOrder"
|
||||||
</div>
|
Label="정렬 순서"
|
||||||
</form>
|
HelperText="작을수록 위에 노출"
|
||||||
}
|
Min="0" Max="9999" />
|
||||||
</div>
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3" Class="d-flex align-center">
|
||||||
|
<MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
|
||||||
|
Label="@(faq.IsActive ? "노출 중" : "비활성")" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" Class="d-flex gap-2 mt-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="@SaveAsync" Disabled="@isSaving">
|
||||||
|
@(isSaving ? "저장 중..." : "저장")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs">
|
||||||
|
취소
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudForm>
|
||||||
|
</MudPaper>
|
||||||
|
</AdminEditorPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int? Id { get; set; }
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm form = null!;
|
||||||
private Faq faq = new() { SortOrder = 10, IsActive = true };
|
private Faq faq = new() { SortOrder = 10, IsActive = true };
|
||||||
|
private bool isValid;
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private bool isSaving;
|
private bool isSaving;
|
||||||
private string SortOrderText { get => faq.SortOrder.ToString(); set => faq.SortOrder = int.TryParse(value, out var n) ? n : 0; }
|
|
||||||
|
private RenderFragment FaqSkeleton => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 5);
|
||||||
|
builder.AddAttribute(2, "Columns", 3);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -61,7 +92,7 @@
|
|||||||
var existing = await FaqClient.GetByIdAsync(Id.Value);
|
var existing = await FaqClient.GetByIdAsync(Id.Value);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "FAQ를 찾을 수 없습니다.");
|
Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,30 +110,33 @@
|
|||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
|
await form.Validate();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(faq.Question) || string.IsNullOrWhiteSpace(faq.Answer))
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("alert", "질문과 답변을 입력하세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Id.HasValue)
|
if (Id.HasValue)
|
||||||
{
|
{
|
||||||
var result = await FaqClient.UpdateAsync(Id.Value, faq);
|
var result = await FaqClient.UpdateAsync(Id.Value, faq);
|
||||||
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 수정되었습니다." : "수정 실패");
|
if (result != null)
|
||||||
|
Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("수정 실패", Severity.Error);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var result = await FaqClient.CreateAsync(faq);
|
var result = await FaqClient.CreateAsync(faq);
|
||||||
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 등록되었습니다." : "등록 실패");
|
if (result != null)
|
||||||
|
Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("등록 실패", Severity.Error);
|
||||||
}
|
}
|
||||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,79 +4,133 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IFaqBrowserClient FaqClient
|
@inject IFaqBrowserClient FaqClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>FAQ 관리</PageTitle>
|
<PageTitle>FAQ 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">홈페이지</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
|
||||||
<h1 class="admin-page-title">FAQ 관리</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">FAQ 관리</MudText>
|
||||||
<p class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
<a class="site-button primary" href="/taxbaik/admin/faqs/create">FAQ 등록</a>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/faqs/create">
|
||||||
|
FAQ 등록
|
||||||
|
</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="admin-surface">
|
<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>
|
||||||
|
|
||||||
|
<AdminDataPanel Loading="@(faqs is null)" SkeletonContent="@FaqListSkeleton">
|
||||||
@if (faqs is null)
|
@if (faqs is null)
|
||||||
{
|
{
|
||||||
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
|
||||||
}
|
}
|
||||||
else if (!faqs.Any())
|
else if (!FilteredFaqs.Any())
|
||||||
{
|
{
|
||||||
<div class="muted">등록된 FAQ가 없습니다.</div>
|
<div class="pa-6 text-center">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
|
||||||
|
<MudText Class="mt-2 text-muted">검색 조건에 맞는 FAQ가 없습니다.</MudText>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-table-wrap">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
<table class="admin-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
|
<th style="width:110px;">순서</th>
|
||||||
|
<th>질문</th>
|
||||||
|
<th style="width:130px;">카테고리</th>
|
||||||
|
<th style="width:90px;">상태</th>
|
||||||
|
<th style="width:160px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in FilteredFaqs)
|
||||||
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<th>순서</th>
|
<td>
|
||||||
<th>질문</th>
|
<div class="d-flex align-center justify-start gap-1">
|
||||||
<th>카테고리</th>
|
<MudText Typo="Typo.body2" Class="mr-2">@item.SortOrder</MudText>
|
||||||
<th>상태</th>
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropUp" Size="Size.Small" OnClick="@(() => MoveUpAsync(item))" Style="padding:2px;" Dense="true" />
|
||||||
<th></th>
|
<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;">
|
||||||
|
@item.Question
|
||||||
|
</MudText>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(item.Category))
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">@item.Category</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (item.IsActive)
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
||||||
|
삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudButtonGroup>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
</tbody>
|
||||||
@foreach (var item in faqs)
|
</MudSimpleTable>
|
||||||
{
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||||
<tr>
|
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||||
<td>@item.SortOrder</td>
|
</MudText>
|
||||||
<td>@item.Question</td>
|
|
||||||
<td>@(string.IsNullOrEmpty(item.Category) ? "" : item.Category)</td>
|
|
||||||
<td><span class="status-pill @(item.IsActive ? "success" : "default")">@(item.IsActive ? "노출 중" : "비활성")</span></td>
|
|
||||||
<td>
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">✎</button>
|
|
||||||
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="muted mt-2">총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</AdminDataPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Faq>? faqs;
|
private List<Faq>? faqs;
|
||||||
|
private string searchQuery = "";
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private RenderFragment FaqListSkeleton => builder =>
|
||||||
{
|
{
|
||||||
if (firstRender && AuthStateTask != null)
|
builder.OpenComponent<AdminSkeletonRows>(0);
|
||||||
|
builder.AddAttribute(1, "Rows", 5);
|
||||||
|
builder.AddAttribute(2, "Columns", 4);
|
||||||
|
builder.CloseComponent();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (AuthStateTask != null)
|
||||||
{
|
{
|
||||||
var authState = await AuthStateTask;
|
var authState = await AuthStateTask;
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,36 +139,100 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
faqs = (await FaqClient.GetAllAsync()).ToList();
|
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
faqs = [];
|
faqs = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private async Task DeleteAsync(Faq item)
|
||||||
{
|
{
|
||||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Question}' 항목을 삭제하시겠습니까?");
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
if (!confirmed) return;
|
"FAQ 삭제",
|
||||||
|
$"'{item.Question}' 항목을 삭제하시겠습니까?",
|
||||||
|
yesText: "삭제", cancelText: "취소");
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await FaqClient.DeleteAsync(item.Id);
|
var success = await FaqClient.DeleteAsync(item.Id);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "FAQ가 삭제되었습니다.");
|
Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
Snackbar.Add("삭제 실패", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,59 @@
|
|||||||
@page "/admin/inquiries/create"
|
@page "/admin/inquiries/create"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Application.DTOs
|
@using TaxBaik.Application.DTOs
|
||||||
@using TaxBaik.Application.Services
|
|
||||||
@using TaxBaik.Web.Components.Admin.Forms
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
@inject InquiryService InquiryService
|
@inject IInquiryBrowserClient InquiryClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>문의 등록</PageTitle>
|
<PageTitle>문의 등록</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<AdminCrudPageShell Title="새 문의 등록"
|
||||||
<div>
|
Eyebrow="Customer Relations"
|
||||||
<div class="admin-eyebrow">Customer Relations</div>
|
Subtitle="고객 문의를 등록합니다. (전화, 오프라인 등)"
|
||||||
<h1 class="admin-page-title">새 문의 등록</h1>
|
Loading="@false"
|
||||||
<p class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</p>
|
OnCancel="@GoBack">
|
||||||
</div>
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||||
</section>
|
</MudPaper>
|
||||||
|
</AdminCrudPageShell>
|
||||||
<div class="admin-surface mt-4">
|
|
||||||
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private void GoBack() => Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleCreate(InquiryForm.InquiryFormModel model)
|
private async Task HandleCreate(InquiryForm.InquiryFormModel model)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await InquiryService.SubmitAsync(model.Name, model.Phone, model.ServiceType, model.Message, model.Email, ipAddress: "admin-registered");
|
var result = await InquiryClient.CreateAsync(new SubmitInquiryDto
|
||||||
await JS.InvokeVoidAsync("alert", "문의가 등록되었습니다.");
|
{
|
||||||
|
Name = model.Name,
|
||||||
|
Phone = model.Phone,
|
||||||
|
Email = model.Email,
|
||||||
|
ServiceType = model.ServiceType,
|
||||||
|
Message = model.Message,
|
||||||
|
SuppressNotification = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("문의가 등록되지 않았습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
|
||||||
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", ex.Message);
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
|
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,75 +3,108 @@
|
|||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
@inject IInquiryBrowserClient InquiryClient
|
@inject IInquiryBrowserClient InquiryClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JS
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>문의 상세</PageTitle>
|
<PageTitle>문의 상세</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="admin-eyebrow">Inquiry Details</div>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Inquiry Details</MudText>
|
||||||
<h1 class="admin-page-title">문의 상세</h1>
|
<MudText Typo="Typo.h4" Class="admin-page-title">문의 상세</MudText>
|
||||||
<p class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</p>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (inquiry != null)
|
@if (inquiry != null)
|
||||||
{
|
{
|
||||||
<div class="admin-page-actions">
|
<MudButton Variant="Variant.Outlined"
|
||||||
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries")'>문의 목록으로</button>
|
Color="Color.Primary"
|
||||||
</div>
|
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
|
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
|
||||||
|
문의 목록으로
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
<div class="admin-detail-grid">
|
<MudGrid Class="mt-4">
|
||||||
<section class="admin-surface">
|
<MudItem xs="12" md="8">
|
||||||
<h3 class="admin-section-title">문의 정보</h3>
|
<AdminDetailSection Title="문의 정보">
|
||||||
<div class="admin-kv-grid">
|
<MudGrid>
|
||||||
<div><span>이름</span><strong>@inquiry.Name</strong></div>
|
<MudItem xs="12" sm="6">
|
||||||
<div><span>연락처</span><strong>@inquiry.Phone</strong></div>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
|
||||||
<div><span>이메일</span><strong>@(inquiry.Email ?? "-")</strong></div>
|
<MudText>@inquiry.Name</MudText>
|
||||||
<div><span>분야</span><strong>@inquiry.ServiceType</strong></div>
|
</MudItem>
|
||||||
<div class="span-2"><span>문의 내용</span><strong style="white-space: pre-wrap;">@inquiry.Message</strong></div>
|
<MudItem xs="12" sm="6">
|
||||||
<div><span>접수일시</span><strong>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</strong></div>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
|
||||||
</div>
|
<MudText>@inquiry.Phone</MudText>
|
||||||
</section>
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
|
||||||
|
<MudText>@(inquiry.Email ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">분야</MudText>
|
||||||
|
<MudText>@inquiry.ServiceType</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">문의 내용</MudText>
|
||||||
|
<MudPaper Class="pa-3 mt-1" Outlined="true">
|
||||||
|
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">접수일시</MudText>
|
||||||
|
<MudText>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</AdminDetailSection>
|
||||||
|
|
||||||
<section class="admin-surface">
|
<AdminDetailSection Title="담당자 메모" CssClass="pa-4 mt-4">
|
||||||
<h3 class="admin-section-title">담당자 메모</h3>
|
<MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)"
|
||||||
<textarea class="admin-input" rows="6" @bind="adminMemo"></textarea>
|
Lines="4" Variant="Variant.Outlined" />
|
||||||
<div class="admin-dialog-actions mt-3">
|
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
|
||||||
<button type="button" class="site-button primary" @onclick="SaveMemo">메모 저장</button>
|
OnClick="SaveMemo">메모 저장</MudButton>
|
||||||
</div>
|
</AdminDetailSection>
|
||||||
</section>
|
</MudItem>
|
||||||
|
|
||||||
<section class="admin-surface">
|
<MudItem xs="12" md="4">
|
||||||
<h3 class="admin-section-title">처리 상태</h3>
|
<AdminDetailSection Title="처리 상태">
|
||||||
<div class="admin-stack">
|
<MudStack Spacing="2">
|
||||||
@foreach (var (key, label) in InquiryStatusMapper.Labels)
|
@foreach (var (key, label) in InquiryStatusMapper.Labels)
|
||||||
{
|
{
|
||||||
<button type="button" class="@GetStatusButtonClass(key)" @onclick="@(() => OnStatusChanged(key))">@label</button>
|
<MudButton Variant="@(inquiry.Status == key ? Variant.Filled : Variant.Outlined)"
|
||||||
}
|
Color="@StatusColor(key)"
|
||||||
</div>
|
FullWidth="true"
|
||||||
</section>
|
OnClick="@(() => OnStatusChanged(key))">
|
||||||
|
@label
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</AdminDetailSection>
|
||||||
|
|
||||||
@if (inquiry.ClientId == null)
|
@if (inquiry.ClientId == null)
|
||||||
{
|
{
|
||||||
<section class="admin-surface">
|
<AdminDetailSection Title="고객 카드 생성" CssClass="pa-4 mt-4">
|
||||||
<h3 class="admin-section-title">고객 카드 생성</h3>
|
<MudText Typo="Typo.body2" Class="mb-3">이 문의를 고객 카드로 등록합니다.</MudText>
|
||||||
<p class="muted">이 문의를 고객 카드로 등록합니다.</p>
|
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
|
||||||
<button type="button" class="site-button primary" @onclick="ConvertToClient">고객으로 등록</button>
|
OnClick="ConvertToClient">
|
||||||
</section>
|
고객으로 등록
|
||||||
}
|
</MudButton>
|
||||||
else
|
</AdminDetailSection>
|
||||||
{
|
}
|
||||||
<section class="admin-surface">
|
else
|
||||||
<h3 class="admin-section-title">연결된 고객</h3>
|
{
|
||||||
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">고객 카드 보기</a>
|
<AdminDetailSection Title="연결된 고객" CssClass="pa-4 mt-4">
|
||||||
</section>
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
|
||||||
}
|
Href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">
|
||||||
</div>
|
고객 카드 보기
|
||||||
|
</MudButton>
|
||||||
|
</AdminDetailSection>
|
||||||
|
}
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-surface">문의를 찾을 수 없습니다.</div>
|
<MudText>문의를 찾을 수 없습니다.</MudText>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -96,16 +129,16 @@ else
|
|||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
inquiry.Status = status;
|
inquiry.Status = status;
|
||||||
await JS.InvokeVoidAsync("alert", "상태가 변경되었습니다.");
|
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "상태 변경에 실패했습니다.");
|
Snackbar.Add("상태 변경에 실패했습니다.", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,16 +151,16 @@ else
|
|||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
inquiry.AdminMemo = adminMemo;
|
inquiry.AdminMemo = adminMemo;
|
||||||
await JS.InvokeVoidAsync("alert", "메모가 저장되었습니다.");
|
Snackbar.Add("메모가 저장되었습니다.", Severity.Success);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "메모 저장에 실패했습니다.");
|
Snackbar.Add("메모 저장에 실패했습니다.", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,19 +179,26 @@ else
|
|||||||
{
|
{
|
||||||
inquiry.ClientId = clientId;
|
inquiry.ClientId = clientId;
|
||||||
inquiry.Status = "consulting";
|
inquiry.Status = "consulting";
|
||||||
await JS.InvokeVoidAsync("alert", "고객 카드가 생성되었습니다.");
|
Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", "고객 카드 생성에 실패했습니다.");
|
Snackbar.Add("고객 카드 생성에 실패했습니다.", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetStatusButtonClass(string status)
|
private Color StatusColor(string status) => status switch
|
||||||
=> inquiry?.Status == status ? "site-button primary" : "site-button secondary";
|
{
|
||||||
|
"new" => Color.Default,
|
||||||
|
"consulting" => Color.Info,
|
||||||
|
"contracted" => Color.Success,
|
||||||
|
"rejected" => Color.Error,
|
||||||
|
"closed" => Color.Dark,
|
||||||
|
_ => Color.Default
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user