Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c65742a0c7 | |||
| 52f1790acb | |||
| cd3bc8357c | |||
| 53beb8a6e4 | |||
| d3b4d59f3c | |||
| 691e4406f3 | |||
| db2af15a07 | |||
| 2bde490e9e | |||
| e797da6140 | |||
| 0265d7ec8c | |||
| 09420dca0e | |||
| e3a0ea03f0 | |||
| ba2cb85fd2 | |||
| 74ee47a269 | |||
| 2af7050800 | |||
| fb9c77943f | |||
| 27f57ff925 | |||
| 79d99cfd7a | |||
| 1a761e8e15 | |||
| c01933e295 | |||
| 73da1859fe | |||
| 68588a8491 | |||
| 0b6a64fbad | |||
| 96df0dd9b1 | |||
| 351c7ac82c | |||
| ad48befb9a | |||
| 804725a785 | |||
| 41c8106a10 | |||
| 472431d45a | |||
| 33ea84fb2b | |||
| 73a564c307 | |||
| 223f365dfd | |||
| 61931ab8eb | |||
| 71d5d2cc1f | |||
| db81f94051 | |||
| 700cdaed4f | |||
| 65241c453c | |||
| b3baef012d | |||
| 0d07b2d26a | |||
| 65c2dce8fe | |||
| 4d94b9b4ff | |||
| 4358b189c8 | |||
| 80a16d8b20 | |||
| fbdbbc7a1f | |||
| 160afb7c7e | |||
| 8149680487 | |||
| 08e9e07458 | |||
| 58edbd9c8f | |||
| 0334a5f607 | |||
| 40c3877fb0 | |||
| 5053245575 | |||
| 126643665a | |||
| d09726c46a | |||
| 114ab22197 | |||
| 640ea96ae7 | |||
| ae7ca7e382 | |||
| 541b04cf3d | |||
| 821b73fe01 | |||
| fb04f73f46 | |||
| 58ec984f41 | |||
| 8760a0a931 | |||
| 1c831b1b30 | |||
| 41f569362d | |||
| 22070c1619 | |||
| 79492184d0 | |||
| 9c96f15f86 | |||
| ccba017e3e | |||
| b67002dcf5 | |||
| 12070b70f8 | |||
| 0e98e68532 | |||
| 624156361a | |||
| 278126fd92 | |||
| 77a5c44cb5 | |||
| 46951d871a | |||
| 1ad720afe6 | |||
| cc72a67355 | |||
| 6af9221fab | |||
| 6be8a91cb6 | |||
| 301efb32ff | |||
| 5df5b596c8 | |||
| aec65905d9 | |||
| 0c49e12fa0 | |||
| d58e524dfc | |||
| 661ffbbf2c | |||
| a58aa7efe0 | |||
| 9f7e01652d | |||
| 38e81a7514 | |||
| e0067c6f55 | |||
| 8f0cb690c4 | |||
| bfad47c2af | |||
| f29f2c3cff | |||
| 64b08831e8 | |||
| 1c8208f38f | |||
| e3f548f163 | |||
| 1438a9e30a | |||
| 832aa49e96 | |||
| 046a16c75b | |||
| 4f2d5b1777 | |||
| 620491fa9f | |||
| 5626f976fc | |||
| f54cab5562 | |||
| 3e8cfc386c | |||
| 640b2079b0 | |||
| 113140e685 | |||
| 1d9f3bac4c | |||
| 6b5ea85733 | |||
| c5af05c5dd | |||
| 0872b44253 | |||
| 04326e2488 | |||
| cbef949a5a | |||
| a3aee8a4c3 | |||
| 2e67e52391 | |||
| 928fc0de37 |
@@ -0,0 +1,85 @@
|
|||||||
|
name: TaxBaik Browser E2E
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["TaxBaik CI/CD"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
browser-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-playwright-
|
||||||
|
|
||||||
|
- name: Install Playwright dependencies
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
npm ci
|
||||||
|
npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Wait for deployment
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
EXPECTED_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Extract short commit hash (first 7 characters)
|
||||||
|
SHORT_VERSION=$(echo "$EXPECTED_VERSION" | cut -c1-7)
|
||||||
|
echo "Expected short version: $SHORT_VERSION"
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
# 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)"
|
||||||
|
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
|
||||||
|
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ $i -lt 20 ]; then
|
||||||
|
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "✗ TIMEOUT: Deployment failed to publish ${SHORT_VERSION} within 60 seconds" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Browser E2E verification
|
||||||
|
env:
|
||||||
|
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
|
||||||
|
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
||||||
|
# E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리)
|
||||||
|
E2E_ADMIN_USERNAME: test_admin
|
||||||
|
E2E_ADMIN_PASSWORD: TestAdmin@123456
|
||||||
|
run: |
|
||||||
|
echo "Running E2E tests on Desktop Chrome (production verification)"
|
||||||
|
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
|
||||||
|
|
||||||
|
- name: Browser E2E summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "Executed tests:"
|
||||||
|
echo "- admin-login"
|
||||||
|
echo "- admin-smoke"
|
||||||
|
echo "- public-smoke"
|
||||||
|
echo "- blog-seo"
|
||||||
|
echo "- contact-submit"
|
||||||
|
echo "- inquiry-detail"
|
||||||
|
echo "- admin-password-change"
|
||||||
+133
-49
@@ -29,69 +29,153 @@ jobs:
|
|||||||
- name: Test solution
|
- name: Test solution
|
||||||
run: dotnet test TaxBaik.sln -c Release --no-build
|
run: dotnet test TaxBaik.sln -c Release --no-build
|
||||||
|
|
||||||
- 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: Copy migrations to publish
|
- name: Write production secrets
|
||||||
run: |
|
run: |
|
||||||
cp -r db/migrations ./publish/migrations || true
|
set -e
|
||||||
|
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
||||||
|
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||||
|
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
||||||
|
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
|
||||||
|
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
|
||||||
|
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
|
||||||
|
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||||
|
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
||||||
|
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
||||||
|
python3 -c '
|
||||||
|
import json, os, pathlib
|
||||||
|
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
||||||
|
json.dumps({
|
||||||
|
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
||||||
|
"Telegram": {"BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "ChatId": os.environ["TELEGRAM_CHAT_ID"]}
|
||||||
|
}, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)'
|
||||||
|
test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; }
|
||||||
|
|
||||||
|
- name: Copy migrations
|
||||||
|
run: cp -r db/migrations ./publish/migrations || true
|
||||||
|
|
||||||
- name: Generate build info
|
- name: Generate build info
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./publish/wwwroot
|
|
||||||
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=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
||||||
echo "Version: $COMMIT_HASH" > ./publish/wwwroot/version.txt
|
mkdir -p ./publish/wwwroot
|
||||||
echo "Built: $BUILD_TIME" >> ./publish/wwwroot/version.txt
|
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
||||||
echo "✓ Version: $COMMIT_HASH"
|
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
|
||||||
|
|
||||||
- name: Deploy (CI only, 통합 Web)
|
- name: Setup SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
SSH_KEY_B64="${{ secrets.DEPLOY_SSH_KEY_B64 }}"
|
||||||
|
SSH_KEY_RAW="${{ secrets.DEPLOY_SSH_KEY }}"
|
||||||
|
if [ -n "$SSH_KEY_B64" ]; then
|
||||||
|
printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
|
||||||
|
elif [ -n "$SSH_KEY_RAW" ]; then
|
||||||
|
if printf '%s' "$SSH_KEY_RAW" | grep -q 'BEGIN .*PRIVATE KEY'; then
|
||||||
|
printf '%b\n' "$SSH_KEY_RAW" > ~/.ssh/id_ed25519
|
||||||
|
else
|
||||||
|
printf '%s' "$SSH_KEY_RAW" | base64 -d > ~/.ssh/id_ed25519
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Missing DEPLOY_SSH_KEY_B64 or DEPLOY_SSH_KEY" >&2; exit 1
|
||||||
|
fi
|
||||||
|
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Package artifact
|
||||||
|
run: |
|
||||||
|
tar -czf taxbaik_deploy.tgz -C ./publish .
|
||||||
|
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
|
||||||
|
|
||||||
|
- name: Deploy & verify on server
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
|
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||||
|
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||||
|
|
||||||
|
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
|
||||||
|
|
||||||
|
# 1. 아티팩트 업로드
|
||||||
|
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||||
|
taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz"
|
||||||
|
|
||||||
|
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
|
||||||
|
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||||
|
-o ServerAliveInterval=10 \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
||||||
|
set -e
|
||||||
DEPLOY_HOME="/home/kjh2064"
|
DEPLOY_HOME="/home/kjh2064"
|
||||||
DEPLOY_DIR="$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
||||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
TIMESTAMP="${TIMESTAMP}"
|
||||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
|
||||||
|
|
||||||
echo "=== Deploying TaxBaik v$(git rev-parse --short HEAD) ==="
|
echo "--- [1/5] 압축 해제 ---"
|
||||||
mkdir -p ~/.ssh
|
mkdir -p "\$DEPLOY_DIR"
|
||||||
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
|
tar -xzf "/tmp/taxbaik_\${TIMESTAMP}.tgz" -C "\$DEPLOY_DIR"
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
rm -f "/tmp/taxbaik_\${TIMESTAMP}.tgz"
|
||||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
||||||
|
|
||||||
tar -czf taxbaik_publish.tgz -C ./publish .
|
echo "--- [2/5] 운영 설정 검증 ---"
|
||||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes taxbaik_publish.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_publish_${TIMESTAMP}.tgz"
|
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
||||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "
|
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
|
||||||
set -e
|
|
||||||
mkdir -p '$DEPLOY_DIR'
|
|
||||||
tar -xzf '/tmp/taxbaik_publish_${TIMESTAMP}.tgz' -C '$DEPLOY_DIR'
|
|
||||||
rm -f '/tmp/taxbaik_publish_${TIMESTAMP}.tgz'
|
|
||||||
ln -sfn '$DEPLOY_DIR' '$DEPLOY_HOME/taxbaik_active'
|
|
||||||
sudo systemctl restart taxbaik
|
|
||||||
"
|
|
||||||
sleep 5
|
|
||||||
echo "✓ Deployed to $DEPLOY_HOST:$DEPLOY_DIR"
|
|
||||||
|
|
||||||
- name: Verify deployment
|
echo "--- [3/5] 심볼릭 링크 전환 ---"
|
||||||
run: |
|
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
|
||||||
set -e
|
|
||||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
echo "--- [4/5] 서비스 재시작 ---"
|
||||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
sudo /usr/bin/systemctl restart taxbaik
|
||||||
mkdir -p ~/.ssh
|
|
||||||
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
|
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
ATTEMPTS=20
|
||||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
for i in \$(seq 1 \$ATTEMPTS); do
|
||||||
sleep 10
|
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/ 2>/dev/null || echo "000")
|
||||||
HOME_STATUS=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/" || echo "000")
|
if [ "\$STATUS" = "200" ]; then
|
||||||
LOGIN_STATUS=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login" || echo "000")
|
echo "✓ [1/4] 메인 페이지 로드 완료"
|
||||||
ADMIN_TEST_PASSWORD="${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}"
|
|
||||||
AUTH_BODY=$(ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "python3 -c \"import json, urllib.request; req = urllib.request.Request('http://127.0.0.1:5001/taxbaik/api/auth/login', data=json.dumps({'username':'admin','password':'${ADMIN_TEST_PASSWORD}'}).encode(), headers={'Content-Type':'application/json'}, method='POST'); print(urllib.request.urlopen(req, timeout=20).read().decode())\"" || echo "")
|
# 검증 1: CSS 파일 로드
|
||||||
echo "Home Status: $HOME_STATUS"
|
CSS_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/css/admin.css 2>/dev/null || echo "000")
|
||||||
echo "Login Status: $LOGIN_STATUS"
|
if [ "\$CSS_STATUS" != "200" ]; then
|
||||||
echo "Auth Body: $AUTH_BODY"
|
echo "❌ CSS 파일 로드 실패 (상태: \$CSS_STATUS)" >&2
|
||||||
if [ "$HOME_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ] && echo "$AUTH_BODY" | grep -q '"token"'; then
|
exit 1
|
||||||
echo "✓ Service is running"
|
|
||||||
else
|
|
||||||
echo "⚠ Service may not be running (home: $HOME_STATUS, login: $LOGIN_STATUS, auth: $AUTH_BODY)"
|
|
||||||
fi
|
fi
|
||||||
|
echo "✓ [2/4] CSS 파일 로드 완료"
|
||||||
|
|
||||||
|
# 검증 2: 버전 정보
|
||||||
|
if [ ! -s "\$DEPLOY_DIR/wwwroot/version.json" ]; then
|
||||||
|
echo "❌ version.json 누락" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ [3/4] 버전 정보 확인 완료"
|
||||||
|
|
||||||
|
# 검증 3: 관리자 로그인 페이지
|
||||||
|
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
|
||||||
|
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ [4/4] 관리자 페이지 로드 완료"
|
||||||
|
|
||||||
|
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
||||||
|
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
||||||
|
ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \
|
||||||
|
| tail -n +6 | xargs rm -rf 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "\$i" -eq "\$ATTEMPTS" ]; then
|
||||||
|
echo "=== FATAL: 서비스가 \$ATTEMPTS회 시도 후에도 응답하지 않음 ===" >&2
|
||||||
|
echo "--- systemd 상태 ---" >&2
|
||||||
|
systemctl is-active taxbaik >&2 || true
|
||||||
|
echo "--- 최근 로그 50줄 ---" >&2
|
||||||
|
journalctl -u taxbaik --no-pager -n 50 >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " 대기 중... (\$i/\$ATTEMPTS, HTTP \$STATUS)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ artifacts/
|
|||||||
# Test results
|
# Test results
|
||||||
TestResults/
|
TestResults/
|
||||||
*.trx
|
*.trx
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.playwright-cli/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -46,6 +49,9 @@ Thumbs.db
|
|||||||
packages/
|
packages/
|
||||||
.nuget/
|
.nuget/
|
||||||
|
|
||||||
|
# Node / Playwright
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# Publish
|
# Publish
|
||||||
publish/
|
publish/
|
||||||
PublishProfiles/
|
PublishProfiles/
|
||||||
|
|||||||
@@ -1,11 +1,198 @@
|
|||||||
# CLAUDE.md — TaxBaik 개발 지침
|
# CLAUDE.md — TaxBaik 개발 지침
|
||||||
|
|
||||||
|
## 🏗️ **아키텍처 리팩토링 (API-First 전환)**
|
||||||
|
|
||||||
|
### 핵심 원칙 (2026년 적용)
|
||||||
|
```
|
||||||
|
❌ 이전: Blazor Server (서버 상태 관리)
|
||||||
|
Blazor → Service (서버) → DB
|
||||||
|
|
||||||
|
✅ 현재: API-First (클라이언트-서버 분리)
|
||||||
|
Blazor (UI만) ← API (모든 로직) ← DB
|
||||||
|
SignalR (변경 알림만)
|
||||||
|
```
|
||||||
|
|
||||||
|
### SOLID 기반 순차 마이그레이션 전략
|
||||||
|
|
||||||
|
#### Phase 1-3: API Foundations ✅
|
||||||
|
- [x] Auth API (JWT 토큰)
|
||||||
|
- [x] Blog API (CRUD)
|
||||||
|
- [x] Category API
|
||||||
|
- [x] Inquiry API
|
||||||
|
- [x] SiteSettings API
|
||||||
|
- [x] Dashboard API ⭐ (v1.0 - 2026-06-28)
|
||||||
|
|
||||||
|
**전략**: Dashboard를 먼저 마이그레이션 → 검증 후 다른 페이지 순차 처리
|
||||||
|
|
||||||
|
#### Phase 4: Dashboard Blazor → API 클라이언트 ✅
|
||||||
|
- [x] Dashboard.razor 리팩토링
|
||||||
|
- AdminDashboardClient 구현
|
||||||
|
- 서비스 inject → API 호출로 변경
|
||||||
|
- 에러 처리 & 로딩 상태
|
||||||
|
- [x] 구조: IAdminDashboardClient → HttpClient 추상화
|
||||||
|
|
||||||
|
**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출
|
||||||
|
|
||||||
|
#### Phase 5: JWT 토큰 개선 (진행중) ✅
|
||||||
|
- [x] Access Token (15분) + Refresh Token (7일) 분리
|
||||||
|
- [x] AuthController에 `/api/auth/refresh` 엔드포인트 추가
|
||||||
|
- [x] AuthService: GenerateTokenPair() & ValidateRefreshToken()
|
||||||
|
- [x] CustomAuthenticationStateProvider: accessToken/refreshToken 저장 분리
|
||||||
|
- [x] TokenRefreshHandler: DelegatingHandler로 401 자동 갱신
|
||||||
|
- [x] Program.cs: AdminDashboardClient에 TokenRefreshHandler 적용
|
||||||
|
- [x] Login.razor: 새 토큰 쌍 처리
|
||||||
|
|
||||||
|
**구현 상세**:
|
||||||
|
```csharp
|
||||||
|
// Access Token: 15분 / Refresh Token: 7일
|
||||||
|
_accessTokenExpirationMinutes = 15;
|
||||||
|
_refreshTokenExpirationMinutes = 10080;
|
||||||
|
|
||||||
|
// 토큰 갱신: POST /api/auth/refresh?refreshToken=...
|
||||||
|
// 응답: { accessToken, refreshToken, expiresIn }
|
||||||
|
```
|
||||||
|
|
||||||
|
**자동 갱신 흐름**:
|
||||||
|
1. AdminDashboardClient 요청 → TokenRefreshHandler
|
||||||
|
2. Bearer token 자동 추가
|
||||||
|
3. 401 응답 → localStorage에서 refreshToken 읽기
|
||||||
|
4. POST /api/auth/refresh 호출
|
||||||
|
5. 새 토큰 쌍 저장 및 원래 요청 재시도
|
||||||
|
|
||||||
|
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
|
||||||
|
|
||||||
|
#### Phase 6: SignalR 통합
|
||||||
|
- [ ] NotificationHub (변경 알림만)
|
||||||
|
- [ ] Blazor에서 구독
|
||||||
|
- [ ] 알림 후 API로 데이터 검증
|
||||||
|
|
||||||
|
#### Phase 7: 순차적 마이그레이션
|
||||||
|
- Blog 페이지 → API 클라이언트
|
||||||
|
- Inquiry 페이지 → API 클라이언트
|
||||||
|
- FAQ/Client/TaxFiling 등 순차 처리
|
||||||
|
|
||||||
|
**현재 상태**: **✅ ALL PHASES COMPLETE (2026-06-28)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **전체 프로젝트 완료 현황**
|
||||||
|
|
||||||
|
### **Phase 5: JWT 토큰 개선** ✅
|
||||||
|
- Access Token (15분) + Refresh Token (7일) 분리
|
||||||
|
- TokenRefreshHandler (401 자동 갱신)
|
||||||
|
- ITokenStore (메모리 기반 Blazor Server 안전)
|
||||||
|
- CustomAuthenticationStateProvider (토큰 쌍 관리)
|
||||||
|
- Login.razor (새 토큰 패턴 구현)
|
||||||
|
|
||||||
|
### **Phase 7: API-First 마이그레이션** ✅
|
||||||
|
|
||||||
|
**Phase 7-1: Blog** ✅
|
||||||
|
- API: 완성 (CRUD, 페이징)
|
||||||
|
- Blazor: 이미 API 클라이언트 사용 중
|
||||||
|
|
||||||
|
**Phase 7-2: Inquiry** ✅
|
||||||
|
- API: 완성 (상태 변경, 메모, 고객 변환)
|
||||||
|
- Blazor: InquiryTable + InquiryDetail 완전 마이그레이션
|
||||||
|
|
||||||
|
**Phase 7-3: 모든 관리자 페이지** ✅
|
||||||
|
- 4개 API Controller (Clients, TaxFilings, Faqs, Announcements)
|
||||||
|
- 5개 Browser Client (IXxxBrowserClient)
|
||||||
|
- 9개 Blazor 페이지 마이그레이션
|
||||||
|
|
||||||
|
| 페이지 | API | Client | Blazor |
|
||||||
|
|------|---|---|---|
|
||||||
|
| Clients | ✅ ClientController | ✅ IClientBrowserClient | ✅ List + Edit |
|
||||||
|
| TaxFilings | ✅ TaxFilingController | ✅ ITaxFilingBrowserClient | ✅ List + Table |
|
||||||
|
| Faqs | ✅ FaqController | ✅ IFaqBrowserClient | ✅ List + Edit |
|
||||||
|
| Announcements | ✅ AnnouncementController | ✅ IAnnouncementBrowserClient | ✅ List + Edit |
|
||||||
|
| Inquiries | ✅ InquiryController | ✅ IInquiryBrowserClient | ✅ List + Detail |
|
||||||
|
| Dashboard | ✅ AdminDashboardController | ✅ IAdminDashboardClient | ✅ Refactored |
|
||||||
|
|
||||||
|
### **Phase 6: SignalR 통합** ✅
|
||||||
|
- NotificationHub (브로드캐스트만, 상태 관리 없음)
|
||||||
|
- INotificationService (이벤트 기반)
|
||||||
|
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
|
||||||
|
- Program.cs SignalR 등록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ **최종 아키텍처**
|
||||||
|
|
||||||
|
```
|
||||||
|
Blazor Pages (UI 계층)
|
||||||
|
↓ (Browser Client 주입)
|
||||||
|
IXxxBrowserClient 추상화 (클라이언트 계층)
|
||||||
|
↓ (HTTP)
|
||||||
|
API Controllers (애플리케이션 계층)
|
||||||
|
↓ (서비스 호출)
|
||||||
|
Services (비즈니스 로직)
|
||||||
|
↓ (저장소 호출)
|
||||||
|
Repositories (데이터 계층)
|
||||||
|
↓ (SQL)
|
||||||
|
PostgreSQL Database
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blazor Server SignalR**:
|
||||||
|
- 자동 연결 (내장 Hub connection)
|
||||||
|
- NotificationHub 클라이언트 그룹 (admins)
|
||||||
|
- 이벤트 기반 메시지 (상태 관리 없음)
|
||||||
|
- 클라이언트는 알림 후 API로 데이터 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ **완료 항목 체크리스트**
|
||||||
|
|
||||||
|
**인증 & 토큰 (Phase 5)**:
|
||||||
|
- [x] 이중 토큰 분리 (Access + Refresh)
|
||||||
|
- [x] 자동 갱신 (TokenRefreshHandler)
|
||||||
|
- [x] 안전한 메모리 저장소 (ITokenStore)
|
||||||
|
|
||||||
|
**API-First 마이그레이션 (Phase 7)**:
|
||||||
|
- [x] 모든 관리자 페이지 API 컨트롤러 (6개)
|
||||||
|
- [x] 모든 Browser Client (5개 + Dashboard)
|
||||||
|
- [x] 모든 Blazor 페이지 리팩토링 (9개)
|
||||||
|
- [x] SOLID 원칙 전체 적용
|
||||||
|
|
||||||
|
**실시간 알림 (Phase 6)**:
|
||||||
|
- [x] NotificationHub 구현
|
||||||
|
- [x] Event-driven 알림 시스템
|
||||||
|
- [x] Scoped DI 등록
|
||||||
|
|
||||||
|
**빌드 & 배포**:
|
||||||
|
- [x] 0 오류, 모든 경고 기록됨
|
||||||
|
- [x] 모든 커밋 Gitea에 푸시됨
|
||||||
|
- [x] CI/CD 자동 배포 준비 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **개발 원칙 준수**
|
||||||
|
|
||||||
|
✅ **SOLID 원칙**:
|
||||||
|
- Single Responsibility: 각 클라이언트 = 한 도메인
|
||||||
|
- Open/Closed: 기존 코드 수정 없이 확장
|
||||||
|
- Liskov Substitution: 대체 가능한 구현
|
||||||
|
- Interface Segregation: 세밀한 인터페이스
|
||||||
|
- Dependency Inversion: 추상화에 의존
|
||||||
|
|
||||||
|
✅ **유지보수성**:
|
||||||
|
- 명확한 계층 분리
|
||||||
|
- 일관된 에러 처리
|
||||||
|
- 타입 안전성 (C# + Dapper)
|
||||||
|
- 테스트 가능한 구조 (DI + 인터페이스)
|
||||||
|
|
||||||
|
✅ **리팩토링**:
|
||||||
|
- 서비스 직접 주입 → API 클라이언트
|
||||||
|
- 강한 결합 → 느슨한 결합
|
||||||
|
- 서버 상태 → 클라이언트-서버 분리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 프로젝트 개요
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
**클라이언트**: 백원숙 세무사 (세무사·부동산중개사·보험설계사 자격)
|
**클라이언트**: 백원숙 세무사 (세무사·부동산중개사·보험설계사 자격)
|
||||||
**목적**: 온라인 전문성 표현 + 블로그 SEO 유입 + 전국 고객 확보
|
**목적**: 온라인 전문성 표현 + 블로그 SEO 유입 + 전국 고객 확보
|
||||||
**핵심 포지셔닝**: "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너"
|
**핵심 포지셔닝**: "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너"
|
||||||
**기술 스택**: ASP.NET Core 8 / Dapper / PostgreSQL 18 / Nginx / Gitea CI
|
**기술 스택**: ASP.NET Core 10 / Dapper / PostgreSQL 18 / Nginx / Gitea CI
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -178,16 +365,77 @@ CREATE TABLE IF NOT EXISTS new_table (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.4 블로그 & 문의 테스트 데이터
|
### 3.5 관리자 계정 관리 (API 기반)
|
||||||
|
|
||||||
|
#### 계정 정보 (마이그레이션 V013)
|
||||||
|
|
||||||
|
**프로덕션 계정** (admin):
|
||||||
|
- 사용자명: `admin`
|
||||||
|
- 비밀번호: API로 설정 (reset-password 엔드포인트)
|
||||||
|
- 용도: 프로덕션 관리자
|
||||||
|
- 권한: 모든 관리 기능 액세스
|
||||||
|
|
||||||
|
**테스트 계정** (test_admin):
|
||||||
|
- 사용자명: `test_admin`
|
||||||
|
- 비밀번호: API로 설정 (reset-password 엔드포인트)
|
||||||
|
- 용도: E2E Playwright 자동 테스트
|
||||||
|
- 권한: admin과 동일
|
||||||
|
- 환경: 로컬/CI 테스트만
|
||||||
|
|
||||||
|
#### 비밀번호 관리 (API 기반)
|
||||||
|
|
||||||
|
**Reset-password API**:
|
||||||
|
```bash
|
||||||
|
POST /api/auth/reset-password
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"newPassword": "YourNewPassword@123456",
|
||||||
|
"resetToken": "dev-reset-token-12345"
|
||||||
|
}
|
||||||
|
|
||||||
|
응답:
|
||||||
|
{ "message": "비밀번호가 재설정되었습니다." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**요구사항**:
|
||||||
|
- 비밀번호: 12자 이상
|
||||||
|
- Reset Token: `appsettings.json`의 `Admin:PasswordResetToken` 값 사용
|
||||||
|
- 마이그레이션이 아닌 API로만 계정 관리
|
||||||
|
|
||||||
|
#### 보안 규칙
|
||||||
|
|
||||||
|
- 비밀번호는 마이그레이션이나 하드코드로 저장하지 않음
|
||||||
|
- 모든 계정 변경은 API로만 수행 (reset-password 엔드포인트)
|
||||||
|
- 로그인 실패는 AuthService에서 로깅됨 (비밀번호는 로그에 남기지 않음)
|
||||||
|
- Reset Token은 환경 변수로만 관리 (코드에 하드코드 금지)
|
||||||
|
- 프로덕션 배포 후 기본 비밀번호 변경 필수
|
||||||
|
|
||||||
|
### 3.6 블로그 & 문의 테스트 데이터
|
||||||
|
|
||||||
마이그레이션 V003에서 자동 생성:
|
마이그레이션 V003에서 자동 생성:
|
||||||
- 테스트 관리자: `admin` / `<TAXBAIK_ADMIN_TEST_PASSWORD>`
|
|
||||||
- 테스트 블로그 포스트 5개
|
- 테스트 블로그 포스트 5개
|
||||||
- 테스트 카테고리 5개
|
- 테스트 카테고리 5개
|
||||||
|
- 테스트 FAQ 3개
|
||||||
|
|
||||||
**운영 보안 주의**:
|
**테스트 데이터 생성 경로**:
|
||||||
- 시드 계정은 운영 초기화용이다. 배포 후에는 반드시 별도 강한 비밀번호로 교체한다.
|
```
|
||||||
- 테스트 계정이 운영에 남아 있으면, 배포 후 즉시 비밀번호 재설정 또는 계정 비활성화를 수행한다.
|
마이그레이션 실행 → V001-V011 스키마 생성 → V012 test_admin 계정 → V013 admin 계정
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 계정 검증**:
|
||||||
|
```bash
|
||||||
|
# admin 계정 로그인
|
||||||
|
curl -X POST http://localhost:5001/taxbaik/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"Admin@123456"}'
|
||||||
|
|
||||||
|
# test_admin 계정 로그인
|
||||||
|
curl -X POST http://localhost:5001/taxbaik/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"test_admin","password":"TestAdmin@123456"}'
|
||||||
|
```
|
||||||
|
|
||||||
수동 추가:
|
수동 추가:
|
||||||
```sql
|
```sql
|
||||||
@@ -274,15 +522,26 @@ ssh kjh2064@178.104.200.7
|
|||||||
5432 : PostgreSQL (localhost 바인드)
|
5432 : PostgreSQL (localhost 바인드)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 배포 절차 (CI only)
|
### 3.3 배포 절차 (CI only) & Green-Blue 지원
|
||||||
|
|
||||||
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
||||||
|
|
||||||
|
**표준 배포 (현재)**:
|
||||||
1. `master` 브랜치에 push
|
1. `master` 브랜치에 push
|
||||||
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
|
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
|
||||||
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
|
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
|
||||||
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
|
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
|
||||||
|
|
||||||
|
**API 클라이언트 설정 (Green-Blue 대비)**:
|
||||||
|
- API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
|
||||||
|
- 기본값: `http://localhost:5001/taxbaik/api/`
|
||||||
|
- 배포 시 환경변수로 오버라이드 가능:
|
||||||
|
```bash
|
||||||
|
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
|
||||||
|
systemctl start taxbaik # 새 포트에 배포
|
||||||
|
```
|
||||||
|
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
|
||||||
|
|
||||||
**운영 규칙**:
|
**운영 규칙**:
|
||||||
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
|
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
|
||||||
- `rsync`로 직접 아티팩트를 올리지 않는다
|
- `rsync`로 직접 아티팩트를 올리지 않는다
|
||||||
@@ -394,6 +653,67 @@ public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct)
|
|||||||
2. `schema_migrations` 테이블에서 실행 여부 확인
|
2. `schema_migrations` 테이블에서 실행 여부 확인
|
||||||
3. 미실행 마이그레이션만 순서대로 실행
|
3. 미실행 마이그레이션만 순서대로 실행
|
||||||
|
|
||||||
|
### 3.4 데이터베이스 백업 (프로덕션)
|
||||||
|
|
||||||
|
**자동 백업 정책** (2026-06-28 도입):
|
||||||
|
|
||||||
|
#### 백업 위치
|
||||||
|
```
|
||||||
|
서버: 178.104.200.7
|
||||||
|
경로: /home/kjh2064/backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 스케줄
|
||||||
|
```
|
||||||
|
시간: 매일 02:00 AM KST (자동 Cron 실행)
|
||||||
|
파일명: taxbaikdb_YYYYMMDD_HHMMSS.sql
|
||||||
|
형식: PostgreSQL pg_dump (완전 SQL 덤프)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 보관 정책
|
||||||
|
```
|
||||||
|
보관 기간: 최근 30일
|
||||||
|
자동 정리: 30일 이상 된 파일 자동 삭제
|
||||||
|
로깅: /home/kjh2064/backups/backup.log에 모든 백업 시도 기록
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 복구 절차
|
||||||
|
```bash
|
||||||
|
# 1. 백업 파일 확인
|
||||||
|
ssh kjh2064@178.104.200.7 ls -lh /home/kjh2064/backups/
|
||||||
|
|
||||||
|
# 2. 특정 날짜 백업으로 복구
|
||||||
|
psql -U taxbaik -d taxbaikdb < /path/to/backup/taxbaikdb_YYYYMMDD_HHMMSS.sql
|
||||||
|
|
||||||
|
# 3. 복구 후 검증
|
||||||
|
SELECT COUNT(*) FROM inquiries; # 데이터 존재 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 백업 스크립트
|
||||||
|
```bash
|
||||||
|
# 파일: /home/kjh2064/backup_taxbaik_db.sh
|
||||||
|
# 수동 실행:
|
||||||
|
ssh kjh2064@178.104.200.7 /home/kjh2064/backup_taxbaik_db.sh
|
||||||
|
|
||||||
|
# Cron 등록:
|
||||||
|
0 2 * * * /home/kjh2064/backup_taxbaik_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 모니터링
|
||||||
|
```bash
|
||||||
|
# 백업 로그 확인
|
||||||
|
ssh kjh2064@178.104.200.7 tail -20 /home/kjh2064/backups/backup.log
|
||||||
|
|
||||||
|
# Cron 상태 확인
|
||||||
|
ssh kjh2064@178.104.200.7 crontab -l | grep backup
|
||||||
|
```
|
||||||
|
|
||||||
|
**중요**:
|
||||||
|
- 백업은 전체 데이터베이스를 포함합니다 (스키마 + 데이터)
|
||||||
|
- 30일 보관 정책으로 최근 한 달 데이터 손실 방지
|
||||||
|
- 자동 실행이므로 수동 개입 불필요
|
||||||
|
- 장애 발생 시 즉시 최근 백업으로 복구 가능
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 코드 규칙
|
## 6. 코드 규칙
|
||||||
@@ -717,7 +1037,7 @@ curl http://127.0.0.1/taxbaik
|
|||||||
curl http://127.0.0.1/taxbaik/admin/login
|
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://178.104.200.7/taxbaik/contact \
|
||||||
@@ -729,6 +1049,87 @@ psql -U taxbaik -d taxbaikdb
|
|||||||
SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**반응형 디자인 E2E 테스트** (test_admin 테스트 계정 사용):
|
||||||
|
```bash
|
||||||
|
# Green-Blue 배포 지원:
|
||||||
|
# - Nginx를 통한 포트 무관 라우팅 (http://localhost/taxbaik)
|
||||||
|
# - 또는 직접 포트 지정 (http://localhost:5001/taxbaik)
|
||||||
|
|
||||||
|
# 방법 1: Nginx 거쳐서 (권장 - active 버전 자동 테스트)
|
||||||
|
export E2E_BASE_URL="http://localhost/taxbaik"
|
||||||
|
export E2E_ADMIN_USERNAME="test_admin"
|
||||||
|
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
|
||||||
|
|
||||||
|
# 방법 2: 직접 포트 지정 (5001 또는 5002)
|
||||||
|
# export E2E_BASE_URL="http://localhost:5001/taxbaik"
|
||||||
|
|
||||||
|
# Playwright로 반응형 테스트 실행 (8개 디바이스 크기)
|
||||||
|
npx playwright test admin-responsive.spec.ts
|
||||||
|
|
||||||
|
# 단일 프로젝트만 (빠른 검증)
|
||||||
|
npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 계정 정보** (마이그레이션 V012-V013):
|
||||||
|
- 사용자명: `test_admin`
|
||||||
|
- 비밀번호: `TestAdmin@123456` (API reset-password로 설정)
|
||||||
|
- 용도: E2E Playwright 자동 테스트 (실 admin 계정과 완전 분리)
|
||||||
|
- 권한: admin과 동일
|
||||||
|
- 비밀번호 변경: `/api/auth/reset-password` API 사용
|
||||||
|
|
||||||
|
**프로덕션 E2E 테스트**:
|
||||||
|
```bash
|
||||||
|
export E2E_BASE_URL="http://178.104.200.7/taxbaik"
|
||||||
|
export E2E_ADMIN_USERNAME="test_admin"
|
||||||
|
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
|
||||||
|
|
||||||
|
npx playwright test # CI에서 배포 후 자동 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 항목**:
|
||||||
|
- ✅ Desktop (1920px, 1440px, 1024px): 메트릭 4개 컬럼
|
||||||
|
- ✅ Tablet L/M (960px, 768px): 메트릭 3/2 컬럼
|
||||||
|
- ✅ Tablet S (600px): 메트릭 1 컬럼, 드로어 축소
|
||||||
|
- ✅ Mobile (480px, 375px): 메트릭 1 컬럼, 모바일 네비게이션
|
||||||
|
- ✅ 텍스트 가독성 (최소 폰트 11px)
|
||||||
|
- ✅ 버튼 접근성 (최소 20x20px)
|
||||||
|
- ✅ 폼 필드 너비 (200px 이상)
|
||||||
|
- ✅ 수평 오버플로우 없음 (모든 크기)
|
||||||
|
|
||||||
|
### CI/CD 파이프라인 최적화 (2026-06-28)
|
||||||
|
|
||||||
|
**목표**: 전체 배포 시간을 최소화하고 명확한 Timeout 설정
|
||||||
|
|
||||||
|
**최적화 항목**:
|
||||||
|
|
||||||
|
| 항목 | 이전 | 현재 | 개선 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **Blazor 프리렌더링** | `prerender: false` | `prerender: true` | 흰 화면 제거 |
|
||||||
|
| **배포 헬스 체크** | 40 × 3초 = 120초 | 20 × 3초 = 60초 | -50% |
|
||||||
|
| **E2E 배포 대기** | 30 × 5초 = 150초 | 20 × 3초 = 60초 | -60% |
|
||||||
|
| **Playwright 병렬** | `fullyParallel: false` | CI에서 `true` | 테스트 병렬화 |
|
||||||
|
| **테스트 재시도** | CI에서 1회 재시도 | 재시도 없음 | 실패 즉시 감지 |
|
||||||
|
| **E2E 프로젝트** | 4개 (Desktop/Mobile/iPad/Galaxy) | 1개 (Desktop Chrome) | -75% 테스트 |
|
||||||
|
|
||||||
|
**예상 실행 시간** (정상 배포 시):
|
||||||
|
- Build: ~3-5분
|
||||||
|
- Test: ~1-2분
|
||||||
|
- Publish: ~1분
|
||||||
|
- Deploy + Health Check: ~3-5분 (기존 2분 → 개선)
|
||||||
|
- E2E Tests: ~5-10분 (Desktop Chrome만, 병렬 처리)
|
||||||
|
- **전체**: ~15-25분 (기존 60분+ → -75% 단축)
|
||||||
|
|
||||||
|
**Timeout 규칙**:
|
||||||
|
- 배포 헬스 체크: 60초 (실패 시 즉시 롤백)
|
||||||
|
- E2E 배포 대기: 60초 (실패 시 테스트 스킵)
|
||||||
|
- Playwright 테스트: 30초/테스트 (느린 테스트는 즉시 실패)
|
||||||
|
- Expect 조건: 10초 (느린 상호작용은 즉시 실패)
|
||||||
|
|
||||||
|
**설정 파일**:
|
||||||
|
- `.gitea/workflows/deploy.yml`: 배포 헬스 체크 60초
|
||||||
|
- `.gitea/workflows/browser-e2e.yml`: E2E 대기 60초, Desktop Chrome만 실행
|
||||||
|
- `playwright.config.ts`: CI에서 병렬 처리, 재시도 없음
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 문제 해결
|
## 12. 문제 해결
|
||||||
@@ -741,9 +1142,60 @@ SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
|||||||
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection "Upgrade"` 헤더가 모두 있는지 확인 |
|
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection "Upgrade"` 헤더가 모두 있는지 확인 |
|
||||||
| 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 |
|
| 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 |
|
||||||
| 로그인 실패 | `admin_users.password_hash`와 bcrypt 해시, `AuthService` 로그, `/api/auth/login` 응답 확인 |
|
| 로그인 실패 | `admin_users.password_hash`와 bcrypt 해시, `AuthService` 로그, `/api/auth/login` 응답 확인 |
|
||||||
|
| API 호출 실패 (배포 후) | Green-Blue 배포 시 `ApiClient__BaseUrl` 환경변수 확인 (현재 active 포트와 일치하는지) |
|
||||||
|
| 반응형 CSS 깨짐 | admin.css 로드 확인 (헬스 체크에 포함됨), 브라우저 DevTools에서 viewport 설정 확인 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 시즌별 마케팅 (Seasonal Marketing)
|
||||||
|
|
||||||
|
### 13.1 핵심 방향
|
||||||
|
|
||||||
|
세무사 사무실은 **1년 중 특정 시기에 특정 고객이 집중**된다. 홈페이지는 이 시기마다 자동으로 전환되어야 한다.
|
||||||
|
|
||||||
|
**목표**: 방문자가 접속한 날짜에 맞는 세무 이벤트를 즉시 인지하고 상담 신청으로 전환
|
||||||
|
|
||||||
|
**전환 방식**:
|
||||||
|
- Hero 섹션 헤드라인과 CTA가 시즌에 맞게 변경됨
|
||||||
|
- 마감 D-7일 이내에는 긴박감 메시지 추가 표시
|
||||||
|
- 시즌 관련 서비스 카드가 맨 앞으로 이동
|
||||||
|
- 최종 CTA도 시즌 문구로 전환
|
||||||
|
- 관리자가 별도 공지사항을 등록하면 모든 페이지 최상단에 배너로 노출
|
||||||
|
|
||||||
|
### 13.2 연간 세무 캘린더
|
||||||
|
|
||||||
|
| 기간 | 이벤트 | Key | 타깃 서비스 |
|
||||||
|
|------|--------|-----|-------------|
|
||||||
|
| 1/1 ~ 1/25 | 부가가치세 2기 확정신고 | `vat-2nd` | business-tax |
|
||||||
|
| 1/15 ~ 2/28 | 연말정산 | `year-end-settlement` | business-tax |
|
||||||
|
| 3/1 ~ 3/31 | 법인세 신고 | `corporate-tax` | business-tax |
|
||||||
|
| 5/1 ~ 5/31 | **종합소득세 신고** (연중 최대 피크) | `income-tax` | business-tax |
|
||||||
|
| 7/1 ~ 7/25 | 부가가치세 1기 확정신고 | `vat-1st` | business-tax |
|
||||||
|
| 11/15 ~ 11/30 | 종합부동산세 납부 | `comprehensive-real-estate-tax` | real-estate-tax |
|
||||||
|
| 12/1 ~ 12/31 | 연말 증여·절세 플래닝 | `year-end-gift` | family-asset |
|
||||||
|
|
||||||
|
캘린더 정의 위치: `TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs`
|
||||||
|
|
||||||
|
시즌 추가/수정은 이 파일만 변경하면 된다. DB·마이그레이션 변경 없음.
|
||||||
|
|
||||||
|
### 13.3 공지사항 (Announcement)
|
||||||
|
|
||||||
|
어드민 `/taxbaik/admin/announcements`에서 관리.
|
||||||
|
|
||||||
|
- **유형**: 일반(info, 파란색) / 배너(banner, 주황색) / 긴급(urgent, 빨간색)
|
||||||
|
- **게시 기간**: 시작일~종료일 설정 가능. 비우면 즉시~무기한
|
||||||
|
- **노출 위치**: 홈페이지 최상단 (공지 배너 스트립)
|
||||||
|
- **우선순위**: sort_order 내림차순
|
||||||
|
|
||||||
|
공지사항은 시즌 Hero와 독립적으로 동작한다. 동시 표시 가능.
|
||||||
|
|
||||||
|
### 13.4 시즌 우선순위 / 광고 규칙 준수
|
||||||
|
|
||||||
|
- 허용: "지금 신고 준비하세요", "마감 전 사전 검토", "D-N일 남았습니다"
|
||||||
|
- 금지: "100% 절세 보장", "최저가 신고", "무료"
|
||||||
|
|
||||||
**마지막 체크리스트:**
|
**마지막 체크리스트:**
|
||||||
- [ ] 솔루션 빌드 성공 (`dotnet build`)
|
- [ ] 솔루션 빌드 성공 (`dotnet build`)
|
||||||
- [ ] 모든 프로젝트 참조 정확
|
- [ ] 모든 프로젝트 참조 정확
|
||||||
|
|||||||
+10
-9
@@ -62,7 +62,7 @@ sudo systemctl reload nginx
|
|||||||
|
|
||||||
2. 배포 워크플로우는 자동으로 실행:
|
2. 배포 워크플로우는 자동으로 실행:
|
||||||
```
|
```
|
||||||
master 브랜치 push → build → publish → restart
|
master 브랜치 push → build → test → publish → restart → health check → Playwright
|
||||||
```
|
```
|
||||||
|
|
||||||
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
|
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
|
||||||
@@ -96,14 +96,15 @@ curl -X POST http://178.104.200.7/taxbaik/api/auth/login \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"username\":\"admin\",\"password\":\"<TAXBAIK_ADMIN_TEST_PASSWORD>\"}"
|
-d "{\"username\":\"admin\",\"password\":\"<TAXBAIK_ADMIN_TEST_PASSWORD>\"}"
|
||||||
|
|
||||||
# 문의 폼 제출 테스트
|
# Playwright 브라우저 검증
|
||||||
curl -X POST http://178.104.200.7/taxbaik/contact \
|
npm run test:e2e
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
|
|
||||||
|
|
||||||
# DB에서 확인
|
# 필요한 경우 개별 테스트 실행
|
||||||
ssh kjh2064@178.104.200.7
|
npx playwright test tests/e2e/admin-login.spec.ts
|
||||||
psql -U taxbaik -d taxbaikdb -c "SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;"
|
npx playwright test tests/e2e/admin-smoke.spec.ts
|
||||||
|
npx playwright test tests/e2e/public-smoke.spec.ts
|
||||||
|
npx playwright test tests/e2e/blog-seo.spec.ts
|
||||||
|
npx playwright test tests/e2e/contact-submit.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 블로그 포스트 확인
|
### 블로그 포스트 확인
|
||||||
@@ -112,7 +113,7 @@ psql -U taxbaik -d taxbaikdb -c "SELECT * FROM inquiries ORDER BY created_at DES
|
|||||||
# 초기 5개 포스트 확인
|
# 초기 5개 포스트 확인
|
||||||
curl http://178.104.200.7/taxbaik/blog
|
curl http://178.104.200.7/taxbaik/blog
|
||||||
|
|
||||||
# 첫 번째 포스트 상세 (slug: accountant-mistakes-5)
|
# 첫 번째 포스트 상세
|
||||||
curl http://178.104.200.7/taxbaik/blog/accountant-mistakes-5
|
curl http://178.104.200.7/taxbaik/blog/accountant-mistakes-5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+32
-29
@@ -1,22 +1,25 @@
|
|||||||
# TaxBaik 배포 완료 보고서
|
# TaxBaik 배포 요약
|
||||||
|
|
||||||
## 📊 최종 완성 현황
|
> 이 문서는 현재 WBS 기준의 검증 문서가 아니라, 과거 배포 요약의 기록이다.
|
||||||
|
> 최신 상태는 `ROADMAP_WBS.md`와 CI 로그를 기준으로 판단한다.
|
||||||
|
|
||||||
### ✅ W0-W6 모든 단계 완료
|
## 📊 과거 기록 현황
|
||||||
|
|
||||||
|
### ⚠️ 과거 기준 기록
|
||||||
|
|
||||||
| 단계 | 항목 | 상태 |
|
| 단계 | 항목 | 상태 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| W0 | 프로젝트 기반 구축 | ✅ 완료 |
|
| W0 | 프로젝트 기반 구축 | 과거 기록 |
|
||||||
| W1 | LLM 개발 지침 (CLAUDE.md) | ✅ 완료 |
|
| W1 | LLM 개발 지침 (CLAUDE.md) | 과거 기록 |
|
||||||
| W2 | 도메인/인프라/서비스 레이어 | ✅ 완료 |
|
| W2 | 도메인/인프라/서비스 레이어 | 과거 기록 |
|
||||||
| **W3** | **공개 홈페이지 (Razor Pages SSR)** | ✅ **배포됨** |
|
| **W3** | **공개 홈페이지 (Razor Pages SSR)** | 과거 기록 |
|
||||||
| **W4** | **관리자 백오피스 (Blazor Server)** | ✅ **배포됨** |
|
| **W4** | **관리자 백오피스 (Blazor Server)** | 과거 기록 |
|
||||||
| **W5** | **스타일링 및 모바일 UX** | ✅ **완성됨** |
|
| **W5** | **스타일링 및 모바일 UX** | 과거 기록 |
|
||||||
| **W6** | **출시 준비 (E2E 테스트)** | ✅ **검증됨** |
|
| **W6** | **출시 준비 (E2E 테스트)** | 과거 기록 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 배포된 엔드포인트 (모두 HTTP 200)
|
## 🚀 과거 배포 엔드포인트 기록
|
||||||
|
|
||||||
### 공개 사이트
|
### 공개 사이트
|
||||||
- 🏠 **홈페이지**: http://178.104.200.7/taxbaik
|
- 🏠 **홈페이지**: http://178.104.200.7/taxbaik
|
||||||
@@ -28,11 +31,11 @@
|
|||||||
### 관리자
|
### 관리자
|
||||||
- 🔐 **로그인**: http://178.104.200.7/taxbaik/admin/login
|
- 🔐 **로그인**: http://178.104.200.7/taxbaik/admin/login
|
||||||
- 📊 **대시보드**: http://178.104.200.7/taxbaik/admin/dashboard
|
- 📊 **대시보드**: http://178.104.200.7/taxbaik/admin/dashboard
|
||||||
- 👤 **기본 계정**: admin / admin123
|
- 계정 정보는 문서에 기록하지 않고 Gitea Secrets 또는 서버 환경변수로만 관리한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 기술 구현
|
## 📁 과거 기술 구성 기록
|
||||||
|
|
||||||
### 공개 사이트
|
### 공개 사이트
|
||||||
- **기술**: ASP.NET Core 10 Razor Pages (SSR)
|
- **기술**: ASP.NET Core 10 Razor Pages (SSR)
|
||||||
@@ -55,16 +58,16 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 데이터베이스
|
## 📊 과거 데이터베이스 기록
|
||||||
|
|
||||||
### 초기 데이터
|
### 초기 데이터
|
||||||
- ✅ **5개 카테고리**: 사업자세무, 부동산세금, 종합소득세, 부가가치세, 가족자산증여
|
- 5개 카테고리: 사업자세무, 부동산세금, 종합소득세, 부가가치세, 가족자산증여
|
||||||
- ✅ **5개 블로그 포스트**: 초기 콘텐츠 포함
|
- 5개 블로그 포스트: 초기 콘텐츠 포함
|
||||||
- ✅ **1개 관리자 계정**: admin/admin123
|
- 관리자 계정: 비밀번호는 문서화하지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 배포 절차
|
## 🔧 과거 배포 절차 기록
|
||||||
|
|
||||||
1. **로컬 빌드**
|
1. **로컬 빌드**
|
||||||
```bash
|
```bash
|
||||||
@@ -98,18 +101,18 @@ e7e01d0 마이그레이션 및 보안 수정
|
|||||||
|
|
||||||
## ✨ 주요 특징
|
## ✨ 주요 특징
|
||||||
|
|
||||||
- ✅ SEO 최적화 (Server-Side Rendering)
|
- SEO 항목 (Server-Side Rendering)
|
||||||
- ✅ 무중단 배포 (Shadow Copy)
|
- 심링크 기반 배포
|
||||||
- ✅ 반응형 모바일 UI
|
- 반응형 모바일 UI
|
||||||
- ✅ 한국어 완전 지원
|
- 한국어 UI
|
||||||
- ✅ 자동 마이그레이션
|
- 자동 마이그레이션
|
||||||
- ✅ 안전한 인증 (쿠키 + 인증)
|
- 인증 항목
|
||||||
- ✅ 체계적인 레이어 구조
|
- 레이어 구조
|
||||||
- ✅ 프로덕션 준비 완료
|
- 기록용 요약일 뿐, 현재 완료 판정 기준은 아니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 다음 단계 (향후 개선)
|
## 🎯 향후 개선 후보
|
||||||
|
|
||||||
1. BCrypt 실제 인증 개선
|
1. BCrypt 실제 인증 개선
|
||||||
2. Blog CRUD 관리자 기능 완성
|
2. Blog CRUD 관리자 기능 완성
|
||||||
@@ -120,5 +123,5 @@ e7e01d0 마이그레이션 및 보안 수정
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**배포 완료**: 2026-06-26
|
**기록일**: 2026-06-26
|
||||||
**상태**: ✅ 운영 중
|
**상태**: 기록용 요약
|
||||||
|
|||||||
+86
-102
@@ -1,34 +1,34 @@
|
|||||||
# TaxBaik 최종 완성 보고서
|
# TaxBaik 과거 완료 요약 기록
|
||||||
|
|
||||||
**프로젝트**: 세무사 백원숙 전문성 표현 홈페이지
|
**프로젝트**: 세무사 백원숙 전문성 표현 홈페이지
|
||||||
**완성일**: 2026-06-26
|
**기록일**: 2026-06-26
|
||||||
**상태**: ✅ **프로덕션 준비 완료**
|
**상태**: 과거 기록. 현재 완료 판정은 `ROADMAP_WBS.md`와 CI/Playwright 로그를 기준으로 한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📌 프로젝트 개요
|
## 📌 프로젝트 개요
|
||||||
|
|
||||||
### 비즈니스 목표
|
### 비즈니스 목표 기록
|
||||||
- ✅ 온라인 전문성 표현
|
- 온라인 전문성 표현
|
||||||
- ✅ 블로그 SEO 유입
|
- 블로그 SEO 유입
|
||||||
- ✅ 전국 고객 확보
|
- 전국 고객 확보
|
||||||
|
|
||||||
### 핵심 포지셔닝
|
### 핵심 포지셔닝
|
||||||
> "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너"
|
> "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 완료된 작업 (W0~W6)
|
## 🎯 과거 기준 작업 기록 (W0~W6)
|
||||||
|
|
||||||
| 단계 | 작업 | 상태 | 커밋 수 |
|
| 단계 | 작업 | 상태 | 커밋 수 |
|
||||||
|------|------|------|--------|
|
|------|------|------|--------|
|
||||||
| **W0** | 프로젝트 기반 구축 | ✅ | 3 |
|
| **W0** | 프로젝트 기반 구축 | 과거 기록 | 3 |
|
||||||
| **W1** | LLM 개발 지침 작성 | ✅ | 1 |
|
| **W1** | LLM 개발 지침 작성 | 과거 기록 | 1 |
|
||||||
| **W2** | Domain/Infrastructure/Application | ✅ | 2 |
|
| **W2** | Domain/Infrastructure/Application | 과거 기록 | 2 |
|
||||||
| **W3** | 공개 홈페이지 (Razor Pages) | ✅ | 4 |
|
| **W3** | 공개 홈페이지 (Razor Pages) | 과거 기록 | 4 |
|
||||||
| **W4** | 관리자 백오피스 (Blazor) | ✅ | 3 |
|
| **W4** | 관리자 백오피스 (Blazor) | 과거 기록 | 3 |
|
||||||
| **W5** | 스타일링 & 성능 최적화 | ✅ | 1 |
|
| **W5** | 스타일링 & 성능 최적화 | 과거 기록 | 1 |
|
||||||
| **W6** | 배포 준비 & CI/CD | ✅ | 5 |
|
| **W6** | 배포 준비 & CI/CD | 과거 기록 | 5 |
|
||||||
|
|
||||||
**총 커밋**: 19개 (모두 한국어)
|
**총 커밋**: 19개 (모두 한국어)
|
||||||
|
|
||||||
@@ -95,24 +95,23 @@ TaxBaik.Admin/ 95 KB (Blazor Server)
|
|||||||
## ✨ 주요 기능
|
## ✨ 주요 기능
|
||||||
|
|
||||||
### 공개 사이트
|
### 공개 사이트
|
||||||
- ✅ SEO 최적화 블로그 (5개 카테고리)
|
- SEO 블로그
|
||||||
- ✅ 온라인 상담 신청 폼
|
- 온라인 상담 신청 폼
|
||||||
- ✅ 반응형 디자인 (모바일 375px+)
|
- 반응형 디자인
|
||||||
- ✅ 성능 최적화 (gzip, lazy load)
|
- 성능 최적화 항목
|
||||||
|
|
||||||
### 관리자 백오피스
|
### 관리자 백오피스
|
||||||
- ✅ 대시보드 (KPI 카드)
|
- 대시보드
|
||||||
- ✅ 블로그 CRUD
|
- 블로그 관리
|
||||||
- ✅ 문의 관리 (상태 변경)
|
- 문의 관리
|
||||||
- ✅ 사이트 설정
|
- 사이트 설정
|
||||||
|
|
||||||
### 보안 & 성능
|
### 보안 & 성능
|
||||||
- ✅ SQL Injection 방지 (파라미터화 쿼리)
|
- SQL Injection 방지 항목
|
||||||
- ✅ CSRF 보호 ([ValidateAntiForgeryToken])
|
- 인증/인가 항목
|
||||||
- ✅ Cookie 기반 인증 (8시간 세션)
|
- gzip 응답 압축
|
||||||
- ✅ gzip 응답 압축
|
- 이미지 lazy load
|
||||||
- ✅ 이미지 lazy load
|
- 폰트 preconnect
|
||||||
- ✅ 폰트 preconnect
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,7 +129,7 @@ Gitea Actions 트리거
|
|||||||
4. 심링크 스왑
|
4. 심링크 스왑
|
||||||
5. systemctl restart
|
5. systemctl restart
|
||||||
↓
|
↓
|
||||||
배포 완료 (무중단)
|
배포 기록 생성
|
||||||
```
|
```
|
||||||
|
|
||||||
### 자동 마이그레이션
|
### 자동 마이그레이션
|
||||||
@@ -143,53 +142,53 @@ schema_migrations 테이블 확인
|
|||||||
↓
|
↓
|
||||||
미실행 마이그레이션 자동 실행
|
미실행 마이그레이션 자동 실행
|
||||||
↓
|
↓
|
||||||
DB 준비 완료
|
DB 준비 기록 생성
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 코드 품질
|
## 📊 과거 코드 품질 기록
|
||||||
|
|
||||||
| 항목 | 상태 | 세부 |
|
| 항목 | 상태 | 세부 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **빌드** | ✅ | 0 errors, 12 warnings (NuGet 보안 정보) |
|
| **빌드** | 과거 기록 | 최신 상태는 CI 로그 기준 |
|
||||||
| **보안** | ✅ | SQL injection 방지, CSRF 보호, 인증 |
|
| **보안** | 과거 기록 | 최신 상태는 코드 리뷰와 테스트 기준 |
|
||||||
| **성능** | ✅ | gzip, lazy load, 메모리 캐시 |
|
| **성능** | 과거 기록 | 최신 상태는 WBS 검증 기준 |
|
||||||
| **SEO** | ✅ | 메타 태그, sitemap, robots.txt |
|
| **SEO** | 과거 기록 | 최신 상태는 `blog-seo` Playwright 기준 |
|
||||||
| **테스트** | ✅ | 구조적 검증 완료 |
|
| **테스트** | 과거 기록 | 최신 상태는 Playwright/CI 기준 |
|
||||||
| **문서** | ✅ | 1,500+ 라인 (개발 + 배포 가이드) |
|
| **문서** | 과거 기록 | 최신 상태는 `ROADMAP_WBS.md` 기준 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 수락 기준
|
## 🎯 과거 수락 기준 기록
|
||||||
|
|
||||||
### 기술적 요구사항
|
### 기술적 요구사항
|
||||||
- [x] ASP.NET Core 8 + C#11 기반
|
- ASP.NET Core 기반
|
||||||
- [x] Dapper + PostgreSQL 사용
|
- Dapper + PostgreSQL 사용
|
||||||
- [x] Razor Pages SSR (공개 사이트)
|
- Razor Pages SSR (공개 사이트)
|
||||||
- [x] Blazor Server (관리자)
|
- Blazor Server (관리자)
|
||||||
- [x] 계층화된 아키텍처 (Domain → Infrastructure → Application → Web/Admin)
|
- 계층화된 아키텍처
|
||||||
- [x] 모든 UI 문자열 한국어
|
- UI 문자열 한국어
|
||||||
|
|
||||||
### 기능 요구사항
|
### 기능 요구사항
|
||||||
- [x] 블로그 (5개 카테고리, SEO 최적화)
|
- 블로그
|
||||||
- [x] 온라인 문의 폼
|
- 온라인 문의 폼
|
||||||
- [x] 관리자 백오피스 (블로그 + 문의 관리)
|
- 관리자 백오피스
|
||||||
- [x] 반응형 디자인
|
- 반응형 디자인
|
||||||
- [x] 성능 최적화
|
- 성능 최적화
|
||||||
|
|
||||||
### 배포 요구사항
|
### 배포 요구사항
|
||||||
- [x] CI/CD 파이프라인 (Gitea Actions)
|
- CI/CD 파이프라인
|
||||||
- [x] 자동 마이그레이션
|
- 자동 마이그레이션
|
||||||
- [x] 무중단 배포 (심링크 스왑)
|
- 심링크 배포
|
||||||
- [x] systemd 서비스 파일
|
- systemd 서비스 파일
|
||||||
- [x] Nginx 리버스 프록시 설정
|
- Nginx 리버스 프록시 설정
|
||||||
|
|
||||||
### 문서 요구사항
|
### 문서 요구사항
|
||||||
- [x] CLAUDE.md (개발 지침)
|
- CLAUDE.md
|
||||||
- [x] DEPLOYMENT_GUIDE.md (배포 가이드)
|
- DEPLOYMENT_GUIDE.md
|
||||||
- [x] README.md (프로젝트 개요)
|
- README.md
|
||||||
- [x] 서버 설치 스크립트
|
- 서버 설치 스크립트
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -229,54 +228,41 @@ b300cd7 완성: 빌드 성공 및 최종 통합 (W0~W6 완료)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎊 최종 체크리스트
|
## 과거 체크리스트 기록
|
||||||
|
|
||||||
### 개발 완료
|
### 개발 기록
|
||||||
- [x] 코드 작성
|
- 코드 작성 기록
|
||||||
- [x] 로컬 빌드 성공
|
- 로컬 빌드 기록
|
||||||
- [x] Git 커밋/푸시
|
- Git 커밋/푸시 기록
|
||||||
|
|
||||||
### 검증 완료
|
### 검증 기록
|
||||||
- [x] 아키텍처 검증
|
- 아키텍처 검토 기록
|
||||||
- [x] 코드 구조 검증
|
- 코드 구조 검토 기록
|
||||||
- [x] 보안 검증
|
- 보안 검토 기록
|
||||||
- [x] 성능 검증
|
- 성능 검토 기록
|
||||||
- [x] SEO 검증
|
- SEO 검토 기록
|
||||||
|
|
||||||
### 배포 준비
|
### 배포 준비
|
||||||
- [x] CI/CD 파이프라인
|
- CI/CD 파이프라인
|
||||||
- [x] 자동 마이그레이션
|
- 자동 마이그레이션
|
||||||
- [x] 배포 스크립트
|
- 배포 스크립트
|
||||||
- [x] 배포 가이드
|
- 배포 가이드
|
||||||
- [x] 모니터링 설정
|
- 모니터링 설정
|
||||||
|
|
||||||
### 문서 완성
|
### 문서 기록
|
||||||
- [x] README.md
|
- README.md
|
||||||
- [x] CLAUDE.md
|
- CLAUDE.md
|
||||||
- [x] DEPLOYMENT_GUIDE.md
|
- DEPLOYMENT_GUIDE.md
|
||||||
- [x] PRODUCTION_CHECKLIST.md
|
- PRODUCTION_CHECKLIST.md
|
||||||
- [x] SERVER_SETUP.sh
|
- SERVER_SETUP.sh
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 다음 단계
|
## 현재 후속 기준
|
||||||
|
|
||||||
### 즉시 실행 (서버에서)
|
1. `ROADMAP_WBS.md`의 미완료 항목을 기준으로 작업한다.
|
||||||
```bash
|
2. 완료 판정은 CI 배포, 배포 검증, Playwright E2E 통과 후에만 한다.
|
||||||
bash SERVER_SETUP.sh # 자동 설치
|
3. 서버 수동 변경은 비상 롤백을 제외하고 금지한다.
|
||||||
sudo systemctl start taxbaik # 서비스 시작
|
|
||||||
curl http://localhost:5001 # 접근 확인
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gitea Actions 활성화
|
|
||||||
1. Secrets 추가: DEPLOY_USER, DEPLOY_HOST, DEPLOY_SSH_KEY
|
|
||||||
2. master 브랜치 푸시 → 자동 배포 트리거
|
|
||||||
|
|
||||||
### 운영 단계
|
|
||||||
1. 초기 로그인 (admin/admin123)
|
|
||||||
2. 블로그 포스트 작성
|
|
||||||
3. SEO 최적화
|
|
||||||
4. 모니터링 시작
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -289,8 +275,6 @@ curl http://localhost:5001 # 접근 확인
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**프로젝트 상태**: ✅ **완성 (COMPLETE)**
|
**프로젝트 상태**: 진행 중
|
||||||
|
|
||||||
모든 제안된 작업이 우선순위 순서대로 완료되었습니다.
|
이 문서는 과거 완료 요약으로 남기고, 현재 진행 상태는 `ROADMAP_WBS.md`를 따른다.
|
||||||
|
|
||||||
배포 준비가 완료되었으므로, 서버에서 `SERVER_SETUP.sh`를 실행하면 즉시 운영을 시작할 수 있습니다.
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ createdb taxbaikdb
|
|||||||
psql -d taxbaikdb -f db/migrations/V001__InitialSchema.sql
|
psql -d taxbaikdb -f db/migrations/V001__InitialSchema.sql
|
||||||
psql -d taxbaikdb -f db/migrations/V002__SeedData.sql
|
psql -d taxbaikdb -f db/migrations/V002__SeedData.sql
|
||||||
psql -d taxbaikdb -f db/migrations/V003__SeedAdminAndBlogPosts.sql
|
psql -d taxbaikdb -f db/migrations/V003__SeedAdminAndBlogPosts.sql
|
||||||
|
psql -d taxbaikdb -f db/migrations/V004__CreateSiteSettings.sql
|
||||||
|
|
||||||
# 3. 환경 변수 설정
|
# 3. 환경 변수 설정
|
||||||
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=postgres;Password=password"
|
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=postgres;Password=password"
|
||||||
@@ -147,13 +148,16 @@ dotnet run --project TaxBaik.Web
|
|||||||
|
|
||||||
배포는 **Gitea Actions CI/CD**만 사용합니다.
|
배포는 **Gitea Actions CI/CD**만 사용합니다.
|
||||||
|
|
||||||
master 브랜치에 푸시하면 자동으로:
|
master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합니다.
|
||||||
1. ✅ .NET 빌드 (Release)
|
1. .NET 빌드 (Release)
|
||||||
2. ✅ 단위 테스트 실행
|
2. 단위 테스트 실행
|
||||||
3. ✅ `TaxBaik.Web` 게시
|
3. Playwright 브라우저 검증 실행
|
||||||
4. ✅ 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
|
4. `TaxBaik.Web` 게시
|
||||||
5. ✅ systemd `taxbaik` 단일 서비스 재시작
|
5. 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
|
||||||
6. ✅ `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/api/auth/login` 헬스 체크
|
6. systemd `taxbaik` 단일 서비스 재시작
|
||||||
|
7. `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/blog/{slug}`, `/taxbaik/api/auth/login` 검증
|
||||||
|
|
||||||
|
배포 완료 판정은 위 단계가 모두 성공하고, 배포본 기준 Playwright E2E가 통과했을 때만 한다.
|
||||||
|
|
||||||
**필수 Gitea Secrets 설정:**
|
**필수 Gitea Secrets 설정:**
|
||||||
- `DEPLOY_USER`: kjh2064
|
- `DEPLOY_USER`: kjh2064
|
||||||
@@ -332,6 +336,6 @@ echo $ConnectionStrings__Default
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**최종 상태**: ✅ **프로덕션 준비 완료**
|
**최종 상태**: 진행 중
|
||||||
|
|
||||||
모든 커밋이 한국어로 작성되었으며, Gitea에 업로드된 상태입니다.
|
완료 판정은 실제 빌드, 테스트, 배포 검증, 브라우저 E2E 통과로만 한다.
|
||||||
|
|||||||
+524
@@ -0,0 +1,524 @@
|
|||||||
|
# TaxBaik 개선 로드맵 WBS
|
||||||
|
|
||||||
|
이 문서는 "완료 보고"가 아니라 검증 가능한 작업 목록이다. 각 WBS는 성공 기준을 통과해야 완료로 본다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 판정 원칙
|
||||||
|
|
||||||
|
- 코드 변경만으로 완료 처리하지 않는다.
|
||||||
|
- 서버 배포 대상 기능은 CI/CD 성공과 실제 동작 확인을 요구한다.
|
||||||
|
- API 기능은 단위 테스트 또는 통합 테스트와 함께 실제 HTTP 호출 결과를 확인한다.
|
||||||
|
- DB 변경은 마이그레이션과 롤백 위험을 문서화한다.
|
||||||
|
- 비밀값은 Gitea Secrets 또는 서버 환경변수로만 관리한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 홈페이지 · SEO · UX ───────────────────────────
|
||||||
|
|
||||||
|
## WBS-UX-01 공개 홈페이지 UX/SEO 검증
|
||||||
|
|
||||||
|
목표: 공개 홈페이지가 검색 유입과 상담 전환에 맞는 구조인지 검증한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 홈/블로그 목록/블로그 상세/상담 문의 페이지 200
|
||||||
|
- 주요 페이지 title/description 존재
|
||||||
|
- 모바일 viewport에서 주요 CTA가 보인다.
|
||||||
|
- 상담 문의 제출 Playwright E2E가 통과한다.
|
||||||
|
- 블로그 상세 SEO 메타 검증이 배포본 기준으로 통과한다.
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] 공개 페이지 Playwright smoke E2E 추가
|
||||||
|
- [x] 상담 문의 제출 E2E 추가
|
||||||
|
- [x] 블로그 상세 SEO 메타 검증 추가
|
||||||
|
|
||||||
|
검증 파일:
|
||||||
|
- `tests/e2e/public-smoke.spec.ts`
|
||||||
|
- `tests/e2e/blog-seo.spec.ts`
|
||||||
|
- `tests/e2e/contact-submit.spec.ts`
|
||||||
|
- `tests/e2e/inquiry-detail.spec.ts`
|
||||||
|
|
||||||
|
## WBS-UX-02 홈페이지 FAQ 섹션 (정적)
|
||||||
|
|
||||||
|
목표: 방문자가 상담 전 자주 묻는 질문에서 직접 답을 얻고 전환율을 높인다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 홈페이지에 4개 FAQ 아코디언 표시 (기장료, 양도세 상담, 무료 상담, 첫 상담 준비물)
|
||||||
|
- 아코디언 열림/닫힘 동작
|
||||||
|
- 모바일에서 가독성 확인
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] Index.cshtml에 FAQ 아코디언 섹션 추가 (최종 CTA 앞)
|
||||||
|
- [x] site.css faq-accordion / faq-item / faq-question / faq-answer 스타일
|
||||||
|
- [x] 배포 완료 (`12070b7`)
|
||||||
|
- [ ] 배포 후 브라우저 아코디언 동작 확인
|
||||||
|
|
||||||
|
## WBS-UX-04 개인정보처리방침·이용약관 페이지
|
||||||
|
|
||||||
|
목표: 법적 의무를 충족하고 방문자 신뢰를 높이는 정책 페이지를 제공한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- `/taxbaik/privacy` 개인정보처리방침 페이지 정상 렌더링 (200)
|
||||||
|
- `/taxbaik/terms` 이용약관 페이지 정상 렌더링 (200)
|
||||||
|
- 푸터에 두 페이지 링크 표시
|
||||||
|
- 개인정보처리방침: 수집 항목, 이용 목적, 보유 기간, 파기 방법, 책임자 정보 포함
|
||||||
|
- 이용약관: 목적, 서비스 범위, 면책 조항, 저작권, 준거법 포함
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] Privacy.cshtml + Privacy.cshtml.cs (Razor Page)
|
||||||
|
- [x] Terms.cshtml + Terms.cshtml.cs (Razor Page)
|
||||||
|
- [x] _Footer.cshtml에 링크 이미 존재 확인
|
||||||
|
- [ ] 배포 후 /taxbaik/privacy, /taxbaik/terms 접근 확인
|
||||||
|
|
||||||
|
## WBS-UX-03 FAQ 관리 (어드민 CRUD)
|
||||||
|
|
||||||
|
목표: 세무사가 관리자 화면에서 FAQ 항목을 직접 등록·수정·삭제·순서 조정한다.
|
||||||
|
홈페이지 FAQ가 하드코딩에서 DB 기반으로 전환되어, 코드 수정 없이 운영 가능해진다.
|
||||||
|
|
||||||
|
설계 방향:
|
||||||
|
- FAQ 항목: 질문(question), 답변(answer), 정렬 순서(sort_order), 활성화 여부(is_active)
|
||||||
|
- 홈페이지는 is_active=TRUE 항목을 sort_order 오름차순으로 표시
|
||||||
|
- 카테고리 태그(선택): "기장·세금신고", "부동산", "증여·상속", "기타" — 홈페이지에서 탭 필터 가능
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 관리자 `/taxbaik/admin/faqs` 목록/생성/수정/삭제/순서변경 동작
|
||||||
|
- 홈페이지 FAQ 섹션이 DB에서 로드 (하드코딩 제거)
|
||||||
|
- 비활성 항목은 홈페이지 미표시
|
||||||
|
- sort_order 기준 정렬
|
||||||
|
|
||||||
|
DB 스키마:
|
||||||
|
- `faqs` 테이블 (V007 마이그레이션)
|
||||||
|
- id SERIAL PK
|
||||||
|
- question VARCHAR(300) NOT NULL
|
||||||
|
- answer TEXT NOT NULL
|
||||||
|
- category VARCHAR(50) — 기장·세금신고, 부동산, 증여·상속, 기타
|
||||||
|
- sort_order INT DEFAULT 0
|
||||||
|
- is_active BOOLEAN DEFAULT TRUE
|
||||||
|
- created_at TIMESTAMPTZ
|
||||||
|
- updated_at TIMESTAMPTZ
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] V007__CreateFaqs.sql 마이그레이션 (기본 FAQ 4개 시드 포함)
|
||||||
|
- [x] Faq 엔티티 (Domain)
|
||||||
|
- [x] IFaqRepository 인터페이스 (Domain)
|
||||||
|
- [x] FaqRepository 구현 (Infrastructure) — sort_order 정렬, CRUD
|
||||||
|
- [x] FaqService 구현 (Application) — Categories 상수, 유효성 검사
|
||||||
|
- [x] FaqList.razor 관리자 목록 (활성/비활성 상태 칩, 삭제 확인)
|
||||||
|
- [x] FaqEdit.razor 관리자 등록/수정 (질문/답변/카테고리/순서/활성 토글)
|
||||||
|
- [x] Index.cshtml FAQ 섹션 하드코딩 → DB 루프로 교체 (빈 DB에도 안전)
|
||||||
|
- [x] IndexModel FaqService 주입, Task.WhenAll 병렬 로드
|
||||||
|
- [x] MainLayout.razor FAQ 관리 메뉴 추가 (홈페이지 그룹 하위)
|
||||||
|
- [ ] 배포 후 관리자에서 FAQ 추가 → 홈페이지 반영 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 시즌별 마케팅 ───────────────────────────────
|
||||||
|
|
||||||
|
## WBS-MKT-01 시즌별 홈페이지 자동 전환
|
||||||
|
|
||||||
|
목표: 세무 신고 시즌마다 홈페이지 Hero·CTA·서비스 카드 순서가 자동 변경된다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 7개 시즌(vat-2nd, year-end-settlement, corporate-tax, income-tax, vat-1st, comprehensive-real-estate-tax, year-end-gift) 날짜 판정 정확
|
||||||
|
- 시즌 중 Hero에 UrgencyBadge 표시
|
||||||
|
- D-7일 이내 긴박감 메시지 표시
|
||||||
|
- FocusService 기준 서비스 카드 순서 자동 정렬
|
||||||
|
- 최종 CTA 시즌 문구 전환
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] TaxSeason / TaxSeasonCalendar 정의
|
||||||
|
- [x] CurrentSeasonDto / SeasonalMarketingService 구현
|
||||||
|
- [x] Index.cshtml Hero 시즌 분기 렌더링
|
||||||
|
- [x] Index.cshtml 서비스 카드 cardOrder 정렬 로직
|
||||||
|
- [x] Index.cshtml 최종 CTA 시즌 전환
|
||||||
|
- [x] CLAUDE.md 섹션 13 세무 캘린더 하네스
|
||||||
|
- [ ] 배포 후 시즌 날짜 경계값 수동 확인
|
||||||
|
|
||||||
|
## WBS-MKT-04 시즌 시뮬레이터 (어드민)
|
||||||
|
|
||||||
|
목표: 관리자가 날짜를 선택해 홈페이지 시즌 화면을 사전에 확인하고 콘텐츠 준비를 계획한다.
|
||||||
|
|
||||||
|
배경: 7개 시즌이 자동 전환되므로, 실제 날짜가 되기 전 미리 Hero 화면을 확인하는 도구가 필요하다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 관리자 `/taxbaik/admin/season-simulator` 접근 가능
|
||||||
|
- 날짜 선택 시 해당 날짜의 Hero 섹션 미리보기 렌더링
|
||||||
|
- 각 시즌 버튼 클릭으로 해당 시즌 첫날로 즉시 이동
|
||||||
|
- 비시즌 날짜 선택 시 기본 Hero 미리보기 표시
|
||||||
|
- 연간 시즌 타임라인 테이블 표시
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] SeasonSimulator.razor 어드민 페이지 구현
|
||||||
|
- [x] 날짜 선택 → 실시간 Hero 미리보기
|
||||||
|
- [x] 시즌 빠른 이동 버튼 (7개 시즌)
|
||||||
|
- [x] 연간 타임라인 테이블 (활성/비활성 구분)
|
||||||
|
- [x] MainLayout.razor 시즌 시뮬레이터 메뉴 추가 (홈페이지 그룹 하위)
|
||||||
|
- [ ] 배포 후 관리자에서 시뮬레이터 동작 확인
|
||||||
|
|
||||||
|
## WBS-MKT-02 관리자 공지사항 (Announcement)
|
||||||
|
|
||||||
|
목표: 운영자가 홈페이지 최상단 배너를 등록·수정·삭제할 수 있다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 관리자 `/taxbaik/admin/announcements` 목록/생성/수정/삭제 동작
|
||||||
|
- is_active=TRUE + 기간 조건(starts_at~ends_at)에 해당하는 공지만 홈페이지에 노출
|
||||||
|
- 유형(info/banner/urgent) 별 색상 배지 표시
|
||||||
|
- 홈페이지 최상단 announcement-bar 노출
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] V005__CreateAnnouncements.sql 마이그레이션
|
||||||
|
- [x] Announcement 엔티티, IAnnouncementRepository, AnnouncementRepository
|
||||||
|
- [x] AnnouncementService 구현
|
||||||
|
- [x] AnnouncementList.razor, AnnouncementEdit.razor 관리자 화면
|
||||||
|
- [x] Index.cshtml 공지사항 배너 렌더링
|
||||||
|
- [x] MainLayout.razor 공지사항 메뉴 추가
|
||||||
|
- [ ] 배포 후 공지 등록 → 홈 노출 확인
|
||||||
|
|
||||||
|
## WBS-MKT-03 블로그 시즌 연동
|
||||||
|
|
||||||
|
목표: 시즌 활성 중 홈페이지 블로그 섹션이 시즌 관련 글을 우선 노출한다.
|
||||||
|
|
||||||
|
배경: 세무 시즌에 맞는 콘텐츠를 전면에 배치해 상담 전환율과 SEO 체류시간을 높인다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 시즌 중: 해당 카테고리 글 최대 2개(이번 시즌 추천 배지) + 최신 글로 3개 채움
|
||||||
|
- 평상시: 최신 글 3개 (기존 동작)
|
||||||
|
- 시즌별 전체 글 보기 버튼 (`/taxbaik/blog?category=<slug>`)
|
||||||
|
- 배너 헤더가 시즌명 표시
|
||||||
|
|
||||||
|
카테고리 → 시즌 슬러그 매핑:
|
||||||
|
- `vat-2nd` / `vat-1st` → `vat`
|
||||||
|
- `income-tax` → `income-tax`
|
||||||
|
- `year-end-settlement` / `corporate-tax` → `business-tax`
|
||||||
|
- `comprehensive-real-estate-tax` → `real-estate-tax`
|
||||||
|
- `year-end-gift` → `family-asset`
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] TaxSeason.RelatedCategorySlug 추가
|
||||||
|
- [x] TaxSeasonCalendar 각 시즌에 카테고리 슬러그 매핑
|
||||||
|
- [x] CurrentSeasonDto.RelatedCategorySlug 추가
|
||||||
|
- [x] SeasonalMarketingService에 RelatedCategorySlug 전달
|
||||||
|
- [x] IBlogPostRepository.GetByCategorySlugAsync 추가
|
||||||
|
- [x] BlogPostRepository.GetByCategorySlugAsync 구현
|
||||||
|
- [x] BlogService.GetSeasonalPostsAsync 추가
|
||||||
|
- [x] IndexModel SeasonalPosts/RecentPosts 분리 로드
|
||||||
|
- [x] Index.cshtml 블로그 섹션 시즌 분기 렌더링
|
||||||
|
- [x] site.css 블로그 시즌 강조 스타일 추가
|
||||||
|
- [ ] 배포 후 시즌 활성 날짜에 블로그 카드 "이번 시즌 추천" 배지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 운영 인프라 ─────────────────────────────────
|
||||||
|
|
||||||
|
## WBS-OPS-01 배포 검증 게이트 고도화
|
||||||
|
|
||||||
|
목표: curl/API만이 아니라 실제 브라우저 검증까지 통과해야 배포를 성공으로 본다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- `dotnet build TaxBaik.sln -c Release` 경고 0, 오류 0
|
||||||
|
- `dotnet test TaxBaik.sln -c Release --no-build` 전체 통과
|
||||||
|
- CI 배포 후 Playwright가 `/taxbaik/admin/login`에서 실제 로그인 수행
|
||||||
|
- 로그인 후 `/taxbaik/admin/dashboard` 도달
|
||||||
|
- 브라우저 console error 및 page error 0개
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] Playwright Test 프로젝트 추가
|
||||||
|
- [x] 관리자 로그인 E2E 추가
|
||||||
|
- [x] CI 배포 후 Playwright 실행 단계 추가
|
||||||
|
- [x] Playwright가 발견한 Blazor DI 결함 수정
|
||||||
|
- [ ] CI run에서 Playwright 전체 통과 확인
|
||||||
|
- [ ] 배포 검증에 블로그 상세/문의/비밀번호 변경 성공 기준 반영 확인
|
||||||
|
|
||||||
|
## WBS-OPS-02 배포 502 / Nginx 유지보수 페이지
|
||||||
|
|
||||||
|
목표: CI 배포 중 502 Bad Gateway 대신 한국어 유지보수 페이지를 제공한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- Nginx error_page 502/503 → maintenance.html 직접 서빙
|
||||||
|
- 배포 중 방문자는 유지보수 페이지(15초 자동 새로고침)를 본다.
|
||||||
|
- 배포 완료 후 정상 서비스 복구
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] maintenance.html 작성
|
||||||
|
- [x] Nginx error_page 502 503 @taxbaik_maintenance 설정
|
||||||
|
- [x] 서버 측 헬스 루프 (40회×3초) 단일 SSH 연결로 처리
|
||||||
|
- [x] CI 배포 단계 헬스 체크 고도화
|
||||||
|
|
||||||
|
## WBS-OPS-03 관리자 401 수정
|
||||||
|
|
||||||
|
목표: 직접 URL 접근 시 관리자 Blazor 페이지가 401로 차단되지 않는다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- `/taxbaik/admin/announcements` 등 직접 접근 시 Blazor Shell 200 응답
|
||||||
|
- 미인증 사용자는 로그인 페이지로 리다이렉트
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] MapRazorComponents().AllowAnonymous() 적용
|
||||||
|
- [x] AuthorizeRouteView → RedirectToLogin 인증 흐름 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 인증 · 관리자 ─────────────────────────────────
|
||||||
|
|
||||||
|
## WBS-AUTH-01 인증/비밀번호 운영 안정화
|
||||||
|
|
||||||
|
목표: DB 직접 수정 대신 API로 관리자 인증 운영 작업을 수행한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 비밀번호 변경 API가 현재 비밀번호를 요구한다.
|
||||||
|
- 비밀번호 재설정 API는 운영 secret 없이는 동작하지 않는다.
|
||||||
|
- 실패 응답은 민감 정보를 노출하지 않는다.
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] 로그인 API 검증
|
||||||
|
- [x] 비밀번호 변경 API 추가
|
||||||
|
- [x] 재설정 API 추가
|
||||||
|
- [x] 관리자 UI에 비밀번호 변경 화면 추가
|
||||||
|
- [x] 비밀번호 변경 Playwright E2E 추가
|
||||||
|
|
||||||
|
## WBS-ADMIN-01 관리자 Blazor 안정화
|
||||||
|
|
||||||
|
목표: 관리자 화면을 일반 웹페이지처럼 명시적 사용자 액션에만 갱신하고, circuit 예외를 배포 전 차단한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 관리자 주요 메뉴 대시보드/블로그/문의/설정/공지사항 circuit error 0개
|
||||||
|
- 저장/삭제/상태 변경 액션은 성공/실패 메시지를 표시한다.
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] 중복 `/admin` 라우트 제거
|
||||||
|
- [x] MudBlazor DI 타입 오류 수정
|
||||||
|
- [x] 관리자 메뉴 smoke E2E 추가
|
||||||
|
- [x] 설정 저장 TODO를 실제 DB 기반 기능으로 전환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 고객지원 백오피스 (CRM) ──────────────────────
|
||||||
|
|
||||||
|
> **배경**: 세무사 사무실에서 고객 정보와 상담 이력이 파편화(메모장·카톡·기억)되면 마감 누락, 서비스 연속성 단절, 재계약 기회 손실이 발생한다.
|
||||||
|
> 30년 경력 세무사가 혼자 또는 소수 인원으로 운영할 때 가장 먼저 필요한 것은 고객 카드와 상담 이력이다.
|
||||||
|
|
||||||
|
## WBS-CRM-01 고객 카드 (Client Card) — Phase 1
|
||||||
|
|
||||||
|
목표: 고객별 기본 정보·서비스 유형·상태를 한 화면에서 관리한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 관리자 `/taxbaik/admin/clients` 목록/검색/생성/수정/삭제 동작
|
||||||
|
- 고객 카드: 이름, 회사명, 연락처, 이메일, 서비스 유형, 세금 유형, 상태, 유입 경로, 메모
|
||||||
|
- 상태 필터(활성/비활성)로 목록 조회
|
||||||
|
- 고객 저장 시 updated_at 자동 갱신
|
||||||
|
|
||||||
|
DB 스키마:
|
||||||
|
- `clients` 테이블 (V006 마이그레이션)
|
||||||
|
- 컬럼: id, name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] V006__CreateClients.sql 마이그레이션
|
||||||
|
- [x] Client 엔티티 (Domain)
|
||||||
|
- [x] IClientRepository 인터페이스 (Domain) — GetPagedAsync 검색+상태 필터
|
||||||
|
- [x] ClientRepository 구현 (Infrastructure) — ILIKE 검색, 페이징
|
||||||
|
- [x] ClientService 구현 (Application) — ServiceTypes/TaxTypes/Sources 상수
|
||||||
|
- [x] ClientList.razor 관리자 목록 화면 — 검색바, 상태 필터, 페이징
|
||||||
|
- [x] ClientEdit.razor 관리자 등록/수정 화면 — 기본/세무/관리 섹션
|
||||||
|
- [x] MainLayout.razor 고객 관리 NavGroup 추가
|
||||||
|
- [ ] 배포 후 고객 등록 → 목록 조회 확인
|
||||||
|
|
||||||
|
## WBS-CRM-02 상담 이력 (Consultation Log) — Phase 1
|
||||||
|
|
||||||
|
목표: 고객별 상담 일자·내용·결과·수수료를 기록해 "이 고객 지난번에 뭐 상담했더라?"를 해결한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 고객 상세에서 상담 이력 목록/추가/삭제 동작
|
||||||
|
- 상담 이력 필드: 날짜, 서비스 유형, 상담 요약, 결과(계약/보류/거절/완료), 수수료
|
||||||
|
- 이력 없는 고객은 빈 목록 표시
|
||||||
|
|
||||||
|
DB 스키마:
|
||||||
|
- `consultations` 테이블 (V008 마이그레이션)
|
||||||
|
- 컬럼: id, client_id(FK), consultation_date, service_type, summary, result, fee, created_at
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] V008__CreateConsultations.sql 마이그레이션
|
||||||
|
- [x] Consultation 엔티티 (Domain)
|
||||||
|
- [x] IConsultationRepository 인터페이스 (Domain)
|
||||||
|
- [x] ConsultationRepository 구현 (Infrastructure)
|
||||||
|
- [x] ConsultationService 구현 (Application)
|
||||||
|
- [x] ClientDetail.razor (고객 상세 + 상담 이력 추가/삭제)
|
||||||
|
- [x] DI 등록 (Infrastructure + Application)
|
||||||
|
- [ ] 배포 후 고객 상세에서 상담 이력 추가 확인
|
||||||
|
|
||||||
|
## WBS-CRM-03 문의 → 고객 전환 — Phase 1
|
||||||
|
|
||||||
|
목표: 홈페이지 문의 접수 건을 클릭 한 번으로 고객 카드로 등록한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 문의 상세에 "고객으로 등록" 버튼 표시
|
||||||
|
- 버튼 클릭 시 고객 카드 자동 생성 후 연결
|
||||||
|
- 이미 연결된 고객이 있으면 버튼 대신 고객 카드 링크 표시
|
||||||
|
- inquiries 테이블에 client_id, admin_memo, updated_at 컬럼 추가
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] V009__AddClientIdToInquiries.sql 마이그레이션
|
||||||
|
- [x] Inquiry 엔티티 client_id, admin_memo, updated_at 추가
|
||||||
|
- [x] IInquiryRepository.LinkClientAsync, UpdateAdminMemoAsync 추가
|
||||||
|
- [x] InquiryRepository 구현
|
||||||
|
- [x] InquiryService.LinkClientAsync, UpdateAdminMemoAsync 추가
|
||||||
|
- [x] ClientService.CreateFromInquiryAsync 추가
|
||||||
|
- [x] InquiryDetail.razor "고객으로 등록" 버튼 + 담당자 메모 추가
|
||||||
|
- [ ] 배포 후 문의 → 고객 전환 흐름 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 고객지원 백오피스 Phase 2 ──────────────────────
|
||||||
|
|
||||||
|
## WBS-CRM-04 신고 일정 캘린더 — Phase 2
|
||||||
|
|
||||||
|
목표: 고객별 신고 예정일과 마감일을 추적해 가산세 리스크를 방지한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 관리자에서 고객별 세금 신고 일정 등록/수정/완료 처리
|
||||||
|
- D-Day 표시 (D-7일 이내 강조)
|
||||||
|
- 이번 달 마감 목록을 대시보드 위젯으로 표시
|
||||||
|
|
||||||
|
DB 스키마:
|
||||||
|
- `tax_filings` 테이블 (V010 마이그레이션)
|
||||||
|
- 컬럼: id, client_id(FK), filing_type, due_date, status(pending/filed/overdue), memo
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] V010__CreateTaxFilings.sql
|
||||||
|
- [x] TaxFiling 엔티티 (Domain)
|
||||||
|
- [x] ITaxFilingRepository, TaxFilingRepository 구현
|
||||||
|
- [x] TaxFilingService 구현 (Application)
|
||||||
|
- [x] TaxFilingList.razor (관리자 신고 일정 화면 + 상태별 탭)
|
||||||
|
- [x] FilingTable.razor (D-Day 강조, 완료 처리, 삭제)
|
||||||
|
- [x] Dashboard.razor에 30일 이내 마감 위젯 추가
|
||||||
|
- [x] MainLayout.razor 신고 일정 메뉴 추가
|
||||||
|
- [x] DI 등록
|
||||||
|
- [ ] 배포 후 신고 일정 등록 → D-Day 표시 확인
|
||||||
|
|
||||||
|
## WBS-CRM-05 문의 접수 현황 강화 — Phase 2
|
||||||
|
|
||||||
|
목표: 문의 상태를 세분화하고 담당자 메모를 기록해 처리 흐름을 추적한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 문의 상태: 신규/상담중/계약완료/거절/종결 5단계
|
||||||
|
- 목록에서 상태 탭 필터로 빠른 분류
|
||||||
|
- 상태 변경 시 updated_at 자동 기록
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] V011__ExtendInquiryStatus.sql 마이그레이션 (contacted→consulting, completed→closed, admin_memo/updated_at 추가)
|
||||||
|
- [x] InquiryStatus enum 5단계 확장
|
||||||
|
- [x] InquiryStatusMapper 5단계 레이블 + TryParse 업데이트
|
||||||
|
- [x] InquiryList.razor 5단계 탭 (신규/상담중/계약완료/거절/종결)
|
||||||
|
- [x] InquiryDetail.razor 5단계 상태 버튼 + 색상 구분
|
||||||
|
- [x] Dashboard.razor 상태 레이블 5단계 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 고객지원 백오피스 Phase 3 ──────────────────────
|
||||||
|
|
||||||
|
## WBS-CRM-06 텔레그램 자동 리포트 — Phase 3
|
||||||
|
|
||||||
|
목표: 세무사에게 일/주 단위 신규 문의·처리 현황·마감 임박 건을 텔레그램으로 전송한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 매일 오전 9시 신규 문의 수, 처리 대기 수 자동 전송
|
||||||
|
- 매주 월요일 주간 리포트 (신규 고객, 이번 주 마감 신고 건)
|
||||||
|
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||||
|
- [ ] 일간/주간 리포트 메시지 템플릿
|
||||||
|
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
||||||
|
|
||||||
|
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||||
|
|
||||||
|
목표: 기장 고객이 본인 신고 현황과 중요 알림을 직접 확인한다.
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 고객 전용 URL + 인증(소셜 로그인 또는 링크 토큰)
|
||||||
|
- 본인 신고 일정, 상담 요약(세무사 허용 항목만) 조회
|
||||||
|
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||||
|
- [ ] 고객 전용 Razor Pages 추가
|
||||||
|
- [ ] 세무사 허용 권한 설정 UI
|
||||||
|
|
||||||
|
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||||
|
|
||||||
|
목표: 고객 포털 접근을 위한 회원가입과 소셜 로그인을 제공한다.
|
||||||
|
가입 마찰을 최소화해 상담 접수 → 고객 포털 전환율을 높인다.
|
||||||
|
|
||||||
|
설계 방향:
|
||||||
|
- 가입 입력 최소화: 이름 + 연락처(또는 이메일) 2필드면 충분
|
||||||
|
- 소셜 로그인 우선: 비밀번호 없이 바로 가입
|
||||||
|
- 기본 계정(이메일/비밀번호) 옵션도 제공 (소셜 없는 사용자 대비)
|
||||||
|
- 고객 포털 전용 인증 — 관리자(admin_users)와 완전히 분리
|
||||||
|
|
||||||
|
지원 소셜 로그인:
|
||||||
|
- 네이버 (Naver OAuth 2.0) — 국내 주요 채널
|
||||||
|
- 카카오 (Kakao Login) — 기존 카카오 채널 연계
|
||||||
|
- 구글 (Google OAuth 2.0) — 해외·젊은 고객층
|
||||||
|
|
||||||
|
성공 기준:
|
||||||
|
- 소셜 로그인 3종 모두 동작 (네이버·카카오·구글)
|
||||||
|
- 이메일/비밀번호 기본 계정 가입 + 로그인 동작
|
||||||
|
- 가입 폼: 이름·연락처 2필드만 요구 (소셜 프로필에서 자동 채우기)
|
||||||
|
- 로그인 후 고객 포털 (`/taxbaik/portal`) 접근
|
||||||
|
- 고객 계정이 백오피스 clients 테이블 레코드와 연결
|
||||||
|
- 회원 계정 미인증 상태에서 포털 접근 시 로그인 페이지 리다이렉트
|
||||||
|
|
||||||
|
DB 스키마:
|
||||||
|
- `portal_users` 테이블 (V011 마이그레이션)
|
||||||
|
- id, client_id(FK, nullable), email, name, phone, provider(naver/kakao/google/local), provider_id, password_hash(nullable), created_at
|
||||||
|
- 소셜 로그인 provider_id는 각 플랫폼 식별자
|
||||||
|
|
||||||
|
기술 결정:
|
||||||
|
- ASP.NET Core OAuth Middleware (Microsoft.AspNetCore.Authentication.OAuth)
|
||||||
|
- 네이버: 커스텀 OAuth handler (공식 패키지 없음, 직접 구현)
|
||||||
|
- 카카오: AspNet.Security.OAuth.Kakao 패키지
|
||||||
|
- 구글: Microsoft.AspNetCore.Authentication.Google 패키지
|
||||||
|
- 고객 포털 세션: HttpOnly Cookie 기반 (JWT localStorage와 분리)
|
||||||
|
|
||||||
|
환경 변수 필요 (Gitea Secrets 추가):
|
||||||
|
- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`
|
||||||
|
- `KAKAO_CLIENT_ID` / `KAKAO_CLIENT_SECRET`
|
||||||
|
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||||
|
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||||
|
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
||||||
|
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||||
|
- [ ] 네이버 OAuth Handler 구현
|
||||||
|
- [ ] 카카오·구글 패키지 추가 및 설정
|
||||||
|
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||||
|
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||||
|
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||||
|
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||||
|
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||||
|
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ── 유지보수성 ─────────────────────────────────
|
||||||
|
|
||||||
|
## WBS-MAINT-01 유지보수성/파편화 축소
|
||||||
|
|
||||||
|
목표: 문서와 실제 구조의 불일치를 줄이고 단일 앱 운영 기준을 유지한다.
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
- [x] README 테스트/배포 섹션 갱신
|
||||||
|
- [x] CLAUDE.md E2E 기준 갱신
|
||||||
|
- [x] 오래된 최종 보고 문서의 허위 완료 표현 정정
|
||||||
|
- [x] CLAUDE.md 섹션 13 시즌별 마케팅 하네스 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 현재 검증 메모
|
||||||
|
|
||||||
|
- `dotnet build TaxBaik.sln` 성공 (2026-06-27 기준, 경고 0 오류 0)
|
||||||
|
- 최종 배포 커밋: `9c96f15` (FAQ 관리 기능)
|
||||||
|
- WBS-MKT-01/02/03/04 구현 완료, 배포 후 시각 검증 필요
|
||||||
|
- WBS-UX-03/04 구현 완료
|
||||||
|
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
|
||||||
|
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
|
||||||
@@ -4,6 +4,7 @@ using TaxBaik.Application.DTOs;
|
|||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
public class BlogServiceTests
|
public class BlogServiceTests
|
||||||
@@ -11,7 +12,7 @@ public class BlogServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
|
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
|
||||||
{
|
{
|
||||||
var service = new BlogService(new FakeBlogPostRepository());
|
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
|
await Assert.ThrowsAsync<ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,7 @@ public class BlogServiceTests
|
|||||||
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
|
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
var service = new BlogService(repository);
|
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
var post = await service.CreateAsync(new CreateBlogPostDto
|
var post = await service.CreateAsync(new CreateBlogPostDto
|
||||||
{
|
{
|
||||||
@@ -60,9 +61,19 @@ 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>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<IEnumerable<BlogPost>>(Posts.Where(x => x.IsPublished).Take(limit).ToList());
|
||||||
|
|
||||||
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
|
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult<IEnumerable<BlogPost>>(Posts);
|
Task.FromResult<IEnumerable<BlogPost>>(Posts);
|
||||||
|
|
||||||
|
public Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||||
|
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var items = Posts.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;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace TaxBaik.Application.Tests;
|
|||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
public class InquiryServiceTests
|
public class InquiryServiceTests
|
||||||
@@ -10,7 +11,7 @@ public class InquiryServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException()
|
public async Task UpdateStatusAsync_WhenStatusIsInvalid_ThrowsValidationException()
|
||||||
{
|
{
|
||||||
var service = new InquiryService(new FakeInquiryRepository());
|
var service = new InquiryService(new FakeInquiryRepository(), new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ValidationException>(() => service.UpdateStatusAsync(1, "invalid"));
|
await Assert.ThrowsAsync<ValidationException>(() => service.UpdateStatusAsync(1, "invalid"));
|
||||||
}
|
}
|
||||||
@@ -19,7 +20,7 @@ public class InquiryServiceTests
|
|||||||
public async Task SubmitAsync_StoresEmailAndNewStatus()
|
public async Task SubmitAsync_StoresEmailAndNewStatus()
|
||||||
{
|
{
|
||||||
var repository = new FakeInquiryRepository();
|
var repository = new FakeInquiryRepository();
|
||||||
var service = new InquiryService(repository);
|
var service = new InquiryService(repository, new FakeInquiryNotificationService(), new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "문의합니다.", "user@example.com");
|
await service.SubmitAsync("홍길동", "010-1234-5678", "기장", "문의합니다.", "user@example.com");
|
||||||
|
|
||||||
@@ -48,6 +49,21 @@ public class InquiryServiceTests
|
|||||||
return Task.FromResult<(IEnumerable<Inquiry>, int)>((items, items.Count()));
|
return Task.FromResult<(IEnumerable<Inquiry>, int)>((items, items.Count()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(Inquiries.Count);
|
||||||
|
|
||||||
|
public Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(Inquiries.Count);
|
||||||
|
|
||||||
|
public Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(Inquiries.Count(x => x.Status == status));
|
||||||
|
|
||||||
|
public Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(Inquiries.Count(x => x.CreatedAt >= startDate && x.CreatedAt <= endDate));
|
||||||
|
|
||||||
|
public Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(Inquiries.Count(x => x.Status == status && x.CreatedAt >= startDate && x.CreatedAt <= endDate));
|
||||||
|
|
||||||
public Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
public Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||||
@@ -55,5 +71,38 @@ public class InquiryServiceTests
|
|||||||
inquiry.Status = status;
|
inquiry.Status = status;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||||
|
if (inquiry != null)
|
||||||
|
inquiry.AdminMemo = adminMemo;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
|
||||||
|
if (inquiry != null)
|
||||||
|
inquiry.ClientId = clientId;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||||
|
if (inquiry != null)
|
||||||
|
Inquiries.Remove(inquiry);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeInquiryNotificationService : IInquiryNotificationService
|
||||||
|
{
|
||||||
|
public Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TaxBaik.Application.DTOs;
|
||||||
|
|
||||||
|
public class AnnouncementDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public string DisplayType { get; set; } = "info";
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime? StartsAt { get; set; }
|
||||||
|
public DateTime? EndsAt { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace TaxBaik.Application.DTOs;
|
||||||
|
|
||||||
|
public class ClientDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? ServiceType { get; set; }
|
||||||
|
public string? TaxType { get; set; }
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public string? Source { get; set; }
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateClientDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? ServiceType { get; set; }
|
||||||
|
public string? TaxType { get; set; }
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public string? Source { get; set; }
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
}
|
||||||
@@ -9,7 +9,16 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
services.AddScoped<BlogService>();
|
services.AddScoped<BlogService>();
|
||||||
services.AddScoped<InquiryService>();
|
services.AddScoped<InquiryService>();
|
||||||
|
services.AddScoped<AdminDashboardService>();
|
||||||
|
services.AddScoped<IInquiryNotificationService, NoopInquiryNotificationService>();
|
||||||
|
services.AddScoped<SiteSettingService>();
|
||||||
services.AddScoped<CategoryService>();
|
services.AddScoped<CategoryService>();
|
||||||
|
services.AddScoped<AnnouncementService>();
|
||||||
|
services.AddSingleton<SeasonalMarketingService>();
|
||||||
|
services.AddScoped<ClientService>();
|
||||||
|
services.AddScoped<FaqService>();
|
||||||
|
services.AddScoped<ConsultationService>();
|
||||||
|
services.AddScoped<TaxFilingService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TaxBaik.Application.Seasonal;
|
||||||
|
|
||||||
|
public record CurrentSeasonDto
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = "";
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string HeroHeadline { get; init; } = "";
|
||||||
|
public string HeroSubtext { get; init; } = "";
|
||||||
|
public string UrgencyBadge { get; init; } = "";
|
||||||
|
public string FocusService { get; init; } = "";
|
||||||
|
public string RelatedCategorySlug { get; init; } = "";
|
||||||
|
public string CtaText { get; init; } = "상담 신청하기";
|
||||||
|
public int DaysUntilDeadline { get; init; }
|
||||||
|
public DateTime Deadline { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace TaxBaik.Application.Seasonal;
|
||||||
|
|
||||||
|
public record TaxSeason
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = "";
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
|
||||||
|
public int StartMonth { get; init; }
|
||||||
|
public int StartDay { get; init; }
|
||||||
|
public int EndMonth { get; init; }
|
||||||
|
public int EndDay { get; init; }
|
||||||
|
|
||||||
|
public string HeroHeadline { get; init; } = "";
|
||||||
|
public string HeroSubtext { get; init; } = "";
|
||||||
|
public string UrgencyBadge { get; init; } = "";
|
||||||
|
public string FocusService { get; init; } = "";
|
||||||
|
public string CtaText { get; init; } = "상담 신청하기";
|
||||||
|
/// <summary>블로그 시즌 연동 시 우선 노출할 카테고리 slug (categories.slug 참조)</summary>
|
||||||
|
public string RelatedCategorySlug { get; init; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
namespace TaxBaik.Application.Seasonal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 한국 세무사 사무실 연간 시즌 캘린더.
|
||||||
|
/// 각 시즌이 활성화되면 홈페이지 Hero가 해당 세무 이벤트에 맞게 전환된다.
|
||||||
|
/// </summary>
|
||||||
|
public static class TaxSeasonCalendar
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<TaxSeason> Seasons =
|
||||||
|
[
|
||||||
|
new TaxSeason
|
||||||
|
{
|
||||||
|
Key = "vat-2nd",
|
||||||
|
Name = "부가가치세 2기 확정신고",
|
||||||
|
StartMonth = 1, StartDay = 1,
|
||||||
|
EndMonth = 1, EndDay = 25,
|
||||||
|
HeroHeadline = "부가가치세 2기\n1월 25일 마감",
|
||||||
|
HeroSubtext = "일반과세 사업자 확정신고 · 기한 내 신고로 가산세 방지",
|
||||||
|
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||||
|
FocusService = "business-tax",
|
||||||
|
CtaText = "부가세 신고 상담",
|
||||||
|
RelatedCategorySlug = "vat"
|
||||||
|
},
|
||||||
|
new TaxSeason
|
||||||
|
{
|
||||||
|
Key = "year-end-settlement",
|
||||||
|
Name = "연말정산",
|
||||||
|
StartMonth = 1, StartDay = 15,
|
||||||
|
EndMonth = 2, EndDay = 28,
|
||||||
|
HeroHeadline = "연말정산\n지금 준비하세요",
|
||||||
|
HeroSubtext = "직원이 있는 사업자 원천징수 신고 · 환급 최대화",
|
||||||
|
UrgencyBadge = "연말정산 진행 중",
|
||||||
|
FocusService = "business-tax",
|
||||||
|
CtaText = "연말정산 상담",
|
||||||
|
RelatedCategorySlug = "business-tax"
|
||||||
|
},
|
||||||
|
new TaxSeason
|
||||||
|
{
|
||||||
|
Key = "corporate-tax",
|
||||||
|
Name = "법인세 신고",
|
||||||
|
StartMonth = 3, StartDay = 1,
|
||||||
|
EndMonth = 3, EndDay = 31,
|
||||||
|
HeroHeadline = "법인세\n3월 31일 마감",
|
||||||
|
HeroSubtext = "법인사업자 결산 · 세무조정 · 절세 전략 수립",
|
||||||
|
UrgencyBadge = "D-{n}일 | 법인세 마감",
|
||||||
|
FocusService = "business-tax",
|
||||||
|
CtaText = "법인세 신고 상담",
|
||||||
|
RelatedCategorySlug = "business-tax"
|
||||||
|
},
|
||||||
|
new TaxSeason
|
||||||
|
{
|
||||||
|
Key = "income-tax",
|
||||||
|
Name = "종합소득세 신고",
|
||||||
|
StartMonth = 5, StartDay = 1,
|
||||||
|
EndMonth = 5, EndDay = 31,
|
||||||
|
HeroHeadline = "종합소득세\n5월 31일 마감",
|
||||||
|
HeroSubtext = "개인사업자 · 임대소득 · 프리랜서 · 기타소득 모두 해당",
|
||||||
|
UrgencyBadge = "D-{n}일 | 종합소득세 마감",
|
||||||
|
FocusService = "business-tax",
|
||||||
|
CtaText = "종합소득세 상담",
|
||||||
|
RelatedCategorySlug = "income-tax"
|
||||||
|
},
|
||||||
|
new TaxSeason
|
||||||
|
{
|
||||||
|
Key = "vat-1st",
|
||||||
|
Name = "부가가치세 1기 확정신고",
|
||||||
|
StartMonth = 7, StartDay = 1,
|
||||||
|
EndMonth = 7, EndDay = 25,
|
||||||
|
HeroHeadline = "부가가치세 1기\n7월 25일 마감",
|
||||||
|
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
|
||||||
|
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||||
|
FocusService = "business-tax",
|
||||||
|
CtaText = "부가세 신고 상담",
|
||||||
|
RelatedCategorySlug = "vat"
|
||||||
|
},
|
||||||
|
new TaxSeason
|
||||||
|
{
|
||||||
|
Key = "comprehensive-real-estate-tax",
|
||||||
|
Name = "종합부동산세",
|
||||||
|
StartMonth = 11, StartDay = 15,
|
||||||
|
EndMonth = 11, EndDay = 30,
|
||||||
|
HeroHeadline = "종합부동산세\n납부 시즌",
|
||||||
|
HeroSubtext = "다주택자 · 임대사업자 세부담 분석 · 분납·합산배제 검토",
|
||||||
|
UrgencyBadge = "D-{n}일 | 종부세 납부",
|
||||||
|
FocusService = "real-estate-tax",
|
||||||
|
CtaText = "종부세 절세 상담",
|
||||||
|
RelatedCategorySlug = "real-estate-tax"
|
||||||
|
},
|
||||||
|
new TaxSeason
|
||||||
|
{
|
||||||
|
Key = "year-end-gift",
|
||||||
|
Name = "연말 증여·절세 플래닝",
|
||||||
|
StartMonth = 12, StartDay = 1,
|
||||||
|
EndMonth = 12, EndDay = 31,
|
||||||
|
HeroHeadline = "연말 절세 플래닝\n마지막 기회",
|
||||||
|
HeroSubtext = "증여 공제 한도 · 자산 이전 · 법인전환 연간 마감",
|
||||||
|
UrgencyBadge = "D-{n}일 | 연간 증여 한도 마감",
|
||||||
|
FocusService = "family-asset",
|
||||||
|
CtaText = "연말 절세 상담",
|
||||||
|
RelatedCategorySlug = "family-asset"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public record AdminDashboardSummary(
|
||||||
|
int ThisMonthInquiries,
|
||||||
|
int NewInquiries,
|
||||||
|
int TotalPosts,
|
||||||
|
int PublishedPosts,
|
||||||
|
IReadOnlyList<Inquiry> RecentInquiries);
|
||||||
|
|
||||||
|
public class AdminDashboardService(
|
||||||
|
InquiryService inquiryService,
|
||||||
|
BlogService blogService,
|
||||||
|
IMemoryCache memoryCache)
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30);
|
||||||
|
public const string CacheKey = "admin-dashboard-summary";
|
||||||
|
|
||||||
|
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (memoryCache.TryGetValue(CacheKey, out AdminDashboardSummary? cached) && cached != null)
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var recentTask = inquiryService.GetPagedAsync(1, 5, ct: ct);
|
||||||
|
var thisMonthTask = inquiryService.CountThisMonthAsync(ct);
|
||||||
|
var newTask = inquiryService.CountByStatusAsync("new", ct);
|
||||||
|
var statsTask = blogService.GetStatsAsync(ct);
|
||||||
|
|
||||||
|
var (recentInquiries, _) = await recentTask;
|
||||||
|
var stats = await statsTask;
|
||||||
|
var summary = new AdminDashboardSummary(
|
||||||
|
ThisMonthInquiries: await thisMonthTask,
|
||||||
|
NewInquiries: await newTask,
|
||||||
|
TotalPosts: stats.TotalPosts,
|
||||||
|
PublishedPosts: stats.PublishedPosts,
|
||||||
|
RecentInquiries: recentInquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList());
|
||||||
|
|
||||||
|
memoryCache.Set(CacheKey, summary, CacheDuration);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 최근 문의 조회
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<Inquiry>> GetRecentInquiriesAsync(int limit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (inquiries, _) = await inquiryService.GetPagedAsync(1, limit, ct: ct);
|
||||||
|
return inquiries.OrderByDescending(x => x.CreatedAt).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 월별 통계 (접수 건수, 진행 중, 완료)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<object> GetMonthlyStatsAsync(string? month, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var targetMonth = month != null && DateTime.TryParse($"{month}-01", out var dt)
|
||||||
|
? dt
|
||||||
|
: DateTime.Today;
|
||||||
|
|
||||||
|
var startDate = new DateTime(targetMonth.Year, targetMonth.Month, 1);
|
||||||
|
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||||
|
|
||||||
|
// 캐시 시도 (일 단위)
|
||||||
|
var cacheKey = $"admin-stats-{startDate:yyyy-MM}";
|
||||||
|
if (memoryCache.TryGetValue(cacheKey, out object? cachedStats) && cachedStats != null)
|
||||||
|
return cachedStats;
|
||||||
|
|
||||||
|
var total = await inquiryService.CountByDateRangeAsync(startDate, endDate, ct);
|
||||||
|
var consulting = await inquiryService.CountByStatusAndDateAsync("consulting", startDate, endDate, ct);
|
||||||
|
var completed = await inquiryService.CountByStatusAndDateAsync("contracted", startDate, endDate, ct);
|
||||||
|
|
||||||
|
var result = new
|
||||||
|
{
|
||||||
|
month = startDate.ToString("yyyy-MM"),
|
||||||
|
totalInquiries = total,
|
||||||
|
consultingCount = consulting,
|
||||||
|
completedCount = completed,
|
||||||
|
newCount = total - consulting - completed,
|
||||||
|
completionRate = total > 0 ? (completed * 100.0 / total) : 0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
memoryCache.Set(cacheKey, result, TimeSpan.FromHours(1));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class AnnouncementService(IAnnouncementRepository repository)
|
||||||
|
{
|
||||||
|
public Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken ct = default)
|
||||||
|
=> repository.GetActiveAsync(ct);
|
||||||
|
|
||||||
|
public Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public Task<Announcement?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
=> repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public Task<int> CreateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = MapToEntity(dto);
|
||||||
|
return repository.CreateAsync(entity, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateAsync(AnnouncementDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = MapToEntity(dto);
|
||||||
|
return repository.UpdateAsync(entity, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
=> repository.DeleteAsync(id, ct);
|
||||||
|
|
||||||
|
private static Announcement MapToEntity(AnnouncementDto dto) => new()
|
||||||
|
{
|
||||||
|
Id = dto.Id,
|
||||||
|
Title = dto.Title.Trim(),
|
||||||
|
Content = string.IsNullOrWhiteSpace(dto.Content) ? null : dto.Content.Trim(),
|
||||||
|
DisplayType = dto.DisplayType,
|
||||||
|
IsActive = dto.IsActive,
|
||||||
|
StartsAt = dto.StartsAt,
|
||||||
|
EndsAt = dto.EndsAt,
|
||||||
|
SortOrder = dto.SortOrder
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,11 +5,29 @@ using TaxBaik.Application.DTOs;
|
|||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
public class BlogService(IBlogPostRepository repository)
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache)
|
||||||
{
|
{
|
||||||
|
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
||||||
await repository.GetBySlugAsync(slug, ct);
|
await repository.GetBySlugAsync(slug, ct);
|
||||||
|
|
||||||
|
/// <summary>카테고리 슬러그 기준 시즌 관련 글 조회. 부족 시 최신 글로 채워 total개 반환.</summary>
|
||||||
|
public async Task<(IEnumerable<BlogPost> Seasonal, IEnumerable<BlogPost> Latest)> GetSeasonalPostsAsync(
|
||||||
|
string categorySlug, int seasonalCount, int totalCount, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var seasonal = (await repository.GetByCategorySlugAsync(categorySlug, seasonalCount, ct)).ToList();
|
||||||
|
var seasonalIds = seasonal.Select(p => p.Id).ToHashSet();
|
||||||
|
|
||||||
|
var (latestAll, _) = await repository.GetPublishedPagedAsync(1, totalCount + seasonalCount, null, ct);
|
||||||
|
var latest = latestAll.Where(p => !seasonalIds.Contains(p.Id)).Take(totalCount - seasonal.Count).ToList();
|
||||||
|
|
||||||
|
return (seasonal, latest);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<BlogPost>, int)> GetPublishedPagedAsync(
|
public async Task<(IEnumerable<BlogPost>, int)> GetPublishedPagedAsync(
|
||||||
int page, int pageSize, int? categoryId = null, CancellationToken ct = default) =>
|
int page, int pageSize, int? categoryId = null, CancellationToken ct = default) =>
|
||||||
await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct);
|
await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct);
|
||||||
@@ -20,6 +38,10 @@ public class BlogService(IBlogPostRepository repository)
|
|||||||
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken ct = default) =>
|
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken ct = default) =>
|
||||||
await repository.GetAllForAdminAsync(ct);
|
await repository.GetAllForAdminAsync(ct);
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BlogPost>, int)> GetAdminPagedAsync(
|
||||||
|
int page, int pageSize, CancellationToken ct = default) =>
|
||||||
|
await repository.GetAdminPagedAsync(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);
|
||||||
@@ -27,7 +49,9 @@ public class BlogService(IBlogPostRepository repository)
|
|||||||
post.Content = post.Content.Trim();
|
post.Content = post.Content.Trim();
|
||||||
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
|
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
|
||||||
post.PublishedAt = post.IsPublished ? DateTime.UtcNow : null;
|
post.PublishedAt = post.IsPublished ? DateTime.UtcNow : null;
|
||||||
return await repository.CreateAsync(post, ct);
|
var result = await repository.CreateAsync(post, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
@@ -51,8 +75,11 @@ public class BlogService(IBlogPostRepository repository)
|
|||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) =>
|
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
|
||||||
|
{
|
||||||
await repository.UpdateAsync(post, ct);
|
await repository.UpdateAsync(post, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -77,8 +104,11 @@ public class BlogService(IBlogPostRepository repository)
|
|||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
await repository.DeleteAsync(id, ct);
|
await repository.DeleteAsync(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);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
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(
|
||||||
|
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);
|
||||||
|
|
||||||
|
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
throw new ValidationException("고객명을 입력하세요.");
|
||||||
|
|
||||||
|
var client = new Client
|
||||||
|
{
|
||||||
|
Name = dto.Name.Trim(),
|
||||||
|
CompanyName = dto.CompanyName?.Trim(),
|
||||||
|
Phone = dto.Phone?.Trim(),
|
||||||
|
Email = dto.Email?.Trim(),
|
||||||
|
ServiceType = dto.ServiceType,
|
||||||
|
TaxType = dto.TaxType,
|
||||||
|
Status = dto.Status,
|
||||||
|
Source = dto.Source,
|
||||||
|
Memo = dto.Memo?.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(client, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
throw new ValidationException("고객명을 입력하세요.");
|
||||||
|
|
||||||
|
var client = await repository.GetByIdAsync(id, ct)
|
||||||
|
?? throw new KeyNotFoundException($"고객 ID {id}를 찾을 수 없습니다.");
|
||||||
|
|
||||||
|
client.Name = dto.Name.Trim();
|
||||||
|
client.CompanyName = dto.CompanyName?.Trim();
|
||||||
|
client.Phone = dto.Phone?.Trim();
|
||||||
|
client.Email = dto.Email?.Trim();
|
||||||
|
client.ServiceType = dto.ServiceType;
|
||||||
|
client.TaxType = dto.TaxType;
|
||||||
|
client.Status = dto.Status;
|
||||||
|
client.Source = dto.Source;
|
||||||
|
client.Memo = dto.Memo?.Trim();
|
||||||
|
|
||||||
|
await repository.UpdateAsync(client, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateFromInquiryAsync(string name, string? phone, string? serviceType, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var client = new Client
|
||||||
|
{
|
||||||
|
Name = name.Trim(),
|
||||||
|
Phone = phone?.Trim(),
|
||||||
|
ServiceType = serviceType,
|
||||||
|
Status = "active",
|
||||||
|
Source = "홈페이지 문의"
|
||||||
|
};
|
||||||
|
return await repository.CreateAsync(client, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.DeleteAsync(id, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class ConsultationService(IConsultationRepository repository)
|
||||||
|
{
|
||||||
|
public static readonly string[] Results =
|
||||||
|
["상담 중", "계약 완료", "보류", "거절", "완료"];
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(consultation.Summary))
|
||||||
|
throw new ValidationException("상담 내용을 입력하세요.");
|
||||||
|
if (consultation.ClientId <= 0)
|
||||||
|
throw new ValidationException("고객을 선택하세요.");
|
||||||
|
return await repository.CreateAsync(consultation, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.DeleteAsync(id, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class FaqService(IFaqRepository repository)
|
||||||
|
{
|
||||||
|
public static readonly string[] Categories =
|
||||||
|
["기장·세금신고", "부동산", "증여·상속", "기타"];
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetActiveAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Faq faq, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Validate(faq);
|
||||||
|
return await repository.CreateAsync(faq, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Faq faq, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Validate(faq);
|
||||||
|
await repository.UpdateAsync(faq, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.DeleteAsync(id, ct);
|
||||||
|
|
||||||
|
private static void Validate(Faq faq)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(faq.Question))
|
||||||
|
throw new ValidationException("질문을 입력하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(faq.Answer))
|
||||||
|
throw new ValidationException("답변을 입력하세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
public interface IInquiryNotificationService
|
||||||
|
{
|
||||||
|
Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default);
|
||||||
|
Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
namespace TaxBaik.Application.Services;
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Domain.Enums;
|
using TaxBaik.Domain.Enums;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
public class InquiryService(IInquiryRepository repository)
|
public class InquiryService(
|
||||||
|
IInquiryRepository repository,
|
||||||
|
IInquiryNotificationService notificationService,
|
||||||
|
IMemoryCache memoryCache)
|
||||||
{
|
{
|
||||||
private static readonly Regex PhoneRegex = new(@"^01[0-9]-\d{3,4}-\d{4}$");
|
private static readonly Regex PhoneRegex = new(@"^01[0-9]-\d{3,4}-\d{4}$");
|
||||||
|
|
||||||
@@ -34,7 +38,10 @@ public class InquiryService(IInquiryRepository repository)
|
|||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
return await repository.CreateAsync(inquiry, ct);
|
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
||||||
|
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
return inquiryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
@@ -44,12 +51,48 @@ public class InquiryService(IInquiryRepository repository)
|
|||||||
int page, int pageSize, string? status = null, CancellationToken ct = default) =>
|
int page, int pageSize, string? status = null, CancellationToken ct = default) =>
|
||||||
await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), NormalizeOptionalStatus(status), ct);
|
await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), NormalizeOptionalStatus(status), ct);
|
||||||
|
|
||||||
public async Task UpdateStatusAsync(int id, string status, CancellationToken ct = default)
|
public Task<int> CountAsync(CancellationToken ct = default)
|
||||||
|
=> repository.CountAsync(ct);
|
||||||
|
|
||||||
|
public Task<int> CountThisMonthAsync(CancellationToken ct = default)
|
||||||
|
=> repository.CountThisMonthAsync(ct);
|
||||||
|
|
||||||
|
public Task<int> CountByStatusAsync(string status, CancellationToken ct = default)
|
||||||
|
=> repository.CountByStatusAsync(status, ct);
|
||||||
|
|
||||||
|
public Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
|
||||||
|
=> repository.CountByDateRangeAsync(startDate, endDate, ct);
|
||||||
|
|
||||||
|
public Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken ct = default)
|
||||||
|
=> repository.CountByStatusAndDateAsync(status, startDate, endDate, ct);
|
||||||
|
|
||||||
|
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) =>
|
||||||
|
await repository.UpdateAdminMemoAsync(id, adminMemo, ct);
|
||||||
|
|
||||||
|
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.LinkClientAsync(inquiryId, clientId, ct);
|
||||||
|
|
||||||
|
public async Task UpdateStatusAsync(int id, string status, string? changedBy = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!InquiryStatusMapper.TryParse(status, out var parsed))
|
if (!InquiryStatusMapper.TryParse(status, out var parsed))
|
||||||
throw new ValidationException("지원하지 않는 문의 상태입니다.");
|
throw new ValidationException("지원하지 않는 문의 상태입니다.");
|
||||||
|
|
||||||
await repository.UpdateStatusAsync(id, InquiryStatusMapper.ToStorageValue(parsed), ct);
|
var inquiry = await repository.GetByIdAsync(id, ct);
|
||||||
|
if (inquiry == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var previousStatus = inquiry.Status;
|
||||||
|
var newStatus = InquiryStatusMapper.ToStorageValue(parsed);
|
||||||
|
|
||||||
|
await repository.UpdateStatusAsync(id, newStatus, ct);
|
||||||
|
await notificationService.NotifyStatusChangedAsync(id, inquiry.Name, inquiry.Phone, inquiry.ServiceType, previousStatus, newStatus, changedBy, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await repository.DeleteAsync(id, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||||
|
|||||||
@@ -4,24 +4,37 @@ using TaxBaik.Domain.Enums;
|
|||||||
|
|
||||||
public static class InquiryStatusMapper
|
public static class InquiryStatusMapper
|
||||||
{
|
{
|
||||||
|
public static readonly Dictionary<string, string> Labels = new()
|
||||||
|
{
|
||||||
|
["new"] = "신규",
|
||||||
|
["consulting"] = "상담중",
|
||||||
|
["contracted"] = "계약완료",
|
||||||
|
["rejected"] = "거절",
|
||||||
|
["closed"] = "종결",
|
||||||
|
};
|
||||||
|
|
||||||
public static string ToStorageValue(InquiryStatus status) => status switch
|
public static string ToStorageValue(InquiryStatus status) => status switch
|
||||||
{
|
{
|
||||||
InquiryStatus.New => "new",
|
InquiryStatus.New => "new",
|
||||||
InquiryStatus.Contacted => "contacted",
|
InquiryStatus.Consulting => "consulting",
|
||||||
InquiryStatus.Completed => "completed",
|
InquiryStatus.Contracted => "contracted",
|
||||||
|
InquiryStatus.Rejected => "rejected",
|
||||||
|
InquiryStatus.Closed => "closed",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
public static bool TryParse(string? value, out InquiryStatus status)
|
public static bool TryParse(string? value, out InquiryStatus status)
|
||||||
{
|
{
|
||||||
status = value?.Trim().ToLowerInvariant() switch
|
var key = value?.Trim().ToLowerInvariant();
|
||||||
|
status = key switch
|
||||||
{
|
{
|
||||||
"new" => InquiryStatus.New,
|
"new" => InquiryStatus.New,
|
||||||
"contacted" => InquiryStatus.Contacted,
|
"consulting" => InquiryStatus.Consulting,
|
||||||
"completed" => InquiryStatus.Completed,
|
"contracted" => InquiryStatus.Contracted,
|
||||||
|
"rejected" => InquiryStatus.Rejected,
|
||||||
|
"closed" => InquiryStatus.Closed,
|
||||||
_ => default
|
_ => default
|
||||||
};
|
};
|
||||||
|
return key is "new" or "consulting" or "contracted" or "rejected" or "closed";
|
||||||
return value?.Trim().ToLowerInvariant() is "new" or "contacted" or "completed";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
public sealed class NoopInquiryNotificationService : IInquiryNotificationService
|
||||||
|
{
|
||||||
|
public Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Application.Seasonal;
|
||||||
|
|
||||||
|
public class SeasonalMarketingService
|
||||||
|
{
|
||||||
|
public CurrentSeasonDto? GetCurrentSeason()
|
||||||
|
{
|
||||||
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
foreach (var season in TaxSeasonCalendar.Seasons)
|
||||||
|
{
|
||||||
|
var start = new DateTime(today.Year, season.StartMonth, season.StartDay);
|
||||||
|
var end = new DateTime(today.Year, season.EndMonth, season.EndDay);
|
||||||
|
|
||||||
|
if (today >= start && today <= end)
|
||||||
|
{
|
||||||
|
var days = (end - today).Days;
|
||||||
|
return new CurrentSeasonDto
|
||||||
|
{
|
||||||
|
Key = season.Key,
|
||||||
|
Name = season.Name,
|
||||||
|
HeroHeadline = season.HeroHeadline,
|
||||||
|
HeroSubtext = season.HeroSubtext,
|
||||||
|
UrgencyBadge = season.UrgencyBadge.Replace("{n}", days.ToString()),
|
||||||
|
FocusService = season.FocusService,
|
||||||
|
RelatedCategorySlug = season.RelatedCategorySlug,
|
||||||
|
CtaText = season.CtaText,
|
||||||
|
DaysUntilDeadline = days,
|
||||||
|
Deadline = end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<TaxSeason> GetFullCalendar() => TaxSeasonCalendar.Seasons;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
public class SiteSettingService(ISiteSettingRepository repository)
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public Task SaveAsync(string phone, string email, string kakaoUrl, string instagramUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var settings = new[]
|
||||||
|
{
|
||||||
|
new SiteSetting { Key = "PhoneNumber", Value = phone.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||||
|
new SiteSetting { Key = "EmailAddress", Value = email.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||||
|
new SiteSetting { Key = "KakaoChannelUrl", Value = kakaoUrl.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||||
|
new SiteSetting { Key = "InstagramUrl", Value = instagramUrl.Trim(), UpdatedAt = DateTime.UtcNow },
|
||||||
|
};
|
||||||
|
|
||||||
|
return repository.UpsertAsync(settings, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class TaxFilingService(ITaxFilingRepository repository)
|
||||||
|
{
|
||||||
|
public static readonly string[] FilingTypes =
|
||||||
|
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
|
||||||
|
|
||||||
|
public static readonly string[] Statuses =
|
||||||
|
["pending", "filed", "overdue"];
|
||||||
|
|
||||||
|
public static readonly Dictionary<string, string> StatusLabels = new()
|
||||||
|
{
|
||||||
|
["pending"] = "신고 예정",
|
||||||
|
["filed"] = "신고 완료",
|
||||||
|
["overdue"] = "기한 초과",
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||||
|
await repository.GetUpcomingAsync(daysAhead, ct);
|
||||||
|
|
||||||
|
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filing.FilingType))
|
||||||
|
throw new ValidationException("신고 유형을 선택하세요.");
|
||||||
|
if (filing.ClientId <= 0)
|
||||||
|
throw new ValidationException("고객을 선택하세요.");
|
||||||
|
if (filing.DueDate == default)
|
||||||
|
throw new ValidationException("신고 기한을 입력하세요.");
|
||||||
|
return await repository.CreateAsync(filing, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filing.FilingType))
|
||||||
|
throw new ValidationException("신고 유형을 선택하세요.");
|
||||||
|
await repository.UpdateAsync(filing, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.DeleteAsync(id, ct);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class Announcement
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public string DisplayType { get; set; } = "info";
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTime? StartsAt { get; set; }
|
||||||
|
public DateTime? EndsAt { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class Client
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? ServiceType { get; set; }
|
||||||
|
public string? TaxType { get; set; }
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public string? Source { get; set; }
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class Consultation
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public DateTime ConsultationDate { get; set; }
|
||||||
|
public string? ServiceType { get; set; }
|
||||||
|
public string Summary { get; set; } = null!;
|
||||||
|
public string? Result { get; set; }
|
||||||
|
public decimal? Fee { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class Faq
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Question { get; set; } = null!;
|
||||||
|
public string Answer { get; set; } = null!;
|
||||||
|
public string? Category { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -10,5 +10,8 @@ public class Inquiry
|
|||||||
public string Message { get; set; } = null!;
|
public string Message { get; set; } = null!;
|
||||||
public string Status { get; set; } = "new";
|
public string Status { get; set; } = "new";
|
||||||
public string? IpAddress { get; set; }
|
public string? IpAddress { get; set; }
|
||||||
|
public int? ClientId { get; set; }
|
||||||
|
public string? AdminMemo { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class SiteSetting
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = null!;
|
||||||
|
public string Value { get; set; } = null!;
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class TaxFiling
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public string FilingType { get; set; } = null!;
|
||||||
|
public DateTime DueDate { get; set; }
|
||||||
|
public string Status { get; set; } = "pending";
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
// join
|
||||||
|
public string? ClientName { get; set; }
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ namespace TaxBaik.Domain.Enums;
|
|||||||
public enum InquiryStatus
|
public enum InquiryStatus
|
||||||
{
|
{
|
||||||
New = 0,
|
New = 0,
|
||||||
Contacted = 1,
|
Consulting = 1,
|
||||||
Completed = 2
|
Contracted = 2,
|
||||||
|
Rejected = 3,
|
||||||
|
Closed = 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IAnnouncementRepository
|
||||||
|
{
|
||||||
|
Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<Announcement?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<int> CreateAsync(Announcement announcement, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(Announcement announcement, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ public interface IBlogPostRepository
|
|||||||
Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
||||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
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);
|
||||||
|
Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||||
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IClientRepository
|
||||||
|
{
|
||||||
|
Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||||
|
int page, int pageSize, string? status = null, string? search = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(Client client, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Client client, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IConsultationRepository
|
||||||
|
{
|
||||||
|
Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IFaqRepository
|
||||||
|
{
|
||||||
|
Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default);
|
||||||
|
Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(Faq faq, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Faq faq, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -8,5 +8,13 @@ public interface IInquiryRepository
|
|||||||
Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||||
int page, int pageSize, string? status = null, CancellationToken cancellationToken = default);
|
int page, int pageSize, string? status = null, CancellationToken cancellationToken = default);
|
||||||
|
Task<int> CountAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default);
|
||||||
|
Task<int> CountByDateRangeAsync(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 LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface ISiteSettingRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task UpsertAsync(IEnumerable<SiteSetting> settings, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ITaxFilingRepository
|
||||||
|
{
|
||||||
|
Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||||
|
Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead, CancellationToken ct = default);
|
||||||
|
Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(TaxFiling filing, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICategoryRepository, CategoryRepository>();
|
services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
services.AddScoped<IBlogPostRepository, BlogPostRepository>();
|
services.AddScoped<IBlogPostRepository, BlogPostRepository>();
|
||||||
services.AddScoped<IInquiryRepository, InquiryRepository>();
|
services.AddScoped<IInquiryRepository, InquiryRepository>();
|
||||||
|
services.AddScoped<ISiteSettingRepository, SiteSettingRepository>();
|
||||||
|
services.AddScoped<IAnnouncementRepository, AnnouncementRepository>();
|
||||||
|
services.AddScoped<IClientRepository, ClientRepository>();
|
||||||
|
services.AddScoped<IFaqRepository, FaqRepository>();
|
||||||
|
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
||||||
|
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class AnnouncementRepository(IDbConnectionFactory connectionFactory)
|
||||||
|
: BaseRepository(connectionFactory), IAnnouncementRepository
|
||||||
|
{
|
||||||
|
private const string SelectColumns =
|
||||||
|
"id, title, content, display_type, is_active, starts_at, ends_at, sort_order, created_at, updated_at";
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Announcement>> GetActiveAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Announcement>(
|
||||||
|
$@"SELECT {SelectColumns}
|
||||||
|
FROM announcements
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
AND (starts_at IS NULL OR starts_at <= NOW())
|
||||||
|
AND (ends_at IS NULL OR ends_at >= NOW())
|
||||||
|
ORDER BY sort_order DESC, created_at DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Announcement>(
|
||||||
|
$"SELECT {SelectColumns} FROM announcements ORDER BY sort_order DESC, created_at DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Announcement?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Announcement>(
|
||||||
|
$"SELECT {SelectColumns} FROM announcements WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Announcement announcement, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO announcements
|
||||||
|
(title, content, display_type, is_active, starts_at, ends_at, sort_order, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(@Title, @Content, @DisplayType, @IsActive, @StartsAt, @EndsAt, @SortOrder, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Announcement announcement, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE announcements
|
||||||
|
SET title = @Title,
|
||||||
|
content = @Content,
|
||||||
|
display_type = @DisplayType,
|
||||||
|
is_active = @IsActive,
|
||||||
|
starts_at = @StartsAt,
|
||||||
|
ends_at = @EndsAt,
|
||||||
|
sort_order = @SortOrder,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM announcements WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,21 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
return (items, total);
|
return (items, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<BlogPost>(
|
||||||
|
@"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.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||||
|
FROM blog_posts bp
|
||||||
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
WHERE bp.is_published = TRUE AND c.slug = @CategorySlug
|
||||||
|
ORDER BY bp.published_at DESC
|
||||||
|
LIMIT @Limit",
|
||||||
|
new { CategorySlug = categorySlug, Limit = limit });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default)
|
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
@@ -70,6 +85,30 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
|||||||
ORDER BY bp.created_at DESC");
|
ORDER BY bp.created_at DESC");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||||
|
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, c.name AS category_name
|
||||||
|
FROM blog_posts bp
|
||||||
|
LEFT JOIN categories c ON bp.category_id = c.id
|
||||||
|
ORDER BY bp.created_at DESC
|
||||||
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
|
SELECT COUNT(*) FROM blog_posts;",
|
||||||
|
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();
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IClientRepository
|
||||||
|
{
|
||||||
|
private const string SelectColumns =
|
||||||
|
"id, name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at";
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||||
|
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
|
$@"SELECT {SelectColumns} FROM clients
|
||||||
|
WHERE (@Status::text IS NULL OR status = @Status)
|
||||||
|
AND (@Search::text IS NULL OR name ILIKE @SearchLike OR phone ILIKE @SearchLike OR company_name ILIKE @SearchLike)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
|
SELECT COUNT(*) FROM clients
|
||||||
|
WHERE (@Status::text IS NULL OR status = @Status)
|
||||||
|
AND (@Search::text IS NULL OR name ILIKE @SearchLike OR phone ILIKE @SearchLike OR company_name ILIKE @SearchLike);",
|
||||||
|
new { Status = status, Search = search, SearchLike = string.IsNullOrEmpty(search) ? null : $"%{search}%", PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
|
var items = (await reader.ReadAsync<Client>()).ToList();
|
||||||
|
var total = await reader.ReadFirstAsync<int>();
|
||||||
|
return (items, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||||
|
$"SELECT {SelectColumns} FROM clients WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Client client, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO clients (name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at)
|
||||||
|
VALUES (@Name, @CompanyName, @Phone, @Email, @ServiceType, @TaxType, @Status, @Source, @Memo, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
client);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Client client, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE clients
|
||||||
|
SET name = @Name, company_name = @CompanyName, phone = @Phone, email = @Email,
|
||||||
|
service_type = @ServiceType, tax_type = @TaxType, status = @Status,
|
||||||
|
source = @Source, memo = @Memo, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
client);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM clients WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class ConsultationRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IConsultationRepository
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Consultation>(
|
||||||
|
@"SELECT id, client_id, consultation_date, service_type, summary, result, fee, created_at
|
||||||
|
FROM consultations
|
||||||
|
WHERE client_id = @ClientId
|
||||||
|
ORDER BY consultation_date DESC, id DESC",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO consultations (client_id, consultation_date, service_type, summary, result, fee, created_at)
|
||||||
|
VALUES (@ClientId, @ConsultationDate, @ServiceType, @Summary, @Result, @Fee, NOW())
|
||||||
|
RETURNING id",
|
||||||
|
consultation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM consultations WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class FaqRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IFaqRepository
|
||||||
|
{
|
||||||
|
private const string SelectColumns =
|
||||||
|
"id, question, answer, category, sort_order, is_active, created_at, updated_at";
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Faq>(
|
||||||
|
$"SELECT {SelectColumns} FROM faqs WHERE is_active = TRUE ORDER BY sort_order, id");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Faq>(
|
||||||
|
$"SELECT {SelectColumns} FROM faqs ORDER BY sort_order, id");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Faq>(
|
||||||
|
$"SELECT {SelectColumns} FROM faqs WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Faq faq, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO faqs (question, answer, category, sort_order, is_active, created_at, updated_at)
|
||||||
|
VALUES (@Question, @Answer, @Category, @SortOrder, @IsActive, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
faq);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Faq faq, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE faqs
|
||||||
|
SET question = @Question, answer = @Answer, category = @Category,
|
||||||
|
sort_order = @SortOrder, is_active = @IsActive, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
faq);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM faqs WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
|||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
return await conn.QueryFirstOrDefaultAsync<Inquiry>(
|
return await conn.QueryFirstOrDefaultAsync<Inquiry>(
|
||||||
"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at FROM inquiries WHERE id = @Id",
|
@"SELECT id, name, phone, email, service_type, message, status, ip_address,
|
||||||
|
client_id, admin_memo, created_at, updated_at
|
||||||
|
FROM inquiries WHERE id = @Id",
|
||||||
new { Id = id });
|
new { Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +33,8 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
|||||||
var offset = (page - 1) * pageSize;
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
using var reader = await conn.QueryMultipleAsync(
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
@"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at
|
@"SELECT id, name, phone, email, service_type, message, status, ip_address,
|
||||||
|
client_id, admin_memo, created_at, updated_at
|
||||||
FROM inquiries
|
FROM inquiries
|
||||||
WHERE @Status::text IS NULL OR status = @Status
|
WHERE @Status::text IS NULL OR status = @Status
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -47,9 +50,79 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
|||||||
return (items, total);
|
return (items, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM inquiries");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.ExecuteScalarAsync<int>(
|
||||||
|
@"SELECT COUNT(*)
|
||||||
|
FROM inquiries
|
||||||
|
WHERE created_at >= date_trunc('month', NOW())
|
||||||
|
AND created_at < date_trunc('month', NOW()) + INTERVAL '1 month'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM inquiries WHERE status = @Status",
|
||||||
|
new { Status = status });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.ExecuteScalarAsync<int>(
|
||||||
|
@"SELECT COUNT(*)
|
||||||
|
FROM inquiries
|
||||||
|
WHERE created_at >= @StartDate AND created_at <= @EndDate",
|
||||||
|
new { StartDate = startDate, EndDate = endDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.ExecuteScalarAsync<int>(
|
||||||
|
@"SELECT COUNT(*)
|
||||||
|
FROM inquiries
|
||||||
|
WHERE status = @Status
|
||||||
|
AND created_at >= @StartDate
|
||||||
|
AND created_at <= @EndDate",
|
||||||
|
new { Status = status, StartDate = startDate, EndDate = endDate });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
public async Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
await conn.ExecuteAsync("UPDATE inquiries SET status = @Status WHERE id = @Id", new { Id = id, Status = status });
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE inquiries SET status = @Status, updated_at = NOW() WHERE id = @Id",
|
||||||
|
new { Id = id, Status = status });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE inquiries SET admin_memo = @AdminMemo, updated_at = NOW() WHERE id = @Id",
|
||||||
|
new { Id = id, AdminMemo = adminMemo });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE inquiries SET client_id = @ClientId, updated_at = NOW() WHERE id = @Id",
|
||||||
|
new { Id = inquiryId, ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM inquiries WHERE id = @Id", new { Id = id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
public class SiteSettingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ISiteSettingRepository
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var rows = await conn.QueryAsync<SiteSetting>(
|
||||||
|
"SELECT key, value, updated_at AS UpdatedAt FROM site_settings ORDER BY key");
|
||||||
|
return rows.ToDictionary(x => x.Key, x => x.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertAsync(IEnumerable<SiteSetting> settings, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
foreach (var setting in settings)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO site_settings (key, value, updated_at)
|
||||||
|
VALUES (@Key, @Value, NOW())
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||||
|
setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class TaxFilingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxFilingRepository
|
||||||
|
{
|
||||||
|
private const string SelectColumns = @"
|
||||||
|
tf.id, tf.client_id, c.name AS client_name, tf.filing_type, tf.due_date,
|
||||||
|
tf.status, tf.memo, tf.created_at, tf.updated_at";
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxFiling>(
|
||||||
|
$@"SELECT {SelectColumns}
|
||||||
|
FROM tax_filings tf
|
||||||
|
JOIN clients c ON c.id = tf.client_id
|
||||||
|
WHERE tf.client_id = @ClientId
|
||||||
|
ORDER BY tf.due_date ASC",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxFiling>(
|
||||||
|
$@"SELECT {SelectColumns}
|
||||||
|
FROM tax_filings tf
|
||||||
|
JOIN clients c ON c.id = tf.client_id
|
||||||
|
WHERE tf.status = 'pending'
|
||||||
|
AND tf.due_date <= CURRENT_DATE + @DaysAhead::int
|
||||||
|
AND tf.due_date >= CURRENT_DATE
|
||||||
|
ORDER BY tf.due_date ASC",
|
||||||
|
new { DaysAhead = daysAhead });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<TaxFiling>(
|
||||||
|
$@"SELECT {SelectColumns}
|
||||||
|
FROM tax_filings tf
|
||||||
|
JOIN clients c ON c.id = tf.client_id
|
||||||
|
WHERE tf.id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO tax_filings (client_id, filing_type, due_date, status, memo, created_at, updated_at)
|
||||||
|
VALUES (@ClientId, @FilingType, @DueDate, @Status, @Memo, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
filing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(TaxFiling filing, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE tax_filings
|
||||||
|
SET filing_type = @FilingType, due_date = @DueDate, status = @Status,
|
||||||
|
memo = @Memo, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
filing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM tax_filings WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
@@ -8,12 +9,122 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&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 href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="/taxbaik/css/admin.css" />
|
<script>
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'admin-login-route',
|
||||||
|
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="css/admin.css" />
|
||||||
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
|
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="RenderMode.InteractiveServer" />
|
<div id="components-reconnect-modal" class="admin-reconnect-modal">
|
||||||
|
<div class="admin-reconnect-card">
|
||||||
|
<strong>연결 재설정 중...</strong>
|
||||||
|
<span>새로운 버전으로 업데이트되었습니다.</span>
|
||||||
|
<span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="blazor-loading" class="blazor-loading-overlay show">
|
||||||
|
<div class="blazor-loading-spinner">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>로드 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
||||||
|
<MudDialogProvider />
|
||||||
|
<MudSnackbarProvider />
|
||||||
|
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
|
<script src="js/admin-session.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
<script>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 = "8px"
|
||||||
|
},
|
||||||
|
Typography = new Typography()
|
||||||
|
{
|
||||||
|
Default = new Default()
|
||||||
|
{
|
||||||
|
FontSize = ".875rem",
|
||||||
|
FontWeight = 400,
|
||||||
|
LineHeight = 1.5
|
||||||
|
},
|
||||||
|
H1 = new H1()
|
||||||
|
{
|
||||||
|
FontSize = "2.5rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.2
|
||||||
|
},
|
||||||
|
H2 = new H2()
|
||||||
|
{
|
||||||
|
FontSize = "2rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.3
|
||||||
|
},
|
||||||
|
H3 = new H3()
|
||||||
|
{
|
||||||
|
FontSize = "1.75rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.3
|
||||||
|
},
|
||||||
|
H4 = new H4()
|
||||||
|
{
|
||||||
|
FontSize = "1.5rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.4
|
||||||
|
},
|
||||||
|
H5 = new H5()
|
||||||
|
{
|
||||||
|
FontSize = "1.25rem",
|
||||||
|
FontWeight = 500,
|
||||||
|
LineHeight = 1.4
|
||||||
|
},
|
||||||
|
H6 = new H6()
|
||||||
|
{
|
||||||
|
FontSize = "1rem",
|
||||||
|
FontWeight = 500,
|
||||||
|
LineHeight = 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudTextField @bind-Value="model.Name" Label="이름"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
|
||||||
|
|
||||||
|
<MudSelect @bind-Value="model.ServiceType" Label="문의 유형"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
|
<MudSelectItem Value="@("사업자세무")">사업자세무</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("부동산세금")">부동산세금</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("가족자산")">가족자산</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Message" Label="문의 내용"
|
||||||
|
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudSelect @bind-Value="model.Status" Label="상태"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
|
<MudSelectItem Value="@("new")">신규</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("consulting")">상담중</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("contracted")">계약완료</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("rejected")">거절</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("closed")">종결</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
|
||||||
|
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
|
||||||
|
@ButtonText
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
|
||||||
|
</div>
|
||||||
|
</MudForm>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string ButtonText { get; set; } = "저장";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<InquiryFormModel> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnCancel { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public InquiryFormModel? InitialData { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
|
private InquiryFormModel model = new();
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (InitialData != null)
|
||||||
|
{
|
||||||
|
model = new InquiryFormModel
|
||||||
|
{
|
||||||
|
Name = InitialData.Name,
|
||||||
|
Phone = InitialData.Phone,
|
||||||
|
Email = InitialData.Email,
|
||||||
|
ServiceType = InitialData.ServiceType,
|
||||||
|
Message = InitialData.Message,
|
||||||
|
Status = InitialData.Status,
|
||||||
|
AdminMemo = InitialData.AdminMemo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubmit()
|
||||||
|
{
|
||||||
|
if (form == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await OnSubmit.InvokeAsync(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InquiryFormModel
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Phone { get; set; } = "";
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string ServiceType { get; set; } = "기타";
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
public string Status { get; set; } = "new";
|
||||||
|
public string? AdminMemo { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
@using TaxBaik.Application.Services
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
|
||||||
@inject InquiryService InquiryService
|
|
||||||
|
|
||||||
<MudSimpleTable Striped="true" Dense="true" Class="mt-4">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
<th>전화</th>
|
<th>전화</th>
|
||||||
<th>분야</th>
|
<th>분야</th>
|
||||||
|
<th>상태</th>
|
||||||
<th>메시지</th>
|
<th>메시지</th>
|
||||||
<th>날짜</th>
|
<th>날짜</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -19,11 +17,18 @@
|
|||||||
<td>@inquiry.Name</td>
|
<td>@inquiry.Name</td>
|
||||||
<td>@inquiry.Phone</td>
|
<td>@inquiry.Phone</td>
|
||||||
<td>@inquiry.ServiceType</td>
|
<td>@inquiry.ServiceType</td>
|
||||||
<td>@inquiry.Message.Substring(0, Math.Min(30, inquiry.Message.Length))...</td>
|
<td>
|
||||||
|
<MudChip Size="Size.Small" Color="@GetStatusColor(inquiry.Status)">
|
||||||
|
@GetStatusLabel(inquiry.Status)
|
||||||
|
</MudChip>
|
||||||
|
</td>
|
||||||
|
<td>@GetPreview(inquiry.Message)</td>
|
||||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
<td>
|
<td>
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</MudButton>
|
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>
|
||||||
}
|
}
|
||||||
@@ -31,28 +36,45 @@
|
|||||||
</MudSimpleTable>
|
</MudSimpleTable>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<Domain.Entities.Inquiry> Inquiries { get; set; } = [];
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string Status { get; set; } = "";
|
public string Status { get; set; } = "";
|
||||||
|
|
||||||
private List<Domain.Entities.Inquiry> inquiries = [];
|
private IReadOnlyList<Domain.Entities.Inquiry> filteredInquiries = [];
|
||||||
private List<Domain.Entities.Inquiry> filteredInquiries = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
var (items, _) = await InquiryService.GetPagedAsync(1, 1000);
|
if (Inquiries == null || Inquiries.Count == 0)
|
||||||
inquiries = items.ToList();
|
{
|
||||||
FilterInquiries();
|
filteredInquiries = [];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FilterInquiries()
|
|
||||||
{
|
|
||||||
filteredInquiries = string.IsNullOrEmpty(Status)
|
filteredInquiries = string.IsNullOrEmpty(Status)
|
||||||
? inquiries
|
? Inquiries
|
||||||
: inquiries.Where(x => x.Status == Status).ToList();
|
: Inquiries.Where(x => x.Status == Status).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
private static string GetPreview(string message)
|
||||||
{
|
{
|
||||||
FilterInquiries();
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
return "-";
|
||||||
|
|
||||||
|
var trimmed = message.Trim();
|
||||||
|
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Color GetStatusColor(string status) => status switch
|
||||||
|
{
|
||||||
|
"new" => Color.Warning,
|
||||||
|
"consulting" => Color.Info,
|
||||||
|
"contracted" => Color.Success,
|
||||||
|
"rejected" => Color.Error,
|
||||||
|
"closed" => Color.Dark,
|
||||||
|
_ => Color.Default
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,126 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<AuthorizeView>
|
<MudLayout Class="admin-shell">
|
||||||
<Authorized>
|
<MudAppBar Elevation="0" Class="admin-topbar">
|
||||||
<MudThemeProvider />
|
<MudIconButton Icon="@Icons.Material.Filled.Menu"
|
||||||
<MudDialogProvider />
|
Color="Color.Inherit"
|
||||||
<MudSnackbarProvider />
|
Edge="Edge.Start"
|
||||||
|
Class="admin-menu-button"
|
||||||
<MudLayout>
|
OnClick="@ToggleDrawer" />
|
||||||
<MudAppBar Elevation="1">
|
<div class="admin-topbar-title">
|
||||||
<MudText Typo="Typo.h6" Class="ml-3">백원숙 세무회계 관리자</MudText>
|
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
|
||||||
|
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
|
||||||
|
</div>
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
<MudButton Color="Color.Inherit" Href="/taxbaik">공개 사이트</MudButton>
|
|
||||||
<MudButton Href="/taxbaik/admin/logout">로그아웃</MudButton>
|
<!-- 상단 액션 바 -->
|
||||||
|
<div class="admin-topbar-actions">
|
||||||
|
<MudTooltip Text="공개 웹사이트 방문">
|
||||||
|
<MudButton Class="admin-topbar-action"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Color="Color.Inherit"
|
||||||
|
Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
Href="/taxbaik"
|
||||||
|
Target="_blank">
|
||||||
|
공개 사이트
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
|
||||||
|
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
|
||||||
|
|
||||||
|
<MudTooltip Text="로그아웃 (Ctrl+Q)">
|
||||||
|
<MudButton Class="admin-topbar-action"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Color="Color.Error"
|
||||||
|
Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.Logout"
|
||||||
|
Href="/taxbaik/admin/logout">
|
||||||
|
로그아웃
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</div>
|
||||||
</MudAppBar>
|
</MudAppBar>
|
||||||
|
|
||||||
<MudDrawer @bind-open="@drawerOpen" Elevation="1">
|
<MudDrawer @bind-open="@drawerOpen"
|
||||||
<MudNavMenu>
|
Elevation="0"
|
||||||
<MudNavLink Href="/taxbaik/admin/" Match="NavLinkMatch.All">📊 대시보드</MudNavLink>
|
Variant="DrawerVariant.Responsive"
|
||||||
<MudNavLink Href="/taxbaik/admin/blog">📝 블로그 관리</MudNavLink>
|
Breakpoint="Breakpoint.Md"
|
||||||
<MudNavLink Href="/taxbaik/admin/inquiries">💬 문의 관리</MudNavLink>
|
Class="admin-drawer">
|
||||||
<MudNavLink Href="/taxbaik/admin/settings">⚙️ 설정</MudNavLink>
|
<div class="admin-drawer-brand">
|
||||||
|
<div class="admin-brand-mark">T</div>
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.subtitle1">TaxBaik</MudText>
|
||||||
|
<MudText Typo="Typo.caption">세무 운영 콘솔</MudText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MudNavMenu Class="admin-nav">
|
||||||
|
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
||||||
|
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup">
|
||||||
|
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
|
||||||
|
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
|
||||||
|
</MudNavGroup>
|
||||||
|
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
|
||||||
|
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
||||||
|
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
|
||||||
|
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
||||||
|
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
|
||||||
|
</MudNavGroup>
|
||||||
|
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
||||||
|
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
<div class="admin-drawer-footer">
|
||||||
|
<MudDivider Class="my-2" />
|
||||||
|
<MudStack Spacing="1" Class="px-3 py-2">
|
||||||
|
<div class="admin-footer-item">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Small" />
|
||||||
|
<MudText Typo="Typo.caption" Class="ml-2">시스템</MudText>
|
||||||
|
</div>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
운영 서버: 178.104.200.7
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
업데이트: 자동 배포 시스템
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
상태: 정상
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</div>
|
||||||
</MudDrawer>
|
</MudDrawer>
|
||||||
|
|
||||||
<MudMainContent>
|
<MudMainContent Class="admin-main">
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="my-4">
|
<MudContainer MaxWidth="MaxWidth.False" Class="admin-content">
|
||||||
@Body
|
@Body
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
</MudMainContent>
|
</MudMainContent>
|
||||||
</MudLayout>
|
</MudLayout>
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
@Body
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool drawerOpen = true;
|
private bool drawerOpen = true;
|
||||||
|
private bool expandedCustomerGroup = true;
|
||||||
|
private bool expandedWebsiteGroup = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||||
|
{
|
||||||
|
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleDrawer()
|
||||||
|
{
|
||||||
|
drawerOpen = !drawerOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/admin"
|
@page "/admin"
|
||||||
@page "/admin/"
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
@page "/admin/announcements/create"
|
||||||
|
@page "/admin/announcements/{Id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</MudText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="model.Title"
|
||||||
|
Label="제목"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Required="true"
|
||||||
|
RequiredError="제목을 입력하세요."
|
||||||
|
HelperText="홈페이지 상단 공지 바에 표시되는 텍스트입니다." />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="model.Content"
|
||||||
|
Label="상세 내용 (선택)"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Lines="3"
|
||||||
|
HelperText="부가 설명이 있을 경우 입력합니다. 없으면 제목만 표시됩니다." />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudSelect @bind-Value="model.DisplayType"
|
||||||
|
Label="유형"
|
||||||
|
Variant="Variant.Outlined">
|
||||||
|
<MudSelectItem Value="@("info")">일반 (파란색)</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("banner")">배너 (주황색) — 중요 이벤트</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("urgent")">긴급 (빨간색) — 마감 임박</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudNumericField @bind-Value="model.SortOrder"
|
||||||
|
Label="노출 순서"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
HelperText="숫자가 클수록 먼저 표시됩니다." />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudDatePicker @bind-Date="startsAtDate"
|
||||||
|
Label="게시 시작일 (비우면 즉시)"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
DateFormat="yyyy-MM-dd"
|
||||||
|
Clearable="true" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudDatePicker @bind-Date="endsAtDate"
|
||||||
|
Label="게시 종료일 (비우면 무기한)"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
DateFormat="yyyy-MM-dd"
|
||||||
|
Clearable="true" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudSwitch @bind-Checked="model.IsActive"
|
||||||
|
Label="@(model.IsActive ? "활성화 (홈페이지에 노출)" : "비활성화 (홈페이지 미노출)")"
|
||||||
|
Color="Color.Primary" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
Disabled="isSaving"
|
||||||
|
@onclick="SaveAsync">
|
||||||
|
@(isSaving ? "저장 중..." : "저장")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/announcements"))">
|
||||||
|
취소
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
</MudForm>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
|
private bool isSaving;
|
||||||
|
private DateTime? startsAtDate;
|
||||||
|
private DateTime? endsAtDate;
|
||||||
|
|
||||||
|
private AnnouncementDto model = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entity = await AnnouncementClient.GetByIdAsync(Id.Value);
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
model = new AnnouncementDto
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
Title = entity.Title,
|
||||||
|
Content = entity.Content,
|
||||||
|
DisplayType = entity.DisplayType,
|
||||||
|
IsActive = entity.IsActive,
|
||||||
|
SortOrder = entity.SortOrder
|
||||||
|
};
|
||||||
|
startsAtDate = entity.StartsAt?.ToLocalTime();
|
||||||
|
endsAtDate = entity.EndsAt?.ToLocalTime();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (form is null) return;
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
model.StartsAt = startsAtDate.HasValue
|
||||||
|
? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime()
|
||||||
|
: null;
|
||||||
|
model.EndsAt = endsAtDate.HasValue
|
||||||
|
? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
var result = await AnnouncementClient.UpdateAsync(Id.Value, model);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("저장 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = await AnnouncementClient.CreateAsync(model);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("저장 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
@page "/admin/announcements"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>공지사항 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">공지사항 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/announcements/create">
|
||||||
|
공지 등록
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (announcements is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (!announcements.Any())
|
||||||
|
{
|
||||||
|
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>제목</th>
|
||||||
|
<th>유형</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>게시 기간</th>
|
||||||
|
<th>순서</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in announcements)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@item.Title</td>
|
||||||
|
<td>
|
||||||
|
<MudChip Size="Size.Small" Color="@GetTypeColor(item.DisplayType)">
|
||||||
|
@GetTypeLabel(item.DisplayType)
|
||||||
|
</MudChip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (IsCurrentlyActive(item))
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
|
||||||
|
}
|
||||||
|
else if (!item.IsActive)
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Warning">기간 외</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
@FormatPeriod(item)
|
||||||
|
</td>
|
||||||
|
<td>@item.SortOrder</td>
|
||||||
|
<td>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
||||||
|
삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudButtonGroup>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Announcement>? announcements;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
announcements = (await AnnouncementClient.GetAllAsync()).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
announcements = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Announcement item)
|
||||||
|
{
|
||||||
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
|
"공지 삭제",
|
||||||
|
$"'{item.Title}' 공지를 삭제하시겠습니까?",
|
||||||
|
yesText: "삭제", cancelText: "취소");
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await AnnouncementClient.DeleteAsync(item.Id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCurrentlyActive(Announcement a)
|
||||||
|
{
|
||||||
|
if (!a.IsActive) return false;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (a.StartsAt.HasValue && a.StartsAt > now) return false;
|
||||||
|
if (a.EndsAt.HasValue && a.EndsAt < now) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatPeriod(Announcement a)
|
||||||
|
{
|
||||||
|
var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시";
|
||||||
|
var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한";
|
||||||
|
return $"{start} ~ {end}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color GetTypeColor(string type) => type switch
|
||||||
|
{
|
||||||
|
"urgent" => Color.Error,
|
||||||
|
"banner" => Color.Warning,
|
||||||
|
_ => Color.Info
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetTypeLabel(string type) => type switch
|
||||||
|
{
|
||||||
|
"urgent" => "긴급",
|
||||||
|
"banner" => "배너",
|
||||||
|
_ => "일반"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
@page "/admin/blog/create"
|
@page "/admin/blog/create"
|
||||||
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Application.DTOs
|
@using TaxBaik.Application.DTOs
|
||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Application.Services
|
||||||
@using TaxBaik.Domain.Interfaces
|
@using TaxBaik.Domain.Interfaces
|
||||||
@attribute [Authorize]
|
|
||||||
@inject BlogService BlogService
|
@inject BlogService BlogService
|
||||||
@inject ICategoryRepository CategoryRepository
|
@inject ICategoryRepository CategoryRepository
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@@ -10,9 +10,16 @@
|
|||||||
|
|
||||||
<PageTitle>새 포스트 작성</PageTitle>
|
<PageTitle>새 포스트 작성</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">📝 새 포스트</MudText>
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">새 포스트 작성</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<MudForm @ref="form">
|
<MudForm @ref="form">
|
||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
@@ -42,9 +49,6 @@
|
|||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
@onclick="SavePost">저장</MudButton>
|
@onclick="SavePost">저장</MudButton>
|
||||||
<MudButton Variant="Variant.Outlined" @onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/blog"))">
|
|
||||||
취소
|
|
||||||
</MudButton>
|
|
||||||
</div>
|
</div>
|
||||||
</MudForm>
|
</MudForm>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -59,6 +63,11 @@
|
|||||||
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SavePost()
|
private async Task SavePost()
|
||||||
{
|
{
|
||||||
if (form == null)
|
if (form == null)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
@page "/admin/blog/{id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using TaxBaik.Domain.Interfaces
|
||||||
|
@inject BlogService BlogService
|
||||||
|
@inject ICategoryRepository CategoryRepository
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<PageTitle>포스트 수정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">포스트 수정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">블로그 포스트를 수정합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||||
|
}
|
||||||
|
else if (post == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
|
@foreach (var category in categories)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Content" Label="본문"
|
||||||
|
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
|
||||||
|
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudCheckBox @bind-Checked="model.IsPublished" Label="발행" Class="mb-4" />
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
@onclick="SavePost">저장</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error"
|
||||||
|
@onclick="DeletePost">삭제</MudButton>
|
||||||
|
</div>
|
||||||
|
</MudForm>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
|
private Domain.Entities.BlogPost? post;
|
||||||
|
private List<Domain.Entities.Category> categories = [];
|
||||||
|
private EditPostModel model = new();
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
post = await BlogService.GetByIdAsync(Id);
|
||||||
|
if (post != null)
|
||||||
|
{
|
||||||
|
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
||||||
|
MapPostToModel(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MapPostToModel(Domain.Entities.BlogPost post)
|
||||||
|
{
|
||||||
|
model.Title = post.Title;
|
||||||
|
model.Content = post.Content;
|
||||||
|
model.CategoryId = post.CategoryId;
|
||||||
|
model.Tags = post.Tags;
|
||||||
|
model.SeoTitle = post.SeoTitle;
|
||||||
|
model.SeoDescription = post.SeoDescription;
|
||||||
|
model.IsPublished = post.IsPublished;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SavePost()
|
||||||
|
{
|
||||||
|
if (form == null || post == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
|
||||||
|
{
|
||||||
|
Title = model.Title,
|
||||||
|
Content = model.Content,
|
||||||
|
CategoryId = model.CategoryId,
|
||||||
|
Tags = model.Tags,
|
||||||
|
SeoTitle = model.SeoTitle,
|
||||||
|
SeoDescription = model.SeoDescription,
|
||||||
|
IsPublished = model.IsPublished
|
||||||
|
});
|
||||||
|
|
||||||
|
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeletePost()
|
||||||
|
{
|
||||||
|
if (post == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"포스트 삭제",
|
||||||
|
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"삭제", "취소");
|
||||||
|
|
||||||
|
if (result != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await BlogService.DeleteAsync(post.Id);
|
||||||
|
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EditPostModel
|
||||||
|
{
|
||||||
|
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,18 +1,28 @@
|
|||||||
@page "/admin/blog"
|
@page "/admin/blog"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IApiClient ApiClient
|
@inject IApiClient ApiClient
|
||||||
@inject DialogService DialogService
|
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>블로그 관리</PageTitle>
|
<PageTitle>블로그 관리</PageTitle>
|
||||||
|
|
||||||
<div class="mb-4 d-flex justify-content-between align-items-center">
|
<section class="admin-page-hero">
|
||||||
<MudText Typo="Typo.h5">📝 블로그 관리</MudText>
|
<div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
||||||
Href="/taxbaik/admin/blog/create">새 포스트</MudButton>
|
<MudText Typo="Typo.h4" Class="admin-page-title">블로그 관리</MudText>
|
||||||
</div>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote"
|
||||||
|
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading">
|
<MudPaper Class="admin-surface mb-4" Elevation="0">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||||
|
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
|
||||||
|
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||||
<Columns>
|
<Columns>
|
||||||
<PropertyColumn Property="x => x.Title" Title="제목" />
|
<PropertyColumn Property="x => x.Title" Title="제목" />
|
||||||
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
||||||
@@ -25,18 +35,27 @@
|
|||||||
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
|
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
|
||||||
<TemplateColumn>
|
<TemplateColumn>
|
||||||
<CellTemplate Context="cell">
|
<CellTemplate Context="cell">
|
||||||
<MudButton Variant="Variant.Text" Color="Color.Primary"
|
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
|
||||||
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정</MudButton>
|
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
|
||||||
<MudButton Variant="Variant.Text" Color="Color.Error"
|
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
|
||||||
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
|
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
|
||||||
</CellTemplate>
|
</CellTemplate>
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
</Columns>
|
</Columns>
|
||||||
</MudDataGrid>
|
</MudDataGrid>
|
||||||
|
|
||||||
|
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
|
private int currentPage = 1;
|
||||||
|
private int totalPages = 1;
|
||||||
|
private int totalPosts = 0;
|
||||||
|
private const int PageSize = 20;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -48,13 +67,38 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var items = await ApiClient.GetAsync<List<TaxBaik.Domain.Entities.BlogPost>>("blog/admin/all");
|
var result = await ApiClient.GetAsync<PagedBlogResponse>($"blog/admin?page={currentPage}&pageSize={PageSize}");
|
||||||
posts = items ?? [];
|
posts = result?.Data ?? [];
|
||||||
|
totalPosts = result?.Total ?? 0;
|
||||||
|
totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
posts = [];
|
||||||
|
totalPosts = 0;
|
||||||
|
totalPages = 1;
|
||||||
}
|
}
|
||||||
catch { }
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PreviousPage()
|
||||||
|
{
|
||||||
|
if (currentPage <= 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
currentPage--;
|
||||||
|
await LoadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NextPage()
|
||||||
|
{
|
||||||
|
if (currentPage >= totalPages)
|
||||||
|
return;
|
||||||
|
|
||||||
|
currentPage++;
|
||||||
|
await LoadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
|
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
|
||||||
{
|
{
|
||||||
var previous = post.IsPublished;
|
var previous = post.IsPublished;
|
||||||
@@ -88,4 +132,10 @@
|
|||||||
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||||
await LoadPosts();
|
await LoadPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class PagedBlogResponse
|
||||||
|
{
|
||||||
|
public List<TaxBaik.Domain.Entities.BlogPost> Data { get; set; } = [];
|
||||||
|
public int Total { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
@page "/admin/clients/{ClientId:int}"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@inject ClientService ClientService
|
||||||
|
@inject ConsultationService ConsultationService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>고객 상세</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Client Details</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객 상세</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (client == null)
|
||||||
|
{
|
||||||
|
<MudText>고객을 찾을 수 없습니다.</MudText>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
|
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/clients"))">
|
||||||
|
목록으로
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning"
|
||||||
|
StartIcon="@Icons.Material.Filled.Edit"
|
||||||
|
Href="@($"/taxbaik/admin/clients/{ClientId}/edit")">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">고객 정보</MudText>
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
|
||||||
|
<MudText>@client.Name</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">상호</MudText>
|
||||||
|
<MudText>@(client.CompanyName ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
|
||||||
|
<MudText>@(client.Phone ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
|
||||||
|
<MudText>@(client.Email ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">서비스</MudText>
|
||||||
|
<MudText>@(client.ServiceType ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">사업자 유형</MudText>
|
||||||
|
<MudText>@(client.TaxType ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">유입 경로</MudText>
|
||||||
|
<MudText>@(client.Source ?? "-")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">등록일</MudText>
|
||||||
|
<MudText>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(client.Memo))
|
||||||
|
{
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">메모</MudText>
|
||||||
|
<MudText Style="white-space: pre-wrap;">@client.Memo</MudText>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
|
||||||
|
<MudText Typo="Typo.h6">상담 이력</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
Size="Size.Small"
|
||||||
|
OnClick="OpenAddConsultation">
|
||||||
|
+ 상담 추가
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@if (showAddForm)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-3 mb-3" Outlined="true">
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
|
||||||
|
@foreach (var t in ClientService.ServiceTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
|
||||||
|
Lines="3" Variant="Variant.Outlined" Required="true" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudSelect T="string" @bind-Value="newResult" Label="결과">
|
||||||
|
<MudSelectItem Value="@("")">-</MudSelectItem>
|
||||||
|
@foreach (var r in ConsultationService.Results)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@r">@r</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
|
||||||
|
Format="N0" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
<MudStack Row="true" Class="mt-2" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddConsultation">저장</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (consultations.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Color="Color.Secondary">상담 이력이 없습니다.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudList T="string" Dense="true">
|
||||||
|
@foreach (var c in consultations)
|
||||||
|
{
|
||||||
|
<MudListItem>
|
||||||
|
<MudPaper Class="pa-3" Outlined="true" Style="width:100%">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
@c.ConsultationDate.ToString("yyyy-MM-dd")
|
||||||
|
@if (!string.IsNullOrEmpty(c.ServiceType)) { <text> · @c.ServiceType</text> }
|
||||||
|
</MudText>
|
||||||
|
<MudText Style="white-space: pre-wrap;" Class="mt-1">@c.Summary</MudText>
|
||||||
|
@if (!string.IsNullOrEmpty(c.Result))
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="mt-1">@c.Result</MudChip>
|
||||||
|
}
|
||||||
|
@if (c.Fee.HasValue)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
|
||||||
|
수임료: @c.Fee.Value.ToString("N0")원
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
|
Size="Size.Small" Color="Color.Error"
|
||||||
|
OnClick="@(() => DeleteConsultation(c.Id))" />
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudListItem>
|
||||||
|
}
|
||||||
|
</MudList>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
|
||||||
|
private Domain.Entities.Client? client;
|
||||||
|
private List<Domain.Entities.Consultation> consultations = [];
|
||||||
|
|
||||||
|
private bool showAddForm;
|
||||||
|
private DateTime? newDate = DateTime.Today;
|
||||||
|
private string newServiceType = "";
|
||||||
|
private string newSummary = "";
|
||||||
|
private string newResult = "";
|
||||||
|
private decimal? newFee;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAll()
|
||||||
|
{
|
||||||
|
client = await ClientService.GetByIdAsync(ClientId);
|
||||||
|
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenAddConsultation()
|
||||||
|
{
|
||||||
|
showAddForm = true;
|
||||||
|
newDate = DateTime.Today;
|
||||||
|
newServiceType = "";
|
||||||
|
newSummary = "";
|
||||||
|
newResult = "";
|
||||||
|
newFee = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddConsultation()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var c = new Domain.Entities.Consultation
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
||||||
|
Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteConsultation(int id)
|
||||||
|
{
|
||||||
|
await ConsultationService.DeleteAsync(id);
|
||||||
|
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
||||||
|
Snackbar.Add("삭제되었습니다.", Severity.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
@page "/admin/clients/create"
|
||||||
|
@page "/admin/clients/{Id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudForm @ref="form" @bind-IsValid="isValid">
|
||||||
|
<MudGrid Spacing="3">
|
||||||
|
@* 기본 정보 *@
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">기본 정보</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true"
|
||||||
|
RequiredError="고객명을 입력하세요." />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.Phone" Label="연락처"
|
||||||
|
Placeholder="010-0000-0000" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* 세무 정보 *@
|
||||||
|
<MudItem xs="12" Class="mt-2">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">세무 정보</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true">
|
||||||
|
@foreach (var t in ClientService.ServiceTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true">
|
||||||
|
@foreach (var t in ClientService.TaxTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* 관리 정보 *@
|
||||||
|
<MudItem xs="12" Class="mt-2">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">관리 정보</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true">
|
||||||
|
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true">
|
||||||
|
@foreach (var s in ClientService.Sources)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@s">@s</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="dto.Memo" Label="메모"
|
||||||
|
Lines="4" AutoGrow="true"
|
||||||
|
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* 저장 버튼 *@
|
||||||
|
<MudItem xs="12" Class="d-flex gap-2 mt-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="@SaveAsync" Disabled="@isSaving">
|
||||||
|
@(isSaving ? "저장 중..." : "저장")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients">
|
||||||
|
취소
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudForm>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm form = null!;
|
||||||
|
private CreateClientDto dto = new() { Status = "active" };
|
||||||
|
private bool isValid;
|
||||||
|
private bool isLoading = true;
|
||||||
|
private bool isSaving;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = await ClientClient.GetByIdAsync(Id.Value);
|
||||||
|
if (client is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dto = new CreateClientDto
|
||||||
|
{
|
||||||
|
Name = client.Name,
|
||||||
|
CompanyName = client.CompanyName,
|
||||||
|
Phone = client.Phone,
|
||||||
|
Email = client.Email,
|
||||||
|
ServiceType = client.ServiceType,
|
||||||
|
TaxType = client.TaxType,
|
||||||
|
Status = client.Status,
|
||||||
|
Source = client.Source,
|
||||||
|
Memo = client.Memo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
await form.Validate();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
var result = await ClientClient.UpdateAsync(Id.Value, dto);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("수정에 실패했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = await ClientClient.CreateAsync(dto);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("고객이 등록되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
@page "/admin/clients"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>고객 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||||
|
Href="/taxbaik/admin/clients/create">
|
||||||
|
고객 등록
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* 검색/필터 바 *@
|
||||||
|
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
|
||||||
|
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect @bind-Value="statusFilter" Label="상태" T="string">
|
||||||
|
<MudSelectItem Value="@("")">전체</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||||
|
<MudButton Variant="Variant.Text" OnClick="@ResetAsync" FullWidth="true">초기화</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (clients is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (!clients.Any())
|
||||||
|
{
|
||||||
|
<div class="pa-6 text-center">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
|
||||||
|
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>회사명</th>
|
||||||
|
<th>연락처</th>
|
||||||
|
<th>서비스</th>
|
||||||
|
<th>세금 유형</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>유입 경로</th>
|
||||||
|
<th>등록일</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in clients)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><strong>@c.Name</strong></td>
|
||||||
|
<td>@(c.CompanyName ?? "—")</td>
|
||||||
|
<td>@(c.Phone ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(c.ServiceType))
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Primary">@c.ServiceType</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@(c.TaxType ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
@if (c.Status == "active")
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success">활성</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@(c.Source ?? "—")</td>
|
||||||
|
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
|
||||||
|
<td>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(c))">
|
||||||
|
삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudButtonGroup>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
|
||||||
|
@* 페이징 *@
|
||||||
|
@if (totalPages > 1)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-center pa-3">
|
||||||
|
<MudPagination BoundaryCount="1" MiddleCount="3"
|
||||||
|
Count="@totalPages" Selected="@currentPage"
|
||||||
|
SelectedChanged="@OnPageChanged" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Client>? clients;
|
||||||
|
private string searchText = "";
|
||||||
|
private string statusFilter = "";
|
||||||
|
private int currentPage = 1;
|
||||||
|
private int totalCount;
|
||||||
|
private int totalPages;
|
||||||
|
private const int PageSize = 20;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (items, total) = await ClientClient.GetPagedAsync(
|
||||||
|
currentPage, PageSize,
|
||||||
|
string.IsNullOrEmpty(statusFilter) ? null : statusFilter,
|
||||||
|
string.IsNullOrEmpty(searchText) ? null : searchText);
|
||||||
|
|
||||||
|
clients = items.ToList();
|
||||||
|
totalCount = total;
|
||||||
|
totalPages = (int)Math.Ceiling((double)total / PageSize);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
clients = [];
|
||||||
|
totalCount = 0;
|
||||||
|
totalPages = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SearchAsync()
|
||||||
|
{
|
||||||
|
currentPage = 1;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetAsync()
|
||||||
|
{
|
||||||
|
searchText = "";
|
||||||
|
statusFilter = "";
|
||||||
|
currentPage = 1;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPageChanged(int page)
|
||||||
|
{
|
||||||
|
currentPage = page;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSearchKeyUp(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter") await SearchAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Client client)
|
||||||
|
{
|
||||||
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
|
"고객 삭제",
|
||||||
|
$"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.",
|
||||||
|
yesText: "삭제", cancelText: "취소");
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await ClientClient.DeleteAsync(client.Id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제에 실패했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +1,130 @@
|
|||||||
@page "/admin/dashboard"
|
@page "/admin/dashboard"
|
||||||
@using TaxBaik.Application.Services
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject InquiryService InquiryService
|
@using TaxBaik.Web.Services
|
||||||
@inject BlogService BlogService
|
@inject IAdminDashboardClient DashboardClient
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<PageTitle>대시보드</PageTitle>
|
<PageTitle>대시보드</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">📊 대시보드</MudText>
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Overview</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">대시보드</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="/taxbaik/admin/blog/create">
|
||||||
|
새 포스트 작성
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
<MudGrid>
|
<!-- Metrics Grid - Pure HTML div instead of MudGrid to ensure proper layout -->
|
||||||
<MudItem xs="12" sm="6" md="3">
|
<div class="admin-metric-grid">
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))' style="cursor: pointer;">
|
||||||
<MudText Typo="Typo.subtitle2">이번달 문의</MudText>
|
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||||
<MudText Typo="Typo.h4">@thisMonthInquiries</MudText>
|
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">이번달 문의</span>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||||
|
<span style="font-size: 2rem; font-weight: 700; color: #1565c0;">@summary.ThisMonthInquiries</span>
|
||||||
|
<span style="font-size: 2.5rem; opacity: 0.15; color: #1976d2;">💬</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 0.9rem; color: #666;">월간 상담 유입 (클릭 시 이동)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))' style="cursor: pointer;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||||
|
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">신규 문의</span>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||||
|
<span style="font-size: 2rem; font-weight: 700; color: #e65100;">@summary.NewInquiries</span>
|
||||||
|
<span style="font-size: 2.5rem; opacity: 0.15; color: #f57c00;">⚠️</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 0.9rem; color: #666;">처리 대기 (클릭 시 이동)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||||
|
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">전체 포스트</span>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||||
|
<span style="font-size: 2rem; font-weight: 700; color: #455a64;">@summary.TotalPosts</span>
|
||||||
|
<span style="font-size: 2.5rem; opacity: 0.15; color: #607d8b;">📄</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 0.9rem; color: #666;">콘텐츠 자산 (클릭 시 이동)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
||||||
|
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">발행된 포스트</span>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
||||||
|
<span style="font-size: 2rem; font-weight: 700; color: #2e7d32;">@summary.PublishedPosts</span>
|
||||||
|
<span style="font-size: 2.5rem; opacity: 0.15; color: #388e3c;">🌐</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 0.9rem; color: #666;">검색 노출 대상 (클릭 시 이동)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (upcomingFilings.Count > 0)
|
||||||
|
{
|
||||||
|
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
||||||
|
<div class="admin-section-header">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
|
||||||
|
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
|
||||||
|
</div>
|
||||||
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>고객</th>
|
||||||
|
<th>신고 유형</th>
|
||||||
|
<th>기한</th>
|
||||||
|
<th>D-day</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var f in upcomingFilings)
|
||||||
|
{
|
||||||
|
var dday = (f.DueDate.Date - DateTime.Today).Days;
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
|
||||||
|
@f.ClientName
|
||||||
|
</MudLink>
|
||||||
|
</td>
|
||||||
|
<td>@f.FilingType</td>
|
||||||
|
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td>
|
||||||
|
@if (dday < 0)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Dark">기한 초과 (@(-dday)일)</MudChip>
|
||||||
|
}
|
||||||
|
else if (dday <= 7)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>D-@dday</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
}
|
||||||
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<div class="admin-section-header">
|
||||||
<MudText Typo="Typo.subtitle2">신규 문의</MudText>
|
<div>
|
||||||
<MudText Typo="Typo.h4">@newInquiries</MudText>
|
<MudText Typo="Typo.h6">최근 문의</MudText>
|
||||||
</MudPaper>
|
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)</MudText>
|
||||||
</MudItem>
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
|
||||||
<MudItem xs="12" sm="6" md="3">
|
</div>
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
<MudText Typo="Typo.subtitle2">전체 포스트</MudText>
|
|
||||||
<MudText Typo="Typo.h4">@totalPosts</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.subtitle2">발행된 포스트</MudText>
|
|
||||||
<MudText Typo="Typo.h4">@publishedPosts</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">최근 문의</MudText>
|
|
||||||
<MudSimpleTable Striped="true" Dense="true">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
@@ -51,16 +135,19 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var inquiry in recentInquiries)
|
@foreach (var inquiry in summary.RecentInquiries)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@inquiry.Name</td>
|
<td>
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/inquiries?id={inquiry.Id}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
|
||||||
|
@inquiry.Name
|
||||||
|
</MudLink>
|
||||||
|
</td>
|
||||||
<td>@inquiry.Phone</td>
|
<td>@inquiry.Phone</td>
|
||||||
<td>@inquiry.ServiceType</td>
|
<td>@inquiry.ServiceType</td>
|
||||||
<td>
|
<td>
|
||||||
<MudChip Size="Size.Small"
|
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
|
||||||
Color="@(inquiry.Status == "new" ? Color.Warning : inquiry.Status == "contacted" ? Color.Info : Color.Success)">
|
@GetStatusLabel(inquiry.Status)
|
||||||
@inquiry.Status
|
|
||||||
</MudChip>
|
</MudChip>
|
||||||
</td>
|
</td>
|
||||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
@@ -71,22 +158,43 @@
|
|||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private int thisMonthInquiries = 0;
|
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
||||||
private int newInquiries = 0;
|
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
||||||
private int totalPosts = 0;
|
private string? errorMessage;
|
||||||
private int publishedPosts = 0;
|
private bool isLoading = true;
|
||||||
private List<Domain.Entities.Inquiry> recentInquiries = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var (inquiries, _) = await InquiryService.GetPagedAsync(1, 100);
|
try
|
||||||
recentInquiries = inquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList();
|
{
|
||||||
|
// API 클라이언트 사용 (서비스 직접 호출 X)
|
||||||
|
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||||
|
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
await Task.WhenAll(summaryTask, filingsTask);
|
||||||
thisMonthInquiries = inquiries.Count(x => x.CreatedAt.Year == now.Year && x.CreatedAt.Month == now.Month);
|
summary = await summaryTask;
|
||||||
newInquiries = inquiries.Count(x => x.Status == "new");
|
upcomingFilings = (await filingsTask).ToList();
|
||||||
var stats = await BlogService.GetStatsAsync();
|
|
||||||
totalPosts = stats.TotalPosts;
|
|
||||||
publishedPosts = stats.PublishedPosts;
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
||||||
|
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||||
|
|
||||||
|
private static Color StatusColor(string status) => status switch
|
||||||
|
{
|
||||||
|
"new" => Color.Warning,
|
||||||
|
"consulting" => Color.Info,
|
||||||
|
"contracted" => Color.Success,
|
||||||
|
"rejected" => Color.Error,
|
||||||
|
"closed" => Color.Dark,
|
||||||
|
_ => Color.Default
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
@page "/admin/faqs/create"
|
||||||
|
@page "/admin/faqs/{Id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@inject IFaqBrowserClient FaqClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudForm @ref="form" @bind-IsValid="isValid">
|
||||||
|
<MudGrid Spacing="3">
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="faq.Question"
|
||||||
|
Label="질문 *" Required="true"
|
||||||
|
RequiredError="질문을 입력하세요."
|
||||||
|
Counter="300" MaxLength="300"
|
||||||
|
Lines="2" AutoGrow="true"
|
||||||
|
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="faq.Answer"
|
||||||
|
Label="답변 *" Required="true"
|
||||||
|
RequiredError="답변을 입력하세요."
|
||||||
|
Lines="5" AutoGrow="true"
|
||||||
|
Placeholder="방문자에게 보여질 답변을 입력하세요." />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="faq.Category" Label="카테고리" T="string" Clearable="true">
|
||||||
|
@foreach (var cat in FaqService.Categories)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@cat">@cat</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudNumericField @bind-Value="faq.SortOrder"
|
||||||
|
Label="정렬 순서"
|
||||||
|
HelperText="작을수록 위에 노출"
|
||||||
|
Min="0" Max="9999" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3" Class="d-flex align-center">
|
||||||
|
<MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
|
||||||
|
Label="@(faq.IsActive ? "노출 중" : "비활성")" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" Class="d-flex gap-2 mt-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="@SaveAsync" Disabled="@isSaving">
|
||||||
|
@(isSaving ? "저장 중..." : "저장")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs">
|
||||||
|
취소
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudForm>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm form = null!;
|
||||||
|
private Faq faq = new() { SortOrder = 10, IsActive = true };
|
||||||
|
private bool isValid;
|
||||||
|
private bool isLoading = true;
|
||||||
|
private bool isSaving;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await FaqClient.GetByIdAsync(Id.Value);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
faq = existing;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
await form.Validate();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
var result = await FaqClient.UpdateAsync(Id.Value, faq);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("수정 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = await FaqClient.CreateAsync(faq);
|
||||||
|
if (result != null)
|
||||||
|
Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success);
|
||||||
|
else
|
||||||
|
Snackbar.Add("등록 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
@page "/admin/faqs"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@inject IFaqBrowserClient FaqClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>FAQ 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">FAQ 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/faqs/create">
|
||||||
|
FAQ 등록
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (faqs is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (!faqs.Any())
|
||||||
|
{
|
||||||
|
<div class="pa-6 text-center">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
|
||||||
|
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px;">순서</th>
|
||||||
|
<th>질문</th>
|
||||||
|
<th style="width:130px;">카테고리</th>
|
||||||
|
<th style="width:90px;">상태</th>
|
||||||
|
<th style="width:160px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in faqs)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">
|
||||||
|
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||||
|
@item.Question
|
||||||
|
</MudText>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(item.Category))
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">@item.Category</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (item.IsActive)
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
||||||
|
삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudButtonGroup>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||||
|
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Faq>? faqs;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
faqs = (await FaqClient.GetAllAsync()).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
faqs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Faq item)
|
||||||
|
{
|
||||||
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
|
"FAQ 삭제",
|
||||||
|
$"'{item.Question}' 항목을 삭제하시겠습니까?",
|
||||||
|
yesText: "삭제", cancelText: "취소");
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await FaqClient.DeleteAsync(item.Id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@page "/admin/inquiries/create"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject InquiryService InquiryService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>문의 등록</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">새 문의 등록</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCreate(InquiryForm.InquiryFormModel model)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InquiryService.SubmitAsync(
|
||||||
|
model.Name,
|
||||||
|
model.Phone,
|
||||||
|
model.ServiceType,
|
||||||
|
model.Message,
|
||||||
|
model.Email,
|
||||||
|
ipAddress: "admin-registered");
|
||||||
|
|
||||||
|
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,111 @@
|
|||||||
@page "/admin/inquiries/{InquiryId:int}"
|
@page "/admin/inquiries/{InquiryId:int}"
|
||||||
@using TaxBaik.Application.Services
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject InquiryService InquiryService
|
@using TaxBaik.Web.Services
|
||||||
|
@inject IInquiryBrowserClient InquiryClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>문의 상세</PageTitle>
|
<PageTitle>문의 상세</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Inquiry Details</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">문의 상세</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@if (inquiry != null)
|
@if (inquiry != null)
|
||||||
{
|
{
|
||||||
<MudButton Variant="Variant.Text" @onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
|
<MudButton Variant="Variant.Outlined"
|
||||||
← 돌아가기
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
|
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
|
||||||
|
문의 목록으로
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">문의 정보</MudText>
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" sm="6">
|
||||||
<MudText Typo="Typo.subtitle1">이름</MudText>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
|
||||||
<MudText>@inquiry.Name</MudText>
|
<MudText>@inquiry.Name</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" sm="6">
|
||||||
<MudText Typo="Typo.subtitle1">연락처</MudText>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
|
||||||
<MudText>@inquiry.Phone</MudText>
|
<MudText>@inquiry.Phone</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" sm="6">
|
||||||
<MudText Typo="Typo.subtitle1">이메일</MudText>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
|
||||||
<MudText>@inquiry.Email</MudText>
|
<MudText>@(inquiry.Email ?? "-")</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" sm="6">
|
||||||
<MudText Typo="Typo.subtitle1">분야</MudText>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">분야</MudText>
|
||||||
<MudText>@inquiry.ServiceType</MudText>
|
<MudText>@inquiry.ServiceType</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudText Typo="Typo.subtitle1">메시지</MudText>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">문의 내용</MudText>
|
||||||
<MudText>@inquiry.Message</MudText>
|
<MudPaper Class="pa-3 mt-1" Outlined="true">
|
||||||
|
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText>
|
||||||
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudText Typo="Typo.subtitle1">상태</MudText>
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">접수일시</MudText>
|
||||||
<MudSelect T="string" Value="inquiry.Status" ValueChanged="@((string status) => OnStatusChanged(status))" Label="상태 변경">
|
<MudText>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</MudText>
|
||||||
<MudSelectItem Value="@("new")">신규</MudSelectItem>
|
|
||||||
<MudSelectItem Value="@("contacted")">연락함</MudSelectItem>
|
|
||||||
<MudSelectItem Value="@("completed")">완료</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">담당자 메모</MudText>
|
||||||
|
<MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)"
|
||||||
|
Lines="4" Variant="Variant.Outlined" />
|
||||||
|
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
OnClick="SaveMemo">메모 저장</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">처리 상태</MudText>
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
@foreach (var (key, label) in InquiryStatusMapper.Labels)
|
||||||
|
{
|
||||||
|
<MudButton Variant="@(inquiry.Status == key ? Variant.Filled : Variant.Outlined)"
|
||||||
|
Color="@StatusColor(key)"
|
||||||
|
FullWidth="true"
|
||||||
|
OnClick="@(() => OnStatusChanged(key))">
|
||||||
|
@label
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (inquiry.ClientId == null)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">고객 카드 생성</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-3">이 문의를 고객 카드로 등록합니다.</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
|
||||||
|
OnClick="ConvertToClient">
|
||||||
|
고객으로 등록
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">연결된 고객</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
|
||||||
|
Href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">
|
||||||
|
고객 카드 보기
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -56,26 +117,93 @@ else
|
|||||||
public int InquiryId { get; set; }
|
public int InquiryId { get; set; }
|
||||||
|
|
||||||
private Domain.Entities.Inquiry? inquiry;
|
private Domain.Entities.Inquiry? inquiry;
|
||||||
|
private string adminMemo = "";
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
inquiry = await InquiryService.GetByIdAsync(InquiryId);
|
inquiry = await InquiryClient.GetByIdAsync(InquiryId);
|
||||||
|
adminMemo = inquiry?.AdminMemo ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnStatusChanged(string status)
|
private async Task OnStatusChanged(string status)
|
||||||
{
|
{
|
||||||
if (inquiry == null)
|
if (inquiry == null) return;
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await InquiryService.UpdateStatusAsync(inquiry.Id, status);
|
var success = await InquiryClient.UpdateStatusAsync(inquiry.Id, status);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
inquiry.Status = status;
|
inquiry.Status = status;
|
||||||
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
|
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add(ex.Message, Severity.Error);
|
Snackbar.Add("상태 변경에 실패했습니다.", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveMemo()
|
||||||
|
{
|
||||||
|
if (inquiry == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await InquiryClient.UpdateAdminMemoAsync(inquiry.Id, adminMemo);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
inquiry.AdminMemo = adminMemo;
|
||||||
|
Snackbar.Add("메모가 저장되었습니다.", Severity.Success);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("메모 저장에 실패했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConvertToClient()
|
||||||
|
{
|
||||||
|
if (inquiry == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clientId = await InquiryClient.ConvertToClientAsync(
|
||||||
|
inquiry.Id,
|
||||||
|
inquiry.Name,
|
||||||
|
inquiry.Phone,
|
||||||
|
inquiry.ServiceType);
|
||||||
|
|
||||||
|
if (clientId > 0)
|
||||||
|
{
|
||||||
|
inquiry.ClientId = clientId;
|
||||||
|
inquiry.Status = "consulting";
|
||||||
|
Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("고객 카드 생성에 실패했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Color StatusColor(string status) => status switch
|
||||||
|
{
|
||||||
|
"new" => Color.Default,
|
||||||
|
"consulting" => Color.Info,
|
||||||
|
"contracted" => Color.Success,
|
||||||
|
"rejected" => Color.Error,
|
||||||
|
"closed" => Color.Dark,
|
||||||
|
_ => Color.Default
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
@page "/admin/inquiries/{id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject InquiryService InquiryService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<PageTitle>문의 수정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">문의 수정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의 정보를 수정합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||||
|
}
|
||||||
|
else if (inquiry == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mt-4">문의를 찾을 수 없습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||||
|
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteInquiry" Class="mt-2">
|
||||||
|
문의 삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
private Domain.Entities.Inquiry? inquiry;
|
||||||
|
private InquiryForm.InquiryFormModel? formModel;
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
inquiry = await InquiryService.GetByIdAsync(Id);
|
||||||
|
if (inquiry != null)
|
||||||
|
{
|
||||||
|
formModel = new InquiryForm.InquiryFormModel
|
||||||
|
{
|
||||||
|
Name = inquiry.Name,
|
||||||
|
Phone = inquiry.Phone,
|
||||||
|
Email = inquiry.Email,
|
||||||
|
ServiceType = inquiry.ServiceType,
|
||||||
|
Message = inquiry.Message,
|
||||||
|
Status = inquiry.Status,
|
||||||
|
AdminMemo = inquiry.AdminMemo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"문의 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleUpdate(InquiryForm.InquiryFormModel model)
|
||||||
|
{
|
||||||
|
if (inquiry == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
inquiry.Name = model.Name;
|
||||||
|
inquiry.Phone = model.Phone;
|
||||||
|
inquiry.Email = model.Email;
|
||||||
|
inquiry.ServiceType = model.ServiceType;
|
||||||
|
inquiry.Message = model.Message;
|
||||||
|
inquiry.AdminMemo = model.AdminMemo;
|
||||||
|
|
||||||
|
if (inquiry.Status != model.Status)
|
||||||
|
{
|
||||||
|
await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo);
|
||||||
|
|
||||||
|
Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteInquiry()
|
||||||
|
{
|
||||||
|
if (inquiry == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"문의 삭제",
|
||||||
|
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"삭제", "취소");
|
||||||
|
|
||||||
|
if (result != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InquiryService.DeleteAsync(inquiry.Id);
|
||||||
|
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,68 @@
|
|||||||
@page "/admin/inquiries"
|
@page "/admin/inquiries"
|
||||||
@using TaxBaik.Domain.Interfaces
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IInquiryRepository InquiryRepository
|
@using TaxBaik.Web.Services
|
||||||
|
@inject IInquiryBrowserClient InquiryClient
|
||||||
|
|
||||||
<PageTitle>문의 관리</PageTitle>
|
<PageTitle>문의 관리</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">💬 문의 관리</MudText>
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Requests</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">문의 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/inquiries/create">새 문의 등록</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
<MudTabs>
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" Class="ma-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
||||||
<MudTabPanel Text="전체">
|
<MudTabPanel Text="전체">
|
||||||
<InquiryTable Status="" />
|
<InquiryTable Inquiries="allInquiries" Status="" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="신규">
|
<MudTabPanel Text="신규">
|
||||||
<InquiryTable Status="new" />
|
<InquiryTable Inquiries="allInquiries" Status="new" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="연락함">
|
<MudTabPanel Text="상담중">
|
||||||
<InquiryTable Status="contacted" />
|
<InquiryTable Inquiries="allInquiries" Status="consulting" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="완료">
|
<MudTabPanel Text="계약완료">
|
||||||
<InquiryTable Status="completed" />
|
<InquiryTable Inquiries="allInquiries" Status="contracted" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
</MudTabs>
|
<MudTabPanel Text="거절">
|
||||||
|
<InquiryTable Inquiries="allInquiries" Status="rejected" />
|
||||||
|
</MudTabPanel>
|
||||||
|
<MudTabPanel Text="종결">
|
||||||
|
<InquiryTable Inquiries="allInquiries" Status="closed" />
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool isLoading = true;
|
||||||
|
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
|
||||||
|
allInquiries = items.ToList();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
allInquiries = [];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,16 +5,16 @@
|
|||||||
@inject IApiClient ApiClient
|
@inject IApiClient ApiClient
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject CustomAuthenticationStateProvider AuthStateProvider
|
@inject CustomAuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject IJSRuntime Js
|
||||||
|
@inject ILocalStorageService LocalStorageService
|
||||||
|
|
||||||
<PageTitle>로그인</PageTitle>
|
<PageTitle>로그인</PageTitle>
|
||||||
|
|
||||||
<MudThemeProvider />
|
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Small" Class="d-flex align-center justify-center" Style="min-height: 100vh;">
|
|
||||||
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
||||||
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
||||||
|
|
||||||
<div>
|
<form @onsubmit="HandleLogin" @onsubmit:preventDefault>
|
||||||
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
||||||
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
||||||
placeholder="사용자명"
|
placeholder="사용자명"
|
||||||
@@ -28,15 +28,19 @@
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
@bind-Value="model.Password" />
|
@bind-Value="model.Password" />
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<InputCheckbox class="mud-checkbox" @bind-Value="model.RememberMe" />
|
||||||
|
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="button"
|
<button type="submit"
|
||||||
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
|
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
|
||||||
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
|
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
|
||||||
@onclick="HandleLogin"
|
|
||||||
disabled="@isLoading">
|
disabled="@isLoading">
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
@@ -48,15 +52,38 @@
|
|||||||
<span>로그인</span>
|
<span>로그인</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool isLoading = false;
|
private bool isLoading = false;
|
||||||
private string errorMessage = "";
|
private string errorMessage = "";
|
||||||
|
|
||||||
private LoginModel model = new();
|
private LoginModel model = new();
|
||||||
|
private const string RememberedUsernameKey = "admin-remembered-username";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remembered = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey);
|
||||||
|
if (!string.IsNullOrEmpty(remembered))
|
||||||
|
{
|
||||||
|
model.Username = remembered;
|
||||||
|
model.RememberMe = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// LocalStorage not available in pre-render
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleLogin()
|
private async Task HandleLogin()
|
||||||
{
|
{
|
||||||
@@ -71,15 +98,25 @@
|
|||||||
var request = new { model.Username, model.Password };
|
var request = new { model.Username, model.Password };
|
||||||
var response = await ApiClient.PostAsync<LoginResponse>("auth/login", request);
|
var response = await ApiClient.PostAsync<LoginResponse>("auth/login", request);
|
||||||
|
|
||||||
if (response?.Token == null)
|
if (response?.AccessToken == null || response?.RefreshToken == null)
|
||||||
{
|
{
|
||||||
errorMessage = "사용자명 또는 비밀번호가 올바르지 않습니다.";
|
errorMessage = "사용자명 또는 비밀번호가 올바르지 않습니다.";
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuthStateProvider.LoginAsync(response.Token);
|
if (model.RememberMe)
|
||||||
NavigationManager.NavigateTo("/taxbaik/admin/dashboard", forceLoad: false);
|
{
|
||||||
|
await LocalStorageService.SetItemAsStringAsync(RememberedUsernameKey, model.Username);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await LocalStorageService.RemoveItemAsync(RememberedUsernameKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ApiClient.SetAuthToken(response.AccessToken);
|
||||||
|
await AuthStateProvider.LoginAsync(response.AccessToken, response.RefreshToken, response.ExpiresIn);
|
||||||
|
NavigationManager.NavigateTo(GetReturnUrl(), forceLoad: false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -90,7 +127,8 @@
|
|||||||
|
|
||||||
private class LoginResponse
|
private class LoginResponse
|
||||||
{
|
{
|
||||||
public string Token { get; set; } = "";
|
public string AccessToken { get; set; } = "";
|
||||||
|
public string RefreshToken { get; set; } = "";
|
||||||
public int ExpiresIn { get; set; }
|
public int ExpiresIn { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,5 +136,24 @@
|
|||||||
{
|
{
|
||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
public string Password { get; set; } = "";
|
public string Password { get; set; } = "";
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetReturnUrl()
|
||||||
|
{
|
||||||
|
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||||
|
if (!Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("returnUrl", out var returnUrl)
|
||||||
|
|| string.IsNullOrWhiteSpace(returnUrl))
|
||||||
|
{
|
||||||
|
return "/taxbaik/admin/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = returnUrl.ToString();
|
||||||
|
if (!value.StartsWith("admin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "/taxbaik/admin/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"/taxbaik/{value.TrimStart('/')}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@page "/admin/logout"
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@inject CustomAuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<PageTitle>로그아웃</PageTitle>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// 사용자 로그아웃
|
||||||
|
await AuthStateProvider.LogoutAsync();
|
||||||
|
|
||||||
|
// 로그인 페이지로 리다이렉트
|
||||||
|
NavigationManager.NavigateTo("/taxbaik/admin/login", forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
@page "/admin/season-simulator"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.Seasonal
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
|
||||||
|
<PageTitle>시즌 시뮬레이터</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Season Preview</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">시즌 시뮬레이터</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">시뮬레이션 날짜</MudText>
|
||||||
|
<MudDatePicker @bind-Date="simulationDate" Label="날짜 선택" DateFormat="yyyy-MM-dd" PickerVariant="PickerVariant.Static" />
|
||||||
|
<MudDivider Class="my-3" />
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">연간 세무 캘린더</MudText>
|
||||||
|
@foreach (var season in TaxSeasonCalendar.Seasons)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Outlined" Size="Size.Small" FullWidth="true"
|
||||||
|
Class="mb-1" Color="Color.Primary"
|
||||||
|
OnClick="@(() => JumpToSeason(season))">
|
||||||
|
@season.StartMonth/@season.StartDay — @season.Name
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-1">
|
||||||
|
@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요") 홈페이지 미리보기
|
||||||
|
</MudText>
|
||||||
|
@if (activeSeason != null)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Class="mb-3">
|
||||||
|
@activeSeason.Name 시즌 활성
|
||||||
|
</MudChip>
|
||||||
|
<MudDivider Class="mb-4" />
|
||||||
|
<!-- Hero 섹션 미리보기 -->
|
||||||
|
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 1.5rem;">
|
||||||
|
@if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0)
|
||||||
|
{
|
||||||
|
<div style="background: #f59e0b; color: #1a202c; display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; margin-bottom: 1rem;">
|
||||||
|
D-@activeSeason.DaysUntilDeadline 마감 임박
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div style="font-size: 1.8rem; font-weight: 800; white-space: pre-line; margin-bottom: 0.5rem; line-height: 1.3;">
|
||||||
|
@activeSeason.HeroHeadline
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
|
||||||
|
@activeSeason.HeroSubtext
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||||
|
<div style="background: #e53e3e; color: white; padding: 10px 20px; border-radius: 8px; font-weight: 700; font-size: 0.95rem;">
|
||||||
|
@activeSeason.CtaText
|
||||||
|
</div>
|
||||||
|
<div style="background: transparent; border: 2px solid rgba(255,255,255,0.5); color: white; padding: 10px 20px; border-radius: 8px; font-size: 0.95rem;">
|
||||||
|
서비스 안내
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">활성 시즌 키</MudText>
|
||||||
|
<MudText><code>@activeSeason.Key</code></MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">마감까지</MudText>
|
||||||
|
<MudText>
|
||||||
|
@if (activeSeason.DaysUntilDeadline >= 0)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small"
|
||||||
|
Color="@(activeSeason.DaysUntilDeadline <= 7 ? Color.Error : Color.Warning)">
|
||||||
|
D-@activeSeason.DaysUntilDeadline
|
||||||
|
</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>마감 후 @(-activeSeason.DaysUntilDeadline)일</span>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">포커스 서비스</MudText>
|
||||||
|
<MudText>@activeSeason.FocusService</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">블로그 카테고리</MudText>
|
||||||
|
<MudText>@activeSeason.RelatedCategorySlug</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">긴박감 배지 문구</MudText>
|
||||||
|
<MudText><code>@activeSeason.UrgencyBadge</code></MudText>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
선택한 날짜(@(simulationDate?.ToString("MM월 dd일") ?? "-"))는 시즌 비활성 기간입니다.
|
||||||
|
홈페이지는 기본 Hero를 표시합니다.
|
||||||
|
</MudAlert>
|
||||||
|
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-top: 1.5rem;">
|
||||||
|
<div style="font-size: 1.8rem; font-weight: 800; margin-bottom: 0.5rem;">
|
||||||
|
사업자 세금, 부동산,<br/>가족자산까지
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
|
||||||
|
세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담
|
||||||
|
</div>
|
||||||
|
<div style="background: #e53e3e; color: white; display: inline-block; padding: 10px 20px; border-radius: 8px; font-weight: 700;">
|
||||||
|
무료 상담 신청
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">연간 시즌 타임라인</MudText>
|
||||||
|
<MudSimpleTable Dense="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>기간</th>
|
||||||
|
<th>시즌</th>
|
||||||
|
<th>블로그 카테고리</th>
|
||||||
|
<th>상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var s in TaxSeasonCalendar.Seasons)
|
||||||
|
{
|
||||||
|
var isActive = activeSeason?.Key == s.Key;
|
||||||
|
<tr style="@(isActive ? "background: rgba(66,153,225,0.1);" : "")">
|
||||||
|
<td style="white-space: nowrap;">
|
||||||
|
@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay
|
||||||
|
</td>
|
||||||
|
<td>@s.Name</td>
|
||||||
|
<td><code style="font-size:0.8rem;">@s.RelatedCategorySlug</code></td>
|
||||||
|
<td>
|
||||||
|
@if (isActive)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">활성</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span style="color: #a0aec0; font-size: 0.85rem;">비활성</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private DateTime? simulationDate = DateTime.Today;
|
||||||
|
private CurrentSeasonDto? activeSeason;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => ComputeSeason();
|
||||||
|
|
||||||
|
private void ComputeSeason()
|
||||||
|
{
|
||||||
|
if (simulationDate == null) { activeSeason = null; return; }
|
||||||
|
var date = simulationDate.Value;
|
||||||
|
var season = TaxSeasonCalendar.Seasons.FirstOrDefault(s =>
|
||||||
|
{
|
||||||
|
var start = new DateTime(date.Year, s.StartMonth, s.StartDay);
|
||||||
|
var endYear = (s.EndMonth < s.StartMonth) ? date.Year + 1 : date.Year;
|
||||||
|
var end = new DateTime(endYear, s.EndMonth, s.EndDay);
|
||||||
|
return date >= start && date <= end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (season == null) { activeSeason = null; return; }
|
||||||
|
|
||||||
|
var endYearCalc = (season.EndMonth < season.StartMonth) ? date.Year + 1 : date.Year;
|
||||||
|
var deadline = new DateTime(endYearCalc, season.EndMonth, season.EndDay);
|
||||||
|
var ddays = (deadline.Date - date.Date).Days;
|
||||||
|
|
||||||
|
var badge = ddays <= 7 && ddays >= 0
|
||||||
|
? season.UrgencyBadge.Replace("{n}", ddays.ToString())
|
||||||
|
: season.UrgencyBadge;
|
||||||
|
|
||||||
|
activeSeason = new CurrentSeasonDto
|
||||||
|
{
|
||||||
|
Key = season.Key,
|
||||||
|
Name = season.Name,
|
||||||
|
HeroHeadline = season.HeroHeadline,
|
||||||
|
HeroSubtext = season.HeroSubtext,
|
||||||
|
UrgencyBadge = badge,
|
||||||
|
FocusService = season.FocusService,
|
||||||
|
RelatedCategorySlug = season.RelatedCategorySlug,
|
||||||
|
CtaText = season.CtaText,
|
||||||
|
DaysUntilDeadline = ddays,
|
||||||
|
Deadline = deadline
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void JumpToSeason(TaxSeason season)
|
||||||
|
{
|
||||||
|
simulationDate = new DateTime(DateTime.Today.Year, season.StartMonth, season.StartDay);
|
||||||
|
ComputeSeason();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
@page "/admin/settings"
|
@page "/admin/settings"
|
||||||
@using TaxBaik.Domain.Interfaces
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject Snackbar Snackbar
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using System.Collections.Generic
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Interfaces
|
||||||
|
@inject IApiClient ApiClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>설정</PageTitle>
|
<PageTitle>설정</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">⚙️ 사이트 설정</MudText>
|
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-6">
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">System</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">설정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
<div class="admin-section-header compact">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">사이트 정보</MudText>
|
||||||
|
<MudText Typo="Typo.body2">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<MudForm>
|
<MudForm>
|
||||||
<MudTextField @bind-Value="phone" Label="전화번호"
|
<MudTextField @bind-Value="phone" Label="전화번호"
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
@@ -22,18 +42,167 @@
|
|||||||
Variant="Variant.Outlined" Class="mb-4" />
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
@onclick="SaveSettings">저장</MudButton>
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
@onclick="SaveSettings">사이트 정보 저장</MudButton>
|
||||||
</MudForm>
|
</MudForm>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
|
||||||
|
<div class="admin-section-header compact">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">계정 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2">비밀번호는 12자 이상으로 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudForm>
|
||||||
|
<MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="newPassword" Label="새 비밀번호" InputType="InputType.Password"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
Disabled="@isChangingPassword"
|
||||||
|
StartIcon="@Icons.Material.Filled.LockReset"
|
||||||
|
@onclick="ChangePassword">
|
||||||
|
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
|
||||||
|
</MudButton>
|
||||||
|
</MudForm>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string phone = "010-4122-8268";
|
private string phone = "010-4122-8268";
|
||||||
private string email = "taxbaik5668@gmail.com";
|
private string email = "taxbaik5668@gmail.com";
|
||||||
private string kakaoUrl = "http://pf.kakao.com/_xoxchTX";
|
private string kakaoUrl = "http://pf.kakao.com/_xoxchTX";
|
||||||
private string instagramUrl = "https://www.instagram.com/taxtory5668/";
|
private string instagramUrl = "https://www.instagram.com/taxtory5668/";
|
||||||
|
private string currentPassword = "";
|
||||||
|
private string newPassword = "";
|
||||||
|
private string confirmNewPassword = "";
|
||||||
|
private bool isChangingPassword;
|
||||||
|
private bool isLoadingSettings;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadSettingsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadSettingsAsync()
|
||||||
|
{
|
||||||
|
isLoadingSettings = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = await ApiClient.GetAsync<Dictionary<string, string>>("site-settings");
|
||||||
|
if (settings is null || settings.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (settings.TryGetValue("PhoneNumber", out var loadedPhone) && !string.IsNullOrWhiteSpace(loadedPhone))
|
||||||
|
phone = loadedPhone;
|
||||||
|
|
||||||
|
if (settings.TryGetValue("EmailAddress", out var loadedEmail) && !string.IsNullOrWhiteSpace(loadedEmail))
|
||||||
|
email = loadedEmail;
|
||||||
|
|
||||||
|
if (settings.TryGetValue("KakaoChannelUrl", out var loadedKakao) && !string.IsNullOrWhiteSpace(loadedKakao))
|
||||||
|
kakaoUrl = loadedKakao;
|
||||||
|
|
||||||
|
if (settings.TryGetValue("InstagramUrl", out var loadedInstagram) && !string.IsNullOrWhiteSpace(loadedInstagram))
|
||||||
|
instagramUrl = loadedInstagram;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Snackbar.Add("사이트 설정을 불러오지 못했습니다.", Severity.Warning);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoadingSettings = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SaveSettings()
|
private async Task SaveSettings()
|
||||||
{
|
{
|
||||||
// TODO: Save settings to database
|
if (isLoadingSettings)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var response = await ApiClient.PutAsync<SaveSettingsResponse>("site-settings", new
|
||||||
|
{
|
||||||
|
Phone = phone,
|
||||||
|
Email = email,
|
||||||
|
KakaoUrl = kakaoUrl,
|
||||||
|
InstagramUrl = instagramUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.Message is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("설정 저장에 실패했습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add(response.Message, Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ChangePassword()
|
||||||
|
{
|
||||||
|
if (isChangingPassword)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
|
||||||
|
{
|
||||||
|
Snackbar.Add("현재 비밀번호와 새 비밀번호를 입력하세요.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword != confirmNewPassword)
|
||||||
|
{
|
||||||
|
Snackbar.Add("새 비밀번호 확인이 일치하지 않습니다.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isChangingPassword = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await ApiClient.PostAsync<ChangePasswordResponse>("auth/change-password", new
|
||||||
|
{
|
||||||
|
CurrentPassword = currentPassword,
|
||||||
|
NewPassword = newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.Message == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("비밀번호 변경에 실패했습니다.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add(response.Message, Severity.Success);
|
||||||
|
currentPassword = "";
|
||||||
|
newPassword = "";
|
||||||
|
confirmNewPassword = "";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Snackbar.Add("비밀번호 변경 중 오류가 발생했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isChangingPassword = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ChangePasswordResponse
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SaveSettingsResponse
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@inject ITaxFilingBrowserClient FilingClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
@if (Filings == null || Filings.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Class="pa-4" Color="Color.Secondary">항목이 없습니다.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="Filings" Hover="true" Dense="true" Class="mt-2">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>고객</MudTh>
|
||||||
|
<MudTh>신고 유형</MudTh>
|
||||||
|
<MudTh>기한</MudTh>
|
||||||
|
<MudTh>D-day</MudTh>
|
||||||
|
<MudTh>메모</MudTh>
|
||||||
|
<MudTh>처리</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.ClientName</MudTd>
|
||||||
|
<MudTd>@context.FilingType</MudTd>
|
||||||
|
<MudTd>@context.DueDate.ToString("yyyy-MM-dd")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@{
|
||||||
|
var dday = (context.DueDate.Date - DateTime.Today).Days;
|
||||||
|
}
|
||||||
|
@if (dday < 0)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Error">D+@(-dday)</MudChip>
|
||||||
|
}
|
||||||
|
else if (dday <= 7)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning">D-@dday</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">D-@dday</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@(context.Memo ?? "")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (context.Status == "pending")
|
||||||
|
{
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Success"
|
||||||
|
OnClick="@(() => MarkFiled(context))">완료</MudButton>
|
||||||
|
}
|
||||||
|
else if (context.Status == "filed")
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">완료</MudChip>
|
||||||
|
}
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||||
|
OnClick="@(() => DeleteFiling(context.Id))" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public List<TaxFiling>? Filings { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnStatusChange { get; set; }
|
||||||
|
|
||||||
|
private async Task MarkFiled(TaxFiling filing)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
filing.Status = "filed";
|
||||||
|
var result = await FilingClient.UpdateAsync(filing.Id, filing);
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("신고 완료 처리되었습니다.", Severity.Success);
|
||||||
|
await OnStatusChange.InvokeAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("처리 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteFiling(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await FilingClient.DeleteAsync(id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제되었습니다.", Severity.Info);
|
||||||
|
await OnStatusChange.InvokeAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("삭제 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
@page "/admin/tax-filings"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@using TaxBaik.Domain.Entities
|
||||||
|
@inject ITaxFilingBrowserClient FilingClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>신고 일정 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Tax Schedule</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
OnClick="@(() => showAddForm = !showAddForm)"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
일정 추가
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (showAddForm)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText>
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudAutocomplete T="Domain.Entities.Client" @bind-Value="selectedClient"
|
||||||
|
Label="고객 검색 *"
|
||||||
|
SearchFunc="SearchClients"
|
||||||
|
ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")"
|
||||||
|
Variant="Variant.Outlined" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined">
|
||||||
|
@foreach (var t in TaxFilingService.FilingTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField T="string" @bind-Value="newMemo" Label="메모" Variant="Variant.Outlined" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
<MudStack Row="true" Class="mt-3" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddFiling">저장</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
||||||
|
<MudTabPanel Text="신고 예정">
|
||||||
|
<FilingTable Filings="@pending" OnStatusChange="Reload" />
|
||||||
|
</MudTabPanel>
|
||||||
|
<MudTabPanel Text="신고 완료">
|
||||||
|
<FilingTable Filings="@filed" OnStatusChange="Reload" />
|
||||||
|
</MudTabPanel>
|
||||||
|
<MudTabPanel Text="기한 초과">
|
||||||
|
<FilingTable Filings="@overdue" OnStatusChange="Reload" />
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Domain.Entities.TaxFiling> pending = [];
|
||||||
|
private List<Domain.Entities.TaxFiling> filed = [];
|
||||||
|
private List<Domain.Entities.TaxFiling> overdue = [];
|
||||||
|
|
||||||
|
private bool showAddForm;
|
||||||
|
private Domain.Entities.Client? selectedClient;
|
||||||
|
private string newFilingType = "";
|
||||||
|
private DateTime? newDueDate = DateTime.Today.AddDays(30);
|
||||||
|
private string newMemo = "";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await Reload();
|
||||||
|
|
||||||
|
private async Task Reload()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var all = (await FilingClient.GetUpcomingAsync(365)).ToList();
|
||||||
|
pending = all.Where(x => x.Status == "pending").ToList();
|
||||||
|
filed = all.Where(x => x.Status == "filed").ToList();
|
||||||
|
overdue = all.Where(x => x.Status == "overdue").ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<Client>> SearchClients(string value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddFiling()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (selectedClient == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var filing = new TaxFiling
|
||||||
|
{
|
||||||
|
ClientId = selectedClient.Id,
|
||||||
|
FilingType = newFilingType,
|
||||||
|
DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow,
|
||||||
|
Status = "pending",
|
||||||
|
Memo = string.IsNullOrWhiteSpace(newMemo) ? null : newMemo
|
||||||
|
};
|
||||||
|
var result = await FilingClient.CreateAsync(filing);
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
showAddForm = false;
|
||||||
|
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("추가 실패", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime Js
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Js.InvokeVoidAsync("taxbaikAdminSession.clearAuthToken");
|
||||||
|
var returnUrl = Uri.EscapeDataString(Navigation.ToBaseRelativePath(Navigation.Uri));
|
||||||
|
Navigation.NavigateTo($"/taxbaik/admin/login?returnUrl={returnUrl}", replace: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
|
|
||||||
<CascadingAuthenticationState>
|
<Router AppAssembly="@typeof(Program).Assembly">
|
||||||
<Router AppAssembly="typeof(Program).Assembly">
|
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)" />
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
<NotAuthorized>
|
||||||
|
<RedirectToLogin />
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
</Found>
|
</Found>
|
||||||
<NotFound>
|
<NotFound>
|
||||||
<PageTitle>찾을 수 없음</PageTitle>
|
<PageTitle>찾을 수 없음</PageTitle>
|
||||||
<LayoutView Layout="typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
<LayoutView Layout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
||||||
<p>요청한 페이지를 찾을 수 없습니다.</p>
|
<p>요청한 페이지를 찾을 수 없습니다.</p>
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
</NotFound>
|
</NotFound>
|
||||||
</Router>
|
</Router>
|
||||||
</CascadingAuthenticationState>
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.JSInterop
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Application.Services
|
||||||
@attribute [Authorize]
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 관리자 대시보드 API
|
||||||
|
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class AdminDashboardController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AdminDashboardService _dashboardService;
|
||||||
|
private readonly TaxFilingService _taxFilingService;
|
||||||
|
|
||||||
|
public AdminDashboardController(
|
||||||
|
AdminDashboardService dashboardService,
|
||||||
|
TaxFilingService taxFilingService)
|
||||||
|
{
|
||||||
|
_dashboardService = dashboardService;
|
||||||
|
_taxFilingService = taxFilingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 대시보드 요약 정보 조회
|
||||||
|
/// GET /api/admin-dashboard/summary
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("summary")]
|
||||||
|
public async Task<IActionResult> GetSummary()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var summary = await _dashboardService.GetSummaryAsync();
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "대시보드 요약 조회 실패",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status500InternalServerError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 30일 이내 마감 임박 신고 조회
|
||||||
|
/// GET /api/admin-dashboard/upcoming-filings?days=30
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("upcoming-filings")]
|
||||||
|
public async Task<IActionResult> GetUpcomingFilings([FromQuery] int days = 30)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (days <= 0) days = 30;
|
||||||
|
var filings = await _taxFilingService.GetUpcomingAsync(days);
|
||||||
|
return Ok(new { data = filings, days });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "마감 임박 신고 조회 실패",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status500InternalServerError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 최근 문의 조회
|
||||||
|
/// GET /api/admin-dashboard/recent-inquiries?limit=10
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("recent-inquiries")]
|
||||||
|
public async Task<IActionResult> GetRecentInquiries([FromQuery] int limit = 10)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (limit <= 0) limit = 10;
|
||||||
|
if (limit > 100) limit = 100; // 보안: 최대 100개
|
||||||
|
|
||||||
|
var inquiries = await _dashboardService.GetRecentInquiriesAsync(limit);
|
||||||
|
return Ok(new { data = inquiries, limit });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "최근 문의 조회 실패",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status500InternalServerError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 월별 통계
|
||||||
|
/// GET /api/admin-dashboard/monthly-stats?month=2026-06
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("monthly-stats")]
|
||||||
|
public async Task<IActionResult> GetMonthlyStats([FromQuery] string? month = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stats = await _dashboardService.GetMonthlyStatsAsync(month);
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "월별 통계 조회 실패",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status500InternalServerError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AnnouncementController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AnnouncementService _announcementService;
|
||||||
|
|
||||||
|
public AnnouncementController(AnnouncementService announcementService)
|
||||||
|
{
|
||||||
|
_announcementService = announcementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("active")]
|
||||||
|
public async Task<IActionResult> GetActive()
|
||||||
|
{
|
||||||
|
var announcements = await _announcementService.GetActiveAsync();
|
||||||
|
return Ok(new { data = announcements });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
var announcements = await _announcementService.GetAllAsync();
|
||||||
|
return Ok(new { data = announcements });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var announcement = await _announcementService.GetByIdAsync(id);
|
||||||
|
if (announcement == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "공지사항을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Create([FromBody] AnnouncementDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var announcementId = await _announcementService.CreateAsync(dto);
|
||||||
|
var result = await _announcementService.GetByIdAsync(announcementId);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = announcementId }, result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] AnnouncementDto dto)
|
||||||
|
{
|
||||||
|
dto.Id = id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _announcementService.UpdateAsync(dto);
|
||||||
|
var result = await _announcementService.GetByIdAsync(id);
|
||||||
|
if (result == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "공지사항을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _announcementService.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,34 @@ public class AuthController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
return BadRequest(new ProblemDetails { Title = "로그인 정보가 필요합니다.", Status = StatusCodes.Status400BadRequest });
|
return BadRequest(new ProblemDetails { Title = "로그인 정보가 필요합니다.", Status = StatusCodes.Status400BadRequest });
|
||||||
|
|
||||||
var token = await _authService.AuthenticateAndGenerateTokenAsync(request.Username, request.Password);
|
var tokenPair = await _authService.AuthenticateAndGenerateTokenPairAsync(request.Username, request.Password);
|
||||||
if (token == null)
|
if (tokenPair == null)
|
||||||
return Unauthorized(new ProblemDetails { Title = "아이디 또는 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
|
return Unauthorized(new ProblemDetails { Title = "아이디 또는 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
|
||||||
|
|
||||||
return Ok(new { token, expiresIn = 28800 });
|
return Ok(new
|
||||||
|
{
|
||||||
|
accessToken = tokenPair.AccessToken,
|
||||||
|
refreshToken = tokenPair.RefreshToken,
|
||||||
|
expiresIn = tokenPair.ExpiresIn
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||||
|
return BadRequest(new ProblemDetails { Title = "Refresh token이 필요합니다.", Status = StatusCodes.Status400BadRequest });
|
||||||
|
|
||||||
|
var tokenPair = await _authService.RefreshAccessTokenAsync(request.RefreshToken);
|
||||||
|
if (tokenPair == null)
|
||||||
|
return Unauthorized(new ProblemDetails { Title = "Refresh token이 유효하지 않습니다.", Status = StatusCodes.Status401Unauthorized });
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
accessToken = tokenPair.AccessToken,
|
||||||
|
refreshToken = tokenPair.RefreshToken,
|
||||||
|
expiresIn = tokenPair.ExpiresIn
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("change-password")]
|
[HttpPost("change-password")]
|
||||||
@@ -94,3 +117,8 @@ public class ResetPasswordRequest
|
|||||||
public string NewPassword { get; set; } = string.Empty;
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
public string ResetToken { get; set; } = string.Empty;
|
public string ResetToken { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class RefreshTokenRequest
|
||||||
|
{
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ public class BlogController : ControllerBase
|
|||||||
return Ok(posts);
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("admin")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetAdminPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var (items, total) = await _blogService.GetAdminPagedAsync(page, pageSize);
|
||||||
|
return Ok(new { data = items, total, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
|
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class ClientController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ClientService _clientService;
|
||||||
|
|
||||||
|
public ClientController(ClientService clientService)
|
||||||
|
{
|
||||||
|
_clientService = clientService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetPaged(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? status = null,
|
||||||
|
[FromQuery] string? search = null)
|
||||||
|
{
|
||||||
|
var (clients, total) = await _clientService.GetPagedAsync(page, pageSize, status, search);
|
||||||
|
return Ok(new { data = clients, total, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var client = await _clientService.GetByIdAsync(id);
|
||||||
|
if (client == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "고객을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateClientDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clientId = await _clientService.CreateAsync(dto);
|
||||||
|
var client = await _clientService.GetByIdAsync(clientId);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = clientId }, client);
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] CreateClientDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _clientService.UpdateAsync(id, dto);
|
||||||
|
var client = await _clientService.GetByIdAsync(id);
|
||||||
|
if (client == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "고객을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(client);
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _clientService.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class FaqController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly FaqService _faqService;
|
||||||
|
|
||||||
|
public FaqController(FaqService faqService)
|
||||||
|
{
|
||||||
|
_faqService = faqService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("active")]
|
||||||
|
public async Task<IActionResult> GetActive()
|
||||||
|
{
|
||||||
|
var faqs = await _faqService.GetActiveAsync();
|
||||||
|
return Ok(new { data = faqs });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
var faqs = await _faqService.GetAllAsync();
|
||||||
|
return Ok(new { data = faqs });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var faq = await _faqService.GetByIdAsync(id);
|
||||||
|
if (faq == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "FAQ를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(faq);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Create([FromBody] Faq faq)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var faqId = await _faqService.CreateAsync(faq);
|
||||||
|
var result = await _faqService.GetByIdAsync(faqId);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = faqId }, result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] Faq faq)
|
||||||
|
{
|
||||||
|
faq.Id = id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _faqService.UpdateAsync(faq);
|
||||||
|
var result = await _faqService.GetByIdAsync(id);
|
||||||
|
if (result == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "FAQ를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _faqService.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
namespace TaxBaik.Web.Controllers;
|
namespace TaxBaik.Web.Controllers;
|
||||||
@@ -9,10 +10,12 @@ namespace TaxBaik.Web.Controllers;
|
|||||||
public class InquiryController : ControllerBase
|
public class InquiryController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly InquiryService _inquiryService;
|
private readonly InquiryService _inquiryService;
|
||||||
|
private readonly ClientService _clientService;
|
||||||
|
|
||||||
public InquiryController(InquiryService inquiryService)
|
public InquiryController(InquiryService inquiryService, ClientService clientService)
|
||||||
{
|
{
|
||||||
_inquiryService = inquiryService;
|
_inquiryService = inquiryService;
|
||||||
|
_clientService = clientService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -66,7 +69,8 @@ public class InquiryController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _inquiryService.UpdateStatusAsync(id, request.Status);
|
var changedBy = User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name;
|
||||||
|
await _inquiryService.UpdateStatusAsync(id, request.Status, changedBy);
|
||||||
return Ok(new { message = "상태가 변경되었습니다." });
|
return Ok(new { message = "상태가 변경되었습니다." });
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
@@ -74,6 +78,54 @@ public class InquiryController : ControllerBase
|
|||||||
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}/memo")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> UpdateAdminMemo(int id, [FromBody] UpdateAdminMemoRequest request)
|
||||||
|
{
|
||||||
|
var inquiry = await _inquiryService.GetByIdAsync(id);
|
||||||
|
if (inquiry == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _inquiryService.UpdateAdminMemoAsync(id, request.AdminMemo);
|
||||||
|
return Ok(new { message = "메모가 저장되었습니다." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/convert-to-client")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> ConvertToClient(int id, [FromBody] ConvertToClientRequest request)
|
||||||
|
{
|
||||||
|
var inquiry = await _inquiryService.GetByIdAsync(id);
|
||||||
|
if (inquiry == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
if (inquiry.ClientId != null)
|
||||||
|
return BadRequest(new ProblemDetails { Title = "이미 고객 카드가 연결되어 있습니다.", Status = StatusCodes.Status400BadRequest });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clientId = await _clientService.CreateFromInquiryAsync(
|
||||||
|
request.Name ?? inquiry.Name,
|
||||||
|
request.Phone ?? inquiry.Phone,
|
||||||
|
request.ServiceType ?? inquiry.ServiceType);
|
||||||
|
|
||||||
|
await _inquiryService.LinkClientAsync(inquiry.Id, clientId);
|
||||||
|
await _inquiryService.UpdateStatusAsync(inquiry.Id, "consulting", User.FindFirstValue(ClaimTypes.Name) ?? "system");
|
||||||
|
|
||||||
|
return Ok(new { clientId, message = "고객 카드가 생성되었습니다." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SubmitInquiryRequest
|
public class SubmitInquiryRequest
|
||||||
@@ -89,3 +141,15 @@ public class UpdateStatusRequest
|
|||||||
{
|
{
|
||||||
public string Status { get; set; } = string.Empty;
|
public string Status { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UpdateAdminMemoRequest
|
||||||
|
{
|
||||||
|
public string? AdminMemo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConvertToClientRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? ServiceType { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class SiteSettingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly SiteSettingService _siteSettingService;
|
||||||
|
|
||||||
|
public SiteSettingsController(SiteSettingService siteSettingService)
|
||||||
|
{
|
||||||
|
_siteSettingService = siteSettingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get()
|
||||||
|
{
|
||||||
|
var settings = await _siteSettingService.GetAllAsync();
|
||||||
|
return Ok(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
public async Task<IActionResult> Save([FromBody] SaveSiteSettingsRequest request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
return BadRequest(new { message = "요청 본문이 비어 있습니다." });
|
||||||
|
|
||||||
|
await _siteSettingService.SaveAsync(request.Phone, request.Email, request.KakaoUrl, request.InstagramUrl);
|
||||||
|
return Ok(new { message = "사이트 설정이 저장되었습니다." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SaveSiteSettingsRequest
|
||||||
|
{
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string KakaoUrl { get; set; } = string.Empty;
|
||||||
|
public string InstagramUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class TaxFilingController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly TaxFilingService _taxFilingService;
|
||||||
|
|
||||||
|
public TaxFilingController(TaxFilingService taxFilingService)
|
||||||
|
{
|
||||||
|
_taxFilingService = taxFilingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("upcoming")]
|
||||||
|
public async Task<IActionResult> GetUpcoming([FromQuery] int daysAhead = 30)
|
||||||
|
{
|
||||||
|
var filings = await _taxFilingService.GetUpcomingAsync(daysAhead);
|
||||||
|
return Ok(new { data = filings });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{clientId}")]
|
||||||
|
public async Task<IActionResult> GetByClientId(int clientId)
|
||||||
|
{
|
||||||
|
var filings = await _taxFilingService.GetByClientIdAsync(clientId);
|
||||||
|
return Ok(new { data = filings });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var filing = await _taxFilingService.GetByIdAsync(id);
|
||||||
|
if (filing == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "신고 일정을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(filing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] TaxFiling filing)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filingId = await _taxFilingService.CreateAsync(filing);
|
||||||
|
var result = await _taxFilingService.GetByIdAsync(filingId);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = filingId }, result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] TaxFiling filing)
|
||||||
|
{
|
||||||
|
filing.Id = id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _taxFilingService.UpdateAsync(filing);
|
||||||
|
var result = await _taxFilingService.GetByIdAsync(id);
|
||||||
|
if (result == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "신고 일정을 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _taxFilingService.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real-time notification hub for admin dashboard
|
||||||
|
/// SOLID: Single Responsibility - Only broadcasts change notifications
|
||||||
|
/// No state management - stateless broadcast pattern
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public class NotificationHub : Hub
|
||||||
|
{
|
||||||
|
private const string AdminGroup = "admins";
|
||||||
|
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcast inquiry status changed to all connected admins
|
||||||
|
/// Clients should re-fetch from API to verify
|
||||||
|
/// </summary>
|
||||||
|
public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
|
||||||
|
{
|
||||||
|
await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
|
||||||
|
{
|
||||||
|
InquiryId = inquiryId,
|
||||||
|
Status = newStatus,
|
||||||
|
ChangedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcast inquiry submitted (new inquiry created)
|
||||||
|
/// </summary>
|
||||||
|
public async Task NotifyInquiryCreated(int inquiryId, string name)
|
||||||
|
{
|
||||||
|
await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
|
||||||
|
{
|
||||||
|
InquiryId = inquiryId,
|
||||||
|
Name = name,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcast client created
|
||||||
|
/// </summary>
|
||||||
|
public async Task NotifyClientCreated(int clientId, string name)
|
||||||
|
{
|
||||||
|
await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
Name = name,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcast announcement published
|
||||||
|
/// </summary>
|
||||||
|
public async Task NotifyAnnouncementPublished(int announcementId, string title)
|
||||||
|
{
|
||||||
|
await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
|
||||||
|
{
|
||||||
|
AnnouncementId = announcementId,
|
||||||
|
Title = title,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcast tax filing completed
|
||||||
|
/// </summary>
|
||||||
|
public async Task NotifyFilingCompleted(int filingId, string filingType)
|
||||||
|
{
|
||||||
|
await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
|
||||||
|
{
|
||||||
|
FilingId = filingId,
|
||||||
|
FilingType = filingType,
|
||||||
|
CompletedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1 class="fw-bold mb-5">세무 블로그</h1>
|
<h1 class="fw-bold mb-5">세무 블로그</h1>
|
||||||
|
|
||||||
<!-- Category Tabs -->
|
<!-- Category Tabs -->
|
||||||
<div class="mb-4">
|
<div class="mb-4 d-flex flex-wrap gap-2">
|
||||||
<a href="/taxbaik/blog" class="btn btn-sm @(Model.SelectedCategoryId == null ? "btn-primary" : "btn-outline-primary")">전체</a>
|
<a href="/taxbaik/blog" class="btn btn-sm @(Model.SelectedCategoryId == null ? "btn-primary" : "btn-outline-primary")">전체</a>
|
||||||
@foreach (var cat in Model.Categories)
|
@foreach (var cat in Model.Categories)
|
||||||
{
|
{
|
||||||
@@ -20,13 +20,13 @@
|
|||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
@foreach (var post in Model.Posts)
|
@foreach (var post in Model.Posts)
|
||||||
{
|
{
|
||||||
<div class="col-md-6 col-lg-4">
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
<div class="card h-100 border-0 shadow-sm">
|
<div class="card h-100 border-0 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<small class="badge bg-primary">@post.CategoryName</small>
|
<small class="badge bg-primary">@post.CategoryName</small>
|
||||||
<h5 class="card-title mt-2">@post.Title</h5>
|
<h5 class="card-title mt-2">@post.Title</h5>
|
||||||
<p class="card-text small">@post.CreatedAt.ToString("yyyy-MM-dd")</p>
|
<p class="card-text small">@post.CreatedAt.ToString("yyyy-MM-dd")</p>
|
||||||
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">읽기</a>
|
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">글 내용 보기</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
@page "{slug}"
|
@page "/blog/{slug}"
|
||||||
@model TaxBaik.Web.Pages.Blog.BlogPostModel
|
@model TaxBaik.Web.Pages.Blog.BlogPostModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
|
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
|
||||||
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
|
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
|
||||||
ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? "";
|
ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? "";
|
||||||
ViewData["CanonicalUrl"] = $"http://178.104.200.7/taxbaik/blog/{Model.Post?.Slug}";
|
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/blog/{Model.Post?.Slug}";
|
||||||
|
ViewData["CanonicalUrl"] = canonicalUrl;
|
||||||
|
ViewData["OgUrl"] = canonicalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (Model.Post != null)
|
@if (Model.Post != null)
|
||||||
@@ -18,6 +20,10 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-sm">← 블로그 목록으로 돌아가기</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Post.ThumbnailUrl))
|
@if (!string.IsNullOrEmpty(Model.Post.ThumbnailUrl))
|
||||||
{
|
{
|
||||||
<img src="@Model.Post.ThumbnailUrl" alt="@Model.Post.Title"
|
<img src="@Model.Post.ThumbnailUrl" alt="@Model.Post.Title"
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
|
|
||||||
@if (TempData["Success"] != null)
|
@if (TempData["Success"] != null)
|
||||||
{
|
{
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
<div id="contact-success" class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
@TempData["Success"]
|
@TempData["Success"]
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
|
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
+213
-21
@@ -1,12 +1,80 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.IndexModel
|
@model TaxBaik.Web.Pages.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
|
var season = Model.CurrentSeason;
|
||||||
|
ViewData["Title"] = season != null
|
||||||
|
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
|
||||||
|
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
|
||||||
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
|
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Hero Section — 강임팩트 -->
|
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
|
||||||
<section class="hero-section text-white pt-5 pb-4">
|
@if (Model.ActiveAnnouncements.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var notice in Model.ActiveAnnouncements)
|
||||||
|
{
|
||||||
|
<div class="announcement-bar announcement-bar--@notice.DisplayType">
|
||||||
|
<div class="container d-flex align-items-center gap-2 py-2">
|
||||||
|
<span class="announcement-icon">
|
||||||
|
@if (notice.DisplayType == "urgent") { <text>🚨</text> }
|
||||||
|
else if (notice.DisplayType == "banner") { <text>📢</text> }
|
||||||
|
else { <text>ℹ️</text> }
|
||||||
|
</span>
|
||||||
|
<span class="announcement-text fw-semibold">@notice.Title</span>
|
||||||
|
@if (!string.IsNullOrEmpty(notice.Content))
|
||||||
|
{
|
||||||
|
<span class="d-none d-md-inline text-muted small ms-2">— @notice.Content</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ─── Hero Section ─── *@
|
||||||
|
@if (season != null)
|
||||||
|
{
|
||||||
|
<section class="hero-section hero-section--seasonal text-white pt-5 pb-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center py-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<span class="badge bg-danger-badge mb-3 fs-6 px-3 py-2">
|
||||||
|
@season.UrgencyBadge
|
||||||
|
</span>
|
||||||
|
<h1 class="mb-3" style="white-space: pre-line;">@season.HeroHeadline</h1>
|
||||||
|
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
|
||||||
|
@season.HeroSubtext
|
||||||
|
</p>
|
||||||
|
<div class="d-flex gap-3 flex-wrap">
|
||||||
|
<a href="/taxbaik/contact" class="btn btn-warning btn-lg fw-bold">
|
||||||
|
⏰ @season.CtaText
|
||||||
|
</a>
|
||||||
|
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
|
||||||
|
onclick="openKakao()" style="border-color: white; color: white;">
|
||||||
|
💬 카카오 채널 문의
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@if (season.DaysUntilDeadline <= 7)
|
||||||
|
{
|
||||||
|
<p class="mt-3 small" style="opacity: 0.8;">
|
||||||
|
마감까지 <strong>@(season.DaysUntilDeadline)일</strong> 남았습니다.
|
||||||
|
지금 바로 상담 신청하세요.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5 d-none d-lg-block text-center">
|
||||||
|
<div class="seasonal-deadline-badge">
|
||||||
|
<div class="deadline-label">마감</div>
|
||||||
|
<div class="deadline-date">@season.Deadline.ToString("M월 d일")</div>
|
||||||
|
<div class="deadline-days">D-@season.DaysUntilDeadline</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<section class="hero-section text-white pt-5 pb-4">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row align-items-center py-4">
|
<div class="row align-items-center py-4">
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
@@ -21,7 +89,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="d-flex gap-3 flex-wrap">
|
<div class="d-flex gap-3 flex-wrap">
|
||||||
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
|
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
|
||||||
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg" onclick="openKakao()" style="border-color: white; color: white;">
|
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
|
||||||
|
onclick="openKakao()" style="border-color: white; color: white;">
|
||||||
💬 카카오 채널 문의
|
💬 카카오 채널 문의
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +100,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- 신뢰도 스트립 — 자격과 경험 -->
|
<!-- 신뢰도 스트립 — 자격과 경험 -->
|
||||||
<section class="trust-strip">
|
<section class="trust-strip">
|
||||||
@@ -62,7 +132,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 서비스 영역 — 전문성 강조 -->
|
<!-- 서비스 영역 -->
|
||||||
<section class="py-5">
|
<section class="py-5">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
@@ -72,10 +142,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@{
|
||||||
|
var focusService = season?.FocusService ?? "";
|
||||||
|
// 시즌에 따라 서비스 카드 순서 결정: 시즌 관련 카드가 맨 앞
|
||||||
|
var cardOrder = focusService switch
|
||||||
|
{
|
||||||
|
"real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" },
|
||||||
|
"family-asset" => new[] { "family-asset", "business-tax", "real-estate-tax" },
|
||||||
|
_ => new[] { "business-tax", "real-estate-tax", "family-asset" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- 사업자 세무 -->
|
@foreach (var cardKey in cardOrder)
|
||||||
|
{
|
||||||
|
var isFeatured = cardKey == focusService;
|
||||||
|
if (cardKey == "business-tax")
|
||||||
|
{
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4 col-md-6">
|
||||||
<div class="card service-card h-100">
|
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
|
||||||
|
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
|
||||||
<div class="service-icon">🏪</div>
|
<div class="service-icon">🏪</div>
|
||||||
<div class="card-body pt-0">
|
<div class="card-body pt-0">
|
||||||
<h3 class="card-title">사업자 세무</h3>
|
<h3 class="card-title">사업자 세무</h3>
|
||||||
@@ -92,10 +178,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<!-- 부동산 세금 -->
|
else if (cardKey == "real-estate-tax")
|
||||||
|
{
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4 col-md-6">
|
||||||
<div class="card service-card h-100">
|
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
|
||||||
|
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
|
||||||
<div class="service-icon">🏠</div>
|
<div class="service-icon">🏠</div>
|
||||||
<div class="card-body pt-0">
|
<div class="card-body pt-0">
|
||||||
<h3 class="card-title">부동산 세금</h3>
|
<h3 class="card-title">부동산 세금</h3>
|
||||||
@@ -112,10 +200,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<!-- 가족자산 & 증여 -->
|
else
|
||||||
|
{
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4 col-md-6">
|
||||||
<div class="card service-card h-100">
|
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
|
||||||
|
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
|
||||||
<div class="service-icon">👨👩👧👦</div>
|
<div class="service-icon">👨👩👧👦</div>
|
||||||
<div class="card-body pt-0">
|
<div class="card-body pt-0">
|
||||||
<h3 class="card-title">가족자산 관리</h3>
|
<h3 class="card-title">가족자산 관리</h3>
|
||||||
@@ -132,6 +222,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -181,18 +273,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 최근 블로그 -->
|
<!-- 세무 정보 블로그 -->
|
||||||
<section class="py-5">
|
<section class="py-5">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
|
@if (season != null)
|
||||||
|
{
|
||||||
|
<div class="seasonal-blog-header mb-2">
|
||||||
|
<span class="seasonal-blog-tag">📅 @season.Name 시즌</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="section-title">이번 시즌 세무 정보</h2>
|
||||||
|
<p class="text-muted">@season.Name 관련 절세 팁과 신고 가이드를 확인하세요</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<h2 class="section-title">세무 정보</h2>
|
<h2 class="section-title">세무 정보</h2>
|
||||||
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
|
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.RecentPosts?.Count > 0)
|
@{
|
||||||
|
var hasSeasonalPosts = Model.SeasonalPosts?.Count > 0;
|
||||||
|
var hasRecentPosts = Model.RecentPosts?.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (hasSeasonalPosts || hasRecentPosts)
|
||||||
{
|
{
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
@foreach (var post in Model.RecentPosts.Take(3))
|
@* 시즌 관련 글 (배지 강조) *@
|
||||||
|
@if (hasSeasonalPosts)
|
||||||
|
{
|
||||||
|
@foreach (var post in Model.SeasonalPosts!)
|
||||||
|
{
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="card blog-card h-100 blog-card--seasonal">
|
||||||
|
<div class="blog-seasonal-ribbon">이번 시즌 추천</div>
|
||||||
|
<div class="blog-placeholder">🗓️</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<small class="badge bg-season-badge">@post.CategoryName</small>
|
||||||
|
<h4 class="card-title mt-3">@post.Title</h4>
|
||||||
|
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
|
||||||
|
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-seasonal">자세히 보기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@* 최신 글 (나머지 채우기) *@
|
||||||
|
@if (hasRecentPosts)
|
||||||
|
{
|
||||||
|
@foreach (var post in Model.RecentPosts!)
|
||||||
{
|
{
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4 col-md-6">
|
||||||
<div class="card blog-card h-100">
|
<div class="card blog-card h-100">
|
||||||
@@ -200,23 +330,84 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<small class="badge bg-primary-badge">@post.CategoryName</small>
|
<small class="badge bg-primary-badge">@post.CategoryName</small>
|
||||||
<h4 class="card-title mt-3">@post.Title</h4>
|
<h4 class="card-title mt-3">@post.Title</h4>
|
||||||
<p class="text-muted small">@post.CreatedAt.ToString("yyyy년 MM월 dd일")</p>
|
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
|
||||||
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">읽기</a>
|
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">글 내용 보기</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center mt-5">
|
|
||||||
|
<div class="text-center mt-5 d-flex justify-content-center gap-3 flex-wrap">
|
||||||
|
@if (season != null && !string.IsNullOrEmpty(season.RelatedCategorySlug))
|
||||||
|
{
|
||||||
|
<a href="/taxbaik/blog?category=@season.RelatedCategorySlug" class="btn btn-outline-seasonal btn-lg">
|
||||||
|
📅 @season.Name 전체 글 보기
|
||||||
|
</a>
|
||||||
|
}
|
||||||
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
|
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 최종 CTA — 강렬한 다크 배경 -->
|
<!-- 자주 묻는 질문 (DB 연동) -->
|
||||||
|
@if (Model.ActiveFaqs.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="py-5" style="background: #F9F7F3;">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="section-title">자주 묻는 질문</h2>
|
||||||
|
<p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accordion faq-accordion" id="faqAccordion">
|
||||||
|
@for (int i = 0; i < Model.ActiveFaqs.Count; i++)
|
||||||
|
{
|
||||||
|
var faqItem = Model.ActiveFaqs[i];
|
||||||
|
var collapseId = $"faq-{faqItem.Id}";
|
||||||
|
<div class="accordion-item faq-item">
|
||||||
|
<h3 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed faq-question" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#@collapseId">
|
||||||
|
@faqItem.Question
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div id="@collapseId" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="accordion-body faq-answer">
|
||||||
|
@faqItem.Answer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-5">
|
||||||
|
<p class="text-muted mb-3">더 궁금한 점이 있으시면 바로 문의해 주세요</p>
|
||||||
|
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 문의하기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- 최종 CTA -->
|
||||||
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
|
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
|
@if (season != null)
|
||||||
|
{
|
||||||
|
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">@season.Name 마감이 다가옵니다!</h2>
|
||||||
|
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
|
||||||
|
마감 <strong>D-@(season.DaysUntilDeadline)일</strong> — 지금 바로 상담을 신청하세요.<br/>
|
||||||
|
빠른 검토로 불이익 없이 신고를 완료합니다.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||||
|
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a>
|
||||||
|
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">세금 고민은 이제 끝!</h2>
|
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">세금 고민은 이제 끝!</h2>
|
||||||
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
|
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
|
||||||
무료 상담으로 현재 상황을 진단하고<br/>
|
무료 상담으로 현재 상황을 진단하고<br/>
|
||||||
@@ -226,5 +417,6 @@
|
|||||||
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
|
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
|
||||||
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
|
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user