Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,73 @@
|
||||
name: TaxBaik Browser E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
browser-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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 }}
|
||||
run: |
|
||||
set -e
|
||||
EXPECTED_VERSION="$(git rev-parse --short HEAD)"
|
||||
for i in $(seq 1 60); do
|
||||
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.txt" || 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: ${EXPECTED_VERSION}" && [ "$BLOG_STATUS" = "200" ]; then
|
||||
echo "Deployment is ready for ${EXPECTED_VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for deployment ${EXPECTED_VERSION}; blog status=${BLOG_STATUS}; version=${VERSION_BODY}"
|
||||
sleep 10
|
||||
done
|
||||
echo "Deployment did not publish expected version ${EXPECTED_VERSION} in time" >&2
|
||||
exit 1
|
||||
|
||||
- name: Browser E2E verification
|
||||
env:
|
||||
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
||||
E2E_ADMIN_USERNAME: admin
|
||||
E2E_ADMIN_PASSWORD: ${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}
|
||||
run: npm run test:e2e
|
||||
|
||||
- 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"
|
||||
+107
-48
@@ -29,69 +29,128 @@ jobs:
|
||||
- name: Test solution
|
||||
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
|
||||
|
||||
- name: Copy migrations to publish
|
||||
- name: Write production secrets
|
||||
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
|
||||
run: |
|
||||
mkdir -p ./publish/wwwroot
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
||||
echo "Version: $COMMIT_HASH" > ./publish/wwwroot/version.txt
|
||||
echo "Built: $BUILD_TIME" >> ./publish/wwwroot/version.txt
|
||||
echo "✓ Version: $COMMIT_HASH"
|
||||
mkdir -p ./publish/wwwroot
|
||||
printf 'Version: %s\nBuilt: %s\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.txt
|
||||
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: |
|
||||
set -e
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
DEPLOY_HOME="/home/kjh2064"
|
||||
DEPLOY_DIR="$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||
|
||||
echo "=== Deploying TaxBaik v$(git rev-parse --short HEAD) ==="
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
|
||||
|
||||
tar -czf taxbaik_publish.tgz -C ./publish .
|
||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes taxbaik_publish.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_publish_${TIMESTAMP}.tgz"
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
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"
|
||||
# 1. 아티팩트 업로드
|
||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||
taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz"
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리)
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||
-o ServerAliveInterval=10 \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
||||
set -e
|
||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
sleep 10
|
||||
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")
|
||||
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")
|
||||
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 "")
|
||||
echo "Home Status: $HOME_STATUS"
|
||||
echo "Login Status: $LOGIN_STATUS"
|
||||
echo "Auth Body: $AUTH_BODY"
|
||||
if [ "$HOME_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ] && echo "$AUTH_BODY" | grep -q '"token"'; then
|
||||
echo "✓ Service is running"
|
||||
else
|
||||
echo "⚠ Service may not be running (home: $HOME_STATUS, login: $LOGIN_STATUS, auth: $AUTH_BODY)"
|
||||
fi
|
||||
DEPLOY_HOME="/home/kjh2064"
|
||||
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
|
||||
TIMESTAMP="${TIMESTAMP}"
|
||||
|
||||
echo "--- [1/5] 압축 해제 ---"
|
||||
mkdir -p "\$DEPLOY_DIR"
|
||||
tar -xzf "/tmp/taxbaik_\${TIMESTAMP}.tgz" -C "\$DEPLOY_DIR"
|
||||
rm -f "/tmp/taxbaik_\${TIMESTAMP}.tgz"
|
||||
|
||||
echo "--- [2/5] 운영 설정 검증 ---"
|
||||
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|
||||
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
|
||||
|
||||
echo "--- [3/5] 심볼릭 링크 전환 ---"
|
||||
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
|
||||
|
||||
echo "--- [4/5] 서비스 재시작 ---"
|
||||
sudo /usr/bin/systemctl restart taxbaik
|
||||
|
||||
echo "--- [5/5] 헬스 체크 (최대 120초) ---"
|
||||
ATTEMPTS=40
|
||||
for i in \$(seq 1 \$ATTEMPTS); do
|
||||
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/ 2>/dev/null || echo "000")
|
||||
if [ "\$STATUS" = "200" ]; then
|
||||
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
|
||||
TestResults/
|
||||
*.trx
|
||||
playwright-report/
|
||||
test-results/
|
||||
.playwright-cli/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@@ -46,6 +49,9 @@ Thumbs.db
|
||||
packages/
|
||||
.nuget/
|
||||
|
||||
# Node / Playwright
|
||||
node_modules/
|
||||
|
||||
# Publish
|
||||
publish/
|
||||
PublishProfiles/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**클라이언트**: 백원숙 세무사 (세무사·부동산중개사·보험설계사 자격)
|
||||
**목적**: 온라인 전문성 표현 + 블로그 SEO 유입 + 전국 고객 확보
|
||||
**핵심 포지셔닝**: "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너"
|
||||
**기술 스택**: ASP.NET Core 8 / Dapper / PostgreSQL 18 / Nginx / Gitea CI
|
||||
**기술 스택**: ASP.NET Core 10 / Dapper / PostgreSQL 18 / Nginx / Gitea CI
|
||||
|
||||
---
|
||||
|
||||
@@ -744,6 +744,55 @@ SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 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`)
|
||||
- [ ] 모든 프로젝트 참조 정확
|
||||
|
||||
+10
-9
@@ -62,7 +62,7 @@ sudo systemctl reload nginx
|
||||
|
||||
2. 배포 워크플로우는 자동으로 실행:
|
||||
```
|
||||
master 브랜치 push → build → publish → restart
|
||||
master 브랜치 push → build → test → publish → restart → health check → Playwright
|
||||
```
|
||||
|
||||
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
|
||||
@@ -96,14 +96,15 @@ curl -X POST http://178.104.200.7/taxbaik/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"admin\",\"password\":\"<TAXBAIK_ADMIN_TEST_PASSWORD>\"}"
|
||||
|
||||
# 문의 폼 제출 테스트
|
||||
curl -X POST http://178.104.200.7/taxbaik/contact \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
|
||||
# Playwright 브라우저 검증
|
||||
npm run test:e2e
|
||||
|
||||
# DB에서 확인
|
||||
ssh kjh2064@178.104.200.7
|
||||
psql -U taxbaik -d taxbaikdb -c "SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;"
|
||||
# 필요한 경우 개별 테스트 실행
|
||||
npx playwright test tests/e2e/admin-login.spec.ts
|
||||
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개 포스트 확인
|
||||
curl http://178.104.200.7/taxbaik/blog
|
||||
|
||||
# 첫 번째 포스트 상세 (slug: 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 | 프로젝트 기반 구축 | ✅ 완료 |
|
||||
| W1 | LLM 개발 지침 (CLAUDE.md) | ✅ 완료 |
|
||||
| W2 | 도메인/인프라/서비스 레이어 | ✅ 완료 |
|
||||
| **W3** | **공개 홈페이지 (Razor Pages SSR)** | ✅ **배포됨** |
|
||||
| **W4** | **관리자 백오피스 (Blazor Server)** | ✅ **배포됨** |
|
||||
| **W5** | **스타일링 및 모바일 UX** | ✅ **완성됨** |
|
||||
| **W6** | **출시 준비 (E2E 테스트)** | ✅ **검증됨** |
|
||||
| W0 | 프로젝트 기반 구축 | 과거 기록 |
|
||||
| W1 | LLM 개발 지침 (CLAUDE.md) | 과거 기록 |
|
||||
| W2 | 도메인/인프라/서비스 레이어 | 과거 기록 |
|
||||
| **W3** | **공개 홈페이지 (Razor Pages SSR)** | 과거 기록 |
|
||||
| **W4** | **관리자 백오피스 (Blazor Server)** | 과거 기록 |
|
||||
| **W5** | **스타일링 및 모바일 UX** | 과거 기록 |
|
||||
| **W6** | **출시 준비 (E2E 테스트)** | 과거 기록 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 배포된 엔드포인트 (모두 HTTP 200)
|
||||
## 🚀 과거 배포 엔드포인트 기록
|
||||
|
||||
### 공개 사이트
|
||||
- 🏠 **홈페이지**: 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/dashboard
|
||||
- 👤 **기본 계정**: admin / admin123
|
||||
- 계정 정보는 문서에 기록하지 않고 Gitea Secrets 또는 서버 환경변수로만 관리한다.
|
||||
|
||||
---
|
||||
|
||||
## 📁 기술 구현
|
||||
## 📁 과거 기술 구성 기록
|
||||
|
||||
### 공개 사이트
|
||||
- **기술**: ASP.NET Core 10 Razor Pages (SSR)
|
||||
@@ -55,16 +58,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 데이터베이스
|
||||
## 📊 과거 데이터베이스 기록
|
||||
|
||||
### 초기 데이터
|
||||
- ✅ **5개 카테고리**: 사업자세무, 부동산세금, 종합소득세, 부가가치세, 가족자산증여
|
||||
- ✅ **5개 블로그 포스트**: 초기 콘텐츠 포함
|
||||
- ✅ **1개 관리자 계정**: admin/admin123
|
||||
- 5개 카테고리: 사업자세무, 부동산세금, 종합소득세, 부가가치세, 가족자산증여
|
||||
- 5개 블로그 포스트: 초기 콘텐츠 포함
|
||||
- 관리자 계정: 비밀번호는 문서화하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 배포 절차
|
||||
## 🔧 과거 배포 절차 기록
|
||||
|
||||
1. **로컬 빌드**
|
||||
```bash
|
||||
@@ -98,18 +101,18 @@ e7e01d0 마이그레이션 및 보안 수정
|
||||
|
||||
## ✨ 주요 특징
|
||||
|
||||
- ✅ SEO 최적화 (Server-Side Rendering)
|
||||
- ✅ 무중단 배포 (Shadow Copy)
|
||||
- ✅ 반응형 모바일 UI
|
||||
- ✅ 한국어 완전 지원
|
||||
- ✅ 자동 마이그레이션
|
||||
- ✅ 안전한 인증 (쿠키 + 인증)
|
||||
- ✅ 체계적인 레이어 구조
|
||||
- ✅ 프로덕션 준비 완료
|
||||
- SEO 항목 (Server-Side Rendering)
|
||||
- 심링크 기반 배포
|
||||
- 반응형 모바일 UI
|
||||
- 한국어 UI
|
||||
- 자동 마이그레이션
|
||||
- 인증 항목
|
||||
- 레이어 구조
|
||||
- 기록용 요약일 뿐, 현재 완료 판정 기준은 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계 (향후 개선)
|
||||
## 🎯 향후 개선 후보
|
||||
|
||||
1. BCrypt 실제 인증 개선
|
||||
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 |
|
||||
| **W1** | LLM 개발 지침 작성 | ✅ | 1 |
|
||||
| **W2** | Domain/Infrastructure/Application | ✅ | 2 |
|
||||
| **W3** | 공개 홈페이지 (Razor Pages) | ✅ | 4 |
|
||||
| **W4** | 관리자 백오피스 (Blazor) | ✅ | 3 |
|
||||
| **W5** | 스타일링 & 성능 최적화 | ✅ | 1 |
|
||||
| **W6** | 배포 준비 & CI/CD | ✅ | 5 |
|
||||
| **W0** | 프로젝트 기반 구축 | 과거 기록 | 3 |
|
||||
| **W1** | LLM 개발 지침 작성 | 과거 기록 | 1 |
|
||||
| **W2** | Domain/Infrastructure/Application | 과거 기록 | 2 |
|
||||
| **W3** | 공개 홈페이지 (Razor Pages) | 과거 기록 | 4 |
|
||||
| **W4** | 관리자 백오피스 (Blazor) | 과거 기록 | 3 |
|
||||
| **W5** | 스타일링 & 성능 최적화 | 과거 기록 | 1 |
|
||||
| **W6** | 배포 준비 & CI/CD | 과거 기록 | 5 |
|
||||
|
||||
**총 커밋**: 19개 (모두 한국어)
|
||||
|
||||
@@ -95,24 +95,23 @@ TaxBaik.Admin/ 95 KB (Blazor Server)
|
||||
## ✨ 주요 기능
|
||||
|
||||
### 공개 사이트
|
||||
- ✅ SEO 최적화 블로그 (5개 카테고리)
|
||||
- ✅ 온라인 상담 신청 폼
|
||||
- ✅ 반응형 디자인 (모바일 375px+)
|
||||
- ✅ 성능 최적화 (gzip, lazy load)
|
||||
- SEO 블로그
|
||||
- 온라인 상담 신청 폼
|
||||
- 반응형 디자인
|
||||
- 성능 최적화 항목
|
||||
|
||||
### 관리자 백오피스
|
||||
- ✅ 대시보드 (KPI 카드)
|
||||
- ✅ 블로그 CRUD
|
||||
- ✅ 문의 관리 (상태 변경)
|
||||
- ✅ 사이트 설정
|
||||
- 대시보드
|
||||
- 블로그 관리
|
||||
- 문의 관리
|
||||
- 사이트 설정
|
||||
|
||||
### 보안 & 성능
|
||||
- ✅ SQL Injection 방지 (파라미터화 쿼리)
|
||||
- ✅ CSRF 보호 ([ValidateAntiForgeryToken])
|
||||
- ✅ Cookie 기반 인증 (8시간 세션)
|
||||
- ✅ gzip 응답 압축
|
||||
- ✅ 이미지 lazy load
|
||||
- ✅ 폰트 preconnect
|
||||
- SQL Injection 방지 항목
|
||||
- 인증/인가 항목
|
||||
- gzip 응답 압축
|
||||
- 이미지 lazy load
|
||||
- 폰트 preconnect
|
||||
|
||||
---
|
||||
|
||||
@@ -130,7 +129,7 @@ Gitea Actions 트리거
|
||||
4. 심링크 스왑
|
||||
5. systemctl restart
|
||||
↓
|
||||
배포 완료 (무중단)
|
||||
배포 기록 생성
|
||||
```
|
||||
|
||||
### 자동 마이그레이션
|
||||
@@ -143,53 +142,53 @@ schema_migrations 테이블 확인
|
||||
↓
|
||||
미실행 마이그레이션 자동 실행
|
||||
↓
|
||||
DB 준비 완료
|
||||
DB 준비 기록 생성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 코드 품질
|
||||
## 📊 과거 코드 품질 기록
|
||||
|
||||
| 항목 | 상태 | 세부 |
|
||||
|------|------|------|
|
||||
| **빌드** | ✅ | 0 errors, 12 warnings (NuGet 보안 정보) |
|
||||
| **보안** | ✅ | SQL injection 방지, CSRF 보호, 인증 |
|
||||
| **성능** | ✅ | gzip, lazy load, 메모리 캐시 |
|
||||
| **SEO** | ✅ | 메타 태그, sitemap, robots.txt |
|
||||
| **테스트** | ✅ | 구조적 검증 완료 |
|
||||
| **문서** | ✅ | 1,500+ 라인 (개발 + 배포 가이드) |
|
||||
| **빌드** | 과거 기록 | 최신 상태는 CI 로그 기준 |
|
||||
| **보안** | 과거 기록 | 최신 상태는 코드 리뷰와 테스트 기준 |
|
||||
| **성능** | 과거 기록 | 최신 상태는 WBS 검증 기준 |
|
||||
| **SEO** | 과거 기록 | 최신 상태는 `blog-seo` Playwright 기준 |
|
||||
| **테스트** | 과거 기록 | 최신 상태는 Playwright/CI 기준 |
|
||||
| **문서** | 과거 기록 | 최신 상태는 `ROADMAP_WBS.md` 기준 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 수락 기준
|
||||
## 🎯 과거 수락 기준 기록
|
||||
|
||||
### 기술적 요구사항
|
||||
- [x] ASP.NET Core 8 + C#11 기반
|
||||
- [x] Dapper + PostgreSQL 사용
|
||||
- [x] Razor Pages SSR (공개 사이트)
|
||||
- [x] Blazor Server (관리자)
|
||||
- [x] 계층화된 아키텍처 (Domain → Infrastructure → Application → Web/Admin)
|
||||
- [x] 모든 UI 문자열 한국어
|
||||
- ASP.NET Core 기반
|
||||
- Dapper + PostgreSQL 사용
|
||||
- Razor Pages SSR (공개 사이트)
|
||||
- Blazor Server (관리자)
|
||||
- 계층화된 아키텍처
|
||||
- UI 문자열 한국어
|
||||
|
||||
### 기능 요구사항
|
||||
- [x] 블로그 (5개 카테고리, SEO 최적화)
|
||||
- [x] 온라인 문의 폼
|
||||
- [x] 관리자 백오피스 (블로그 + 문의 관리)
|
||||
- [x] 반응형 디자인
|
||||
- [x] 성능 최적화
|
||||
- 블로그
|
||||
- 온라인 문의 폼
|
||||
- 관리자 백오피스
|
||||
- 반응형 디자인
|
||||
- 성능 최적화
|
||||
|
||||
### 배포 요구사항
|
||||
- [x] CI/CD 파이프라인 (Gitea Actions)
|
||||
- [x] 자동 마이그레이션
|
||||
- [x] 무중단 배포 (심링크 스왑)
|
||||
- [x] systemd 서비스 파일
|
||||
- [x] Nginx 리버스 프록시 설정
|
||||
- CI/CD 파이프라인
|
||||
- 자동 마이그레이션
|
||||
- 심링크 배포
|
||||
- systemd 서비스 파일
|
||||
- Nginx 리버스 프록시 설정
|
||||
|
||||
### 문서 요구사항
|
||||
- [x] CLAUDE.md (개발 지침)
|
||||
- [x] DEPLOYMENT_GUIDE.md (배포 가이드)
|
||||
- [x] README.md (프로젝트 개요)
|
||||
- [x] 서버 설치 스크립트
|
||||
- CLAUDE.md
|
||||
- DEPLOYMENT_GUIDE.md
|
||||
- README.md
|
||||
- 서버 설치 스크립트
|
||||
|
||||
---
|
||||
|
||||
@@ -229,54 +228,41 @@ b300cd7 완성: 빌드 성공 및 최종 통합 (W0~W6 완료)
|
||||
|
||||
---
|
||||
|
||||
## 🎊 최종 체크리스트
|
||||
## 과거 체크리스트 기록
|
||||
|
||||
### 개발 완료
|
||||
- [x] 코드 작성
|
||||
- [x] 로컬 빌드 성공
|
||||
- [x] Git 커밋/푸시
|
||||
### 개발 기록
|
||||
- 코드 작성 기록
|
||||
- 로컬 빌드 기록
|
||||
- Git 커밋/푸시 기록
|
||||
|
||||
### 검증 완료
|
||||
- [x] 아키텍처 검증
|
||||
- [x] 코드 구조 검증
|
||||
- [x] 보안 검증
|
||||
- [x] 성능 검증
|
||||
- [x] SEO 검증
|
||||
### 검증 기록
|
||||
- 아키텍처 검토 기록
|
||||
- 코드 구조 검토 기록
|
||||
- 보안 검토 기록
|
||||
- 성능 검토 기록
|
||||
- SEO 검토 기록
|
||||
|
||||
### 배포 준비
|
||||
- [x] CI/CD 파이프라인
|
||||
- [x] 자동 마이그레이션
|
||||
- [x] 배포 스크립트
|
||||
- [x] 배포 가이드
|
||||
- [x] 모니터링 설정
|
||||
- CI/CD 파이프라인
|
||||
- 자동 마이그레이션
|
||||
- 배포 스크립트
|
||||
- 배포 가이드
|
||||
- 모니터링 설정
|
||||
|
||||
### 문서 완성
|
||||
- [x] README.md
|
||||
- [x] CLAUDE.md
|
||||
- [x] DEPLOYMENT_GUIDE.md
|
||||
- [x] PRODUCTION_CHECKLIST.md
|
||||
- [x] SERVER_SETUP.sh
|
||||
### 문서 기록
|
||||
- README.md
|
||||
- CLAUDE.md
|
||||
- DEPLOYMENT_GUIDE.md
|
||||
- PRODUCTION_CHECKLIST.md
|
||||
- SERVER_SETUP.sh
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
## 현재 후속 기준
|
||||
|
||||
### 즉시 실행 (서버에서)
|
||||
```bash
|
||||
bash SERVER_SETUP.sh # 자동 설치
|
||||
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. 모니터링 시작
|
||||
1. `ROADMAP_WBS.md`의 미완료 항목을 기준으로 작업한다.
|
||||
2. 완료 판정은 CI 배포, 배포 검증, Playwright E2E 통과 후에만 한다.
|
||||
3. 서버 수동 변경은 비상 롤백을 제외하고 금지한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -289,8 +275,6 @@ curl http://localhost:5001 # 접근 확인
|
||||
|
||||
---
|
||||
|
||||
**프로젝트 상태**: ✅ **완성 (COMPLETE)**
|
||||
**프로젝트 상태**: 진행 중
|
||||
|
||||
모든 제안된 작업이 우선순위 순서대로 완료되었습니다.
|
||||
|
||||
배포 준비가 완료되었으므로, 서버에서 `SERVER_SETUP.sh`를 실행하면 즉시 운영을 시작할 수 있습니다.
|
||||
이 문서는 과거 완료 요약으로 남기고, 현재 진행 상태는 `ROADMAP_WBS.md`를 따른다.
|
||||
|
||||
@@ -119,6 +119,7 @@ createdb taxbaikdb
|
||||
psql -d taxbaikdb -f db/migrations/V001__InitialSchema.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/V004__CreateSiteSettings.sql
|
||||
|
||||
# 3. 환경 변수 설정
|
||||
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=postgres;Password=password"
|
||||
@@ -147,13 +148,16 @@ dotnet run --project TaxBaik.Web
|
||||
|
||||
배포는 **Gitea Actions CI/CD**만 사용합니다.
|
||||
|
||||
master 브랜치에 푸시하면 자동으로:
|
||||
1. ✅ .NET 빌드 (Release)
|
||||
2. ✅ 단위 테스트 실행
|
||||
3. ✅ `TaxBaik.Web` 게시
|
||||
4. ✅ 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
|
||||
5. ✅ systemd `taxbaik` 단일 서비스 재시작
|
||||
6. ✅ `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/api/auth/login` 헬스 체크
|
||||
master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합니다.
|
||||
1. .NET 빌드 (Release)
|
||||
2. 단위 테스트 실행
|
||||
3. Playwright 브라우저 검증 실행
|
||||
4. `TaxBaik.Web` 게시
|
||||
5. 원격 서버 배포 디렉토리 업로드 및 `taxbaik_active` 심링크 교체
|
||||
6. systemd `taxbaik` 단일 서비스 재시작
|
||||
7. `/taxbaik/`, `/taxbaik/admin/login`, `/taxbaik/blog/{slug}`, `/taxbaik/api/auth/login` 검증
|
||||
|
||||
배포 완료 판정은 위 단계가 모두 성공하고, 배포본 기준 Playwright E2E가 통과했을 때만 한다.
|
||||
|
||||
**필수 Gitea Secrets 설정:**
|
||||
- `DEPLOY_USER`: kjh2064
|
||||
@@ -332,6 +336,6 @@ echo $ConnectionStrings__Default
|
||||
|
||||
---
|
||||
|
||||
**최종 상태**: ✅ **프로덕션 준비 완료**
|
||||
**최종 상태**: 진행 중
|
||||
|
||||
모든 커밋이 한국어로 작성되었으며, Gitea에 업로드된 상태입니다.
|
||||
완료 판정은 실제 빌드, 테스트, 배포 검증, 브라우저 E2E 통과로만 한다.
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# TaxBaik 개선 로드맵 WBS
|
||||
|
||||
이 문서는 "완료 보고"가 아니라 검증 가능한 작업 목록이다. 각 WBS는 성공 기준을 통과해야 완료로 본다.
|
||||
|
||||
## 완료 판정 원칙
|
||||
|
||||
- 코드 변경만으로 완료 처리하지 않는다.
|
||||
- 서버 배포 대상 기능은 CI/CD 성공과 Playwright 브라우저 테스트 통과를 모두 요구한다.
|
||||
- API 기능은 단위 테스트 또는 통합 테스트와 함께 실제 HTTP 호출 결과를 확인한다.
|
||||
- DB 변경은 마이그레이션과 롤백 위험을 문서화한다.
|
||||
- 비밀값은 Gitea Secrets 또는 서버 환경변수로만 관리한다.
|
||||
|
||||
## 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개
|
||||
- `blog-seo`, `contact-submit`, `admin-password-change`가 배포본 기준으로 통과
|
||||
|
||||
Todo:
|
||||
- [x] Playwright Test 프로젝트 추가
|
||||
- [x] 관리자 로그인 E2E 추가
|
||||
- [x] CI 배포 후 Playwright 실행 단계 추가
|
||||
- [x] Playwright가 발견한 Blazor DI 결함 수정
|
||||
- [ ] CI run에서 Playwright 전체 통과 확인
|
||||
- [ ] 배포 검증에 블로그 상세/문의/비밀번호 변경 성공 기준 반영 확인
|
||||
|
||||
## WBS-AUTH-01 인증/비밀번호 운영 안정화
|
||||
|
||||
목표: DB 직접 수정 대신 API로 관리자 인증 운영 작업을 수행한다.
|
||||
|
||||
성공 기준:
|
||||
- 비밀번호 변경 API가 현재 비밀번호를 요구한다.
|
||||
- 비밀번호 재설정 API는 운영 secret 없이는 동작하지 않는다.
|
||||
- 실패 응답은 민감 정보를 노출하지 않는다.
|
||||
- Playwright 로그인 테스트와 비밀번호 변경 테스트가 변경 후에도 통과한다.
|
||||
|
||||
Todo:
|
||||
- [x] 로그인 API 검증
|
||||
- [x] 비밀번호 변경 API 추가
|
||||
- [x] 재설정 API 추가
|
||||
- [x] 관리자 UI에 비밀번호 변경 화면 추가
|
||||
- [x] 비밀번호 변경 Playwright E2E 추가
|
||||
|
||||
## WBS-ADMIN-01 관리자 Blazor 안정화
|
||||
|
||||
목표: 관리자 화면을 일반 웹페이지처럼 명시적 사용자 액션에만 갱신하고, circuit 예외를 배포 전 차단한다.
|
||||
|
||||
성공 기준:
|
||||
- 관리자 주요 메뉴 대시보드/블로그/문의/설정 접근 시 circuit error 0개
|
||||
- 잘못된 DI 타입 주입 0건
|
||||
- 저장/삭제/상태 변경 액션은 성공/실패 메시지를 표시한다.
|
||||
- Playwright가 주요 메뉴 smoke test를 수행한다.
|
||||
|
||||
Todo:
|
||||
- [x] 중복 `/admin` 라우트 제거
|
||||
- [x] MudBlazor DI 타입 오류 수정
|
||||
- [x] 관리자 메뉴 smoke E2E 추가
|
||||
- [x] 설정 저장 TODO를 실제 DB 기반 기능으로 전환
|
||||
- [x] 대시보드/목록 읽기 성능 개선
|
||||
|
||||
## 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-MAINT-01 유지보수성/파편화 축소
|
||||
|
||||
목표: 문서와 실제 구조의 불일치를 줄이고 단일 앱 운영 기준을 유지한다.
|
||||
|
||||
성공 기준:
|
||||
- README/CLAUDE/DEPLOYMENT_GUIDE의 .NET 버전, 앱 구조, 테스트 기준이 실제 코드와 일치
|
||||
- 배포 문서에 Playwright 검증 절차 포함
|
||||
- 오래된 분리 Admin 서비스 문서 제거 또는 명확히 deprecated 처리
|
||||
|
||||
Todo:
|
||||
- [x] README 테스트/배포 섹션 갱신
|
||||
- [x] CLAUDE.md E2E 기준 갱신
|
||||
- [x] 오래된 최종 보고 문서의 허위 완료 표현 정정
|
||||
|
||||
### 현재 검증 메모
|
||||
- `dotnet build TaxBaik.sln` 성공
|
||||
- 시크릿 없는 로컬 Playwright 전체 실행: 공개 smoke, blog SEO 통과 / 관리자 시나리오는 자격 증명 미설정으로 스킵
|
||||
- 배포본 `version.txt`: `8f0cb69`
|
||||
- 배포본 블로그 상세: HTTP 200
|
||||
- CI Playwright 전체 통과는 최신 커밋 배포 후 재확인 필요
|
||||
- 비밀번호 변경 E2E는 배포 환경 자격 증명 확인 대기
|
||||
@@ -4,6 +4,7 @@ using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
public class BlogServiceTests
|
||||
@@ -11,7 +12,7 @@ public class BlogServiceTests
|
||||
[Fact]
|
||||
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
|
||||
{
|
||||
@@ -32,7 +33,7 @@ public class BlogServiceTests
|
||||
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
|
||||
{
|
||||
@@ -63,6 +64,13 @@ public class BlogServiceTests
|
||||
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
|
||||
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)
|
||||
{
|
||||
post.Id = Posts.Count + 1;
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace TaxBaik.Application.Tests;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
public class InquiryServiceTests
|
||||
@@ -10,7 +11,7 @@ public class InquiryServiceTests
|
||||
[Fact]
|
||||
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"));
|
||||
}
|
||||
@@ -19,7 +20,7 @@ public class InquiryServiceTests
|
||||
public async Task SubmitAsync_StoresEmailAndNewStatus()
|
||||
{
|
||||
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");
|
||||
|
||||
@@ -48,6 +49,15 @@ public class InquiryServiceTests
|
||||
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 UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||
@@ -56,4 +66,13 @@ public class InquiryServiceTests
|
||||
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>
|
||||
<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.runner.visualstudio" Version="3.1.5">
|
||||
<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; }
|
||||
}
|
||||
@@ -9,7 +9,12 @@ public static class DependencyInjection
|
||||
{
|
||||
services.AddScoped<BlogService>();
|
||||
services.AddScoped<InquiryService>();
|
||||
services.AddScoped<AdminDashboardService>();
|
||||
services.AddScoped<IInquiryNotificationService, NoopInquiryNotificationService>();
|
||||
services.AddScoped<SiteSettingService>();
|
||||
services.AddScoped<CategoryService>();
|
||||
services.AddScoped<AnnouncementService>();
|
||||
services.AddSingleton<SeasonalMarketingService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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 CtaText { get; init; } = "상담 신청하기";
|
||||
public int DaysUntilDeadline { get; init; }
|
||||
public DateTime Deadline { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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; } = "상담 신청하기";
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 = "부가세 신고 상담"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
Key = "year-end-settlement",
|
||||
Name = "연말정산",
|
||||
StartMonth = 1, StartDay = 15,
|
||||
EndMonth = 2, EndDay = 28,
|
||||
HeroHeadline = "연말정산\n지금 준비하세요",
|
||||
HeroSubtext = "직원이 있는 사업자 원천징수 신고 · 환급 최대화",
|
||||
UrgencyBadge = "연말정산 진행 중",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "연말정산 상담"
|
||||
},
|
||||
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 = "법인세 신고 상담"
|
||||
},
|
||||
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 = "종합소득세 상담"
|
||||
},
|
||||
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 = "부가세 신고 상담"
|
||||
},
|
||||
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 = "종부세 절세 상담"
|
||||
},
|
||||
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 = "연말 절세 상담"
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,7 +5,9 @@ using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
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?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
||||
await repository.GetBySlugAsync(slug, ct);
|
||||
@@ -20,6 +22,10 @@ public class BlogService(IBlogPostRepository repository)
|
||||
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken ct = default) =>
|
||||
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)
|
||||
{
|
||||
ValidatePost(post);
|
||||
@@ -27,7 +33,9 @@ public class BlogService(IBlogPostRepository repository)
|
||||
post.Content = post.Content.Trim();
|
||||
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
|
||||
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)
|
||||
@@ -51,8 +59,11 @@ public class BlogService(IBlogPostRepository repository)
|
||||
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);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||
{
|
||||
@@ -77,8 +88,11 @@ public class BlogService(IBlogPostRepository repository)
|
||||
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);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.IncrementViewCountAsync(id, ct);
|
||||
|
||||
@@ -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;
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Enums;
|
||||
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}$");
|
||||
|
||||
@@ -34,7 +38,10 @@ public class InquiryService(IInquiryRepository repository)
|
||||
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) =>
|
||||
@@ -44,12 +51,30 @@ public class InquiryService(IInquiryRepository repository)
|
||||
int page, int pageSize, string? status = null, CancellationToken ct = default) =>
|
||||
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 async Task UpdateStatusAsync(int id, string status, string? changedBy = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!InquiryStatusMapper.TryParse(status, out var parsed))
|
||||
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);
|
||||
}
|
||||
|
||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||
|
||||
@@ -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,38 @@
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<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,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,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);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ public interface IBlogPostRepository
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, 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 UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -8,5 +8,8 @@ public interface IInquiryRepository
|
||||
Task<Inquiry?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||
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 UpdateStatusAsync(int id, string status, 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);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||
services.AddScoped<IBlogPostRepository, BlogPostRepository>();
|
||||
services.AddScoped<IInquiryRepository, InquiryRepository>();
|
||||
services.AddScoped<ISiteSettingRepository, SiteSettingRepository>();
|
||||
services.AddScoped<IAnnouncementRepository, AnnouncementRepository>();
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,30 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
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)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -47,6 +47,30 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
||||
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 UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@@ -8,12 +9,25 @@
|
||||
<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="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
<script>
|
||||
document.documentElement.classList.toggle(
|
||||
'admin-login-route',
|
||||
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
||||
</script>
|
||||
<link rel="stylesheet" href="/taxbaik/css/admin.css" />
|
||||
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
|
||||
</head>
|
||||
<body>
|
||||
<Routes @rendermode="RenderMode.InteractiveServer" />
|
||||
<div id="components-reconnect-modal" class="admin-reconnect-modal">
|
||||
<div class="admin-reconnect-card">
|
||||
<strong>관리자 세션을 다시 연결하고 있습니다.</strong>
|
||||
<span>배포 또는 서버 재시작 중이면 잠시 후 자동으로 새로고침됩니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="/taxbaik/js/admin-session.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script>window.taxbaikAdminSession?.watchReconnect();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
@using TaxBaik.Application.Services
|
||||
@inject InquiryService InquiryService
|
||||
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="mt-4">
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>전화</th>
|
||||
<th>분야</th>
|
||||
<th>상태</th>
|
||||
<th>메시지</th>
|
||||
<th>날짜</th>
|
||||
<th></th>
|
||||
@@ -19,11 +20,16 @@
|
||||
<td>@inquiry.Name</td>
|
||||
<td>@inquiry.Phone</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>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">문의 내용 확인</MudButton>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -39,7 +45,7 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var (items, _) = await InquiryService.GetPagedAsync(1, 1000);
|
||||
var (items, _) = await InquiryService.GetPagedAsync(1, 100);
|
||||
inquiries = items.ToList();
|
||||
FilterInquiries();
|
||||
}
|
||||
@@ -51,6 +57,31 @@
|
||||
: inquiries.Where(x => x.Status == Status).ToList();
|
||||
}
|
||||
|
||||
private static string GetPreview(string message)
|
||||
{
|
||||
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,
|
||||
"contacted" => Color.Info,
|
||||
"completed" => Color.Success,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private static string GetStatusLabel(string status) => status switch
|
||||
{
|
||||
"new" => "신규",
|
||||
"contacted" => "연락함",
|
||||
"completed" => "완료",
|
||||
_ => status
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
FilterInquiries();
|
||||
|
||||
@@ -1,41 +1,70 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudThemeProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<MudLayout Class="admin-shell">
|
||||
<MudAppBar Elevation="0" Class="admin-topbar">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu"
|
||||
Color="Color.Inherit"
|
||||
Edge="Edge.Start"
|
||||
Class="admin-menu-button"
|
||||
OnClick="@ToggleDrawer" />
|
||||
<div class="admin-topbar-title">
|
||||
<MudText Typo="Typo.caption">TaxBaik Backoffice</MudText>
|
||||
<MudText Typo="Typo.h6">백원숙 세무회계 관리자</MudText>
|
||||
</div>
|
||||
<MudSpacer />
|
||||
<MudButton Class="admin-topbar-action"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Inherit"
|
||||
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||
Href="/taxbaik">
|
||||
공개 사이트
|
||||
</MudButton>
|
||||
<MudButton Class="admin-topbar-action"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Logout"
|
||||
Href="/taxbaik/admin/logout">
|
||||
로그아웃
|
||||
</MudButton>
|
||||
</MudAppBar>
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="ml-3">백원숙 세무회계 관리자</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Color="Color.Inherit" Href="/taxbaik">공개 사이트</MudButton>
|
||||
<MudButton Href="/taxbaik/admin/logout">로그아웃</MudButton>
|
||||
</MudAppBar>
|
||||
<MudDrawer @bind-open="@drawerOpen"
|
||||
Elevation="0"
|
||||
Variant="DrawerVariant.Responsive"
|
||||
Breakpoint="Breakpoint.Md"
|
||||
Class="admin-drawer">
|
||||
<div class="admin-drawer-brand">
|
||||
<div class="admin-brand-mark">T</div>
|
||||
<div>
|
||||
<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>
|
||||
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
||||
</MudNavMenu>
|
||||
<div class="admin-drawer-footer">
|
||||
<MudText Typo="Typo.caption">운영 기준</MudText>
|
||||
<MudText Typo="Typo.body2">변경 사항은 배포 후 Playwright로 검증합니다.</MudText>
|
||||
</div>
|
||||
</MudDrawer>
|
||||
|
||||
<MudDrawer @bind-open="@drawerOpen" Elevation="1">
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/taxbaik/admin/" Match="NavLinkMatch.All">📊 대시보드</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/blog">📝 블로그 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/inquiries">💬 문의 관리</MudNavLink>
|
||||
<MudNavLink Href="/taxbaik/admin/settings">⚙️ 설정</MudNavLink>
|
||||
</MudNavMenu>
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="my-4">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
@Body
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
<MudMainContent Class="admin-main">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="admin-content">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private bool drawerOpen = true;
|
||||
|
||||
private void ToggleDrawer()
|
||||
{
|
||||
drawerOpen = !drawerOpen;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/admin"
|
||||
@page "/admin/"
|
||||
@attribute [Authorize]
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
@page "/admin/announcements/create"
|
||||
@page "/admin/announcements/{Id:int}/edit"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
@inject AnnouncementService AnnouncementService
|
||||
@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)
|
||||
{
|
||||
var entity = await AnnouncementService.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();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
await AnnouncementService.UpdateAsync(model);
|
||||
else
|
||||
await AnnouncementService.CreateAsync(model);
|
||||
|
||||
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
@page "/admin/announcements"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.Domain.Entities
|
||||
@inject AnnouncementService AnnouncementService
|
||||
@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()
|
||||
{
|
||||
announcements = (await AnnouncementService.GetAllAsync()).ToList();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Announcement item)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"공지 삭제",
|
||||
$"'{item.Title}' 공지를 삭제하시겠습니까?",
|
||||
yesText: "삭제", cancelText: "취소");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
await AnnouncementService.DeleteAsync(item.Id);
|
||||
Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
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"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.Domain.Interfaces
|
||||
@attribute [Authorize]
|
||||
@inject BlogService BlogService
|
||||
@inject ICategoryRepository CategoryRepository
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
@page "/admin/blog"
|
||||
@attribute [Authorize]
|
||||
@inject IApiClient ApiClient
|
||||
@inject DialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>블로그 관리</PageTitle>
|
||||
|
||||
<div class="mb-4 d-flex justify-content-between align-items-center">
|
||||
<MudText Typo="Typo.h5">📝 블로그 관리</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
Href="/taxbaik/admin/blog/create">새 포스트</MudButton>
|
||||
</div>
|
||||
<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.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>
|
||||
<PropertyColumn Property="x => x.Title" Title="제목" />
|
||||
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
||||
@@ -25,18 +35,27 @@
|
||||
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
|
||||
<TemplateColumn>
|
||||
<CellTemplate Context="cell">
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary"
|
||||
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error"
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
|
||||
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
|
||||
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
</MudDataGrid>
|
||||
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||
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()
|
||||
{
|
||||
@@ -48,13 +67,38 @@
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
var items = await ApiClient.GetAsync<List<TaxBaik.Domain.Entities.BlogPost>>("blog/admin/all");
|
||||
posts = items ?? [];
|
||||
var result = await ApiClient.GetAsync<PagedBlogResponse>($"blog/admin?page={currentPage}&pageSize={PageSize}");
|
||||
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;
|
||||
}
|
||||
|
||||
private async Task PreviousPage()
|
||||
{
|
||||
if (currentPage <= 1)
|
||||
return;
|
||||
|
||||
currentPage--;
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
private async Task NextPage()
|
||||
{
|
||||
if (currentPage >= totalPages)
|
||||
return;
|
||||
|
||||
currentPage++;
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
|
||||
{
|
||||
var previous = post.IsPublished;
|
||||
@@ -88,4 +132,10 @@
|
||||
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
private class PagedBlogResponse
|
||||
{
|
||||
public List<TaxBaik.Domain.Entities.BlogPost> Data { get; set; } = [];
|
||||
public int Total { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,64 @@
|
||||
@page "/admin/dashboard"
|
||||
@using TaxBaik.Application.Services
|
||||
@attribute [Authorize]
|
||||
@inject InquiryService InquiryService
|
||||
@inject BlogService BlogService
|
||||
@using TaxBaik.Application.Services
|
||||
@inject AdminDashboardService DashboardService
|
||||
|
||||
<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>
|
||||
<MudGrid Class="admin-metric-grid">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.subtitle2">이번달 문의</MudText>
|
||||
<MudText Typo="Typo.h4">@thisMonthInquiries</MudText>
|
||||
<MudPaper Class="admin-metric-card accent-blue" Elevation="0">
|
||||
<MudText Typo="Typo.caption">이번달 문의</MudText>
|
||||
<MudText Typo="Typo.h3">@summary.ThisMonthInquiries</MudText>
|
||||
<MudText Typo="Typo.body2">월간 상담 유입</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">@newInquiries</MudText>
|
||||
<MudPaper Class="admin-metric-card accent-amber" Elevation="0">
|
||||
<MudText Typo="Typo.caption">신규 문의</MudText>
|
||||
<MudText Typo="Typo.h3">@summary.NewInquiries</MudText>
|
||||
<MudText Typo="Typo.body2">처리 대기</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">@totalPosts</MudText>
|
||||
<MudPaper Class="admin-metric-card accent-slate" Elevation="0">
|
||||
<MudText Typo="Typo.caption">전체 포스트</MudText>
|
||||
<MudText Typo="Typo.h3">@summary.TotalPosts</MudText>
|
||||
<MudText Typo="Typo.body2">콘텐츠 자산</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 Class="admin-metric-card accent-green" Elevation="0">
|
||||
<MudText Typo="Typo.caption">발행된 포스트</MudText>
|
||||
<MudText Typo="Typo.h3">@summary.PublishedPosts</MudText>
|
||||
<MudText Typo="Typo.body2">검색 노출 대상</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">
|
||||
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
||||
<div class="admin-section-header">
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">최근 문의</MudText>
|
||||
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
|
||||
</div>
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
@@ -51,7 +69,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var inquiry in recentInquiries)
|
||||
@foreach (var inquiry in summary.RecentInquiries)
|
||||
{
|
||||
<tr>
|
||||
<td>@inquiry.Name</td>
|
||||
@@ -60,7 +78,7 @@
|
||||
<td>
|
||||
<MudChip Size="Size.Small"
|
||||
Color="@(inquiry.Status == "new" ? Color.Warning : inquiry.Status == "contacted" ? Color.Info : Color.Success)">
|
||||
@inquiry.Status
|
||||
@GetStatusLabel(inquiry.Status)
|
||||
</MudChip>
|
||||
</td>
|
||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||
@@ -71,22 +89,18 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private int thisMonthInquiries = 0;
|
||||
private int newInquiries = 0;
|
||||
private int totalPosts = 0;
|
||||
private int publishedPosts = 0;
|
||||
private List<Domain.Entities.Inquiry> recentInquiries = [];
|
||||
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var (inquiries, _) = await InquiryService.GetPagedAsync(1, 100);
|
||||
recentInquiries = inquiries.OrderByDescending(x => x.CreatedAt).Take(5).ToList();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
thisMonthInquiries = inquiries.Count(x => x.CreatedAt.Year == now.Year && x.CreatedAt.Month == now.Month);
|
||||
newInquiries = inquiries.Count(x => x.Status == "new");
|
||||
var stats = await BlogService.GetStatsAsync();
|
||||
totalPosts = stats.TotalPosts;
|
||||
publishedPosts = stats.PublishedPosts;
|
||||
}
|
||||
summary = await DashboardService.GetSummaryAsync();
|
||||
}
|
||||
|
||||
private static string GetStatusLabel(string status) => status switch
|
||||
{
|
||||
"new" => "신규",
|
||||
"contacted" => "연락함",
|
||||
"completed" => "완료",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@page "/admin/inquiries/{InquiryId:int}"
|
||||
@using TaxBaik.Application.Services
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.Services
|
||||
@inject InquiryService InquiryService
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -9,11 +9,24 @@
|
||||
|
||||
@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>
|
||||
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudText Typo="Typo.h5">문의 상세</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.List"
|
||||
Href="/taxbaik/admin/inquiries">
|
||||
다른 문의도 보기
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle1">이름</MudText>
|
||||
@@ -33,7 +46,9 @@
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1">메시지</MudText>
|
||||
<MudText>@inquiry.Message</MudText>
|
||||
<MudPaper Class="pa-3 mt-2" Outlined="true">
|
||||
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1">상태</MudText>
|
||||
@@ -42,6 +57,11 @@
|
||||
<MudSelectItem Value="@("contacted")">연락함</MudSelectItem>
|
||||
<MudSelectItem Value="@("completed")">완료</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudStack Row="true" Class="mt-3" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="@(() => OnStatusChanged("new"))">신규</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="@(() => OnStatusChanged("contacted"))">연락함</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Success" OnClick="@(() => OnStatusChanged("completed"))">완료</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
@@ -69,7 +89,7 @@ else
|
||||
|
||||
try
|
||||
{
|
||||
await InquiryService.UpdateStatusAsync(inquiry.Id, status);
|
||||
await InquiryService.UpdateStatusAsync(inquiry.Id, status, "관리자");
|
||||
inquiry.Status = status;
|
||||
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
@page "/admin/inquiries"
|
||||
@using TaxBaik.Domain.Interfaces
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Domain.Interfaces
|
||||
@inject IInquiryRepository InquiryRepository
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<MudTabs>
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
||||
<MudTabPanel Text="전체">
|
||||
<InquiryTable Status="" />
|
||||
</MudTabPanel>
|
||||
@@ -21,3 +28,4 @@
|
||||
<InquiryTable Status="completed" />
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
</MudPaper>
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
@inject IApiClient ApiClient
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject CustomAuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime Js
|
||||
|
||||
<PageTitle>로그인</PageTitle>
|
||||
|
||||
<MudThemeProvider />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="d-flex align-center justify-center" Style="min-height: 100vh;">
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
|
||||
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
||||
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
||||
|
||||
@@ -58,6 +59,12 @@
|
||||
|
||||
private LoginModel model = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
|
||||
}
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
if (isLoading)
|
||||
@@ -78,8 +85,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await ApiClient.SetAuthToken(response.Token);
|
||||
await AuthStateProvider.LoginAsync(response.Token);
|
||||
NavigationManager.NavigateTo("/taxbaik/admin/dashboard", forceLoad: false);
|
||||
NavigationManager.NavigateTo(GetReturnUrl(), forceLoad: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -99,4 +107,22 @@
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { 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('/')}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
@page "/admin/settings"
|
||||
@using TaxBaik.Domain.Interfaces
|
||||
@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>
|
||||
|
||||
<MudText Typo="Typo.h5" Class="mb-4">⚙️ 사이트 설정</MudText>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<MudTextField @bind-Value="phone" Label="전화번호"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
@@ -22,18 +40,167 @@
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
@onclick="SaveSettings">저장</MudButton>
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
@onclick="SaveSettings">사이트 정보 저장</MudButton>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="5">
|
||||
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
|
||||
<div class="admin-section-header compact">
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">계정 관리</MudText>
|
||||
<MudText Typo="Typo.body2">비밀번호는 12자 이상으로 관리합니다.</MudText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudForm>
|
||||
<MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="newPassword" Label="새 비밀번호" InputType="InputType.Password"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
Disabled="@isChangingPassword"
|
||||
StartIcon="@Icons.Material.Filled.LockReset"
|
||||
@onclick="ChangePassword">
|
||||
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
|
||||
</MudButton>
|
||||
</MudForm>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private string phone = "010-4122-8268";
|
||||
private string email = "taxbaik5668@gmail.com";
|
||||
private string kakaoUrl = "http://pf.kakao.com/_xoxchTX";
|
||||
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()
|
||||
{
|
||||
// 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,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.Authorization
|
||||
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<PageTitle>찾을 수 없음</PageTitle>
|
||||
<LayoutView Layout="typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
||||
<p>요청한 페이지를 찾을 수 없습니다.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<PageTitle>찾을 수 없음</PageTitle>
|
||||
<LayoutView Layout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
|
||||
<p>요청한 페이지를 찾을 수 없습니다.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Application.Services
|
||||
@attribute [Authorize]
|
||||
|
||||
@@ -40,6 +40,14 @@ public class BlogController : ControllerBase
|
||||
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]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Controllers;
|
||||
@@ -66,7 +67,8 @@ public class InquiryController : ControllerBase
|
||||
|
||||
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 = "상태가 변경되었습니다." });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1 class="fw-bold mb-5">세무 블로그</h1>
|
||||
|
||||
<!-- 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>
|
||||
@foreach (var cat in Model.Categories)
|
||||
{
|
||||
@@ -20,13 +20,13 @@
|
||||
<div class="row g-4">
|
||||
@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-body">
|
||||
<small class="badge bg-primary">@post.CategoryName</small>
|
||||
<h5 class="card-title mt-2">@post.Title</h5>
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
@page "{slug}"
|
||||
@page "/blog/{slug}"
|
||||
@model TaxBaik.Web.Pages.Blog.BlogPostModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
|
||||
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
|
||||
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)
|
||||
@@ -18,6 +20,10 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="mb-4">
|
||||
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-sm">← 블로그 목록으로 돌아가기</a>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Post.ThumbnailUrl))
|
||||
{
|
||||
<img src="@Model.Post.ThumbnailUrl" alt="@Model.Post.Title"
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
@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"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
+201
-94
@@ -1,37 +1,107 @@
|
||||
@page
|
||||
@model TaxBaik.Web.Pages.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
|
||||
var season = Model.CurrentSeason;
|
||||
ViewData["Title"] = season != null
|
||||
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
|
||||
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
|
||||
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
|
||||
}
|
||||
|
||||
<!-- Hero Section — 강임팩트 -->
|
||||
<section class="hero-section text-white pt-5 pb-4">
|
||||
<div class="container">
|
||||
<div class="row align-items-center py-4">
|
||||
<div class="col-lg-7">
|
||||
<span class="badge bg-primary-badge mb-3">경험 있는 세무사의 맞춤 전략</span>
|
||||
<h1 class="mb-3">
|
||||
세금과 자산<br/>
|
||||
<span style="color: #E8E4D8;">한 번에 해결하는</span>
|
||||
</h1>
|
||||
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
|
||||
사업자 세무, 부동산 거래, 가족자산 관리를 위한<br/>
|
||||
통합 솔루션을 제공합니다.
|
||||
</p>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
|
||||
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg" onclick="openKakao()" style="border-color: white; color: white;">
|
||||
💬 카카오 채널 문의
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 d-none d-lg-block text-center">
|
||||
<div style="font-size: 120px; opacity: 0.15;">📋</div>
|
||||
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
|
||||
@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>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@* ─── Hero Section ─── *@
|
||||
@if (season != null)
|
||||
{
|
||||
<section class="hero-section hero-section--seasonal text-white pt-5 pb-4">
|
||||
<div class="container">
|
||||
<div class="row align-items-center py-4">
|
||||
<div class="col-lg-7">
|
||||
<span class="badge bg-danger-badge mb-3 fs-6 px-3 py-2">
|
||||
@season.UrgencyBadge
|
||||
</span>
|
||||
<h1 class="mb-3" style="white-space: pre-line;">@season.HeroHeadline</h1>
|
||||
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
|
||||
@season.HeroSubtext
|
||||
</p>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<a href="/taxbaik/contact" class="btn btn-warning btn-lg fw-bold">
|
||||
⏰ @season.CtaText
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
|
||||
onclick="openKakao()" style="border-color: white; color: white;">
|
||||
💬 카카오 채널 문의
|
||||
</a>
|
||||
</div>
|
||||
@if (season.DaysUntilDeadline <= 7)
|
||||
{
|
||||
<p class="mt-3 small" style="opacity: 0.8;">
|
||||
마감까지 <strong>@(season.DaysUntilDeadline)일</strong> 남았습니다.
|
||||
지금 바로 상담 신청하세요.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="col-lg-5 d-none d-lg-block text-center">
|
||||
<div class="seasonal-deadline-badge">
|
||||
<div class="deadline-label">마감</div>
|
||||
<div class="deadline-date">@season.Deadline.ToString("M월 d일")</div>
|
||||
<div class="deadline-days">D-@season.DaysUntilDeadline</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="hero-section text-white pt-5 pb-4">
|
||||
<div class="container">
|
||||
<div class="row align-items-center py-4">
|
||||
<div class="col-lg-7">
|
||||
<span class="badge bg-primary-badge mb-3">경험 있는 세무사의 맞춤 전략</span>
|
||||
<h1 class="mb-3">
|
||||
세금과 자산<br/>
|
||||
<span style="color: #E8E4D8;">한 번에 해결하는</span>
|
||||
</h1>
|
||||
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
|
||||
사업자 세무, 부동산 거래, 가족자산 관리를 위한<br/>
|
||||
통합 솔루션을 제공합니다.
|
||||
</p>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
|
||||
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
|
||||
onclick="openKakao()" style="border-color: white; color: white;">
|
||||
💬 카카오 채널 문의
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 d-none d-lg-block text-center">
|
||||
<div style="font-size: 120px; opacity: 0.15;">📋</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- 신뢰도 스트립 — 자격과 경험 -->
|
||||
<section class="trust-strip">
|
||||
@@ -62,7 +132,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 서비스 영역 — 전문성 강조 -->
|
||||
<!-- 서비스 영역 -->
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
@@ -72,66 +142,88 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@{
|
||||
var focusService = season?.FocusService ?? "";
|
||||
// 시즌에 따라 서비스 카드 순서 결정: 시즌 관련 카드가 맨 앞
|
||||
var cardOrder = focusService switch
|
||||
{
|
||||
"real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" },
|
||||
"family-asset" => new[] { "family-asset", "business-tax", "real-estate-tax" },
|
||||
_ => new[] { "business-tax", "real-estate-tax", "family-asset" }
|
||||
};
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- 사업자 세무 -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card service-card h-100">
|
||||
<div class="service-icon">🏪</div>
|
||||
<div class="card-body pt-0">
|
||||
<h3 class="card-title">사업자 세무</h3>
|
||||
<ul class="list-unstyled small mb-3">
|
||||
<li class="mb-2">✓ 정확한 기장 및 결산</li>
|
||||
<li class="mb-2">✓ 세금계산서 관리</li>
|
||||
<li class="mb-2">✓ 경비처리 최적화</li>
|
||||
<li class="mb-2">✓ 절세 전략 수립</li>
|
||||
</ul>
|
||||
<p class="text-muted small">
|
||||
초기부터 세무 전략을 수립하면 연간 최대 수백만 원의 절세가 가능합니다.
|
||||
</p>
|
||||
<a href="/taxbaik/services#business-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
|
||||
@foreach (var cardKey in cardOrder)
|
||||
{
|
||||
var isFeatured = cardKey == focusService;
|
||||
if (cardKey == "business-tax")
|
||||
{
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
|
||||
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
|
||||
<div class="service-icon">🏪</div>
|
||||
<div class="card-body pt-0">
|
||||
<h3 class="card-title">사업자 세무</h3>
|
||||
<ul class="list-unstyled small mb-3">
|
||||
<li class="mb-2">✓ 정확한 기장 및 결산</li>
|
||||
<li class="mb-2">✓ 세금계산서 관리</li>
|
||||
<li class="mb-2">✓ 경비처리 최적화</li>
|
||||
<li class="mb-2">✓ 절세 전략 수립</li>
|
||||
</ul>
|
||||
<p class="text-muted small">
|
||||
초기부터 세무 전략을 수립하면 연간 최대 수백만 원의 절세가 가능합니다.
|
||||
</p>
|
||||
<a href="/taxbaik/services#business-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 부동산 세금 -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card service-card h-100">
|
||||
<div class="service-icon">🏠</div>
|
||||
<div class="card-body pt-0">
|
||||
<h3 class="card-title">부동산 세금</h3>
|
||||
<ul class="list-unstyled small mb-3">
|
||||
<li class="mb-2">✓ 양도세 최소화</li>
|
||||
<li class="mb-2">✓ 취득세 절감</li>
|
||||
<li class="mb-2">✓ 임대소득 관리</li>
|
||||
<li class="mb-2">✓ 다주택자 세무</li>
|
||||
</ul>
|
||||
<p class="text-muted small">
|
||||
부동산 거래 시 미리 상담하면 세금 부담을 크게 줄일 수 있습니다.
|
||||
</p>
|
||||
<a href="/taxbaik/services#real-estate-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
|
||||
}
|
||||
else if (cardKey == "real-estate-tax")
|
||||
{
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
|
||||
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
|
||||
<div class="service-icon">🏠</div>
|
||||
<div class="card-body pt-0">
|
||||
<h3 class="card-title">부동산 세금</h3>
|
||||
<ul class="list-unstyled small mb-3">
|
||||
<li class="mb-2">✓ 양도세 최소화</li>
|
||||
<li class="mb-2">✓ 취득세 절감</li>
|
||||
<li class="mb-2">✓ 임대소득 관리</li>
|
||||
<li class="mb-2">✓ 다주택자 세무</li>
|
||||
</ul>
|
||||
<p class="text-muted small">
|
||||
부동산 거래 시 미리 상담하면 세금 부담을 크게 줄일 수 있습니다.
|
||||
</p>
|
||||
<a href="/taxbaik/services#real-estate-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 가족자산 & 증여 -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card service-card h-100">
|
||||
<div class="service-icon">👨👩👧👦</div>
|
||||
<div class="card-body pt-0">
|
||||
<h3 class="card-title">가족자산 관리</h3>
|
||||
<ul class="list-unstyled small mb-3">
|
||||
<li class="mb-2">✓ 증여세 전략</li>
|
||||
<li class="mb-2">✓ 상속세 대비</li>
|
||||
<li class="mb-2">✓ 자산 이전 계획</li>
|
||||
<li class="mb-2">✓ 가족법인 설립</li>
|
||||
</ul>
|
||||
<p class="text-muted small">
|
||||
세대 이전 전에 사전 계획하면 세금 부담을 현저히 줄일 수 있습니다.
|
||||
</p>
|
||||
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
|
||||
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
|
||||
<div class="service-icon">👨👩👧👦</div>
|
||||
<div class="card-body pt-0">
|
||||
<h3 class="card-title">가족자산 관리</h3>
|
||||
<ul class="list-unstyled small mb-3">
|
||||
<li class="mb-2">✓ 증여세 전략</li>
|
||||
<li class="mb-2">✓ 상속세 대비</li>
|
||||
<li class="mb-2">✓ 자산 이전 계획</li>
|
||||
<li class="mb-2">✓ 가족법인 설립</li>
|
||||
</ul>
|
||||
<p class="text-muted small">
|
||||
세대 이전 전에 사전 계획하면 세금 부담을 현저히 줄일 수 있습니다.
|
||||
</p>
|
||||
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -201,7 +293,7 @@
|
||||
<small class="badge bg-primary-badge">@post.CategoryName</small>
|
||||
<h4 class="card-title mt-3">@post.Title</h4>
|
||||
<p class="text-muted 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>
|
||||
@@ -214,17 +306,32 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최종 CTA — 강렬한 다크 배경 -->
|
||||
<!-- 최종 CTA -->
|
||||
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
|
||||
<div class="container text-center">
|
||||
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">세금 고민은 이제 끝!</h2>
|
||||
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
|
||||
무료 상담으로 현재 상황을 진단하고<br/>
|
||||
맞춤형 절세 전략을 받아보세요.
|
||||
</p>
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
|
||||
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
|
||||
</div>
|
||||
@if (season != null)
|
||||
{
|
||||
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">@season.Name 마감이 다가옵니다!</h2>
|
||||
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
|
||||
마감 <strong>D-@(season.DaysUntilDeadline)일</strong> — 지금 바로 상담을 신청하세요.<br/>
|
||||
빠른 검토로 불이익 없이 신고를 완료합니다.
|
||||
</p>
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a>
|
||||
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">세금 고민은 이제 끝!</h2>
|
||||
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
|
||||
무료 상담으로 현재 상황을 진단하고<br/>
|
||||
맞춤형 절세 전략을 받아보세요.
|
||||
</p>
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
|
||||
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using TaxBaik.Application.Seasonal;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
@@ -7,16 +8,36 @@ namespace TaxBaik.Web.Pages;
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly BlogService _blogService;
|
||||
private readonly SeasonalMarketingService _seasonalMarketingService;
|
||||
private readonly AnnouncementService _announcementService;
|
||||
|
||||
public List<BlogPost> RecentPosts { get; set; } = [];
|
||||
public CurrentSeasonDto? CurrentSeason { get; set; }
|
||||
public List<Announcement> ActiveAnnouncements { get; set; } = [];
|
||||
|
||||
public IndexModel(BlogService blogService)
|
||||
public IndexModel(
|
||||
BlogService blogService,
|
||||
SeasonalMarketingService seasonalMarketingService,
|
||||
AnnouncementService announcementService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
_seasonalMarketingService = seasonalMarketingService;
|
||||
_announcementService = announcementService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
CurrentSeason = _seasonalMarketingService.GetCurrentSeason();
|
||||
|
||||
try
|
||||
{
|
||||
ActiveAnnouncements = (await _announcementService.GetActiveAsync()).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ActiveAnnouncements = [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<header class="sticky-top bg-white border-bottom">
|
||||
<nav class="navbar navbar-expand-lg navbar-light container-fluid px-3">
|
||||
<header class="sticky-top bg-white border-bottom site-header">
|
||||
<nav class="navbar navbar-expand-lg navbar-light container-fluid px-3 py-2">
|
||||
<a class="navbar-brand fw-bold" href="/taxbaik">
|
||||
<span class="text-primary">백원숙</span> 세무회계
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto gap-2">
|
||||
<div class="collapse navbar-collapse mt-3 mt-lg-0" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto gap-2 align-items-lg-center">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/taxbaik">홈</a>
|
||||
</li>
|
||||
@@ -21,7 +21,7 @@
|
||||
<a class="nav-link" href="/taxbaik/blog">블로그</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-primary btn-sm ms-2" href="/taxbaik/contact">상담신청</a>
|
||||
<a class="btn btn-primary btn-sm ms-lg-2 w-100 w-lg-auto" href="/taxbaik/contact">상담신청</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+11
-1
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MudBlazor.Services;
|
||||
using TaxBaik.Application;
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Infrastructure;
|
||||
using TaxBaik.Web.Services;
|
||||
|
||||
@@ -23,6 +24,10 @@ builder.Services.AddHealthChecks();
|
||||
// Razor Pages + Blazor Server 통합
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
|
||||
{
|
||||
options.DetailedErrors = true;
|
||||
});
|
||||
|
||||
// JWT 인증
|
||||
var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||
@@ -70,6 +75,7 @@ builder.Services.AddMemoryCache();
|
||||
builder.Services.AddResponseCompression(opts => {
|
||||
opts.Providers.Add<GzipCompressionProvider>();
|
||||
});
|
||||
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
||||
|
||||
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
|
||||
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
||||
@@ -136,6 +142,10 @@ if (!app.Environment.IsDevelopment())
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapRazorPages();
|
||||
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>().AddInteractiveServerRenderMode();
|
||||
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
|
||||
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
|
||||
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AllowAnonymous();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
public class TelegramInquiryNotificationService : IInquiryNotificationService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<TelegramInquiryNotificationService> _logger;
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public TelegramInquiryNotificationService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<TelegramInquiryNotificationService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_baseUrl = (_configuration["App:PublicBaseUrl"] ?? "http://178.104.200.7/taxbaik").TrimEnd('/');
|
||||
}
|
||||
|
||||
public async Task NotifyCreatedAsync(int inquiryId, string name, string phone, string serviceType, string message, string? ipAddress, DateTime createdAtUtc, CancellationToken ct = default)
|
||||
{
|
||||
var botToken = _configuration["Telegram:BotToken"];
|
||||
var chatId = _configuration["Telegram:ChatId"];
|
||||
if (string.IsNullOrWhiteSpace(botToken) || string.IsNullOrWhiteSpace(chatId))
|
||||
{
|
||||
_logger.LogWarning("텔레그램 새 문의 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:ChatId를 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
var adminLink = $"{_baseUrl}/admin/inquiries";
|
||||
var summary = message.Length > 180 ? message[..180] + "..." : message;
|
||||
var createdAtKst = createdAtUtc.AddHours(9);
|
||||
var text = new StringBuilder()
|
||||
.AppendLine("🆕 새 문의가 접수되었습니다.")
|
||||
.AppendLine()
|
||||
.AppendLine($"문의 번호: #{inquiryId}")
|
||||
.AppendLine($"제목: {serviceType}")
|
||||
.AppendLine($"이름: {name}")
|
||||
.AppendLine($"연락처: {phone}")
|
||||
.AppendLine($"접수 시각: {createdAtKst:yyyy-MM-dd HH:mm:ss} KST")
|
||||
.AppendLine($"IP: {(string.IsNullOrWhiteSpace(ipAddress) ? "-" : ipAddress)}")
|
||||
.AppendLine()
|
||||
.AppendLine("내용 요약:")
|
||||
.AppendLine(summary)
|
||||
.AppendLine()
|
||||
.AppendLine($"답변 목록 링크: {adminLink}")
|
||||
.ToString();
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var url = $"https://api.telegram.org/bot{botToken}/sendMessage";
|
||||
var payload = new
|
||||
{
|
||||
chat_id = chatId,
|
||||
text,
|
||||
disable_web_page_preview = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(url, payload, ct);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("텔레그램 알림 전송 실패: {StatusCode} {ResponseBody}", response.StatusCode, Truncate(responseBody));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("텔레그램 새 문의 알림 전송 성공: #{InquiryId}, message_id={MessageId}", inquiryId, TryGetMessageId(responseBody));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "텔레그램 알림 전송 중 오류 발생");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyStatusChangedAsync(int inquiryId, string name, string phone, string serviceType, string previousStatus, string newStatus, string? changedBy = null, CancellationToken ct = default)
|
||||
{
|
||||
var botToken = _configuration["Telegram:BotToken"];
|
||||
var chatId = _configuration["Telegram:ChatId"];
|
||||
if (string.IsNullOrWhiteSpace(botToken) || string.IsNullOrWhiteSpace(chatId))
|
||||
{
|
||||
_logger.LogWarning("텔레그램 상태 변경 알림 설정이 누락되었습니다. Telegram:BotToken 또는 Telegram:ChatId를 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
var adminLink = $"{_baseUrl}/admin/inquiries";
|
||||
var text = new StringBuilder()
|
||||
.AppendLine("✏️ 문의 상태가 변경되었습니다.")
|
||||
.AppendLine()
|
||||
.AppendLine($"문의 번호: #{inquiryId}")
|
||||
.AppendLine($"제목: {serviceType}")
|
||||
.AppendLine($"이름: {name}")
|
||||
.AppendLine($"연락처: {phone}")
|
||||
.AppendLine($"상태: {FormatStatus(previousStatus)} -> {FormatStatus(newStatus)}")
|
||||
.AppendLine($"변경자: {(string.IsNullOrWhiteSpace(changedBy) ? "-" : changedBy)}")
|
||||
.AppendLine()
|
||||
.AppendLine($"답변 목록 링크: {adminLink}")
|
||||
.ToString();
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var url = $"https://api.telegram.org/bot{botToken}/sendMessage";
|
||||
var payload = new
|
||||
{
|
||||
chat_id = chatId,
|
||||
text,
|
||||
disable_web_page_preview = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(url, payload, ct);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("텔레그램 상태 변경 알림 실패: {StatusCode} {ResponseBody}", response.StatusCode, Truncate(responseBody));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("텔레그램 상태 변경 알림 전송 성공: #{InquiryId}, message_id={MessageId}", inquiryId, TryGetMessageId(responseBody));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "텔레그램 상태 변경 알림 중 오류 발생");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatStatus(string status) => status switch
|
||||
{
|
||||
"new" => "신규",
|
||||
"contacted" => "연락함",
|
||||
"completed" => "완료",
|
||||
_ => status
|
||||
};
|
||||
|
||||
private static string TryGetMessageId(string responseBody)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
if (document.RootElement.TryGetProperty("result", out var result)
|
||||
&& result.TryGetProperty("message_id", out var messageId))
|
||||
{
|
||||
return messageId.ToString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string Truncate(string value)
|
||||
=> value.Length <= 500 ? value : value[..500] + "...";
|
||||
}
|
||||
@@ -11,6 +11,13 @@
|
||||
"Jwt": {
|
||||
"SecretKey": "dev-secret-key-change-in-production-min-32-chars!"
|
||||
},
|
||||
"App": {
|
||||
"PublicBaseUrl": "http://178.104.200.7/taxbaik"
|
||||
},
|
||||
"Telegram": {
|
||||
"BotToken": "",
|
||||
"ChatId": ""
|
||||
},
|
||||
"SiteSettings": {
|
||||
"PhoneNumber": "010-4122-8268",
|
||||
"EmailAddress": "taxbaik5668@gmail.com",
|
||||
|
||||
@@ -44,47 +44,45 @@ html, body {
|
||||
margin-left: 12px !important;
|
||||
}
|
||||
|
||||
/* MudContainer */
|
||||
.mud-container {
|
||||
/* Login page scoped fallback styles. Keep these scoped so they do not override MudBlazor globally. */
|
||||
.admin-login-page.mud-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mud-container-maxwidth-small {
|
||||
.admin-login-page.mud-container-maxwidth-small {
|
||||
max-width: 600px !important;
|
||||
}
|
||||
|
||||
/* MudPaper */
|
||||
.mud-paper {
|
||||
.admin-login-page .mud-paper {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mud-paper.elevation-3 {
|
||||
.admin-login-page .mud-paper.elevation-3 {
|
||||
box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
/* MudText */
|
||||
.mud-typography {
|
||||
.admin-login-page .mud-typography {
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mud-typography--h4 {
|
||||
.admin-login-page .mud-typography--h4 {
|
||||
font-size: 2.125rem;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.mud-typography--body1 {
|
||||
.admin-login-page .mud-typography--body1 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
.admin-login-page input[type="text"],
|
||||
.admin-login-page input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
@@ -95,14 +93,14 @@ input[type="password"] {
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus {
|
||||
.admin-login-page input[type="text"]:focus,
|
||||
.admin-login-page input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
label {
|
||||
.admin-login-page label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
@@ -110,8 +108,8 @@ label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* MudButton */
|
||||
button {
|
||||
/* Login button fallback. Do not apply this to all admin buttons. */
|
||||
.admin-login-page button {
|
||||
width: 100%;
|
||||
padding: 12px 24px;
|
||||
margin-top: 12px;
|
||||
@@ -125,17 +123,17 @@ button {
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
.admin-login-page button:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
.admin-login-page button:disabled {
|
||||
background-color: #bdbdbd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* MudAlert */
|
||||
.mud-alert {
|
||||
.admin-login-page .mud-alert {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
@@ -144,23 +142,23 @@ button:disabled {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.mud-alert--error {
|
||||
.admin-login-page .mud-alert--error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.mud-alert--success {
|
||||
.admin-login-page .mud-alert--success {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.mud-alert--info {
|
||||
.admin-login-page .mud-alert--info {
|
||||
background-color: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
/* Progress Circle */
|
||||
.mud-progress-circular {
|
||||
.admin-login-page .mud-progress-circular {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -169,14 +167,49 @@ button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.admin-reconnect-modal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-reconnect-modal.components-reconnect-show,
|
||||
.admin-reconnect-modal.components-reconnect-failed,
|
||||
.admin-reconnect-modal.components-reconnect-rejected {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
}
|
||||
|
||||
.admin-reconnect-card {
|
||||
width: min(420px, 100%);
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.admin-reconnect-card strong,
|
||||
.admin-reconnect-card span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-reconnect-card span {
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.mud-container-maxwidth-small {
|
||||
.admin-login-page.mud-container-maxwidth-small {
|
||||
max-width: 100% !important;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mud-typography--h4 {
|
||||
.admin-login-page .mud-typography--h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,38 @@ body.with-mobile-cta {
|
||||
.container {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.site-header .navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.site-header .navbar-nav {
|
||||
padding: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.site-header .nav-link,
|
||||
.site-header .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-header .navbar-toggler {
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.site-header .navbar-collapse {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
min-width: 2.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 375px) {
|
||||
@@ -445,6 +477,10 @@ body.with-mobile-cta {
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero-section .d-flex {
|
||||
gap: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 일반 유틸리티 ===== */
|
||||
@@ -518,3 +554,90 @@ img {
|
||||
background-color: rgba(200, 157, 110, 0.15) !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
/* ===== 공지사항 배너 ===== */
|
||||
.announcement-bar {
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.announcement-bar--info {
|
||||
background: #E8F4FD;
|
||||
color: #1565C0;
|
||||
}
|
||||
.announcement-bar--banner {
|
||||
background: #FFF8E1;
|
||||
color: #E65100;
|
||||
}
|
||||
.announcement-bar--urgent {
|
||||
background: #FFEBEE;
|
||||
color: #C62828;
|
||||
}
|
||||
.announcement-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.announcement-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ===== 시즌 Hero ===== */
|
||||
.hero-section--seasonal {
|
||||
background: linear-gradient(135deg, #1F3A30 0%, #2E5C4E 60%, #3D7A68 100%);
|
||||
}
|
||||
.bg-danger-badge {
|
||||
background-color: rgba(198, 40, 40, 0.85) !important;
|
||||
color: #fff !important;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* D-Day 카운트다운 위젯 */
|
||||
.seasonal-deadline-badge {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid rgba(255,255,255,0.25);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: white;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.deadline-label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.deadline-date {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.deadline-days {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 900;
|
||||
color: #FFD54F;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ===== 서비스 카드 시즌 강조 ===== */
|
||||
.service-card--featured {
|
||||
border: 2px solid var(--color-primary) !important;
|
||||
position: relative;
|
||||
}
|
||||
.service-card-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 14px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
window.taxbaikAdminSession = {
|
||||
syncRouteClass: function () {
|
||||
document.documentElement.classList.toggle(
|
||||
'admin-login-route',
|
||||
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
||||
},
|
||||
clearAuthToken: function () {
|
||||
try {
|
||||
localStorage.removeItem('auth_token');
|
||||
} catch {
|
||||
// Ignore storage errors; redirect still recovers the session.
|
||||
}
|
||||
},
|
||||
watchReconnect: function () {
|
||||
window.taxbaikAdminSession.syncRouteClass();
|
||||
window.addEventListener('popstate', window.taxbaikAdminSession.syncRouteClass);
|
||||
|
||||
const modal = document.getElementById('components-reconnect-modal');
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reloadOnRejectedCircuit = () => {
|
||||
const className = modal.className || '';
|
||||
if (className.includes('components-reconnect-failed') ||
|
||||
className.includes('components-reconnect-rejected')) {
|
||||
window.setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
new MutationObserver(reloadOnRejectedCircuit)
|
||||
.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
};
|
||||
@@ -8,13 +8,17 @@ function openKakao() {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Sticky header shadow
|
||||
const navbar = document.querySelector('.navbar');
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.scrollY > 0) {
|
||||
navbar.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||
} else {
|
||||
navbar.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)';
|
||||
}
|
||||
});
|
||||
if (!navbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setShadow = () => {
|
||||
navbar.style.boxShadow = window.scrollY > 0
|
||||
? '0 2px 8px rgba(0,0,0,0.1)'
|
||||
: '0 1px 3px rgba(0,0,0,0.1)';
|
||||
};
|
||||
|
||||
setShadow();
|
||||
window.addEventListener('scroll', setShadow, { passive: true });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="refresh" content="15" />
|
||||
<title>잠시 점검 중 — 백원숙 세무회계</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; }
|
||||
body {
|
||||
background: #F9F7F3;
|
||||
color: #3D2817;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2.5rem;
|
||||
box-shadow: 0 8px 32px rgba(61,40,23,.10);
|
||||
}
|
||||
.icon { font-size: 3.5rem; margin-bottom: 1.25rem; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #C89D6E;
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 { font-size: 1.6rem; color: #2E5C4E; font-weight: 800; margin-bottom: 1rem; line-height: 1.35; }
|
||||
p { color: #6B5D4F; line-height: 1.85; font-size: 0.95rem; }
|
||||
.divider { border: none; border-top: 1px solid #EFE9DD; margin: 1.75rem 0; }
|
||||
.kakao-btn {
|
||||
display: inline-block;
|
||||
background: #FEE500;
|
||||
color: #3D2817;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
padding: 0.65rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.timer { font-size: 0.78rem; color: #A09080; margin-top: 1.5rem; }
|
||||
.footer { font-size: 0.75rem; color: #C0ADA0; margin-top: 2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">🔧</div>
|
||||
<div class="badge">서비스 업데이트 중</div>
|
||||
<h1>잠시 후 다시 접속해 주세요</h1>
|
||||
<p>
|
||||
더 나은 서비스를 위해 업데이트 작업을 진행하고 있습니다.<br />
|
||||
보통 <strong>1~2분</strong> 이내에 완료됩니다.
|
||||
</p>
|
||||
<hr class="divider" />
|
||||
<p>급하신 세무 문의는 카카오 채널로 연락해 주세요.</p>
|
||||
<a class="kakao-btn" href="http://pf.kakao.com/_xoxchTX" target="_blank">
|
||||
💬 카카오 채널 상담
|
||||
</a>
|
||||
<p class="timer">이 페이지는 15초 후 자동으로 새로고침됩니다.</p>
|
||||
<p class="footer">© 2026 백원숙 세무회계</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO site_settings (key, value, updated_at)
|
||||
VALUES
|
||||
('PhoneNumber', '010-4122-8268', NOW()),
|
||||
('EmailAddress', 'taxbaik5668@gmail.com', NOW()),
|
||||
('KakaoChannelUrl', 'http://pf.kakao.com/_xoxchTX', NOW()),
|
||||
('InstagramUrl', 'https://www.instagram.com/taxtory5668/', NOW())
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
display_type VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN announcements.display_type IS 'banner | info | urgent';
|
||||
Generated
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "taxbaik",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.57.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.57.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 10_000
|
||||
},
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const password = process.env.E2E_ADMIN_PASSWORD;
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('admin authentication', () => {
|
||||
test('logs in through the real browser UI and reaches dashboard', async ({ page }) => {
|
||||
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', message => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', error => {
|
||||
consoleErrors.push(error.message);
|
||||
});
|
||||
|
||||
await page.goto(`${baseUrl}/admin/login`);
|
||||
await expect(page.locator('input[placeholder="사용자명"]')).toBeVisible();
|
||||
await expect(page.locator('input[placeholder="비밀번호"]')).toBeVisible();
|
||||
|
||||
await page.locator('input[placeholder="사용자명"]').fill(username);
|
||||
await page.locator('input[placeholder="비밀번호"]').fill(password);
|
||||
await page.getByRole('button', { name: '로그인' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
||||
await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible();
|
||||
expect(consoleErrors, 'browser console/page errors').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { getAdminToken, installAdminToken } from './helpers/admin-auth';
|
||||
|
||||
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const currentPassword = process.env.E2E_ADMIN_CURRENT_PASSWORD;
|
||||
const newPassword = process.env.E2E_ADMIN_NEW_PASSWORD;
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('admin password change', () => {
|
||||
test('changes password through the real admin UI', async ({ page, request }) => {
|
||||
test.skip(!currentPassword || !newPassword, 'E2E_ADMIN_CURRENT_PASSWORD and E2E_ADMIN_NEW_PASSWORD are required.');
|
||||
|
||||
const token = await getAdminToken(request, baseUrl, username, currentPassword);
|
||||
await installAdminToken(page, token);
|
||||
await page.goto(`${baseUrl}/admin/settings`);
|
||||
await expect(page.getByRole('heading', { name: /사이트 설정|설정/ })).toBeVisible();
|
||||
|
||||
await page.getByRole('textbox', { name: '현재 비밀번호' }).fill(currentPassword);
|
||||
await page.getByRole('textbox', { name: '새 비밀번호' }).fill(newPassword);
|
||||
await page.getByRole('textbox', { name: '새 비밀번호 확인' }).fill(newPassword);
|
||||
await page.getByRole('button', { name: '비밀번호 변경' }).click();
|
||||
|
||||
await expect(page.getByText(/비밀번호가 변경되었습니다|비밀번호 변경/)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginThroughAdminUi, navigateInBlazor } from './helpers/admin-auth';
|
||||
|
||||
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const password = process.env.E2E_ADMIN_PASSWORD;
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('admin smoke', () => {
|
||||
test('navigates the main admin menus without circuit errors', async ({ page }) => {
|
||||
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', message => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', error => {
|
||||
consoleErrors.push(error.message);
|
||||
});
|
||||
|
||||
await loginThroughAdminUi(page, baseUrl, username, password);
|
||||
|
||||
const menuChecks = [
|
||||
{ path: '/admin/dashboard', content: /이번달 문의/ },
|
||||
{ path: '/admin/blog', content: /전체 포스트/ },
|
||||
{ path: '/admin/inquiries', content: /문의 관리/ },
|
||||
{ path: '/admin/settings', content: /계정 관리/ },
|
||||
];
|
||||
|
||||
for (const check of menuChecks) {
|
||||
await navigateInBlazor(page, `${baseUrl}${check.path}`);
|
||||
await expect(page).toHaveURL(new RegExp(`${check.path}$`));
|
||||
await expect(page.locator('.mud-main-content').getByText(check.content).first()).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
expect(consoleErrors, 'browser console/page errors').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('blog seo', () => {
|
||||
test('exposes title description and canonical on blog detail pages', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/blog`);
|
||||
const firstPost = page.locator('a[href^="/taxbaik/blog/"]').filter({ hasText: '글 내용 보기' }).first();
|
||||
await expect(firstPost).toBeVisible();
|
||||
const detailHref = await firstPost.getAttribute('href');
|
||||
expect(detailHref).toMatch(/^\/taxbaik\/blog\/[a-z0-9-]+$/);
|
||||
const detailPath = detailHref?.replace('/taxbaik', '') ?? '/blog';
|
||||
const response = await page.goto(`${baseUrl}${detailPath}`);
|
||||
expect(response, 'blog detail response should be returned').toBeTruthy();
|
||||
expect(response!.status(), `blog detail response for ${detailPath} should be successful`).toBe(200);
|
||||
|
||||
await expect(page.locator('meta[name="description"]')).toHaveAttribute('content', /.+/);
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', /\/taxbaik\/blog\/[a-z0-9-]+$/);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { findInquiryByName, getAdminToken, loginThroughAdminUi, navigateInBlazor } from './helpers/admin-auth';
|
||||
|
||||
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const password = process.env.E2E_ADMIN_PASSWORD;
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('contact submit', () => {
|
||||
test('creates an inquiry through the public API', async ({ request }) => {
|
||||
const stamp = Date.now();
|
||||
|
||||
const createResponse = await request.post(`${baseUrl}/api/inquiry`, {
|
||||
data: {
|
||||
name: `Public-${stamp}`,
|
||||
phone: '010-1234-5678',
|
||||
email: `public-${stamp}@example.com`,
|
||||
serviceType: '기타',
|
||||
message: 'Playwright로 전송한 공개 문의 테스트입니다.',
|
||||
},
|
||||
});
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.message).toContain('상담 신청이 접수되었습니다');
|
||||
});
|
||||
|
||||
test('creates an inquiry and shows it in admin list', async ({ page, request }) => {
|
||||
test.skip(!password, 'E2E_ADMIN_PASSWORD is required to verify the admin list.');
|
||||
|
||||
const stamp = Date.now();
|
||||
const name = `E2E-${stamp}`;
|
||||
const phone = '010-1234-5678';
|
||||
const email = `e2e-${stamp}@example.com`;
|
||||
const message = 'Playwright로 전송한 공개 문의 테스트입니다.';
|
||||
|
||||
const createResponse = await request.post(`${baseUrl}/api/inquiry`, {
|
||||
data: {
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
serviceType: '기타',
|
||||
message,
|
||||
},
|
||||
});
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
|
||||
const token = await getAdminToken(request, baseUrl, username, password);
|
||||
const inquiry = await findInquiryByName(request, baseUrl, token, name);
|
||||
expect(inquiry.phone).toBe(phone);
|
||||
expect(inquiry.message).toContain(message.slice(0, 20));
|
||||
|
||||
await loginThroughAdminUi(page, baseUrl, username, password);
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/inquiries`);
|
||||
await expect(page.locator('.mud-main-content').getByText('문의 관리').first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
export type InquiryListItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function getAdminToken(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const response = await request.post(`${baseUrl}/api/auth/login`, {
|
||||
data: { username, password },
|
||||
});
|
||||
|
||||
expect(response.status(), 'login API should accept the configured admin credentials').toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body?.token, 'login API should return a token').toBeTruthy();
|
||||
return body.token as string;
|
||||
}
|
||||
|
||||
export async function installAdminToken(page: Page, token: string) {
|
||||
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
|
||||
}
|
||||
|
||||
export async function loginThroughAdminUi(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
await page.goto(`${baseUrl}/admin/login`);
|
||||
await page.locator('input[placeholder="사용자명"]').fill(username);
|
||||
await page.locator('input[placeholder="비밀번호"]').fill(password);
|
||||
await page.getByRole('button', { name: '로그인' }).click();
|
||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
||||
await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
export async function navigateInBlazor(page: Page, targetUrl: string) {
|
||||
await page.evaluate(url => {
|
||||
const blazor = (window as typeof window & { Blazor?: { navigateTo: (target: string) => void } }).Blazor;
|
||||
if (blazor) {
|
||||
blazor.navigateTo(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
}, targetUrl);
|
||||
}
|
||||
|
||||
export async function findInquiryByName(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
name: string,
|
||||
) {
|
||||
const response = await request.get(`${baseUrl}/api/inquiry?page=1&pageSize=100`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(response.status(), 'admin inquiry list API should be accessible with the token').toBe(200);
|
||||
const body = await response.json();
|
||||
const items = (body?.data ?? []) as InquiryListItem[];
|
||||
const inquiry = items.find(item => item.name === name);
|
||||
expect(inquiry, `created inquiry ${name} should appear in the admin inquiry API`).toBeTruthy();
|
||||
return inquiry!;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { findInquiryByName, getAdminToken, loginThroughAdminUi, navigateInBlazor } from './helpers/admin-auth';
|
||||
|
||||
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const password = process.env.E2E_ADMIN_PASSWORD;
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('inquiry detail', () => {
|
||||
test('shows the created inquiry and admin action links', async ({ page, request }) => {
|
||||
const stamp = Date.now();
|
||||
const name = `Detail-${stamp}`;
|
||||
const phone = '010-9876-5432';
|
||||
const email = `detail-${stamp}@example.com`;
|
||||
const message = '상세 화면 검증용 문의입니다.';
|
||||
|
||||
const createResponse = await request.post(`${baseUrl}/api/inquiry`, {
|
||||
data: {
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
serviceType: '기타',
|
||||
message,
|
||||
},
|
||||
});
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.message).toContain('상담 신청이 접수되었습니다');
|
||||
|
||||
test.skip(!password, 'E2E_ADMIN_PASSWORD is required to verify inquiry detail.');
|
||||
|
||||
const token = await getAdminToken(request, baseUrl, username, password);
|
||||
const inquiry = await findInquiryByName(request, baseUrl, token, name);
|
||||
|
||||
await loginThroughAdminUi(page, baseUrl, username, password);
|
||||
await navigateInBlazor(page, `${baseUrl}/admin/inquiries/${inquiry.id}`);
|
||||
|
||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/inquiries\/\d+$/);
|
||||
await expect(page.getByText(name, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(phone, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '신규' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '연락함' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '완료' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '문의 목록으로 돌아가기' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '다른 문의도 보기' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
|
||||
|
||||
test.describe('public smoke', () => {
|
||||
test('loads the main public pages with SEO basics', async ({ page }) => {
|
||||
await page.goto(baseUrl);
|
||||
await expect(page).toHaveTitle(/백원숙 세무회계/);
|
||||
await expect(page.locator('meta[name="description"]')).toHaveAttribute('content', /사업자 기장|부동산|종합소득세/);
|
||||
await expect(page.getByRole('heading', { name: '세금과 자산 한 번에 해결하는' })).toBeVisible();
|
||||
|
||||
await page.goto(`${baseUrl}/blog`);
|
||||
await expect(page).toHaveTitle(/블로그/);
|
||||
await expect(page.getByRole('heading', { name: /세무 블로그/ })).toBeVisible();
|
||||
|
||||
await page.goto(`${baseUrl}/contact`);
|
||||
await expect(page).toHaveTitle(/상담 신청/);
|
||||
await expect(page.getByRole('heading', { name: /상담 신청/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /상담신청/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user