Compare commits
71 Commits
f4e063280e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 959a4ea00b | |||
| eccbd00902 | |||
| dce8e23676 | |||
| fb3ee4aad6 | |||
| 228dc6683d | |||
| 262046628d | |||
| bcc3d6e680 | |||
| e0945f46a6 | |||
| 4d3933f22e | |||
| a781aa5ecb | |||
| c569f32c03 | |||
| 5e0a4c07d2 | |||
| 22a1a4c4ad | |||
| a80651cd15 | |||
| 7389d5aeb3 | |||
| 4f012fa1ad | |||
| 66d7256d72 | |||
| 066cac0ce2 | |||
| 505bd9f663 | |||
| 8812e7e5d2 | |||
| bc1373b6b8 | |||
| 16d74c1299 | |||
| d94edc5a30 | |||
| 51beea51b6 | |||
| d891f3f491 | |||
| 59811cee52 | |||
| fd74240a72 | |||
| da085a7700 | |||
| 29a2d65065 | |||
| 2215c8ddb5 | |||
| 05dfa17dde | |||
| d95a88bc3d | |||
| a508b08509 | |||
| 81b2b1ebdf | |||
| 0cf881c652 | |||
| a49a75dd36 | |||
| 26ea7e17d0 | |||
| bbb5d8dc55 | |||
| dfbc077679 | |||
| 5e1ebd8448 | |||
| 3c4905a249 | |||
| bfc19adf47 | |||
| 1cb35810b6 | |||
| d4ea2c18c1 | |||
| ccbc1d3698 | |||
| 946e305e2c | |||
| a2e2064056 | |||
| 91424bd8a2 | |||
| b9466b30a2 | |||
| 8acc988174 | |||
| 89d457c549 | |||
| 1441d6b306 | |||
| 7ca47e792b | |||
| 44777f4a1f | |||
| 2f72834317 | |||
| c5e6e4ba57 | |||
| f7813450d7 | |||
| 2f4ec5efea | |||
| a53372e4f1 | |||
| 4348de0707 | |||
| 3831f52253 | |||
| 6b6359007c | |||
| f5249afbd0 | |||
| e3addc1a79 | |||
| bfc4d70ebd | |||
| 6407afece5 | |||
| f4deff4e78 | |||
| 58eb297865 | |||
| e1c4630159 | |||
| 00521668be | |||
| 8c0c30525d |
@@ -52,18 +52,11 @@ jobs:
|
|||||||
echo "Expected short version: $SHORT_VERSION"
|
echo "Expected short version: $SHORT_VERSION"
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
||||||
VERSION_BODY="$(curl -fsS "https://www.taxbaik.com/taxbaik/version.json" 2>/dev/null || true)"
|
VERSION_BODY="$(curl -fsS "https://www.taxbaik.com/version.json" 2>/dev/null || true)"
|
||||||
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/taxbaik/blog/accountant-mistakes-5" || true)"
|
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/blog" || true)"
|
||||||
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/taxbaik/admin/login" || true)"
|
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/admin/login" || true)"
|
||||||
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
|
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
|
||||||
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
||||||
ROOT_URL="https://www.taxbaik.com/" \
|
|
||||||
ADMIN_URL="https://www.taxbaik.com/taxbaik/admin/login" \
|
|
||||||
PUBLIC_MARKER="백원숙 세무회계" \
|
|
||||||
PUBLIC_FORBIDDEN="관리자" \
|
|
||||||
ADMIN_MARKER="관리자 로그인" \
|
|
||||||
MAX_RETRIES=1 \
|
|
||||||
bash ./scripts/taxbaik-smoke.sh
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ $i -lt 20 ]; then
|
if [ $i -lt 20 ]; then
|
||||||
@@ -76,8 +69,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Browser verifications in parallel
|
- name: Browser verifications in parallel
|
||||||
env:
|
env:
|
||||||
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
|
# Nginx를 통해 root 경로로 라우팅 (PathBase 없음)
|
||||||
E2E_BASE_URL: https://www.taxbaik.com/taxbaik
|
E2E_BASE_URL: https://www.taxbaik.com
|
||||||
# E2E 테스트는 운영에서 검증된 admin 계정 사용
|
# E2E 테스트는 운영에서 검증된 admin 계정 사용
|
||||||
E2E_ADMIN_USERNAME: admin
|
E2E_ADMIN_USERNAME: admin
|
||||||
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
|
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
|
||||||
@@ -103,15 +96,15 @@ jobs:
|
|||||||
run_job "browser-e2e" bash -lc 'npm run test:e2e:ci'
|
run_job "browser-e2e" bash -lc 'npm run test:e2e:ci'
|
||||||
run_job "api-smoke" bash -lc '
|
run_job "api-smoke" bash -lc '
|
||||||
set -e
|
set -e
|
||||||
TOKEN="$(curl -s -X POST "https://www.taxbaik.com/taxbaik/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c '\''import sys, json; print(json.load(sys.stdin)["accessToken"])'\'')"
|
TOKEN="$(curl -s -X POST "https://www.taxbaik.com/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c '\''import sys, json; print(json.load(sys.stdin)["accessToken"])'\'')"
|
||||||
test -n "$TOKEN"
|
test -n "$TOKEN"
|
||||||
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null
|
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/blog/admin?page=1&pageSize=1" >/dev/null
|
||||||
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/faq" >/dev/null
|
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/faq" >/dev/null
|
||||||
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/announcement" >/dev/null
|
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/announcement" >/dev/null
|
||||||
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null
|
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/inquiry?page=1&pageSize=1" >/dev/null
|
||||||
curl -fsS "https://www.taxbaik.com/taxbaik/favicon.svg" >/dev/null
|
curl -fsS "https://www.taxbaik.com/favicon.svg" >/dev/null
|
||||||
curl -fsS "https://www.taxbaik.com/taxbaik/favicon.ico" >/dev/null
|
curl -fsS "https://www.taxbaik.com/favicon.ico" >/dev/null
|
||||||
curl -fsS "https://www.taxbaik.com/taxbaik/robots.txt" >/dev/null
|
curl -fsS "https://www.taxbaik.com/robots.txt" >/dev/null
|
||||||
'
|
'
|
||||||
|
|
||||||
failures=0
|
failures=0
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
python3 scripts/validate_locked_db_connection.py
|
python3 scripts/validate_locked_db_connection.py
|
||||||
|
|
||||||
|
- name: Validate SEO search guardrails
|
||||||
|
run: python3 scripts/validate_seo.py
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
run: dotnet build src/TaxBaik.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true
|
run: dotnet build src/TaxBaik.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true
|
||||||
|
|
||||||
@@ -85,16 +88,12 @@ jobs:
|
|||||||
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
||||||
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
|
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
|
||||||
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||||
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
# JWT는 현재 관리자 인증에 사용하지 않지만, 포털 인증/토큰 교환용 설정으로 유지한다.
|
||||||
if [ -z "$JWT_SECRET_KEY" ]; then
|
# 없으면 런타임에서 AuthService가 실패하므로, CI 배포에서만 한 번 생성해 넣는다.
|
||||||
JWT_SECRET_KEY="$(openssl rand -base64 48 | tr -d '\n')"
|
|
||||||
echo "⚠️ TAXBAIK_JWT_SECRET_KEY is missing; generating a one-off production secret for this deploy." >&2
|
|
||||||
fi
|
|
||||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&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; }
|
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
|
||||||
[ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
|
[ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
|
||||||
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
|
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
|
||||||
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
|
||||||
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
||||||
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
||||||
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
||||||
@@ -106,7 +105,6 @@ jobs:
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
|
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
|
||||||
},
|
},
|
||||||
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
|
||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
|
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
|
||||||
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
|
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
|
||||||
@@ -344,6 +342,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "✓ [6/6] 관리자 페이지 로드 완료"
|
echo "✓ [6/6] 관리자 페이지 로드 완료"
|
||||||
|
|
||||||
|
# 검색 엔진은 Content-Type뿐 아니라 유효한 XML 본문을 요구한다.
|
||||||
|
# 공통 Razor 레이아웃이 다시 섞이는 회귀를 배포 직후 차단한다.
|
||||||
|
SITEMAP_BODY=\$(curl -fsS http://127.0.0.1:5001/sitemap.xml 2>/dev/null || true)
|
||||||
|
RSS_BODY=\$(curl -fsS http://127.0.0.1:5001/rss.xml 2>/dev/null || true)
|
||||||
|
if ! printf '%s' "\$SITEMAP_BODY" | grep -q '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' \
|
||||||
|
|| printf '%s' "\$SITEMAP_BODY" | grep -q '<!DOCTYPE html>'; then
|
||||||
|
echo "❌ sitemap.xml XML 응답 검증 실패" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "\$RSS_BODY" | grep -q '<rss version="2.0"' \
|
||||||
|
|| printf '%s' "\$RSS_BODY" | grep -q '<!DOCTYPE html>'; then
|
||||||
|
echo "❌ rss.xml XML 응답 검증 실패" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ [7/7] 사이트맵 및 RSS XML 응답 검증 완료"
|
||||||
|
|
||||||
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
|
||||||
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
# 구 배포 디렉토리 정리 (최근 5개 보존)
|
||||||
ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \
|
ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \
|
||||||
|
|||||||
@@ -63,3 +63,4 @@ appsettings.Development.json
|
|||||||
|
|
||||||
# Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md
|
# Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md
|
||||||
.scratch/
|
.scratch/
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
# CLAUDE.md — TaxBaik 운영 메모
|
# CLAUDE.md — TaxBaik 운영 메모
|
||||||
|
|
||||||
|
## ⚠️ **중요: 2026-07-09 아키텍처 변경**
|
||||||
|
|
||||||
|
**현재 기준 (2026-07-09~):**
|
||||||
|
- 관리자 UI: **Razor Pages + Cookie Authentication** (서버 렌더링)
|
||||||
|
- UI 프레임워크: **Tabler** (Bootstrap 5 기반)
|
||||||
|
- 인증: **Cookie Authentication** (브라우저), JWT (외부 API)
|
||||||
|
- **자세한 기준은 아래 우선 기준의 문서들을 참고해주세요.**
|
||||||
|
|
||||||
|
**이전 기준 (2026-07-03 이전):**
|
||||||
|
- 관리자 UI: Blazor WebAssembly + MudBlazor (Phase 8/9 참고, 이제 레거시)
|
||||||
|
- 해당 섹션은 과거 이력으로만 참고 (기준이 아님)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 우선 기준
|
## 우선 기준
|
||||||
|
|
||||||
1. [docs/INDEX.md](./docs/INDEX.md)
|
1. [docs/INDEX.md](./docs/INDEX.md)
|
||||||
2. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
|
2. [docs/ADMIN_RAZORPAGES_ROADMAP.md](./docs/ADMIN_RAZORPAGES_ROADMAP.md) ⭐ **신규 어드민 표준**
|
||||||
3. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md)
|
3. [docs/ADMIN_LEGACY_BLAZOR.md](./docs/ADMIN_LEGACY_BLAZOR.md) (레거시 Blazor 관리)
|
||||||
4. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
|
4. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
|
||||||
5. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)
|
5. [docs/STACK_TEMPLATE_WBS.md](./docs/STACK_TEMPLATE_WBS.md) (갱신됨)
|
||||||
|
6. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md) ⭐ **더존 회계시스템 고밀도 레이아웃 표준 (행 높이 32px, 조밀한 그리드, 대시보드 형태의 넓은 여백 배제)**
|
||||||
|
7. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
|
||||||
|
8. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)
|
||||||
|
|
||||||
이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보 기준은 위 문서를 따른다.
|
이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보/더존 고밀도 회계시스템 레이아웃 기준은 위 문서를 따른다. 대시보드와 같은 널찍한 여백 위주 화면 디자인은 특화 페이지(대시보드 홈 등)로 국한하며, 그 외 비즈니스/세무/활동 관리 데이터 그리드 및 폼 페이지는 '더존 회계시스템 스타일의 고밀도 레이아웃(32px 행 높이, 조밀한 셀 패딩, 낭비 없는 스크린 사용)'을 절대적으로 준수한다.
|
||||||
|
|
||||||
## 🎯 **개발 핵심 지침 (Development Standards 2026-07)**
|
## 🎯 **개발 핵심 지침 (Development Standards 2026-07)**
|
||||||
|
|
||||||
@@ -315,7 +332,7 @@ _refreshTokenExpirationMinutes = 10080;
|
|||||||
|
|
||||||
**완료**: 2026-06-28 / 모든 도메인 API-First 마이그레이션 완료
|
**완료**: 2026-06-28 / 모든 도메인 API-First 마이그레이션 완료
|
||||||
|
|
||||||
#### Phase 8: WebAssembly 렌더 모드 전환 ✅ (2026-07-03)
|
#### Phase 8: [레거시] WebAssembly 렌더 모드 전환 ✅ (2026-07-03)
|
||||||
- [x] InteractiveWebAssemblyRenderMode 적용 (Blazor Server → WebAssembly)
|
- [x] InteractiveWebAssemblyRenderMode 적용 (Blazor Server → WebAssembly)
|
||||||
- [x] Admin 컴포넌트 WebAssembly 클라이언트 전환
|
- [x] Admin 컴포넌트 WebAssembly 클라이언트 전환
|
||||||
- [x] 서버 상태 관리 제거 (Circuit 불필요)
|
- [x] 서버 상태 관리 제거 (Circuit 불필요)
|
||||||
@@ -362,7 +379,7 @@ export DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
|||||||
**⚠️ Phase 8 알려진 한계 (Phase 9에서 수정됨)**:
|
**⚠️ Phase 8 알려진 한계 (Phase 9에서 수정됨)**:
|
||||||
Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@rendermode`를 지정해 `prerender: false`로 고정했다. 그 결과 로그인 화면을 포함한 모든 어드민 페이지가 WASM 다운로드 완료 전까지 빈 화면/스피너만 보여주는 문제가 있었다(`scripts/validate_admin_render.sh`에 이 트레이드오프가 "기능 우선, 흰 화면 0.5~2초 감수"로 기록되어 있었음). 이는 `docs/ENGINEERING_HARNESS.md`의 "로그인 화면은 예외적으로 서버 프리렌더 허용" 규칙을 충족하지 못한 상태였다. Phase 9에서 페이지별 개별 렌더모드 지정으로 교체했다.
|
Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@rendermode`를 지정해 `prerender: false`로 고정했다. 그 결과 로그인 화면을 포함한 모든 어드민 페이지가 WASM 다운로드 완료 전까지 빈 화면/스피너만 보여주는 문제가 있었다(`scripts/validate_admin_render.sh`에 이 트레이드오프가 "기능 우선, 흰 화면 0.5~2초 감수"로 기록되어 있었음). 이는 `docs/ENGINEERING_HARNESS.md`의 "로그인 화면은 예외적으로 서버 프리렌더 허용" 규칙을 충족하지 못한 상태였다. Phase 9에서 페이지별 개별 렌더모드 지정으로 교체했다.
|
||||||
|
|
||||||
#### Phase 9: 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
|
#### Phase 9: [레거시] 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
|
||||||
- [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음)
|
- [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음)
|
||||||
- [x] `Login.razor`만 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨
|
- [x] `Login.razor`만 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨
|
||||||
- [x] 나머지 `[Authorize]` 어드민 페이지는 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`로 명시 유지 → 인증 컨텍스트 없이 prerender될 때 `AuthorizeRouteView`가 빈 화면을 그리는 문제(Phase 8 초기에 겪었던 문제) 재발 방지
|
- [x] 나머지 `[Authorize]` 어드민 페이지는 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`로 명시 유지 → 인증 컨텍스트 없이 prerender될 때 `AuthorizeRouteView`가 빈 화면을 그리는 문제(Phase 8 초기에 겪었던 문제) 재발 방지
|
||||||
@@ -372,7 +389,7 @@ Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@r
|
|||||||
|
|
||||||
**완료**: 2026-07-03 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지
|
**완료**: 2026-07-03 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지
|
||||||
|
|
||||||
#### Phase 13: FastEndpoints 마이그레이션 ✅ (2026-07-03)
|
#### Phase 13: [레거시] FastEndpoints 마이그레이션 ✅ (2026-07-03)
|
||||||
- [x] AdminDashboardController → FastEndpoints 마이그레이션
|
- [x] AdminDashboardController → FastEndpoints 마이그레이션
|
||||||
- GetSummaryEndpoint.cs (GET /api/admin-dashboard/summary)
|
- GetSummaryEndpoint.cs (GET /api/admin-dashboard/summary)
|
||||||
- GetUpcomingFilingsEndpoint.cs (GET /api/admin-dashboard/upcoming-filings)
|
- GetUpcomingFilingsEndpoint.cs (GET /api/admin-dashboard/upcoming-filings)
|
||||||
|
|||||||
@@ -18,12 +18,12 @@
|
|||||||
| 영역 | 권장 방식 | 비고 |
|
| 영역 | 권장 방식 | 비고 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Public Home | Static SSR + 필요한 부분만 Interactive WebAssembly | 초기 로딩과 SEO 우선 |
|
| Public Home | Static SSR + 필요한 부분만 Interactive WebAssembly | 초기 로딩과 SEO 우선 |
|
||||||
| Login | Static SSR 또는 Razor Page form | MudBlazor 의존 금지 |
|
| Login | Razor Pages form + Cookie Authentication | MudBlazor 의존 금지 (레거시: Interactive WebAssembly 참고) |
|
||||||
| Home/Dashboard | Interactive WebAssembly + MudBlazor | 사용자 인터랙션 중심 |
|
| Home/Dashboard | Razor Pages + Cookie Authentication | 서버 렌더링 우선 (레거시: Interactive WebAssembly + MudBlazor 참고) |
|
||||||
| Admin CRUD | Interactive WebAssembly + API-first | BrowserClient 경유 |
|
| Admin CRUD | Razor Pages + Repository Pattern + Tabler UI | Cookie Authentication 기준 (레거시: Interactive WebAssembly + API-first 참고) |
|
||||||
| API | Minimal API 또는 Controller + OpenAPI | 계약 우선 |
|
| API | Controller + OpenAPI (외부 클라이언트) | 계약 우선, JWT 기반 |
|
||||||
| Persistence | Dapper + `NpgsqlDataSource` singleton | connection pool 직접 관리 금지 |
|
| Persistence | Dapper + `NpgsqlDataSource` singleton | connection pool 직접 관리 금지 |
|
||||||
| Auth | 브라우저는 HttpOnly Secure Cookie 우선, 외부 클라이언트만 JWT | localStorage 저장 금지 |
|
| Auth | 브라우저는 Cookie Authentication, 외부 클라이언트만 JWT | localStorage 저장 금지 |
|
||||||
|
|
||||||
현재 저장소 기준 예외:
|
현재 저장소 기준 예외:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# SEO Search Guardrails
|
||||||
|
|
||||||
|
These rules protect search discovery and must remain enforced by `scripts/validate_seo.py` in CI.
|
||||||
|
|
||||||
|
- `sitemap.xml` and `rss.xml` are XML-only responses. They must not render the shared HTML layout.
|
||||||
|
- `sitemap.xml` includes canonical, indexable URLs only. Redirect-only URLs and authenticated routes are excluded.
|
||||||
|
- Blog URLs use the post's actual `UpdatedAt` value for `lastmod`; the homepage uses the most recent active FAQ, announcement, or displayed blog update.
|
||||||
|
- Sitemap collection must page through every published blog post. Do not introduce a fixed total-content limit.
|
||||||
|
- `/rss.xml` is the one canonical feed. `/feed.xml` permanently redirects to it.
|
||||||
|
- `robots.txt` declares one canonical sitemap and never blocks XML resources.
|
||||||
|
- Canonical URLs always use `https://www.taxbaik.com` and include the current indexable path. Paginated and category-filtered blog pages set an explicit canonical URL.
|
||||||
|
|
||||||
|
Run locally with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/validate_seo.py
|
||||||
|
```
|
||||||
@@ -32,7 +32,7 @@ def is_server_healthy(url):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
port = 5001
|
port = 5001
|
||||||
base_url = f"http://localhost:{port}/taxbaik"
|
base_url = f"http://localhost:{port}"
|
||||||
health_url = f"{base_url}/healthz"
|
health_url = f"{base_url}/healthz"
|
||||||
|
|
||||||
server_process = None
|
server_process = None
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Static SEO guardrails run in CI before deployment."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WEB = ROOT / "src" / "TaxBaik.Web"
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def read(relative: str) -> str:
|
||||||
|
return (ROOT / relative).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition: bool, message: str) -> None:
|
||||||
|
if not condition:
|
||||||
|
errors.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
sitemap_view = read("src/TaxBaik.Web/Pages/Sitemap.cshtml")
|
||||||
|
rss_view = read("src/TaxBaik.Web/Pages/Rss.cshtml")
|
||||||
|
feed_view = read("src/TaxBaik.Web/Pages/Feed.cshtml")
|
||||||
|
sitemap_model = read("src/TaxBaik.Web/Pages/Sitemap.cshtml.cs")
|
||||||
|
layout = read("src/TaxBaik.Web/Pages/_Layout.cshtml")
|
||||||
|
robots = read("src/TaxBaik.Web/wwwroot/robots.txt")
|
||||||
|
|
||||||
|
require("Layout = null" in sitemap_view, "sitemap.xml must render without the HTML layout")
|
||||||
|
require("Layout = null" in rss_view, "rss.xml must render without the HTML layout")
|
||||||
|
require("Html.Raw(rssContent.ToString())" in rss_view, "RSS XML must not be HTML-encoded")
|
||||||
|
require("Model.Items" in rss_view, "RSS view must render from the combined feed items")
|
||||||
|
require("StatusCodes.Status308PermanentRedirect" in feed_view, "feed.xml must permanently redirect to rss.xml")
|
||||||
|
require("DateTime.UtcNow:yyyy-MM-dd" not in sitemap_view, "sitemap lastmod must not use the current request date")
|
||||||
|
require("GetAllPublishedPostsAsync" in sitemap_model, "sitemap must page through all published blog posts")
|
||||||
|
require("/faq" not in sitemap_model and "/announcement" not in sitemap_model and "/inquiry" not in sitemap_model,
|
||||||
|
"redirect-only URLs must not be included in the sitemap")
|
||||||
|
require("var canonicalUrl" in layout and "Context.Request.Path" in layout,
|
||||||
|
"default canonical URL must use the current path")
|
||||||
|
|
||||||
|
sitemap_directives = re.findall(r"^Sitemap:\s*(\S+)\s*$", robots, flags=re.MULTILINE)
|
||||||
|
require(sitemap_directives == ["https://www.taxbaik.com/sitemap.xml"],
|
||||||
|
"robots.txt must declare exactly one canonical sitemap URL")
|
||||||
|
require(not re.search(r"^Disallow:\s*.*\.xml", robots, flags=re.MULTILINE),
|
||||||
|
"robots.txt must not block XML feeds or the sitemap")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("SEO guardrail validation failed:", file=sys.stderr)
|
||||||
|
for error in errors:
|
||||||
|
print(f"- {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("SEO guardrail validation passed")
|
||||||
@@ -13,7 +13,11 @@ public class BlogServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
|
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
|
||||||
{
|
{
|
||||||
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>());
|
var service = new BlogService(
|
||||||
|
new FakeBlogPostRepository(),
|
||||||
|
new FakeCategoryRepository(),
|
||||||
|
new MemoryCache(new MemoryCacheOptions()),
|
||||||
|
new PassThroughValidator<CreateBlogPostDto>());
|
||||||
|
|
||||||
await Assert.ThrowsAsync<TaxBaik.Application.Services.ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
|
await Assert.ThrowsAsync<TaxBaik.Application.Services.ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
|
||||||
{
|
{
|
||||||
@@ -34,7 +38,11 @@ public class BlogServiceTests
|
|||||||
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
|
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>());
|
var service = new BlogService(
|
||||||
|
repository,
|
||||||
|
new FakeCategoryRepository(),
|
||||||
|
new MemoryCache(new MemoryCacheOptions()),
|
||||||
|
new PassThroughValidator<CreateBlogPostDto>());
|
||||||
|
|
||||||
var post = await service.CreateAsync(new CreateBlogPostDto
|
var post = await service.CreateAsync(new CreateBlogPostDto
|
||||||
{
|
{
|
||||||
@@ -55,7 +63,11 @@ public class BlogServiceTests
|
|||||||
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
|
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>());
|
var service = new BlogService(
|
||||||
|
repository,
|
||||||
|
new FakeCategoryRepository(),
|
||||||
|
new MemoryCache(new MemoryCacheOptions()),
|
||||||
|
new PassThroughValidator<CreateBlogPostDto>());
|
||||||
|
|
||||||
await service.DeleteAsync(1);
|
await service.DeleteAsync(1);
|
||||||
|
|
||||||
@@ -131,6 +143,34 @@ public class BlogServiceTests
|
|||||||
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeCategoryRepository : ICategoryRepository
|
||||||
|
{
|
||||||
|
private readonly List<Category> _categories =
|
||||||
|
[
|
||||||
|
new Category { Id = 1, Name = "사업자 세무", Slug = "business-tax", SortOrder = 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
public Task<IEnumerable<Category>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<IEnumerable<Category>>(_categories);
|
||||||
|
|
||||||
|
public Task<Category?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(_categories.FirstOrDefault(x => x.Slug == slug));
|
||||||
|
|
||||||
|
public Task<Category?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(_categories.FirstOrDefault(x => x.Id == id));
|
||||||
|
|
||||||
|
public Task<int> CreateAsync(Category category, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
category.Id = _categories.Count + 1;
|
||||||
|
_categories.Add(category);
|
||||||
|
return Task.FromResult(category.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateAsync(Category category, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task DeleteAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class PassThroughValidator<T> : IValidator<T>
|
private sealed class PassThroughValidator<T> : IValidator<T>
|
||||||
{
|
{
|
||||||
public FluentValidation.Results.ValidationResult Validate(T instance) => new();
|
public FluentValidation.Results.ValidationResult Validate(T instance) => new();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ public class CreateBlogPostDto
|
|||||||
{
|
{
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
public required string Content { get; set; }
|
public required string Content { get; set; }
|
||||||
public int? CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
public string? SeoTitle { get; set; }
|
public string? SeoTitle { get; set; }
|
||||||
public string? SeoDescription { get; set; }
|
public string? SeoDescription { get; set; }
|
||||||
@@ -18,7 +18,7 @@ public class BlogPostResponseDto
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
public int? CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
public string? SeoTitle { get; set; }
|
public string? SeoTitle { get; set; }
|
||||||
public string? SeoDescription { get; set; }
|
public string? SeoDescription { get; set; }
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ public sealed class CreateBlogPostDtoValidator : AbstractValidator<CreateBlogPos
|
|||||||
{
|
{
|
||||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(ValidationRules.TitleMaxLength);
|
RuleFor(x => x.Title).NotEmpty().MaximumLength(ValidationRules.TitleMaxLength);
|
||||||
RuleFor(x => x.Content).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.ContentMaxLength);
|
RuleFor(x => x.Content).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.ContentMaxLength);
|
||||||
|
RuleFor(x => x.CategoryId).GreaterThan(0);
|
||||||
RuleFor(x => x.Tags).MaximumLength(ValidationRules.TagsMaxLength);
|
RuleFor(x => x.Tags).MaximumLength(ValidationRules.TagsMaxLength);
|
||||||
RuleFor(x => x.SeoTitle).MaximumLength(ValidationRules.SeoTitleMaxLength);
|
RuleFor(x => x.SeoTitle)
|
||||||
RuleFor(x => x.SeoDescription).MaximumLength(ValidationRules.SeoDescriptionMaxLength);
|
.NotEmpty().When(x => x.IsPublished)
|
||||||
|
.MaximumLength(ValidationRules.SeoTitleMaxLength);
|
||||||
|
RuleFor(x => x.SeoDescription)
|
||||||
|
.NotEmpty().When(x => x.IsPublished)
|
||||||
|
.MaximumLength(ValidationRules.SeoDescriptionMaxLength);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ using TaxBaik.Domain.Interfaces;
|
|||||||
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache, IValidator<CreateBlogPostDto> validator)
|
public class BlogService(
|
||||||
|
IBlogPostRepository repository,
|
||||||
|
ICategoryRepository categoryRepository,
|
||||||
|
IMemoryCache memoryCache,
|
||||||
|
IValidator<CreateBlogPostDto> validator)
|
||||||
{
|
{
|
||||||
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.GetByIdAsync(id, ct);
|
await repository.GetByIdAsync(id, ct);
|
||||||
@@ -50,6 +54,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
|||||||
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
|
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ValidatePost(post);
|
ValidatePost(post);
|
||||||
|
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
|
||||||
post.Title = post.Title.Trim();
|
post.Title = post.Title.Trim();
|
||||||
post.Content = post.Content.Trim();
|
post.Content = post.Content.Trim();
|
||||||
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
|
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
|
||||||
@@ -62,6 +67,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
|||||||
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
validator.ValidateAndThrow(dto);
|
validator.ValidateAndThrow(dto);
|
||||||
|
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
|
||||||
var post = new BlogPost
|
var post = new BlogPost
|
||||||
{
|
{
|
||||||
Title = dto.Title,
|
Title = dto.Title,
|
||||||
@@ -83,6 +89,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
|||||||
|
|
||||||
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
|
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
|
||||||
await repository.UpdateAsync(post, ct);
|
await repository.UpdateAsync(post, ct);
|
||||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
}
|
}
|
||||||
@@ -90,6 +97,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
|||||||
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
validator.ValidateAndThrow(dto);
|
validator.ValidateAndThrow(dto);
|
||||||
|
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
|
||||||
var post = await repository.GetByIdAsync(id, ct);
|
var post = await repository.GetByIdAsync(id, ct);
|
||||||
if (post == null)
|
if (post == null)
|
||||||
return null;
|
return null;
|
||||||
@@ -178,6 +186,15 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
|||||||
|
|
||||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
private async Task<int> EnsureCategoryIdAsync(int categoryId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (categoryId > 0)
|
||||||
|
return categoryId;
|
||||||
|
|
||||||
|
var firstCategory = (await categoryRepository.GetAllAsync(ct)).FirstOrDefault();
|
||||||
|
return firstCategory?.Id ?? throw new ValidationException("블로그 카테고리를 먼저 생성하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default)
|
public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
|
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Data;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text.RegularExpressions;
|
||||||
using Npgsql;
|
using DbUp;
|
||||||
|
using DbUp.Engine;
|
||||||
using TaxBaik.Domain.Interfaces;
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
namespace TaxBaik.Infrastructure.Data;
|
namespace TaxBaik.Infrastructure.Data;
|
||||||
@@ -18,159 +20,191 @@ public class MigrationRunner
|
|||||||
|
|
||||||
public async Task RunAsync()
|
public async Task RunAsync()
|
||||||
{
|
{
|
||||||
await EnsureMigrationTableAsync();
|
await Task.Run(() => RunMigrations());
|
||||||
await ExecutePendingMigrationsAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureMigrationTableAsync()
|
private void RunMigrations()
|
||||||
{
|
{
|
||||||
using var conn = new NpgsqlConnection(_connectionString);
|
|
||||||
await conn.OpenAsync();
|
|
||||||
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = @"
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version VARCHAR(50) PRIMARY KEY,
|
|
||||||
description VARCHAR(500),
|
|
||||||
installed_on TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);";
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExecutePendingMigrationsAsync()
|
|
||||||
{
|
|
||||||
var executedMigrations = await GetExecutedMigrationsAsync();
|
|
||||||
var migrations = GetAvailableMigrations();
|
|
||||||
|
|
||||||
foreach (var migration in migrations.OrderBy(x => x.Version))
|
|
||||||
{
|
|
||||||
if (!executedMigrations.Contains(migration.Version))
|
|
||||||
{
|
|
||||||
await ExecuteMigrationAsync(migration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HashSet<string>> GetExecutedMigrationsAsync()
|
|
||||||
{
|
|
||||||
var executed = new HashSet<string>();
|
|
||||||
using var conn = new NpgsqlConnection(_connectionString);
|
|
||||||
await conn.OpenAsync();
|
|
||||||
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;";
|
|
||||||
|
|
||||||
using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
while (await reader.ReadAsync())
|
|
||||||
{
|
|
||||||
executed.Add(reader.GetString(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return executed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Migration> GetAvailableMigrations()
|
|
||||||
{
|
|
||||||
var migrations = new List<Migration>();
|
|
||||||
|
|
||||||
// Try file system first (for deployment), then embedded resources
|
// Try file system first (for deployment), then embedded resources
|
||||||
var migrationDirs = new[]
|
var migrationDirs = new[]
|
||||||
{
|
{
|
||||||
"./migrations", // relative
|
"./migrations", // relative
|
||||||
"/home/kjh2064/taxbaik_active/migrations" // deployment
|
"/home/kjh2064/taxbaik_active/migrations", // deployment
|
||||||
|
"./db/migrations",
|
||||||
|
"../db/migrations"
|
||||||
};
|
};
|
||||||
|
|
||||||
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
|
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
|
||||||
|
|
||||||
|
var upgradeEngineBuilder = DeployChanges.To
|
||||||
|
.PostgresqlDatabase(_connectionString)
|
||||||
|
.JournalTo(new DbUpCustomJournal(_connectionString))
|
||||||
|
.LogToConsole();
|
||||||
|
|
||||||
if (migrationPath != null && Directory.Exists(migrationPath))
|
if (migrationPath != null && Directory.Exists(migrationPath))
|
||||||
{
|
{
|
||||||
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x);
|
Console.WriteLine($"[DbUp] Loading migration scripts from filesystem path: {migrationPath}");
|
||||||
foreach (var file in files)
|
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsFromFileSystem(migrationPath);
|
||||||
{
|
|
||||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
|
||||||
if (fileName.StartsWith("V"))
|
|
||||||
{
|
|
||||||
var version = fileName.Substring(1, fileName.IndexOf('_') - 1);
|
|
||||||
var description = fileName.Substring(fileName.IndexOf('_') + 2);
|
|
||||||
var sql = File.ReadAllText(file);
|
|
||||||
|
|
||||||
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
Console.WriteLine("[DbUp] Loading migration scripts embedded in Assembly.");
|
||||||
var resourceNames = assembly.GetManifestResourceNames()
|
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsEmbeddedInAssembly(
|
||||||
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
Assembly.GetExecutingAssembly(),
|
||||||
.OrderBy(x => x);
|
name => name.Contains(".Migrations.V") && name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var resourceName in resourceNames)
|
var upgradeEngine = upgradeEngineBuilder.Build();
|
||||||
|
|
||||||
|
if (upgradeEngine.IsUpgradeRequired())
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DbUp] Database upgrade is required. Running migrations...");
|
||||||
|
var result = upgradeEngine.PerformUpgrade();
|
||||||
|
if (!result.Successful)
|
||||||
{
|
{
|
||||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
Console.WriteLine($"[DbUp] Database migration failed: {result.Error}");
|
||||||
if (stream == null)
|
throw result.Error;
|
||||||
continue;
|
}
|
||||||
|
Console.WriteLine("[DbUp] Database migration completed successfully.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DbUp] Database is up-to-date. No migrations applied.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var reader = new StreamReader(stream);
|
public class DbUpCustomJournal : IJournal
|
||||||
var sql = reader.ReadToEnd();
|
{
|
||||||
var fileName = Path.GetFileNameWithoutExtension(resourceName);
|
private readonly string _connectionString;
|
||||||
var versionStart = fileName.IndexOf('V');
|
|
||||||
var versionEnd = fileName.IndexOf('_', versionStart + 1);
|
|
||||||
if (versionStart < 0 || versionEnd < 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1);
|
public DbUpCustomJournal(string connectionString)
|
||||||
var description = fileName.Substring(versionEnd + 1);
|
{
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
public string[] GetExecutedScripts()
|
||||||
|
{
|
||||||
|
var executedVersions = new HashSet<string>();
|
||||||
|
|
||||||
|
using (var conn = new Npgsql.NpgsqlConnection(_connectionString))
|
||||||
|
{
|
||||||
|
conn.Open();
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(50) PRIMARY KEY,
|
||||||
|
description VARCHAR(500),
|
||||||
|
installed_on TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT version FROM schema_migrations;";
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
executedVersions.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrations;
|
// Collect all possible script names from both embedded resources and filesystem
|
||||||
|
var allScriptNames = new List<string>();
|
||||||
|
|
||||||
|
// 1. Assembly resources
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var resourceNames = assembly.GetManifestResourceNames()
|
||||||
|
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase));
|
||||||
|
allScriptNames.AddRange(resourceNames);
|
||||||
|
|
||||||
|
// 2. Filesystem
|
||||||
|
var migrationDirs = new[]
|
||||||
|
{
|
||||||
|
"./migrations",
|
||||||
|
"/home/kjh2064/taxbaik_active/migrations",
|
||||||
|
"./db/migrations",
|
||||||
|
"../db/migrations"
|
||||||
|
};
|
||||||
|
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
|
||||||
|
if (migrationPath != null)
|
||||||
|
{
|
||||||
|
var files = Directory.GetFiles(migrationPath, "V*.sql");
|
||||||
|
allScriptNames.AddRange(files.Select(Path.GetFileName).Where(x => x != null)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var executedScriptNames = new List<string>();
|
||||||
|
foreach (var scriptName in allScriptNames.Distinct())
|
||||||
|
{
|
||||||
|
var match = Regex.Match(scriptName, @"[Vv](?<version>\d+)__");
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
var ver = match.Groups["version"].Value;
|
||||||
|
if (executedVersions.Contains(ver))
|
||||||
|
{
|
||||||
|
executedScriptNames.Add(scriptName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return executedScriptNames.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExecuteMigrationAsync(Migration migration)
|
public void StoreExecutedScript(SqlScript script, Func<IDbCommand> dbCommandFactory)
|
||||||
{
|
{
|
||||||
using var conn = new NpgsqlConnection(_connectionString);
|
var match = Regex.Match(script.Name, @"[Vv](?<version>\d+)__(?<desc>.*)$");
|
||||||
await conn.OpenAsync();
|
string version;
|
||||||
|
string description;
|
||||||
|
|
||||||
try
|
if (match.Success)
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
version = match.Groups["version"].Value;
|
||||||
cmd.CommandText = migration.Sql;
|
description = match.Groups["desc"].Value.Replace(".sql", "").Replace("_", " ");
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
|
|
||||||
using var insertCmd = conn.CreateCommand();
|
|
||||||
insertCmd.CommandText =
|
|
||||||
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description);";
|
|
||||||
insertCmd.Parameters.AddWithValue("@version", migration.Version);
|
|
||||||
insertCmd.Parameters.AddWithValue("@description", migration.Description);
|
|
||||||
await insertCmd.ExecuteNonQueryAsync();
|
|
||||||
|
|
||||||
Console.WriteLine($"✓ Migration {migration.Version} executed");
|
|
||||||
}
|
}
|
||||||
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
|
else
|
||||||
{
|
{
|
||||||
// Already executed previously; mark as done
|
version = script.Name;
|
||||||
Console.WriteLine($"ℹ Migration {migration.Version} already applied");
|
description = "Executed via DbUp";
|
||||||
using var insertCmd = conn.CreateCommand();
|
|
||||||
insertCmd.CommandText =
|
|
||||||
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description) ON CONFLICT (version) DO NOTHING;";
|
|
||||||
insertCmd.Parameters.AddWithValue("@version", migration.Version);
|
|
||||||
insertCmd.Parameters.AddWithValue("@description", migration.Description);
|
|
||||||
await insertCmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
using (var cmd = dbCommandFactory())
|
||||||
{
|
{
|
||||||
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
|
cmd.CommandText = @"
|
||||||
throw;
|
INSERT INTO schema_migrations (version, description)
|
||||||
|
VALUES (@version, @description)
|
||||||
|
ON CONFLICT (version) DO NOTHING;";
|
||||||
|
|
||||||
|
var pVersion = cmd.CreateParameter();
|
||||||
|
pVersion.ParameterName = "@version";
|
||||||
|
pVersion.Value = version;
|
||||||
|
cmd.Parameters.Add(pVersion);
|
||||||
|
|
||||||
|
var pDesc = cmd.CreateParameter();
|
||||||
|
pDesc.ParameterName = "@description";
|
||||||
|
pDesc.Value = description;
|
||||||
|
cmd.Parameters.Add(pDesc);
|
||||||
|
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Migration
|
public void EnsureTableExistsAndIsLatestVersion(Func<IDbCommand> dbCommandFactory)
|
||||||
{
|
{
|
||||||
public required string Version { get; set; }
|
using (var cmd = dbCommandFactory())
|
||||||
public required string Description { get; set; }
|
{
|
||||||
public required string Sql { get; set; }
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(50) PRIMARY KEY,
|
||||||
|
description VARCHAR(500),
|
||||||
|
installed_on TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapper" Version="2.1.15" />
|
<PackageReference Include="Dapper" Version="2.1.15" />
|
||||||
|
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
<PackageReference Include="Npgsql" Version="10.0.3" />
|
<PackageReference Include="Npgsql" Version="10.0.3" />
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace TaxBaik.Web.Models;
|
||||||
|
|
||||||
|
public sealed record AdminPageHeaderModel(
|
||||||
|
string Eyebrow,
|
||||||
|
string Title,
|
||||||
|
string? Subtitle,
|
||||||
|
string? ActionHref,
|
||||||
|
string? ActionText);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@page
|
||||||
|
@model TaxBaik.Web.Pages.Admin.Announcements.CreateModel
|
||||||
|
@{ ViewData["Title"] = "공지사항 작성"; }
|
||||||
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 작성", "Tabler 표준 폼으로 공지사항을 등록합니다.", null, null))
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<label asp-for="Input.Title" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Title" class="form-control form-control-sm" />
|
||||||
|
<span asp-validation-for="Input.Title" class="text-danger small"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label asp-for="Input.DisplayType" class="form-label form-label-sm"></label>
|
||||||
|
<select asp-for="Input.DisplayType" class="form-select form-select-sm">
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="success">success</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="danger">danger</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.Content" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.Content" class="form-control form-control-sm" rows="8"></textarea>
|
||||||
|
<span asp-validation-for="Input.Content" class="text-danger small"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.StartsAt" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.StartsAt" class="form-control form-control-sm" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.EndsAt" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.EndsAt" class="form-control form-control-sm" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-check">
|
||||||
|
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label form-label-sm">활성</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-end gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/announcements">취소</a>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Announcements;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class CreateModel(AnnouncementService announcementService) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty]
|
||||||
|
public AnnouncementDto Input { get; set; } = new();
|
||||||
|
|
||||||
|
public IActionResult OnGet() => Page();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Page();
|
||||||
|
|
||||||
|
await announcementService.CreateAsync(Input, ct);
|
||||||
|
return RedirectToPage("/Admin/Announcements/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@page "/admin/announcements/{id:int}"
|
||||||
|
@model TaxBaik.Web.Pages.Admin.Announcements.DetailModel
|
||||||
|
@{ ViewData["Title"] = "공지사항 상세"; }
|
||||||
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 상세", "공지사항의 노출 정보를 확인합니다.", null, null))
|
||||||
|
|
||||||
|
@if (Model.Item is null)
|
||||||
|
{
|
||||||
|
@await Html.PartialAsync("_EmptyState", "공지사항을 찾을 수 없습니다.")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row details-grid mb-0">
|
||||||
|
<dt class="col-sm-3">제목</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.Title</dd>
|
||||||
|
<dt class="col-sm-3">유형</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.DisplayType</dd>
|
||||||
|
<dt class="col-sm-3">내용</dt>
|
||||||
|
<dd class="col-sm-9"><div class="pre-wrap">@Model.Item.Content</div></dd>
|
||||||
|
<dt class="col-sm-3">활성</dt>
|
||||||
|
<dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Item.IsActive ? "active" : "inactive")</dd>
|
||||||
|
<dt class="col-sm-3">정렬</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.SortOrder</dd>
|
||||||
|
<dt class="col-sm-3">시작일</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.StartsAt?.ToString("yyyy-MM-dd HH:mm")</dd>
|
||||||
|
<dt class="col-sm-3">종료일</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.EndsAt?.ToString("yyyy-MM-dd HH:mm")</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-primary btn-sm flex-fill" href="/admin/announcements/@Model.Item.Id/edit">수정</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm flex-fill" href="/admin/announcements">목록</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Announcements;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class DetailModel(AnnouncementService announcementService) : PageModel
|
||||||
|
{
|
||||||
|
public Announcement? Item { get; private set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Item = await announcementService.GetByIdAsync(id, ct);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@page "/admin/announcements/{id:int}/edit"
|
||||||
|
@model TaxBaik.Web.Pages.Admin.Announcements.EditModel
|
||||||
|
@{ ViewData["Title"] = "공지사항 수정"; }
|
||||||
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 수정", "기존 공지사항을 Tabler 표준 폼으로 수정합니다.", null, null))
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="card-header p-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
|
||||||
|
<div class="fw-semibold small">@Model.Input.Title</div>
|
||||||
|
<div class="text-muted small">유형: @Model.Input.DisplayType</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary-lt text-secondary small">원본 확인 후 수정</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
|
<input type="hidden" asp-for="Input.Id" />
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<label asp-for="Input.Title" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Title" class="form-control form-control-sm" />
|
||||||
|
<span asp-validation-for="Input.Title" class="text-danger small"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label asp-for="Input.DisplayType" class="form-label form-label-sm"></label>
|
||||||
|
<select asp-for="Input.DisplayType" class="form-select form-select-sm">
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="success">success</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="danger">danger</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.Content" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.Content" class="form-control form-control-sm" rows="8"></textarea>
|
||||||
|
<span asp-validation-for="Input.Content" class="text-danger small"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.StartsAt" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.StartsAt" class="form-control form-control-sm" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.EndsAt" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.EndsAt" class="form-control form-control-sm" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-check">
|
||||||
|
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label form-label-sm">활성</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between gap-2">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit" formaction="?handler=Delete" formmethod="post" onclick="return confirm('정말 삭제하시겠습니까?');">삭제</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/announcements">취소</a>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Announcements;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class EditModel(AnnouncementService announcementService) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public AnnouncementDto Input { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var item = await announcementService.GetByIdAsync(Id, ct);
|
||||||
|
if (item is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
Input = new AnnouncementDto
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Title = item.Title,
|
||||||
|
Content = item.Content,
|
||||||
|
DisplayType = item.DisplayType,
|
||||||
|
IsActive = item.IsActive,
|
||||||
|
StartsAt = item.StartsAt,
|
||||||
|
EndsAt = item.EndsAt,
|
||||||
|
SortOrder = item.SortOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Page();
|
||||||
|
|
||||||
|
await announcementService.UpdateAsync(Input, ct);
|
||||||
|
return RedirectToPage("/Admin/Announcements/Index");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await announcementService.DeleteAsync(Id, ct);
|
||||||
|
return RedirectToPage("/Admin/Announcements/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,33 +2,58 @@
|
|||||||
@model TaxBaik.Web.Pages.Admin.Announcements.IndexModel
|
@model TaxBaik.Web.Pages.Admin.Announcements.IndexModel
|
||||||
@{ ViewData["Title"] = "공지사항"; }
|
@{ ViewData["Title"] = "공지사항"; }
|
||||||
|
|
||||||
<section class="container-fluid py-4">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("Content", "공지사항 관리", "공지 목록을 서버 렌더링으로 표시합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 관리", "공지 목록을 서버 렌더링으로 표시합니다.", "/admin/announcements/create", "공지 작성"))
|
||||||
<div class="card">
|
@if (Model.Items.Count == 0)
|
||||||
<div class="table-responsive">
|
{
|
||||||
<table class="table table-vcenter card-table">
|
<div class="card">
|
||||||
<thead>
|
<div class="card-body p-2">
|
||||||
<tr><th>제목</th><th>유형</th><th>활성</th><th>정렬</th></tr>
|
@await Html.PartialAsync("_EmptyState", "공지사항이 없습니다.")
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
|
||||||
@if (Model.Items.Count == 0)
|
|
||||||
{
|
|
||||||
<tr><td colspan="4"><partial name="_EmptyState" model="공지사항이 없습니다." /></td></tr>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foreach (var item in Model.Items)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@item.Title</td>
|
|
||||||
<td>@item.DisplayType</td>
|
|
||||||
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td>
|
|
||||||
<td>@item.SortOrder</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive table-responsive--separator">
|
||||||
|
<table class="table table-vcenter card-table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>제목</th>
|
||||||
|
<th>유형</th>
|
||||||
|
<th>활성</th>
|
||||||
|
<th>정렬</th>
|
||||||
|
<th class="text-end">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">@item.Title</td>
|
||||||
|
<td>@item.DisplayType</td>
|
||||||
|
<td>@await Html.PartialAsync("_StatusBadge", item.IsActive ? "active" : "inactive")</td>
|
||||||
|
<td>@item.SortOrder</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-list flex-nowrap justify-content-end">
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/admin/announcements/@item.Id">상세</a>
|
||||||
|
<a class="btn btn-sm btn-primary" href="/admin/announcements/@item.Id/edit">수정</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,36 @@
|
|||||||
@model TaxBaik.Web.Pages.Admin.Blog.CreateModel
|
@model TaxBaik.Web.Pages.Admin.Blog.CreateModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "블로그 등록";
|
ViewData["Title"] = "블로그 등록";
|
||||||
|
ViewData["BlogCategories"] = Model.Categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
<partial name="_BlogEditor" model="new TaxBaik.Web.Pages.Shared.BlogEditorModel(
|
<section class="admin-page-shell">
|
||||||
Model.Input.Title,
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("콘텐츠", "새 글 작성", "블로그 글을 등록합니다.", null, null))
|
||||||
Model.Input.Content,
|
|
||||||
Model.Input.CategoryId,
|
<div class="card">
|
||||||
Model.Input.IsPublished,
|
<div class="card-body p-2">
|
||||||
Model.Input.ThumbnailUrl,
|
@await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel(
|
||||||
Model.Input.SeoTitle,
|
Model.Input.Title,
|
||||||
Model.Input.SeoDescription,
|
Model.Input.Content,
|
||||||
false)" />
|
Model.Input.CategoryId,
|
||||||
|
Model.Input.IsPublished,
|
||||||
|
Model.Input.ThumbnailUrl,
|
||||||
|
Model.Input.SeoTitle,
|
||||||
|
Model.Input.SeoDescription,
|
||||||
|
false))
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
|
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using TaxBaik.Application.DTOs;
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Web.Services;
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
namespace TaxBaik.Web.Pages.Admin.Blog;
|
namespace TaxBaik.Web.Pages.Admin.Blog;
|
||||||
|
|
||||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
public class CreateModel(BlogService blogService) : PageModel
|
public class CreateModel(BlogService blogService, CategoryService categoryService) : PageModel
|
||||||
{
|
{
|
||||||
[BindProperty]
|
[BindProperty]
|
||||||
public CreateBlogPostDto Input { get; set; } = new()
|
public CreateBlogPostDto Input { get; set; } = new()
|
||||||
@@ -17,14 +18,47 @@ public class CreateModel(BlogService blogService) : PageModel
|
|||||||
Title = string.Empty
|
Title = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
public IActionResult OnGet() => Page();
|
public IReadOnlyList<Category> Categories { get; private set; } = [];
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Categories = (await EnsureCategoriesAsync(ct)).ToList();
|
||||||
|
if (Categories.Count > 0)
|
||||||
|
Input.CategoryId = Categories[0].Id;
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
Categories = (await EnsureCategoriesAsync(ct)).ToList();
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Page();
|
return Page();
|
||||||
|
|
||||||
await blogService.CreateAsync(Input, ct);
|
await blogService.CreateAsync(Input, ct);
|
||||||
return RedirectToPage("/Admin/Blog/Index");
|
return RedirectToPage("/Admin/Blog/Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<Category>> EnsureCategoriesAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var categories = (await categoryService.GetAllAsync(ct)).ToList();
|
||||||
|
if (categories.Count > 0)
|
||||||
|
return categories;
|
||||||
|
|
||||||
|
var defaults = new[]
|
||||||
|
{
|
||||||
|
("사업자 세무", "business-tax", 1),
|
||||||
|
("부동산 세금", "real-estate-tax", 2),
|
||||||
|
("종합소득세", "income-tax", 3),
|
||||||
|
("부가가치세", "vat", 4),
|
||||||
|
("가족자산·증여", "family-asset", 5)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (name, slug, sortOrder) in defaults)
|
||||||
|
{
|
||||||
|
await categoryService.CreateAsync(name, null, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await categoryService.GetAllAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
@page "{id:int}"
|
@page "/admin/blog/{id:int}/edit"
|
||||||
@model TaxBaik.Web.Pages.Admin.Blog.EditModel
|
@model TaxBaik.Web.Pages.Admin.Blog.EditModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "블로그 수정";
|
ViewData["Title"] = "블로그 수정";
|
||||||
ViewData["BlogEditorId"] = Model.Id;
|
ViewData["BlogEditorId"] = Model.Id;
|
||||||
|
ViewData["BlogCategories"] = Model.Categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
<partial name="_BlogEditor" model='new TaxBaik.Web.Pages.Shared.BlogEditorModel(
|
<section class="admin-page-shell">
|
||||||
Model.Input.Title,
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("콘텐츠", "글 수정", "블로그 글을 수정합니다.", null, null))
|
||||||
Model.Input.Content,
|
|
||||||
Model.Input.CategoryId,
|
<div class="card">
|
||||||
Model.Input.IsPublished,
|
<div class="card-body p-2">
|
||||||
Model.Input.ThumbnailUrl,
|
@await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel(
|
||||||
Model.Input.SeoTitle,
|
Model.Input.Title,
|
||||||
Model.Input.SeoDescription,
|
Model.Input.Content,
|
||||||
true,
|
Model.Input.CategoryId,
|
||||||
"?handler=Delete")' />
|
Model.Input.IsPublished,
|
||||||
|
Model.Input.ThumbnailUrl,
|
||||||
|
Model.Input.SeoTitle,
|
||||||
|
Model.Input.SeoDescription,
|
||||||
|
true,
|
||||||
|
"?handler=Delete"))
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
|
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using TaxBaik.Application.DTOs;
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Web.Services;
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
namespace TaxBaik.Web.Pages.Admin.Blog;
|
namespace TaxBaik.Web.Pages.Admin.Blog;
|
||||||
|
|
||||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
public class EditModel(BlogService blogService) : PageModel
|
public class EditModel(BlogService blogService, CategoryService categoryService) : PageModel
|
||||||
{
|
{
|
||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)]
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
@@ -20,17 +21,20 @@ public class EditModel(BlogService blogService) : PageModel
|
|||||||
Title = string.Empty
|
Title = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public IReadOnlyList<Category> Categories { get; private set; } = [];
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
|
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var post = await blogService.GetByIdAsync(Id, ct);
|
var post = await blogService.GetByIdAsync(Id, ct);
|
||||||
if (post is null)
|
if (post is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
Categories = (await EnsureCategoriesAsync(ct)).ToList();
|
||||||
Input = new CreateBlogPostDto
|
Input = new CreateBlogPostDto
|
||||||
{
|
{
|
||||||
Title = post.Title,
|
Title = post.Title,
|
||||||
Content = post.Content,
|
Content = post.Content,
|
||||||
CategoryId = post.CategoryId,
|
CategoryId = post.CategoryId ?? 0,
|
||||||
Tags = post.Tags,
|
Tags = post.Tags,
|
||||||
SeoTitle = post.SeoTitle,
|
SeoTitle = post.SeoTitle,
|
||||||
SeoDescription = post.SeoDescription,
|
SeoDescription = post.SeoDescription,
|
||||||
@@ -44,6 +48,7 @@ public class EditModel(BlogService blogService) : PageModel
|
|||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
Categories = (await EnsureCategoriesAsync(ct)).ToList();
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Page();
|
return Page();
|
||||||
|
|
||||||
@@ -59,4 +64,27 @@ public class EditModel(BlogService blogService) : PageModel
|
|||||||
await blogService.DeleteAsync(Id, ct);
|
await blogService.DeleteAsync(Id, ct);
|
||||||
return RedirectToPage("/Admin/Blog/Index");
|
return RedirectToPage("/Admin/Blog/Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<Category>> EnsureCategoriesAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var categories = (await categoryService.GetAllAsync(ct)).ToList();
|
||||||
|
if (categories.Count > 0)
|
||||||
|
return categories;
|
||||||
|
|
||||||
|
var defaults = new[]
|
||||||
|
{
|
||||||
|
("사업자 세무", "business-tax", 1),
|
||||||
|
("부동산 세금", "real-estate-tax", 2),
|
||||||
|
("종합소득세", "income-tax", 3),
|
||||||
|
("부가가치세", "vat", 4),
|
||||||
|
("가족자산·증여", "family-asset", 5)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (name, slug, sortOrder) in defaults)
|
||||||
|
{
|
||||||
|
await categoryService.CreateAsync(name, null, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await categoryService.GetAllAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,65 +4,79 @@
|
|||||||
ViewData["Title"] = "블로그";
|
ViewData["Title"] = "블로그";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container-xl admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("콘텐츠", "블로그 관리", "검색, 발행 상태, 작성일 기준의 블로그 목록입니다.", "/admin/blog/create", "새 글 작성")' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("콘텐츠", "블로그 관리", "검색, 발행 상태, 작성일 기준의 블로그 목록입니다.", "/admin/blog/create", "새 글 작성"))
|
||||||
|
|
||||||
<form method="get" class="card admin-page-card mb-3">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body p-2" style="min-height: auto;">
|
||||||
<div class="row g-3 align-items-end">
|
<form method="get">
|
||||||
<div class="col-md-6">
|
<div class="row g-2 align-items-end">
|
||||||
<label class="form-label" for="SearchQuery">검색</label>
|
<div class="col-md-4">
|
||||||
<input class="form-control" id="SearchQuery" name="searchQuery" value="@Model.SearchQuery" placeholder="제목 또는 본문 검색" />
|
<label class="form-label form-label-sm" for="SearchQuery">검색</label>
|
||||||
|
<input class="form-control form-control-sm" id="SearchQuery" name="searchQuery" value="@Model.SearchQuery" placeholder="제목 또는 본문" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">검색</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/blog">초기화</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/blog?showArchived=true">내린 글</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 d-flex gap-2">
|
</form>
|
||||||
<button class="btn btn-primary" type="submit">검색</button>
|
|
||||||
<a class="btn btn-outline-secondary" href="/admin/blog">초기화</a>
|
|
||||||
<a class="btn btn-outline-secondary" href="/admin/blog?showArchived=true">내린 글 보기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
@if (Model.Items.Count > 0)
|
||||||
|
{
|
||||||
<div class="card admin-page-card">
|
<div class="table-responsive table-responsive--separator">
|
||||||
<div class="table-responsive">
|
<table class="table table-vcenter card-table table-hover">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<thead>
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>제목</th>
|
|
||||||
<th>발행</th>
|
|
||||||
<th>조회수</th>
|
|
||||||
<th>작성일</th>
|
|
||||||
<th class="text-end">작업</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@if (Model.Items.Count == 0)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td colspan="5"><partial name="_EmptyState" model="블로그 글이 없습니다." /></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foreach (var post in Model.Items)
|
|
||||||
{
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<th>제목</th>
|
||||||
<div class="fw-semibold">@post.Title</div>
|
<th>발행</th>
|
||||||
<div class="text-muted small">@post.Slug</div>
|
<th>조회수</th>
|
||||||
</td>
|
<th>작성일</th>
|
||||||
<td><partial name="_StatusBadge" model="@(post.IsPublished ? "active" : "inactive")" /></td>
|
<th class="text-end">작업</th>
|
||||||
<td>@post.ViewCount</td>
|
|
||||||
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a class="btn btn-sm btn-outline-primary" href="/admin/blog/@post.Id/edit">수정</a>
|
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="/blog/@post.Slug" target="_blank">보기</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
</thead>
|
||||||
}
|
<tbody>
|
||||||
</tbody>
|
@foreach (var post in Model.Items)
|
||||||
</table>
|
{
|
||||||
</div>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">@post.Title</div>
|
||||||
|
<div class="text-muted small">@post.Slug</div>
|
||||||
|
</td>
|
||||||
|
<td>@await Html.PartialAsync("_StatusBadge", post.IsPublished ? "active" : "inactive")</td>
|
||||||
|
<td>@post.ViewCount</td>
|
||||||
|
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-list flex-nowrap justify-content-end">
|
||||||
|
<a class="btn btn-sm btn-primary" href="/admin/blog/@post.Id/edit">수정</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/blog/@post.Slug" target="_blank">보기</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card-body py-4">
|
||||||
|
@await Html.PartialAsync("_EmptyState", "블로그 글이 없습니다.")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.ClientLogs.IndexModel
|
@model TaxBaik.Web.Pages.Admin.ClientLogs.IndexModel
|
||||||
@{ ViewData["Title"] = "클라이언트 로그"; }
|
@{ ViewData["Title"] = "클라이언트 로그"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("운영 로그", "클라이언트 로그", "고객별 작업 이력을 확인합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 로그", "클라이언트 로그", "고객별 작업 이력을 확인합니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="클라이언트 로그" />
|
@await Html.PartialAsync("_ComingSoon", "클라이언트 로그")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,55 +4,64 @@
|
|||||||
ViewData["Title"] = "고객 등록";
|
ViewData["Title"] = "고객 등록";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("고객 관리", "고객 등록", "FluentValidation과 Tabler 표준 폼으로 신규 고객을 등록합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 등록", "FluentValidation과 Tabler 표준 폼으로 신규 고객을 등록합니다.", null, null))
|
||||||
|
|
||||||
<form method="post" class="card shadow-sm border-0">
|
<form method="post" class="card">
|
||||||
<div class="card-body">
|
<div class="card-body p-2">
|
||||||
<partial name="_ValidationSummary" />
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
<div class="row g-3">
|
<div class="row g-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.Name" class="form-label"></label>
|
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Name" class="form-control" />
|
<input asp-for="Input.Name" class="form-control form-control-sm" />
|
||||||
<span asp-validation-for="Input.Name" class="text-danger small"></span>
|
<span asp-validation-for="Input.Name" class="text-danger small"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.CompanyName" class="form-label"></label>
|
<label asp-for="Input.CompanyName" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.CompanyName" class="form-control" />
|
<input asp-for="Input.CompanyName" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.Phone" class="form-label"></label>
|
<label asp-for="Input.Phone" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Phone" class="form-control" />
|
<input asp-for="Input.Phone" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.Email" class="form-label"></label>
|
<label asp-for="Input.Email" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Email" class="form-control" />
|
<input asp-for="Input.Email" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="Input.Status" class="form-label"></label>
|
<label asp-for="Input.Status" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Status" class="form-control" />
|
<input asp-for="Input.Status" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="Input.ServiceType" class="form-label"></label>
|
<label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.ServiceType" class="form-control" />
|
<input asp-for="Input.ServiceType" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="Input.TaxType" class="form-label"></label>
|
<label asp-for="Input.TaxType" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.TaxType" class="form-control" />
|
<input asp-for="Input.TaxType" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label asp-for="Input.Source" class="form-label"></label>
|
<label asp-for="Input.Source" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Source" class="form-control" />
|
<input asp-for="Input.Source" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label asp-for="Input.Memo" class="form-label"></label>
|
<label asp-for="Input.Memo" class="form-label form-label-sm"></label>
|
||||||
<textarea asp-for="Input.Memo" class="form-control" rows="5"></textarea>
|
<textarea asp-for="Input.Memo" class="form-control form-control-sm" rows="5"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white d-flex justify-content-end gap-2">
|
<div class="card-footer d-flex justify-content-end gap-2">
|
||||||
<a class="btn btn-outline-secondary" href="/admin/clients">취소</a>
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/clients">취소</a>
|
||||||
<button class="btn btn-primary" type="submit">저장</button>
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
@page "{id:int}"
|
@page "/admin/clients/{id:int}"
|
||||||
@model TaxBaik.Web.Pages.Admin.Clients.DetailModel
|
@model TaxBaik.Web.Pages.Admin.Clients.DetailModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "고객 상세";
|
ViewData["Title"] = "고객 상세";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("고객 관리", "고객 상세", "고객의 기본 정보와 수정 경로를 확인합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 상세", "고객의 기본 정보와 수정 경로를 확인합니다.", null, null))
|
||||||
|
|
||||||
@if (Model.Client is null)
|
@if (Model.Client is null)
|
||||||
{
|
{
|
||||||
<partial name="_EmptyState" model="조회한 고객을 찾을 수 없습니다." />
|
@await Html.PartialAsync("_EmptyState", "조회한 고객을 찾을 수 없습니다.")
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row mb-0">
|
<dl class="row details-grid mb-0">
|
||||||
<dt class="col-sm-3">고객명</dt>
|
<dt class="col-sm-3">고객명</dt>
|
||||||
<dd class="col-sm-9">@Model.Client.Name</dd>
|
<dd class="col-sm-9">@Model.Client.Name</dd>
|
||||||
<dt class="col-sm-3">회사명</dt>
|
<dt class="col-sm-3">회사명</dt>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<dt class="col-sm-3">이메일</dt>
|
<dt class="col-sm-3">이메일</dt>
|
||||||
<dd class="col-sm-9">@Model.Client.Email</dd>
|
<dd class="col-sm-9">@Model.Client.Email</dd>
|
||||||
<dt class="col-sm-3">상태</dt>
|
<dt class="col-sm-3">상태</dt>
|
||||||
<dd class="col-sm-9"><partial name="_StatusBadge" model="Model.Client.Status" /></dd>
|
<dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Client.Status)</dd>
|
||||||
<dt class="col-sm-3">서비스 유형</dt>
|
<dt class="col-sm-3">서비스 유형</dt>
|
||||||
<dd class="col-sm-9">@Model.Client.ServiceType</dd>
|
<dd class="col-sm-9">@Model.Client.ServiceType</dd>
|
||||||
<dt class="col-sm-3">세금 유형</dt>
|
<dt class="col-sm-3">세금 유형</dt>
|
||||||
@@ -45,13 +45,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card">
|
||||||
<div class="card-body d-grid gap-2">
|
<div class="card-body p-2">
|
||||||
<a class="btn btn-primary" href="/admin/clients/@Model.Client.Id/edit">수정</a>
|
<div class="d-flex gap-2">
|
||||||
<a class="btn btn-outline-secondary" href="/admin/clients">목록</a>
|
<a class="btn btn-primary btn-sm flex-fill" href="/admin/clients/@Model.Client.Id/edit">수정</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm flex-fill" href="/admin/clients">목록</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,87 @@
|
|||||||
@page "{id:int}"
|
@page "/admin/clients/{id:int}/edit"
|
||||||
@model TaxBaik.Web.Pages.Admin.Clients.EditModel
|
@model TaxBaik.Web.Pages.Admin.Clients.EditModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "고객 수정";
|
ViewData["Title"] = "고객 수정";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("고객 관리", "고객 수정", "기존 고객 정보를 불러와 Tabler 표준 폼으로 수정합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 수정", "기존 고객 정보를 불러와 Tabler 표준 폼으로 수정합니다.", null, null))
|
||||||
|
|
||||||
<form method="post" class="card shadow-sm border-0">
|
<div class="card mb-2">
|
||||||
<div class="card-body">
|
<div class="card-body p-2">
|
||||||
<partial name="_ValidationSummary" />
|
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
|
||||||
<div class="row g-3">
|
<div class="fw-semibold">@Model.Input.Name</div>
|
||||||
|
<div class="text-muted small">@Model.Input.CompanyName / @Model.Input.Phone</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
|
<div class="row g-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.Name" class="form-label"></label>
|
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Name" class="form-control" />
|
<input asp-for="Input.Name" class="form-control form-control-sm" />
|
||||||
<span asp-validation-for="Input.Name" class="text-danger small"></span>
|
<span asp-validation-for="Input.Name" class="text-danger small"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.CompanyName" class="form-label"></label>
|
<label asp-for="Input.CompanyName" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.CompanyName" class="form-control" />
|
<input asp-for="Input.CompanyName" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.Phone" class="form-label"></label>
|
<label asp-for="Input.Phone" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Phone" class="form-control" />
|
<input asp-for="Input.Phone" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Input.Email" class="form-label"></label>
|
<label asp-for="Input.Email" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Email" class="form-control" />
|
<input asp-for="Input.Email" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="Input.Status" class="form-label"></label>
|
<label asp-for="Input.Status" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Status" class="form-control" />
|
<input asp-for="Input.Status" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="Input.ServiceType" class="form-label"></label>
|
<label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.ServiceType" class="form-control" />
|
<input asp-for="Input.ServiceType" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="Input.TaxType" class="form-label"></label>
|
<label asp-for="Input.TaxType" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.TaxType" class="form-control" />
|
<input asp-for="Input.TaxType" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label asp-for="Input.Source" class="form-label"></label>
|
<label asp-for="Input.Source" class="form-label form-label-sm"></label>
|
||||||
<input asp-for="Input.Source" class="form-control" />
|
<input asp-for="Input.Source" class="form-control form-control-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label asp-for="Input.Memo" class="form-label"></label>
|
<label asp-for="Input.Memo" class="form-label form-label-sm"></label>
|
||||||
<textarea asp-for="Input.Memo" class="form-control" rows="5"></textarea>
|
<textarea asp-for="Input.Memo" class="form-control form-control-sm" rows="5"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white d-flex justify-content-end gap-2">
|
<div class="card-footer d-flex justify-content-end gap-2">
|
||||||
<a class="btn btn-outline-secondary" href="/admin/clients/@Model.Id">취소</a>
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/clients/@Model.Id">취소</a>
|
||||||
<button class="btn btn-primary" type="submit">저장</button>
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form method="post" asp-page-handler="Delete" class="mt-3" onsubmit="return confirm('정말 삭제하시겠습니까?');">
|
<div class="card mt-2">
|
||||||
<button class="btn btn-outline-danger" type="submit">삭제</button>
|
<div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
|
||||||
</form>
|
<div>
|
||||||
|
<div class="text-muted small text-uppercase">위험 작업</div>
|
||||||
|
<div class="fw-semibold small">고객을 삭제합니다.</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Delete" onsubmit="return confirm('정말 삭제하시겠습니까?');">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">삭제</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,83 +4,93 @@
|
|||||||
ViewData["Title"] = "고객 관리";
|
ViewData["Title"] = "고객 관리";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("고객 관리", "고객 목록", "검색, 상태 필터, 페이지네이션 기준의 고객 목록입니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 목록", "검색, 상태 필터, 페이지네이션 기준의 고객 목록입니다.", "/admin/clients/create", "고객 등록"))
|
||||||
|
|
||||||
<form method="get" class="card shadow-sm border-0 mb-4">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body p-2" style="min-height: auto;">
|
||||||
<div class="row g-3 align-items-end">
|
<form method="get">
|
||||||
<div class="col-md-4">
|
<div class="row g-2 align-items-end">
|
||||||
<label class="form-label" for="Search">검색</label>
|
<div class="col-md-4">
|
||||||
<input class="form-control" id="Search" name="search" value="@Model.Search" placeholder="이름, 전화, 회사명" />
|
<label class="form-label form-label-sm" for="Search">검색</label>
|
||||||
|
<input class="form-control form-control-sm" id="Search" name="search" value="@Model.Search" placeholder="이름, 전화, 회사" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label form-label-sm" for="Status">상태</label>
|
||||||
|
<select class="form-select form-select-sm" id="Status" name="status">
|
||||||
|
<option value="" selected="@(string.IsNullOrWhiteSpace(Model.Status))">전체</option>
|
||||||
|
<option value="active" selected="@(Model.Status == "active")">active</option>
|
||||||
|
<option value="inactive" selected="@(Model.Status == "inactive")">inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label form-label-sm" for="PageSize">건수</label>
|
||||||
|
<select class="form-select form-select-sm" id="PageSize" name="pageSize">
|
||||||
|
<option value="10" selected="@(Model.PageSize == 10)">10</option>
|
||||||
|
<option value="20" selected="@(Model.PageSize == 20)">20</option>
|
||||||
|
<option value="50" selected="@(Model.PageSize == 50)">50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">검색</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/clients">초기화</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
</form>
|
||||||
<label class="form-label" for="Status">상태</label>
|
|
||||||
<select class="form-select" id="Status" name="status">
|
|
||||||
<option value="" selected="@(string.IsNullOrWhiteSpace(Model.Status))">전체</option>
|
|
||||||
<option value="active" selected="@(Model.Status == "active")">active</option>
|
|
||||||
<option value="inactive" selected="@(Model.Status == "inactive")">inactive</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label" for="PageSize">페이지 크기</label>
|
|
||||||
<select class="form-select" id="PageSize" name="pageSize">
|
|
||||||
<option value="10" selected="@(Model.PageSize == 10)">10</option>
|
|
||||||
<option value="20" selected="@(Model.PageSize == 20)">20</option>
|
|
||||||
<option value="50" selected="@(Model.PageSize == 50)">50</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 d-grid d-sm-flex gap-2">
|
|
||||||
<button class="btn btn-primary" type="submit">검색</button>
|
|
||||||
<a class="btn btn-outline-secondary" href="/admin/clients">초기화</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
@if (Model.Items.Count > 0)
|
||||||
|
{
|
||||||
<div class="card shadow-sm border-0">
|
<div class="table-responsive table-responsive--separator">
|
||||||
<div class="table-responsive">
|
<table class="table table-vcenter card-table table-hover">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<thead>
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>고객명</th>
|
|
||||||
<th>회사명</th>
|
|
||||||
<th>전화</th>
|
|
||||||
<th>상태</th>
|
|
||||||
<th>등록일</th>
|
|
||||||
<th class="text-end">작업</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@if (Model.Items.Count == 0)
|
|
||||||
{
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6">
|
<th>고객명</th>
|
||||||
<partial name="_EmptyState" model="검색 결과가 없습니다." />
|
<th>회사명</th>
|
||||||
</td>
|
<th>전화</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>등록일</th>
|
||||||
|
<th class="text-end">작업</th>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
</thead>
|
||||||
else
|
<tbody>
|
||||||
{
|
@foreach (var item in Model.Items)
|
||||||
foreach (var item in Model.Items)
|
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-semibold">@item.Name</td>
|
<td class="fw-semibold">@item.Name</td>
|
||||||
<td>@item.CompanyName</td>
|
<td>@item.CompanyName</td>
|
||||||
<td>@item.Phone</td>
|
<td>@item.Phone</td>
|
||||||
<td><partial name="_StatusBadge" model="item.Status" /></td>
|
<td>@await Html.PartialAsync("_StatusBadge", item.Status)</td>
|
||||||
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
|
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="/admin/clients/@item.Id">상세</a>
|
<div class="btn-list flex-nowrap justify-content-end">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="/admin/clients/@item.Id/edit">수정</a>
|
<a class="btn btn-sm btn-outline-secondary" href="/admin/clients/@item.Id">상세</a>
|
||||||
|
<a class="btn btn-sm btn-primary" href="/admin/clients/@item.Id/edit">수정</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card-body">
|
||||||
|
@await Html.PartialAsync("_EmptyState", "검색 결과가 없습니다.")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<partial name="_Pagination" model="new TaxBaik.Web.Pages.Shared.PaginationModel(Model.CurrentPage, Model.TotalPages, Model.BuildPageUrl)" />
|
@await Html.PartialAsync("_Pagination", new TaxBaik.Web.Pages.Shared.PaginationModel(Model.CurrentPage, Model.TotalPages, Model.BuildPageUrl))
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.CommonCodes.IndexModel
|
@model TaxBaik.Web.Pages.Admin.CommonCodes.IndexModel
|
||||||
@{ ViewData["Title"] = "공통코드"; }
|
@{ ViewData["Title"] = "공통코드"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("운영 설정", "공통 코드", "코드성 데이터를 표준화해 관리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 설정", "공통 코드", "코드성 데이터를 표준화해 관리합니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="공통 코드" />
|
@await Html.PartialAsync("_ComingSoon", "공통 코드")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,33 +2,46 @@
|
|||||||
@model TaxBaik.Web.Pages.Admin.Companies.IndexModel
|
@model TaxBaik.Web.Pages.Admin.Companies.IndexModel
|
||||||
@{ ViewData["Title"] = "회사"; }
|
@{ ViewData["Title"] = "회사"; }
|
||||||
|
|
||||||
<section class="container-fluid py-4">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("CRM", "회사 관리", "고객사 회사를 관리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("CRM", "회사 관리", "고객사 회사를 관리합니다.", null, null))
|
||||||
<div class="card">
|
@if (Model.Items.Count == 0)
|
||||||
<div class="table-responsive">
|
{
|
||||||
<table class="table table-vcenter card-table">
|
<div class="card">
|
||||||
<thead>
|
<div class="card-body p-2">
|
||||||
<tr><th>코드</th><th>회사명</th><th>담당자</th><th>활성</th></tr>
|
@await Html.PartialAsync("_EmptyState", "회사가 없습니다.")
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</div>
|
||||||
@if (Model.Items.Count == 0)
|
}
|
||||||
{
|
else
|
||||||
<tr><td colspan="4"><partial name="_EmptyState" model="회사가 없습니다." /></td></tr>
|
{
|
||||||
}
|
<div class="card">
|
||||||
else
|
<div class="table-responsive table-responsive--separator">
|
||||||
{
|
<table class="table table-vcenter card-table">
|
||||||
foreach (var item in Model.Items)
|
<thead>
|
||||||
|
<tr><th>코드</th><th>회사명</th><th>담당자</th><th>활성</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@item.CompanyCode</td>
|
<td>@item.CompanyCode</td>
|
||||||
<td>@item.CompanyName</td>
|
<td>@item.CompanyName</td>
|
||||||
<td>@item.ContactPerson</td>
|
<td>@item.ContactPerson</td>
|
||||||
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td>
|
<td>@await Html.PartialAsync("_StatusBadge", item.IsActive ? "active" : "inactive")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.ConsultingActivities.IndexModel
|
@model TaxBaik.Web.Pages.Admin.ConsultingActivities.IndexModel
|
||||||
@{ ViewData["Title"] = "상담 활동"; }
|
@{ ViewData["Title"] = "상담 활동"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("세무 운영", "상담 활동", "상담 히스토리와 후속 조치를 관리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "상담 활동", "상담 히스토리와 후속 조치를 관리합니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="상담 활동" />
|
@await Html.PartialAsync("_ComingSoon", "상담 활동")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.Contracts.IndexModel
|
@model TaxBaik.Web.Pages.Admin.Contracts.IndexModel
|
||||||
@{ ViewData["Title"] = "계약"; }
|
@{ ViewData["Title"] = "계약"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("세무 운영", "계약 관리", "계약 상태와 만료일을 추적합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "계약 관리", "계약 상태와 만료일을 추적합니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="계약 관리" />
|
@await Html.PartialAsync("_ComingSoon", "계약 관리")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.Customers.CreateModel
|
@model TaxBaik.Web.Pages.Admin.Customers.CreateModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
@page "{id:int}"
|
@page "/admin/customers/{id:int}"
|
||||||
@model TaxBaik.Web.Pages.Admin.Customers.DetailModel
|
@model TaxBaik.Web.Pages.Admin.Customers.DetailModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
@page "{id:int}"
|
@page "/admin/customers/{id:int}/edit"
|
||||||
@model TaxBaik.Web.Pages.Admin.Customers.EditModel
|
@model TaxBaik.Web.Pages.Admin.Customers.EditModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.Customers.IndexModel
|
@model TaxBaik.Web.Pages.Admin.Customers.IndexModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,115 +6,153 @@
|
|||||||
|
|
||||||
<section class="admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<div class="page-header d-print-none mb-4">
|
<div class="page-header d-print-none mb-4">
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center g-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="page-pretitle">TaxBaik Admin</div>
|
<div class="page-pretitle text-uppercase font-numeric text-primary fw-semibold" style="letter-spacing: 1px;">TAXBAIK ADMIN SYSTEM</div>
|
||||||
<h2 class="page-title">대시보드</h2>
|
<h2 class="page-title text-dark fw-bold h2 mb-1">대시보드</h2>
|
||||||
<p class="text-muted mb-0">관리자 업무 흐름의 시작점입니다.</p>
|
<p class="text-muted small mb-0">관리자 업무 흐름을 관리하는 중앙 컨트롤 타워입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto ms-auto">
|
<div class="col-auto ms-auto">
|
||||||
<div class="text-end small text-muted">
|
<div class="card shadow-sm border-0 bg-white px-3 py-2 rounded-3 d-flex align-items-center flex-row gap-2">
|
||||||
<div>로그인 사용자</div>
|
<span class="avatar avatar-sm rounded bg-primary-lt text-primary"><i class="bi bi-person-fill"></i></span>
|
||||||
<div class="fw-semibold text-body">@User.Identity?.Name</div>
|
<div>
|
||||||
</div>
|
<div class="text-muted small" style="font-size: 0.7rem; line-height: 1;">접속 계정</div>
|
||||||
</div>
|
<div class="fw-semibold text-dark font-numeric" style="font-size: 0.85rem;">@User.Identity?.Name</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row row-deck row-cards g-3 mb-4">
|
|
||||||
<div class="col-md-6 col-xl-3">
|
|
||||||
<div class="card card-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase mb-1">고객 관리</div>
|
|
||||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="h4 mb-1">고객 목록</h2>
|
|
||||||
<p class="text-muted mb-0">검색/상세/수정/삭제</p>
|
|
||||||
</div>
|
|
||||||
<span class="avatar avatar-rounded bg-primary-lt text-primary">01</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-xl-3">
|
|
||||||
<div class="card card-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase mb-1">DB 관리</div>
|
|
||||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="h4 mb-1">Flyway</h2>
|
|
||||||
<p class="text-muted mb-0">SQL migration 추적</p>
|
|
||||||
</div>
|
|
||||||
<span class="avatar avatar-rounded bg-indigo-lt text-indigo">02</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-xl-3">
|
|
||||||
<div class="card card-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase mb-1">인증</div>
|
|
||||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="h4 mb-1">Cookie Auth</h2>
|
|
||||||
<p class="text-muted mb-0">서버 세션 유지</p>
|
|
||||||
</div>
|
|
||||||
<span class="avatar avatar-rounded bg-green-lt text-green">03</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-xl-3">
|
|
||||||
<div class="card card-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase mb-1">콘텐츠</div>
|
|
||||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="h4 mb-1">블로그 편집</h2>
|
|
||||||
<p class="text-muted mb-0">에디터 + 미리보기</p>
|
|
||||||
</div>
|
|
||||||
<span class="avatar avatar-rounded bg-yellow-lt text-yellow">04</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row row-deck row-cards g-3">
|
<!-- Quick Stats Grid -->
|
||||||
<div class="col-12 col-xl-8">
|
<div class="row g-3 mb-4">
|
||||||
<div class="card">
|
<div class="col-md-6 col-xl-3">
|
||||||
<div class="card-header">
|
<div class="card shadow-sm border-0 rounded-3">
|
||||||
<h3 class="card-title">빠른 작업</h3>
|
<div class="card-body p-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<span class="text-muted small text-uppercase fw-semibold" style="letter-spacing: 0.5px;">고객 관리</span>
|
||||||
|
<span class="avatar avatar-md rounded-circle bg-blue-lt text-blue"><i class="bi bi-people-fill"></i></span>
|
||||||
|
</div>
|
||||||
|
<h3 class="fw-bold text-dark mb-1 font-numeric">고객 목록</h3>
|
||||||
|
<p class="text-muted small mb-0">전체 고객 및 고객사 정보 통합 관리</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<div class="card shadow-sm border-0 rounded-3">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<span class="text-muted small text-uppercase fw-semibold" style="letter-spacing: 0.5px;">데이터베이스</span>
|
||||||
|
<span class="avatar avatar-md rounded-circle bg-indigo-lt text-indigo"><i class="bi bi-database-fill-check"></i></span>
|
||||||
|
</div>
|
||||||
|
<h3 class="fw-bold text-dark mb-1 font-numeric">DbUp Migration</h3>
|
||||||
|
<p class="text-muted small mb-0">SQL 스키마 형상 버전 실시간 추적</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<div class="card shadow-sm border-0 rounded-3">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<span class="text-muted small text-uppercase fw-semibold" style="letter-spacing: 0.5px;">보안 및 세션</span>
|
||||||
|
<span class="avatar avatar-md rounded-circle bg-green-lt text-green"><i class="bi bi-shield-lock-fill"></i></span>
|
||||||
|
</div>
|
||||||
|
<h3 class="fw-bold text-dark mb-1 font-numeric">Cookie Auth</h3>
|
||||||
|
<p class="text-muted small mb-0">서버 사이드 세션 무결성 보호</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<div class="card shadow-sm border-0 rounded-3">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<span class="text-muted small text-uppercase fw-semibold" style="letter-spacing: 0.5px;">콘텐츠 관리</span>
|
||||||
|
<span class="avatar avatar-md rounded-circle bg-yellow-lt text-yellow"><i class="bi bi-journal-text"></i></span>
|
||||||
|
</div>
|
||||||
|
<h3 class="fw-bold text-dark mb-1 font-numeric">블로그 & 공지</h3>
|
||||||
|
<p class="text-muted small mb-0">웹 에디터를 통한 정보 업데이트</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Section Grid -->
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm border-0 rounded-3">
|
||||||
|
<div class="card-header bg-transparent py-3 border-light">
|
||||||
|
<h3 class="card-title text-dark fw-bold mb-0" style="font-size: 1rem;">
|
||||||
|
<i class="bi bi-lightning-charge text-primary me-2"></i> 바로가기 링크
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/customers">
|
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 px-4 border-0 border-bottom" href="/admin/customers">
|
||||||
<span>고객 목록으로 이동</span>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span class="text-muted">관리</span>
|
<span class="avatar avatar-xs bg-primary-lt text-primary rounded"><i class="bi bi-people"></i></span>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold text-dark">고객 정보 조회</div>
|
||||||
|
<div class="text-muted small">등록된 개인 고객의 기본 상세 데이터 보기</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary px-2.5 py-1.5 fs-8 fw-semibold">바로가기</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/blog">
|
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 px-4 border-0 border-bottom" href="/admin/blog">
|
||||||
<span>블로그 글 작성</span>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span class="text-muted">에디터</span>
|
<span class="avatar avatar-xs bg-success-lt text-success rounded"><i class="bi bi-pencil-square"></i></span>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold text-dark">새 블로그 포스트 작성</div>
|
||||||
|
<div class="text-muted small">웹 에디터 모드 및 즉시 발행</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary px-2.5 py-1.5 fs-8 fw-semibold">바로가기</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/inquiries">
|
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 px-4" href="/admin/inquiries">
|
||||||
<span>문의 처리</span>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span class="text-muted">업무</span>
|
<span class="avatar avatar-xs bg-info-lt text-info rounded"><i class="bi bi-chat-left-dots"></i></span>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold text-dark">신규 문의 처리</div>
|
||||||
|
<div class="text-muted small">고객이 홈페이지를 통해 접수한 세무 관련 상담 대응</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary px-2.5 py-1.5 fs-8 fw-semibold">바로가기</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-xl-4">
|
<div class="col-lg-4">
|
||||||
<div class="card">
|
<div class="card shadow-sm border-0 rounded-3 bg-white h-100">
|
||||||
<div class="card-header">
|
<div class="card-header bg-transparent py-3 border-light">
|
||||||
<h3 class="card-title">운영 노트</h3>
|
<h3 class="card-title text-dark fw-bold mb-0" style="font-size: 1rem;">
|
||||||
|
<i class="bi bi-info-circle text-primary me-2"></i> 아키텍처 노하우
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<div class="alert alert-info mb-0">
|
<div class="alert alert-info d-flex align-items-start gap-2 mb-3" role="alert">
|
||||||
Razor Pages, Dapper, Flyway, Cookie Auth, Tabler 기준으로 서버 렌더링 중심의 어드민을 유지합니다.
|
<i class="bi bi-info-circle-fill text-info mt-1"></i>
|
||||||
|
<div class="small leading-relaxed text-muted">
|
||||||
|
본 시스템은 Razor Pages, Dapper, DbUp, Cookie Auth 및 Tabler CSS 라이브러리를 탑재한 서버 사이드 렌더링 프레임워크로 가볍고 안전합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-light border border-light-subtle rounded-3 p-3 mt-3">
|
||||||
|
<div class="fw-bold text-dark small mb-2"><i class="bi bi-shield-check text-success me-1"></i> 개발 보안 규칙</div>
|
||||||
|
<ul class="list-unstyled small text-muted mb-0 vstack gap-2">
|
||||||
|
<li><i class="bi bi-check-circle-fill text-primary me-1"></i> 애플리케이션 레이어 Audit 처리</li>
|
||||||
|
<li><i class="bi bi-check-circle-fill text-primary me-1"></i> DB 트리거(Trigger)의 사용 금지</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@page
|
||||||
|
@model TaxBaik.Web.Pages.Admin.Faqs.CreateModel
|
||||||
|
@{ ViewData["Title"] = "FAQ 작성"; }
|
||||||
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 작성", "Tabler 표준 폼으로 FAQ를 등록합니다.", null, null))
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<label asp-for="Input.Question" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Question" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label asp-for="Input.Category" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Category" class="form-control form-control-sm" list="faq-categories" />
|
||||||
|
<datalist id="faq-categories">
|
||||||
|
@foreach (var category in Model.Categories)
|
||||||
|
{
|
||||||
|
<option value="@category"></option>
|
||||||
|
}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.Answer" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.Answer" class="form-control form-control-sm" rows="10"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-check">
|
||||||
|
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label form-label-sm">활성</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-end gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/faqs">취소</a>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Faqs;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class CreateModel(FaqService faqService) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty]
|
||||||
|
public Faq Input { get; set; } = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Categories => FaqService.Categories;
|
||||||
|
|
||||||
|
public IActionResult OnGet()
|
||||||
|
{
|
||||||
|
Input.IsActive = true;
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Page();
|
||||||
|
|
||||||
|
await faqService.CreateAsync(Input, ct);
|
||||||
|
return RedirectToPage("/Admin/Faqs/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@page "/admin/faqs/{id:int}"
|
||||||
|
@model TaxBaik.Web.Pages.Admin.Faqs.DetailModel
|
||||||
|
@{ ViewData["Title"] = "FAQ 상세"; }
|
||||||
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 상세", "FAQ의 질문, 답변, 상태를 확인합니다.", null, null))
|
||||||
|
|
||||||
|
@if (Model.Item is null)
|
||||||
|
{
|
||||||
|
@await Html.PartialAsync("_EmptyState", "FAQ를 찾을 수 없습니다.")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row details-grid mb-0">
|
||||||
|
<dt class="col-sm-3">질문</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.Question</dd>
|
||||||
|
<dt class="col-sm-3">답변</dt>
|
||||||
|
<dd class="col-sm-9"><div class="pre-wrap">@Model.Item.Answer</div></dd>
|
||||||
|
<dt class="col-sm-3">카테고리</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.Category</dd>
|
||||||
|
<dt class="col-sm-3">활성</dt>
|
||||||
|
<dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Item.IsActive ? "active" : "inactive")</dd>
|
||||||
|
<dt class="col-sm-3">정렬</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Item.SortOrder</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-primary btn-sm flex-fill" href="/admin/faqs/@Model.Item.Id/edit">수정</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm flex-fill" href="/admin/faqs">목록</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Faqs;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class DetailModel(FaqService faqService) : PageModel
|
||||||
|
{
|
||||||
|
public Faq? Item { get; private set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Item = await faqService.GetByIdAsync(id, ct);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
@page "/admin/faqs/{id:int}/edit"
|
||||||
|
@model TaxBaik.Web.Pages.Admin.Faqs.EditModel
|
||||||
|
@{ ViewData["Title"] = "FAQ 수정"; }
|
||||||
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 수정", "기존 FAQ를 Tabler 표준 폼으로 수정합니다.", null, null))
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="card-header p-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
|
||||||
|
<div class="fw-semibold small">@Model.Input.Question</div>
|
||||||
|
<div class="text-muted small">카테고리: @Model.Input.Category</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary-lt text-secondary small">원본 확인 후 수정</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
|
<input type="hidden" asp-for="Input.Id" />
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<label asp-for="Input.Question" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Question" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label asp-for="Input.Category" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Category" class="form-control form-control-sm" list="faq-categories" />
|
||||||
|
<datalist id="faq-categories">
|
||||||
|
@foreach (var category in Model.Categories)
|
||||||
|
{
|
||||||
|
<option value="@category"></option>
|
||||||
|
}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.Answer" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.Answer" class="form-control form-control-sm" rows="10"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-check">
|
||||||
|
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label form-label-sm">활성</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between gap-2">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit" formaction="?handler=Delete" formmethod="post" onclick="return confirm('정말 삭제하시겠습니까?');">삭제</button>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/faqs">취소</a>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Faqs;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class EditModel(FaqService faqService) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public Faq Input { get; set; } = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Categories => FaqService.Categories;
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var faq = await faqService.GetByIdAsync(Id, ct);
|
||||||
|
if (faq is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
Input = new Faq
|
||||||
|
{
|
||||||
|
Id = faq.Id,
|
||||||
|
Question = faq.Question,
|
||||||
|
Answer = faq.Answer,
|
||||||
|
Category = faq.Category,
|
||||||
|
SortOrder = faq.SortOrder,
|
||||||
|
IsActive = faq.IsActive
|
||||||
|
};
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Page();
|
||||||
|
|
||||||
|
await faqService.UpdateAsync(Input, ct);
|
||||||
|
return RedirectToPage("/Admin/Faqs/Index");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await faqService.DeleteAsync(Id, ct);
|
||||||
|
return RedirectToPage("/Admin/Faqs/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,33 +2,52 @@
|
|||||||
@model TaxBaik.Web.Pages.Admin.Faqs.IndexModel
|
@model TaxBaik.Web.Pages.Admin.Faqs.IndexModel
|
||||||
@{ ViewData["Title"] = "FAQ"; }
|
@{ ViewData["Title"] = "FAQ"; }
|
||||||
|
|
||||||
<section class="container-fluid py-4">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("Support", "FAQ 관리", "질문과 답변을 관리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 관리", "질문과 답변을 관리합니다.", "/admin/faqs/create", "FAQ 작성"))
|
||||||
<div class="card">
|
@if (Model.Items.Count == 0)
|
||||||
<div class="table-responsive">
|
{
|
||||||
<table class="table table-vcenter card-table">
|
<div class="card">
|
||||||
<thead>
|
<div class="card-body p-2">
|
||||||
<tr><th>질문</th><th>카테고리</th><th>활성</th><th>정렬</th></tr>
|
@await Html.PartialAsync("_EmptyState", "FAQ가 없습니다.")
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</div>
|
||||||
@if (Model.Items.Count == 0)
|
}
|
||||||
{
|
else
|
||||||
<tr><td colspan="4"><partial name="_EmptyState" model="FAQ가 없습니다." /></td></tr>
|
{
|
||||||
}
|
<div class="card">
|
||||||
else
|
<div class="table-responsive table-responsive--separator">
|
||||||
{
|
<table class="table table-vcenter card-table table-hover">
|
||||||
foreach (var item in Model.Items)
|
<thead>
|
||||||
|
<tr><th>질문</th><th>카테고리</th><th>활성</th><th>정렬</th><th class="text-end">작업</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@item.Question</td>
|
<td>@item.Question</td>
|
||||||
<td>@item.Category</td>
|
<td>@item.Category</td>
|
||||||
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td>
|
<td>@await Html.PartialAsync("_StatusBadge", item.IsActive ? "active" : "inactive")</td>
|
||||||
<td>@item.SortOrder</td>
|
<td>@item.SortOrder</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-list flex-nowrap justify-content-end">
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/admin/faqs/@item.Id">상세</a>
|
||||||
|
<a class="btn btn-sm btn-primary" href="/admin/faqs/@item.Id/edit">수정</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,25 +4,62 @@
|
|||||||
ViewData["Title"] = "관리자 홈";
|
ViewData["Title"] = "관리자 홈";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container py-5">
|
<section class="admin-page-shell">
|
||||||
<div class="row align-items-center g-4">
|
<div class="page-header d-print-none mb-4">
|
||||||
<div class="col-lg-8">
|
<div class="row align-items-center">
|
||||||
<p class="text-uppercase text-muted small mb-2">TaxBaik Admin</p>
|
<div class="col">
|
||||||
<h1 class="display-6 fw-bold mb-3">고객관리 어드민</h1>
|
<div class="page-pretitle">TaxBaik Admin</div>
|
||||||
<p class="lead text-muted mb-0">
|
<h2 class="page-title">고객관리 어드민</h2>
|
||||||
Razor Pages + Cookie Authentication + Dapper + Flyway 기준으로 정리하는 서버 렌더링 어드민입니다.
|
<p class="text-muted mb-0">Razor Pages + Cookie Authentication + Dapper + DbUp 기준으로 정리하는 서버 렌더링 어드민입니다.</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
</div>
|
||||||
<div class="card shadow-sm border-0">
|
|
||||||
<div class="card-body p-4">
|
<div class="row row-deck row-cards g-3">
|
||||||
<div class="d-grid gap-2">
|
<div class="col-md-6 col-xl-4">
|
||||||
<a class="btn btn-dark" href="/admin/dashboard">대시보드</a>
|
<div class="card card-sm">
|
||||||
<a class="btn btn-outline-secondary" href="/admin/login?returnUrl=%2Fadmin%2Fdashboard">다시 로그인</a>
|
<div class="card-body">
|
||||||
</div>
|
<div class="text-muted small text-uppercase mb-1">진입</div>
|
||||||
|
<h3 class="h5 mb-2">대시보드</h3>
|
||||||
|
<p class="text-muted mb-0">업무 시작점과 주요 상태를 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="btn btn-primary w-100" href="/admin/dashboard">대시보드 열기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xl-4">
|
||||||
|
<div class="card card-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small text-uppercase mb-1">세션</div>
|
||||||
|
<h3 class="h5 mb-2">인증 상태</h3>
|
||||||
|
<p class="text-muted mb-0">쿠키 인증과 배포 간 유지성을 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="btn btn-outline-secondary w-100" href="/admin/login?returnUrl=%2Fadmin%2Fdashboard">다시 로그인</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xl-4">
|
||||||
|
<div class="card card-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small text-uppercase mb-1">구조</div>
|
||||||
|
<h3 class="h5 mb-2">Tabler UI</h3>
|
||||||
|
<p class="text-muted mb-0">공통 카드, 테이블, 배지, 폼을 표준화합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="badge bg-blue-lt text-blue">Server Rendered</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,56 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.Inquiries.CreateModel
|
@model TaxBaik.Web.Pages.Admin.Inquiries.CreateModel
|
||||||
@{ ViewData["Title"] = "문의 등록"; }
|
@{ ViewData["Title"] = "문의 등록"; }
|
||||||
<section class="container py-5"><h1 class="h2 fw-bold">문의 등록</h1></section>
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 등록", "관리자가 직접 상담 요청을 등록할 수 있습니다.", null, null))
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Name" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Phone" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Phone" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Email" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Email" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.ServiceType" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.Message" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.Message" class="form-control form-control-sm" rows="8"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Status" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Status" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.AdminMemo" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.AdminMemo" class="form-control form-control-sm" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-end gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/inquiries">취소</a>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,44 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
using TaxBaik.Web.Services;
|
using TaxBaik.Web.Services;
|
||||||
namespace TaxBaik.Web.Pages.Admin.Inquiries;
|
|
||||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
|
||||||
public class CreateModel : PageModel { }
|
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Inquiries;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class CreateModel(InquiryService inquiryService) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty]
|
||||||
|
public UpdateInquiryDto Input { get; set; } = new();
|
||||||
|
|
||||||
|
public IActionResult OnGet()
|
||||||
|
{
|
||||||
|
Input.Status = "new";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Page();
|
||||||
|
|
||||||
|
var inquiryId = await inquiryService.SubmitAsync(
|
||||||
|
Input.Name,
|
||||||
|
Input.Phone,
|
||||||
|
string.IsNullOrWhiteSpace(Input.ServiceType) ? "기타" : Input.ServiceType,
|
||||||
|
Input.Message,
|
||||||
|
Input.Email,
|
||||||
|
suppressNotification: true,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Input.Status))
|
||||||
|
await inquiryService.UpdateStatusAsync(inquiryId, Input.Status, changedBy: User.Identity?.Name, ct: ct);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Input.AdminMemo))
|
||||||
|
await inquiryService.UpdateAdminMemoAsync(inquiryId, Input.AdminMemo, ct);
|
||||||
|
|
||||||
|
return RedirectToPage("/Admin/Inquiries/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,65 @@
|
|||||||
@page "{id:int}"
|
@page "/admin/inquiries/{id:int}"
|
||||||
@model TaxBaik.Web.Pages.Admin.Inquiries.DetailModel
|
@model TaxBaik.Web.Pages.Admin.Inquiries.DetailModel
|
||||||
@{ ViewData["Title"] = "문의 상세"; }
|
@{ ViewData["Title"] = "문의 상세"; }
|
||||||
<section class="container py-5"><h1 class="h2 fw-bold">문의 상세</h1></section>
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 상세", "문의 요청과 처리 내역을 확인합니다.", null, null))
|
||||||
|
|
||||||
|
@if (Model.Inquiry is null)
|
||||||
|
{
|
||||||
|
@await Html.PartialAsync("_EmptyState", "문의 정보를 찾을 수 없습니다.")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row details-grid mb-0">
|
||||||
|
<dt class="col-sm-3">이름</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Inquiry.Name</dd>
|
||||||
|
<dt class="col-sm-3">전화</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Inquiry.Phone</dd>
|
||||||
|
<dt class="col-sm-3">이메일</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Inquiry.Email</dd>
|
||||||
|
<dt class="col-sm-3">서비스</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Inquiry.ServiceType</dd>
|
||||||
|
<dt class="col-sm-3">상태</dt>
|
||||||
|
<dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Inquiry.Status)</dd>
|
||||||
|
<dt class="col-sm-3">메시지</dt>
|
||||||
|
<dd class="col-sm-9"><div class="pre-wrap">@Model.Inquiry.Message</div></dd>
|
||||||
|
<dt class="col-sm-3">관리 메모</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Inquiry.AdminMemo</dd>
|
||||||
|
<dt class="col-sm-3">등록일</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Inquiry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</dd>
|
||||||
|
<dt class="col-sm-3">수정일</dt>
|
||||||
|
<dd class="col-sm-9">@Model.Inquiry.UpdatedAt?.ToString("yyyy-MM-dd HH:mm")</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-flex gap-2 flex-column">
|
||||||
|
<a class="btn btn-primary btn-sm" href="/admin/inquiries/@Model.Inquiry.Id/edit">수정</a>
|
||||||
|
<form method="post" asp-page-handler="Delete" onsubmit="return confirm('정말 삭제하시겠습니까?');">
|
||||||
|
<button class="btn btn-outline-danger btn-sm w-100" type="submit">삭제</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/inquiries">목록</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Web.Services;
|
using TaxBaik.Web.Services;
|
||||||
namespace TaxBaik.Web.Pages.Admin.Inquiries;
|
|
||||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
|
||||||
public class DetailModel : PageModel { }
|
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Inquiries;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class DetailModel(InquiryService inquiryService) : PageModel
|
||||||
|
{
|
||||||
|
public Inquiry? Inquiry { get; private set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Inquiry = await inquiryService.GetByIdAsync(id, ct);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await inquiryService.DeleteAsync(id, ct);
|
||||||
|
return RedirectToPage("/Admin/Inquiries/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,68 @@
|
|||||||
@page "{id:int}"
|
@page "/admin/inquiries/{id:int}/edit"
|
||||||
@model TaxBaik.Web.Pages.Admin.Inquiries.EditModel
|
@model TaxBaik.Web.Pages.Admin.Inquiries.EditModel
|
||||||
@{ ViewData["Title"] = "문의 수정"; }
|
@{ ViewData["Title"] = "문의 수정"; }
|
||||||
<section class="container py-5"><h1 class="h2 fw-bold">문의 수정</h1></section>
|
|
||||||
|
<section class="admin-page-shell">
|
||||||
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 수정", "문의 내용을 수정하고 상태를 갱신합니다.", null, null))
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="card-header p-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
|
||||||
|
<div class="fw-semibold small">@Model.Input.Name</div>
|
||||||
|
<div class="text-muted small">@Model.Input.Phone / @Model.Input.ServiceType</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary-lt text-secondary small">원본 확인 후 수정</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
|
<input type="hidden" asp-for="Id" />
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Name" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Phone" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Phone" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Email" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Email" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.ServiceType" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Input.Status" class="form-label form-label-sm"></label>
|
||||||
|
<input asp-for="Input.Status" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.Message" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.Message" class="form-control form-control-sm" rows="8"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Input.AdminMemo" class="form-label form-label-sm"></label>
|
||||||
|
<textarea asp-for="Input.AdminMemo" class="form-control form-control-sm" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between gap-2">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit" formaction="?handler=Delete" formmethod="post" onclick="return confirm('정말 삭제하시겠습니까?');">삭제</button>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/admin/inquiries/@Model.Id">취소</a>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,56 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.DTOs;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
using TaxBaik.Web.Services;
|
using TaxBaik.Web.Services;
|
||||||
namespace TaxBaik.Web.Pages.Admin.Inquiries;
|
|
||||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
|
||||||
public class EditModel : PageModel { }
|
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Admin.Inquiries;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class EditModel(InquiryService inquiryService) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public UpdateInquiryDto Input { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var inquiry = await inquiryService.GetByIdAsync(Id, ct);
|
||||||
|
if (inquiry is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
Input = new UpdateInquiryDto
|
||||||
|
{
|
||||||
|
Name = inquiry.Name,
|
||||||
|
Phone = inquiry.Phone,
|
||||||
|
Email = inquiry.Email,
|
||||||
|
ServiceType = inquiry.ServiceType,
|
||||||
|
Message = inquiry.Message,
|
||||||
|
Status = inquiry.Status,
|
||||||
|
AdminMemo = inquiry.AdminMemo
|
||||||
|
};
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Page();
|
||||||
|
|
||||||
|
var updated = await inquiryService.UpdateAsync(Id, Input, ct);
|
||||||
|
if (updated is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return RedirectToPage("/Admin/Inquiries/Detail", new { id = Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await inquiryService.DeleteAsync(Id, ct);
|
||||||
|
return RedirectToPage("/Admin/Inquiries/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,53 +2,71 @@
|
|||||||
@model TaxBaik.Web.Pages.Admin.Inquiries.IndexModel
|
@model TaxBaik.Web.Pages.Admin.Inquiries.IndexModel
|
||||||
@{ ViewData["Title"] = "문의"; }
|
@{ ViewData["Title"] = "문의"; }
|
||||||
|
|
||||||
<section class="container-fluid py-4">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("Customer Requests", "문의 관리", "상담 요청을 상태별로 확인합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 관리", "상담 요청을 상태별로 확인합니다.", "/admin/inquiries/create", "문의 등록"))
|
||||||
<form method="get" class="card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-3 align-items-end">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="Status">상태</label>
|
|
||||||
<input class="form-control" id="Status" name="status" value="@Model.Status" placeholder="new / consulting / contracted ..." />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 d-grid">
|
|
||||||
<button class="btn btn-primary" type="submit">필터</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="table-responsive">
|
<div class="card-body p-2" style="min-height: auto;">
|
||||||
<table class="table table-vcenter card-table">
|
<form method="get">
|
||||||
<thead>
|
<div class="row g-2 align-items-end">
|
||||||
<tr>
|
<div class="col-md-3">
|
||||||
<th>이름</th>
|
<label class="form-label form-label-sm" for="Status">상태</label>
|
||||||
<th>전화</th>
|
<input class="form-control form-control-sm" id="Status" name="status" value="@Model.Status" placeholder="new, consulting..." />
|
||||||
<th>서비스</th>
|
</div>
|
||||||
<th>상태</th>
|
<div class="col-auto">
|
||||||
<th>등록일</th>
|
<button class="btn btn-primary btn-sm" type="submit">필터</button>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</form>
|
||||||
@if (Model.Items.Count == 0)
|
</div>
|
||||||
{
|
@if (Model.Items.Count > 0)
|
||||||
<tr><td colspan="5"><partial name="_EmptyState" model="문의 목록이 없습니다." /></td></tr>
|
{
|
||||||
}
|
<div class="table-responsive table-responsive--separator">
|
||||||
else
|
<table class="table table-vcenter card-table table-hover">
|
||||||
{
|
<thead>
|
||||||
foreach (var item in Model.Items)
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>전화</th>
|
||||||
|
<th>서비스</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>등록일</th>
|
||||||
|
<th class="text-end">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@item.Name</td>
|
<td>@item.Name</td>
|
||||||
<td>@item.Phone</td>
|
<td>@item.Phone</td>
|
||||||
<td>@item.ServiceType</td>
|
<td>@item.ServiceType</td>
|
||||||
<td><partial name="_StatusBadge" model="@(item.Status)" /></td>
|
<td>@await Html.PartialAsync("_StatusBadge", item.Status)</td>
|
||||||
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
|
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-list flex-nowrap justify-content-end">
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/admin/inquiries/@item.Id">상세</a>
|
||||||
|
<a class="btn btn-sm btn-primary" href="/admin/inquiries/@item.Id/edit">수정</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card-body">
|
||||||
|
@await Html.PartialAsync("_EmptyState", "문의 목록이 없습니다.")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,17 +50,32 @@ public class LoginModel(AuthService authService) : PageModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
|
public async Task<IActionResult> OnPostAsync([FromServices] IIpLockoutService lockoutService, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await authService.AuthenticateAsync(Username, Password);
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
|
||||||
|
if (lockoutService.IsLockedOut(ip))
|
||||||
|
{
|
||||||
|
ErrorMessage = "보안 정책에 의해 연속 로그인 실패로 인한 임시 접근이 제한되었습니다. 15분 후 다시 시도해 주세요.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await authService.AuthenticateAsync(Username, Password, ip);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
|
if (lockoutService.IsLockedOut(ip))
|
||||||
|
{
|
||||||
|
ErrorMessage = "로그인 실패 횟수가 초과하여 15분간 로그인이 제한됩니다.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
|
||||||
|
}
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<meta http-equiv="refresh" content="0;url=/admin/login" />
|
<meta http-equiv="refresh" content="0;url=/admin/login" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.RevenueTrackings.IndexModel
|
@model TaxBaik.Web.Pages.Admin.RevenueTrackings.IndexModel
|
||||||
@{ ViewData["Title"] = "매출 추적"; }
|
@{ ViewData["Title"] = "매출 추적"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("세무 운영", "매출 추적", "월별 매출 추이를 확인하는 분석 화면입니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "매출 추적", "월별 매출 추이를 확인하는 분석 화면입니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="매출 추적" />
|
@await Html.PartialAsync("_ComingSoon", "매출 추적")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.SeasonSimulator.IndexModel
|
@model TaxBaik.Web.Pages.Admin.SeasonSimulator.IndexModel
|
||||||
@{ ViewData["Title"] = "시즌 시뮬레이터"; }
|
@{ ViewData["Title"] = "시즌 시뮬레이터"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("운영 도구", "시즌 시뮬레이터", "정책 변경 시 영향을 미리 확인하는 도구입니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 도구", "시즌 시뮬레이터", "정책 변경 시 영향을 미리 확인하는 도구입니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="시즌 시뮬레이터" />
|
@await Html.PartialAsync("_ComingSoon", "시즌 시뮬레이터")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.Settings.IndexModel
|
@model TaxBaik.Web.Pages.Admin.Settings.IndexModel
|
||||||
@{ ViewData["Title"] = "설정"; }
|
@{ ViewData["Title"] = "설정"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("환경 설정", "설정", "시스템 기본값과 관리자 운영 정책을 정리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("환경 설정", "설정", "시스템 기본값과 관리자 운영 정책을 정리합니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="설정 기능" />
|
@await Html.PartialAsync("_ComingSoon", "설정 기능")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.TaxFilingSchedules.IndexModel
|
@model TaxBaik.Web.Pages.Admin.TaxFilingSchedules.IndexModel
|
||||||
@{ ViewData["Title"] = "신고 일정"; }
|
@{ ViewData["Title"] = "신고 일정"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("세무 운영", "세금 신고 일정", "세무 신고 마감 일정과 상태를 관리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "세금 신고 일정", "세무 신고 마감 일정과 상태를 관리합니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="세금 신고 일정" />
|
@await Html.PartialAsync("_ComingSoon", "세금 신고 일정")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
@page
|
@page
|
||||||
@model TaxBaik.Web.Pages.Admin.TaxFilings.IndexModel
|
@model TaxBaik.Web.Pages.Admin.TaxFilings.IndexModel
|
||||||
@{ ViewData["Title"] = "세금 신고"; }
|
@{ ViewData["Title"] = "세금 신고"; }
|
||||||
<section class="container admin-page-shell">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("세무 운영", "세금 신고", "신고 상태를 한 화면에서 관리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "세금 신고", "신고 상태를 한 화면에서 관리합니다.", null, null))
|
||||||
<partial name="_ComingSoon" model="세금 신고 관리" />
|
@await Html.PartialAsync("_ComingSoon", "세금 신고 관리")
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,33 +2,46 @@
|
|||||||
@model TaxBaik.Web.Pages.Admin.TaxProfiles.IndexModel
|
@model TaxBaik.Web.Pages.Admin.TaxProfiles.IndexModel
|
||||||
@{ ViewData["Title"] = "세무 프로필"; }
|
@{ ViewData["Title"] = "세무 프로필"; }
|
||||||
|
|
||||||
<section class="container-fluid py-4">
|
<section class="admin-page-shell">
|
||||||
<partial name="_AdminPageHeader" model='("Tax", "세무 프로필 관리", "고객별 세무 속성을 관리합니다.", null, null)' />
|
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Tax", "세무 프로필 관리", "고객별 세무 속성을 관리합니다.", null, null))
|
||||||
<div class="card">
|
@if (Model.Items.Count == 0)
|
||||||
<div class="table-responsive">
|
{
|
||||||
<table class="table table-vcenter card-table">
|
<div class="card">
|
||||||
<thead>
|
<div class="card-body p-2">
|
||||||
<tr><th>고객ID</th><th>사업유형</th><th>세무위험</th><th>다음신고일</th></tr>
|
@await Html.PartialAsync("_EmptyState", "세무 프로필이 없습니다.")
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</div>
|
||||||
@if (Model.Items.Count == 0)
|
}
|
||||||
{
|
else
|
||||||
<tr><td colspan="4"><partial name="_EmptyState" model="세무 프로필이 없습니다." /></td></tr>
|
{
|
||||||
}
|
<div class="card">
|
||||||
else
|
<div class="table-responsive table-responsive--separator">
|
||||||
{
|
<table class="table table-vcenter card-table">
|
||||||
foreach (var item in Model.Items)
|
<thead>
|
||||||
|
<tr><th>고객ID</th><th>사업유형</th><th>세무위험</th><th>다음신고일</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@item.ClientId</td>
|
<td>@item.ClientId</td>
|
||||||
<td>@item.BusinessType</td>
|
<td>@item.BusinessType</td>
|
||||||
<td><partial name="_StatusBadge" model="@(item.TaxRiskLevel ?? "normal")" /></td>
|
<td>@await Html.PartialAsync("_StatusBadge", item.TaxRiskLevel ?? "normal")</td>
|
||||||
<td>@item.NextFilingDueDate?.ToString("yyyy-MM-dd")</td>
|
<td>@item.NextFilingDueDate?.ToString("yyyy-MM-dd")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,123 +14,250 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>@(ViewData["Title"] ?? "TaxBaik Admin")</title>
|
<title>@(ViewData["Title"] ?? "TaxBaik Admin")</title>
|
||||||
<meta name="theme-color" content="#111827" />
|
<meta name="theme-color" content="#1F4E79" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
|
<!-- Tabler Core & Icons -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/css/tabler.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/css/tabler.min.css" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
|
||||||
|
|
||||||
|
<!-- Fast head theme load script to prevent flashing -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var theme = localStorage.getItem('tablerTheme') || 'light';
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="admin-app">
|
<body class="layout-fluid">
|
||||||
<div class="page admin-shell">
|
<div class="page">
|
||||||
<header class="navbar navbar-expand-md navbar-light d-print-none admin-topbar">
|
<!-- Sidebar Panel (Tabler Native Vertical Navbar) -->
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg navbar-dark bg-dark d-print-none" data-bs-theme="dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<!-- Toggle Button for Mobile -->
|
||||||
<div class="navbar-brand navbar-brand-autodark">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<a href="/admin/dashboard" class="text-decoration-none text-reset fw-semibold">TaxBaik Admin</a>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</div>
|
</button>
|
||||||
<div class="d-none d-lg-flex flex-column">
|
|
||||||
<span class="text-uppercase text-muted small">Admin Console</span>
|
<!-- Logo & Branding -->
|
||||||
<span class="fw-semibold">Razor Pages + Tabler</span>
|
<h1 class="navbar-brand navbar-brand-autodark px-2">
|
||||||
|
<a href="/admin/dashboard" class="text-decoration-none text-white fw-bold d-flex align-items-center gap-2">
|
||||||
|
<span class="avatar avatar-sm bg-primary text-white rounded-3"><i class="bi bi-safe-fill"></i></span>
|
||||||
|
<span>TaxBaik <span class="text-primary-light small">Admin</span></span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Mobile User Dropdown -->
|
||||||
|
<div class="navbar-nav flex-row d-lg-none">
|
||||||
|
<div class="nav-item dropdown">
|
||||||
|
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
|
||||||
|
<span class="avatar avatar-sm rounded-circle"><i class="bi bi-person-circle"></i></span>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
|
||||||
|
<div class="dropdown-item small text-muted font-numeric">@User.Identity?.Name</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<a href="/admin/logout" class="dropdown-item text-danger"><i class="bi bi-box-arrow-right me-1"></i> 로그아웃</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-nav flex-row order-md-last gap-2 align-items-center">
|
|
||||||
<span class="text-muted small d-none d-md-inline">@User.Identity?.Name</span>
|
<!-- Navigation Sidebar Links -->
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="/admin/logout">로그아웃</a>
|
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||||
|
<ul class="navbar-nav pt-lg-3">
|
||||||
|
<!-- 기본 관리 -->
|
||||||
|
<li class="nav-item dropdown @(coreOpen ? "active" : "")">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#menu-core" data-bs-toggle="dropdown" data-bs-auto-close="false" role="button" aria-expanded="@(coreOpen ? "true" : "false")">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block"><i class="bi bi-speedometer2"></i></span>
|
||||||
|
<span class="nav-link-title fw-semibold">기본 관리</span>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu @(coreOpen ? "show" : "")">
|
||||||
|
<a class="dropdown-item @activeClass("/admin/dashboard")" href="/admin/dashboard">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>대시보드
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/settings")" href="/admin/settings">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>설정
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/clientlogs")" href="/admin/clientlogs">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>클라이언트 로그
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- 고객/상담 -->
|
||||||
|
<li class="nav-item dropdown @(customerOpen ? "active" : "")">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#menu-customer" data-bs-toggle="dropdown" data-bs-auto-close="false" role="button" aria-expanded="@(customerOpen ? "true" : "false")">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block"><i class="bi bi-people"></i></span>
|
||||||
|
<span class="nav-link-title fw-semibold">고객/상담</span>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu @(customerOpen ? "show" : "")">
|
||||||
|
<a class="dropdown-item @activeClass("/admin/customers")" href="/admin/customers">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>고객
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/clients")" href="/admin/clients">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>고객사
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/inquiries")" href="/admin/inquiries">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>문의 관리
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/consultingactivities")" href="/admin/consultingactivities">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>상담 활동
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- 콘텐츠 운영 -->
|
||||||
|
<li class="nav-item dropdown @(contentOpen ? "active" : "")">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#menu-content" data-bs-toggle="dropdown" data-bs-auto-close="false" role="button" aria-expanded="@(contentOpen ? "true" : "false")">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block"><i class="bi bi-file-earmark-richtext"></i></span>
|
||||||
|
<span class="nav-link-title fw-semibold">콘텐츠 운영</span>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu @(contentOpen ? "show" : "")">
|
||||||
|
<a class="dropdown-item @activeClass("/admin/blog")" href="/admin/blog">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>블로그 편집
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/announcements")" href="/admin/announcements">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>공지사항
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/faqs")" href="/admin/faqs">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>FAQ 관리
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- 세무 관리 -->
|
||||||
|
<li class="nav-item dropdown @(financeOpen ? "active" : "")">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#menu-finance" data-bs-toggle="dropdown" data-bs-auto-close="false" role="button" aria-expanded="@(financeOpen ? "true" : "false")">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block"><i class="bi bi-calculator"></i></span>
|
||||||
|
<span class="nav-link-title fw-semibold">세무 관리</span>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu @(financeOpen ? "show" : "")">
|
||||||
|
<a class="dropdown-item @activeClass("/admin/taxprofiles")" href="/admin/taxprofiles">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>세무 프로필
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/taxfilings")" href="/admin/taxfilings">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>신고 관리
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/taxfilingschedules")" href="/admin/taxfilingschedules">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>신고 일정
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item @activeClass("/admin/revenuetrackings")" href="/admin/revenuetrackings">
|
||||||
|
<i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>매출 추적
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</aside>
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="container-fluid">
|
<!-- Main Wrapper Panel -->
|
||||||
<div class="row g-3">
|
<div class="page-wrapper d-flex flex-column min-vh-100">
|
||||||
<aside class="col-12 col-lg-3 col-xxl-2">
|
<!-- Topbar (Search, Breadcrumbs, Theme Switcher, User Profiles) -->
|
||||||
<div class="card shadow-sm border-0 admin-sidebar-card sticky-top">
|
<header class="navbar navbar-expand-md navbar-light d-none d-lg-flex d-print-none sticky-top border-bottom">
|
||||||
<div class="card-header admin-sidebar-header">
|
<div class="container-xl justify-content-between">
|
||||||
<div>
|
<!-- Left breadcrumb -->
|
||||||
<div class="text-uppercase text-muted small">Navigation</div>
|
<div class="d-flex align-items-center">
|
||||||
<h3 class="card-title mb-0">관리 메뉴</h3>
|
<nav aria-label="breadcrumb" class="d-inline-block">
|
||||||
</div>
|
<ol class="breadcrumb breadcrumb-muted mb-0 small">
|
||||||
</div>
|
<li class="breadcrumb-item"><a href="/admin/dashboard" class="text-muted"><i class="bi bi-house-door-fill"></i></a></li>
|
||||||
<div class="card-body p-0 admin-sidebar-body">
|
<li class="breadcrumb-item active text-dark fw-semibold" aria-current="page">@breadcrumbText</li>
|
||||||
<div class="accordion accordion-flush" id="adminSidebarAccordion">
|
</ol>
|
||||||
<div class="accordion-item">
|
</nav>
|
||||||
<h2 class="accordion-header">
|
</div>
|
||||||
<button class="accordion-button @(coreOpen ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuCore" aria-expanded="@(coreOpen ? "true" : "false")">
|
|
||||||
기본
|
<!-- Right Actions -->
|
||||||
</button>
|
<div class="navbar-nav flex-row align-items-center gap-3">
|
||||||
</h2>
|
<!-- Dark/Light Theme Switcher Button -->
|
||||||
<div id="adminMenuCore" class="accordion-collapse collapse @(coreOpen ? "show" : string.Empty)" data-bs-parent="#adminSidebarAccordion">
|
<div class="nav-item">
|
||||||
<div class="list-group list-group-flush list-group-transparent">
|
<a href="#" class="nav-link px-0 text-muted" onclick="toggleTheme(); return false;" title="테마 전환" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/dashboard")" href="/admin/dashboard">▣ 대시보드</a>
|
<i class="bi bi-sun fs-3 theme-icon-light d-none"></i>
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/settings")" href="/admin/settings">⚙ 설정</a>
|
<i class="bi bi-moon-stars-fill fs-3 theme-icon-dark"></i>
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/clientlogs")" href="/admin/clientlogs">⌘ 클라이언트 로그</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="accordion-item">
|
|
||||||
<h2 class="accordion-header">
|
|
||||||
<button class="accordion-button @(customerOpen ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuCustomer" aria-expanded="@(customerOpen ? "true" : "false")">
|
|
||||||
고객/상담
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="adminMenuCustomer" class="accordion-collapse collapse @(customerOpen ? "show" : string.Empty)" data-bs-parent="#adminSidebarAccordion">
|
|
||||||
<div class="list-group list-group-flush list-group-transparent">
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/customers")" href="/admin/customers">◫ 고객</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/clients")" href="/admin/clients">◫ 고객사</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/inquiries")" href="/admin/inquiries">◫ 문의</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/consultingactivities")" href="/admin/consultingactivities">◫ 상담 활동</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="accordion-item">
|
|
||||||
<h2 class="accordion-header">
|
|
||||||
<button class="accordion-button @(contentOpen ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuContent" aria-expanded="@(contentOpen ? "true" : "false")">
|
|
||||||
콘텐츠
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="adminMenuContent" class="accordion-collapse collapse @(contentOpen ? "show" : string.Empty)" data-bs-parent="#adminSidebarAccordion">
|
|
||||||
<div class="list-group list-group-flush list-group-transparent">
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/blog")" href="/admin/blog">✎ 블로그</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/announcements")" href="/admin/announcements">★ 공지사항</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/faqs")" href="/admin/faqs">? FAQ</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="accordion-item">
|
|
||||||
<h2 class="accordion-header">
|
|
||||||
<button class="accordion-button @(financeOpen ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuFinance" aria-expanded="@(financeOpen ? "true" : "false")">
|
|
||||||
세무 관리
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="adminMenuFinance" class="accordion-collapse collapse @(financeOpen ? "show" : string.Empty)" data-bs-parent="#adminSidebarAccordion">
|
|
||||||
<div class="list-group list-group-flush list-group-transparent">
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/taxprofiles")" href="/admin/taxprofiles">◧ 세무 프로필</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/taxfilings")" href="/admin/taxfilings">◧ 신고 관리</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/taxfilingschedules")" href="/admin/taxfilingschedules">◧ 신고 일정</a>
|
|
||||||
<a class="list-group-item list-group-item-action @activeClass("/admin/revenuetrackings")" href="/admin/revenuetrackings">◧ 매출 추적</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
<main class="col-12 col-lg-9 col-xxl-10">
|
<!-- Divider -->
|
||||||
<div class="card shadow-sm border-0 admin-content-card admin-main-surface">
|
<div class="vr bg-light opacity-50" style="height: 1.5rem;"></div>
|
||||||
<div class="card-body p-3 p-md-4 p-xl-5">
|
|
||||||
<nav aria-label="breadcrumb" class="mb-4">
|
<!-- Profile & Logout -->
|
||||||
<ol class="breadcrumb breadcrumb-muted">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<li class="breadcrumb-item"><a href="/admin/dashboard">관리자</a></li>
|
<span class="avatar avatar-xs rounded-circle bg-secondary-lt text-secondary"><i class="bi bi-person-circle"></i></span>
|
||||||
<li class="breadcrumb-item active" aria-current="page">@breadcrumbText</li>
|
<span class="text-dark small fw-semibold font-numeric me-2">@User.Identity?.Name</span>
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
@RenderBody()
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<a class="btn btn-outline-danger btn-sm px-3 rounded-pill fw-semibold" href="/admin/logout">
|
||||||
|
<i class="bi bi-box-arrow-right me-1"></i> 로그아웃
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page Body Area -->
|
||||||
|
<div class="page-body flex-fill py-4">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="card shadow-sm border border-light-subtle rounded-3 overflow-hidden bg-surface">
|
||||||
|
<div class="card-body p-4 p-md-5">
|
||||||
|
@RenderBody()
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Area -->
|
||||||
|
<footer class="footer footer-transparent d-print-none py-3 border-top mt-auto bg-surface">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row text-center align-items-center flex-row-reverse">
|
||||||
|
<div class="col-lg-auto ms-lg-auto">
|
||||||
|
<ul class="list-inline list-inline-dots mb-0">
|
||||||
|
<li class="list-inline-item">Razor Pages + Dapper Architecture</li>
|
||||||
|
<li class="list-inline-item">Cookie Security Authentication</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-auto mt-3 mt-lg-0 text-muted">
|
||||||
|
© 2026 TaxBaik. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabler Javascript -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js" defer></script>
|
||||||
@await RenderSectionAsync("Scripts", required: false)
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
|
|
||||||
|
<!-- Theme Manager Javascript -->
|
||||||
|
<script>
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
localStorage.setItem('tablerTheme', theme);
|
||||||
|
|
||||||
|
var iconLight = document.querySelector('.theme-icon-light');
|
||||||
|
var iconDark = document.querySelector('.theme-icon-dark');
|
||||||
|
|
||||||
|
if (iconLight && iconDark) {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
iconLight.classList.remove('d-none');
|
||||||
|
iconDark.classList.add('d-none');
|
||||||
|
} else {
|
||||||
|
iconLight.classList.add('d-none');
|
||||||
|
iconDark.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
var currentTheme = localStorage.getItem('tablerTheme') || 'light';
|
||||||
|
var newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
applyTheme(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Theme Icons on Load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var currentTheme = localStorage.getItem('tablerTheme') || 'light';
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@using TaxBaik.Web
|
||||||
|
@namespace TaxBaik.Web.Pages.Admin
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,3 +2,12 @@
|
|||||||
Layout = "_AdminLayout";
|
Layout = "_AdminLayout";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
@model TaxBaik.Web.Pages.Blog.BlogIndexModel
|
@model TaxBaik.Web.Pages.Blog.BlogIndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "블로그 | 백원숙 세무회계";
|
ViewData["Title"] = "블로그 | 백원숙 세무회계";
|
||||||
|
ViewData["Description"] = "세금 신고, 부동산 세금, 증여·상속 관련 최신 세무 정보와 절세 팁을 확인하세요.";
|
||||||
|
var canonicalQuery = Model.SelectedCategoryId.HasValue
|
||||||
|
? $"?categoryId={Model.SelectedCategoryId.Value}" + (Model.CurrentPage > 1 ? $"&page={Model.CurrentPage}" : string.Empty)
|
||||||
|
: Model.CurrentPage > 1 ? $"?page={Model.CurrentPage}" : string.Empty;
|
||||||
|
ViewData["CanonicalUrl"] = $"https://www.taxbaik.com/blog{canonicalQuery}";
|
||||||
|
ViewData["OgUrl"] = ViewData["CanonicalUrl"];
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
|
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
|
||||||
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
|
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
|
||||||
ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? "";
|
ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? "";
|
||||||
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/blog/{Model.Post?.Slug}";
|
var canonicalUrl = $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(Model.Post?.Slug ?? string.Empty)}";
|
||||||
ViewData["CanonicalUrl"] = canonicalUrl;
|
ViewData["CanonicalUrl"] = canonicalUrl;
|
||||||
ViewData["OgUrl"] = canonicalUrl;
|
ViewData["OgUrl"] = canonicalUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,6 @@
|
|||||||
@page "/feed.xml"
|
@page "/feed.xml"
|
||||||
@model TaxBaik.Web.Pages.RssModel
|
|
||||||
@{
|
@{
|
||||||
Response.ContentType = "application/rss+xml; charset=utf-8";
|
Layout = null;
|
||||||
}@{
|
Response.StatusCode = StatusCodes.Status308PermanentRedirect;
|
||||||
var rssContent = new System.Text.StringBuilder();
|
Response.Headers.Location = "/rss.xml";
|
||||||
rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
|
}
|
||||||
rssContent.AppendLine("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
|
|
||||||
rssContent.AppendLine(" <channel>");
|
|
||||||
rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>");
|
|
||||||
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
|
|
||||||
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
|
|
||||||
rssContent.AppendLine(" <language>ko-kr</language>");
|
|
||||||
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
|
|
||||||
rssContent.AppendLine(" <ttl>60</ttl>");
|
|
||||||
|
|
||||||
foreach (var post in Model.Posts)
|
|
||||||
{
|
|
||||||
rssContent.AppendLine(" <item>");
|
|
||||||
rssContent.AppendLine($" <title>{System.Net.WebUtility.HtmlEncode(post.Title)}</title>");
|
|
||||||
rssContent.AppendLine($" <link>https://www.taxbaik.com/blog/{post.Slug}</link>");
|
|
||||||
rssContent.AppendLine($" <guid isPermaLink=\"true\">https://www.taxbaik.com/blog/{post.Slug}</guid>");
|
|
||||||
rssContent.AppendLine($" <pubDate>{post.PublishedAt?.ToString("R")}</pubDate>");
|
|
||||||
var desc = post.Content?.Substring(0, Math.Min(200, post.Content?.Length ?? 0)) ?? "";
|
|
||||||
rssContent.AppendLine($" <description>{System.Net.WebUtility.HtmlEncode(desc)}</description>");
|
|
||||||
if (!string.IsNullOrEmpty(post.Content))
|
|
||||||
{
|
|
||||||
rssContent.AppendLine($" <content:encoded><![CDATA[{post.Content}]]></content:encoded>");
|
|
||||||
}
|
|
||||||
rssContent.AppendLine(" </item>");
|
|
||||||
}
|
|
||||||
|
|
||||||
rssContent.AppendLine(" </channel>");
|
|
||||||
rssContent.AppendLine("</rss>");
|
|
||||||
}@rssContent.ToString()
|
|
||||||
|
|||||||
@@ -5,46 +5,57 @@
|
|||||||
ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
|
ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="bg-light py-5">
|
<div class="bg-light py-5 min-vh-75">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- 상단 헤더 & 환영 문구 -->
|
<!-- 상단 헤더 & 환영 문구 -->
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom">
|
<div class="card border-0 shadow-sm mb-4 rounded-4 overflow-hidden" style="background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);">
|
||||||
<div>
|
<div class="card-body p-4 p-md-5 text-white position-relative">
|
||||||
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p>
|
<div class="d-flex flex-wrap justify-content-between align-items-center position-relative" style="z-index: 2;">
|
||||||
<h1 class="display-6 fw-bold text-dark">안녕하세요, @(User.Identity?.Name)님!</h1>
|
<div>
|
||||||
@if (Model.ClientInfo != null)
|
<span class="badge bg-primary-badge px-3 py-1.5 mb-2 fs-8 font-numeric text-white" style="background-color: rgba(255, 255, 255, 0.15) !important;">TAXBAIK PORTAL</span>
|
||||||
{
|
<h1 class="display-6 fw-bold mb-2 text-white">안녕하세요, @(User.Identity?.Name)님!</h1>
|
||||||
<p class="text-muted mb-0">
|
@if (Model.ClientInfo != null)
|
||||||
<i class="bi bi-building"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)
|
{
|
||||||
| <i class="bi bi-telephone"></i> @Model.ClientInfo.Phone
|
<div class="d-flex flex-wrap gap-3 align-items-center opacity-90 small">
|
||||||
</p>
|
<span><i class="bi bi-building me-1"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)</span>
|
||||||
}
|
<span class="d-none d-sm-inline">|</span>
|
||||||
</div>
|
<span><i class="bi bi-telephone me-1"></i> @Model.ClientInfo.Phone</span>
|
||||||
<div class="mt-3 mt-sm-0">
|
</div>
|
||||||
<form method="post" action="/portal/logout" class="d-inline">
|
}
|
||||||
@Html.AntiForgeryToken()
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
<div class="mt-3 mt-sm-0">
|
||||||
<i class="bi bi-box-arrow-right"></i> 로그아웃
|
<form method="post" action="/portal/logout" class="d-inline">
|
||||||
</button>
|
@Html.AntiForgeryToken()
|
||||||
</form>
|
<button type="submit" class="btn btn-sm btn-outline-light px-3 py-2 fw-semibold">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> 로그아웃
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Premium decorative circle background -->
|
||||||
|
<div class="position-absolute end-0 top-0 bottom-0 overflow-hidden d-none d-lg-block" style="width: 300px; opacity: 0.1;">
|
||||||
|
<svg viewBox="0 0 100 100" fill="currentColor" class="text-white h-100 w-100">
|
||||||
|
<circle cx="80" cy="50" r="40" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.ClientInfo == null)
|
@if (Model.ClientInfo == null)
|
||||||
{
|
{
|
||||||
<!-- 연동 대기 경고 -->
|
<!-- 연동 대기 경고 -->
|
||||||
<div class="card border-warning shadow-sm mb-5">
|
<div class="card border-0 shadow-lg rounded-4 mb-5">
|
||||||
<div class="card-body p-5 text-center">
|
<div class="card-body p-5 text-center">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
|
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
|
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
|
||||||
<p class="text-muted max-width-md mx-auto mb-4">
|
<p class="text-muted max-width-md mx-auto mb-4 leading-relaxed">
|
||||||
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 레코드를 찾지 못했습니다.<br />
|
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 정보를 찾지 못했습니다.<br />
|
||||||
세무사 측에서 고객 등록을 완료하거나 관리자 백오피스에서 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
|
세무사 측에서 고객 등록을 완료하거나 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
|
||||||
</p>
|
</p>
|
||||||
<a href="/contact" class="btn btn-primary px-4 py-2">
|
<a href="/contact" class="btn btn-primary px-4 py-2.5 fw-bold">
|
||||||
<i class="bi bi-chat-dots"></i> 세무사에게 문의하기
|
<i class="bi bi-chat-dots-fill"></i> 세무사에게 문의하기
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,19 +65,19 @@
|
|||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
|
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card glass-card mb-4">
|
<div class="card border-0 shadow-sm rounded-4 h-100">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4 pb-2 border-bottom">
|
||||||
<h3 class="h5 fw-bold text-dark mb-0">
|
<h3 class="h5 fw-bold text-dark mb-0">
|
||||||
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
|
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
|
||||||
</h3>
|
</h3>
|
||||||
<span class="badge bg-secondary">총 @(Model.Filings.Count)건</span>
|
<span class="badge bg-primary-badge font-numeric">총 @(Model.Filings.Count)건</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!Model.Filings.Any())
|
@if (!Model.Filings.Any())
|
||||||
{
|
{
|
||||||
<div class="text-center py-5 text-muted">
|
<div class="text-center py-5 text-muted">
|
||||||
<i class="bi bi-folder-x display-4 d-block mb-3 text-secondary"></i>
|
<i class="bi bi-folder-x display-4 d-block mb-3 text-secondary" style="opacity: 0.5;"></i>
|
||||||
등록된 세무 신고 일정이 없습니다.
|
등록된 세무 신고 일정이 없습니다.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -76,10 +87,10 @@
|
|||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">신고 종류</th>
|
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">신고 종류</th>
|
||||||
<th scope="col">신고 기한</th>
|
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">신고 기한</th>
|
||||||
<th scope="col">진행 상태</th>
|
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">진행 상태</th>
|
||||||
<th scope="col">메모</th>
|
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">메모</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -88,28 +99,28 @@
|
|||||||
var dDay = (filing.DueDate - DateTime.Today).Days;
|
var dDay = (filing.DueDate - DateTime.Today).Days;
|
||||||
var statusClass = filing.Status switch
|
var statusClass = filing.Status switch
|
||||||
{
|
{
|
||||||
"filed" => "bg-success-subtle text-success",
|
"filed" => "bg-success-subtle text-success border border-success-subtle",
|
||||||
"overdue" => "bg-danger-subtle text-danger",
|
"overdue" => "bg-danger-subtle text-danger border border-danger-subtle",
|
||||||
_ => "bg-warning-subtle text-warning-emphasis"
|
_ => "bg-warning-subtle text-warning-emphasis border border-warning-subtle"
|
||||||
};
|
};
|
||||||
var statusLabel = filing.Status switch
|
var statusLabel = filing.Status switch
|
||||||
{
|
{
|
||||||
"filed" => "신고 완료",
|
"filed" => "신고 완료",
|
||||||
"overdue" => "기한 초과",
|
"overdue" => "기한 초과",
|
||||||
_ => $"D-{dDay}"
|
_ => dDay == 0 ? "D-Day" : dDay < 0 ? $"기한 지남 ({Math.Abs(dDay)}일)" : $"D-{dDay}"
|
||||||
};
|
};
|
||||||
|
|
||||||
<tr>
|
<tr class="admin-row-clickable">
|
||||||
<td>
|
<td class="py-3">
|
||||||
<span class="fw-bold text-dark">@filing.FilingType</span>
|
<span class="fw-bold text-dark">@filing.FilingType</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="py-3">
|
||||||
<span>@filing.DueDate.ToString("yyyy-MM-dd")</span>
|
<span class="font-numeric text-muted">@filing.DueDate.ToString("yyyy-MM-dd")</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="py-3">
|
||||||
<span class="badge @statusClass px-2.5 py-1.5 fs-7">@statusLabel</span>
|
<span class="badge @statusClass px-2.5 py-1.5 fs-8 fw-bold">@statusLabel</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">
|
<td class="py-3 text-muted small">
|
||||||
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
|
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -124,16 +135,16 @@
|
|||||||
|
|
||||||
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
|
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card glass-card">
|
<div class="card border-0 shadow-sm rounded-4 h-100">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h3 class="h5 fw-bold text-dark mb-4">
|
<h3 class="h5 fw-bold text-dark mb-4 pb-2 border-bottom">
|
||||||
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
|
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@if (!Model.Consultations.Any())
|
@if (!Model.Consultations.Any())
|
||||||
{
|
{
|
||||||
<div class="text-center py-5 text-muted">
|
<div class="text-center py-5 text-muted">
|
||||||
<i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary"></i>
|
<i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary" style="opacity: 0.5;"></i>
|
||||||
최근 상담 이력이 없습니다.
|
최근 상담 이력이 없습니다.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -142,15 +153,18 @@
|
|||||||
<div class="timeline ps-2">
|
<div class="timeline ps-2">
|
||||||
@foreach (var activity in Model.Consultations)
|
@foreach (var activity in Model.Consultations)
|
||||||
{
|
{
|
||||||
<div class="timeline-item-modern">
|
<div class="position-relative ps-4 pb-4 border-start border-2 border-light-subtle">
|
||||||
|
<!-- Timeline Dot -->
|
||||||
|
<div class="position-absolute start-0 translate-middle-x rounded-circle bg-primary" style="width: 12px; height: 12px; top: 6px; border: 3px solid #fff;"></div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
|
<span class="badge bg-primary-badge px-2 py-1 fs-8">@activity.ActivityType</span>
|
||||||
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
|
<small class="text-muted font-numeric">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-dark small mb-1 fw-semibold">@activity.Description</p>
|
<p class="text-dark small mb-1 fw-semibold leading-normal">@activity.Description</p>
|
||||||
@if (!string.IsNullOrEmpty(activity.Outcome))
|
@if (!string.IsNullOrEmpty(activity.Outcome))
|
||||||
{
|
{
|
||||||
<div class="bg-light p-2 rounded small text-muted mt-1">
|
<div class="bg-light p-2.5 rounded-3 small text-muted mt-1.5 border border-light">
|
||||||
<strong>결과:</strong> @activity.Outcome
|
<strong>결과:</strong> @activity.Outcome
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,35 +6,77 @@
|
|||||||
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/login";
|
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/login";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container py-5" style="max-width: 560px;">
|
<div class="bg-light py-5 min-vh-75 d-flex align-items-center">
|
||||||
<h1 class="h3 fw-bold mb-4">고객 포털 로그인</h1>
|
<div class="container" style="max-width: 480px;">
|
||||||
<div class="alert alert-secondary">
|
<div class="card shadow-lg border-0 rounded-4 overflow-hidden">
|
||||||
포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다.
|
<div class="card-body p-4 p-md-5">
|
||||||
</div>
|
<div class="text-center mb-4">
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
<span class="badge bg-primary-badge px-3 py-2 mb-2 fs-7 font-numeric">TAXBAIK MY PORTAL</span>
|
||||||
{
|
<h1 class="h3 fw-bold text-dark mb-2">고객 포털 로그인</h1>
|
||||||
<div class="alert alert-danger">@Model.ErrorMessage</div>
|
<p class="text-muted small">세무 신고 현황 및 상담 이력을 실시간으로 확인하세요.</p>
|
||||||
}
|
</div>
|
||||||
<form method="post" class="vstack gap-3">
|
|
||||||
<div>
|
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||||
<label class="form-label">이메일</label>
|
{
|
||||||
<input class="form-control" asp-for="Email" />
|
<div class="alert alert-danger d-flex align-items-center gap-2" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
<div>@Model.ErrorMessage</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post" class="vstack gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold text-dark small" asp-for="Email">이메일 주소</label>
|
||||||
|
<div class="input-icon">
|
||||||
|
<span class="input-icon-addon">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
</span>
|
||||||
|
<input class="form-control" asp-for="Email" placeholder="example@email.com" required type="email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold text-dark small" asp-for="Password">비밀번호</label>
|
||||||
|
<div class="input-icon">
|
||||||
|
<span class="input-icon-addon">
|
||||||
|
<i class="bi bi-lock"></i>
|
||||||
|
</span>
|
||||||
|
<input class="form-control" asp-for="Password" type="password" placeholder="••••••••" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100 py-2.5 fw-bold mt-2" type="submit">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> 로그인
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="hr-text my-4"><span class="text-muted small">또는 소셜 계정으로 로그인</span></div>
|
||||||
|
|
||||||
|
<div class="vstack gap-2">
|
||||||
|
<form method="post" asp-page-handler="Google" class="w-100">
|
||||||
|
<button class="btn btn-outline-dark w-100 d-flex align-items-center justify-content-center gap-2 bg-white" type="submit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-google text-danger" viewBox="0 0 16 16">
|
||||||
|
<path d="M15.545 6.558a9.42 9.42 0 0 1 .139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 1 1 8 0c2.24 0 4 1.11 5.33 2.23l-2.03 2.03C10.18 3.24 9.22 2.72 8 2.72c-2.427 0-4.437 2.03-4.437 4.437 0 2.427 2.01 4.437 4.437 4.437 1.89 0 3.05-.98 3.57-2.005H8v-2.74H15.54z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Google 계정으로 로그인</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" asp-page-handler="Naver" class="w-100">
|
||||||
|
<button class="btn w-100 d-flex align-items-center justify-content-center gap-2 text-white" style="background-color: #03C75A;" type="submit">
|
||||||
|
<span class="fw-bold" style="font-family: 'Outfit';">N</span>
|
||||||
|
<span>네이버로 로그인</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" asp-page-handler="Kakao" class="w-100">
|
||||||
|
<button class="btn w-100 d-flex align-items-center justify-content-center gap-2 text-dark" style="background-color: #FEE500;" type="submit">
|
||||||
|
<i class="bi bi-chat-fill"></i>
|
||||||
|
<span>카카오로 로그인</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4 pt-2 border-top">
|
||||||
|
<p class="text-muted small mb-0">아직 회원이 아니신가요? <a href="/portal/register" class="text-primary fw-bold">회원가입하기</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="form-label">비밀번호</label>
|
|
||||||
<input class="form-control" asp-for="Password" type="password" />
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-dark" type="submit">로그인</button>
|
|
||||||
</form>
|
|
||||||
<div class="d-grid gap-2 mt-4">
|
|
||||||
<form method="post" asp-page-handler="Google">
|
|
||||||
<button class="btn btn-outline-dark w-100" type="submit">Google로 로그인</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" asp-page-handler="Naver">
|
|
||||||
<button class="btn btn-outline-success w-100" type="submit">Naver로 로그인</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" asp-page-handler="Kakao">
|
|
||||||
<button class="btn btn-outline-warning w-100" type="submit">Kakao로 로그인</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|||||||
@@ -6,30 +6,71 @@
|
|||||||
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/register";
|
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/register";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container py-5" style="max-width: 640px;">
|
<div class="bg-light py-5 min-vh-75 d-flex align-items-center">
|
||||||
<h1 class="h3 fw-bold mb-4">고객 포털 회원가입</h1>
|
<div class="container" style="max-width: 560px;">
|
||||||
<div class="alert alert-secondary">
|
<div class="card shadow-lg border-0 rounded-4 overflow-hidden">
|
||||||
가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다.
|
<div class="card-body p-4 p-md-5">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<span class="badge bg-primary-badge px-3 py-2 mb-2 fs-7 font-numeric">TAXBAIK MY PORTAL</span>
|
||||||
|
<h1 class="h3 fw-bold text-dark mb-2">고객 포털 회원가입</h1>
|
||||||
|
<p class="text-muted small">포털 가입 후 세무 대리 고객 정보와 연동하여 편리한 서비스를 이용하세요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary d-flex align-items-start gap-2 mb-4" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill text-secondary mt-1"></i>
|
||||||
|
<div class="small text-muted">
|
||||||
|
세무사에 등록된 연락처 또는 이메일과 일치하게 가입하셔야 자동으로 세무 정보가 연동됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold text-dark small" asp-for="Name">이름</label>
|
||||||
|
<div class="input-icon">
|
||||||
|
<span class="input-icon-addon">
|
||||||
|
<i class="bi bi-person"></i>
|
||||||
|
</span>
|
||||||
|
<input class="form-control" asp-for="Name" placeholder="홍길동" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold text-dark small" asp-for="Phone">연락처</label>
|
||||||
|
<div class="input-icon">
|
||||||
|
<span class="input-icon-addon">
|
||||||
|
<i class="bi bi-telephone"></i>
|
||||||
|
</span>
|
||||||
|
<input class="form-control" asp-for="Phone" placeholder="010-1234-5678" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-semibold text-dark small" asp-for="Email">이메일 주소</label>
|
||||||
|
<div class="input-icon">
|
||||||
|
<span class="input-icon-addon">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
</span>
|
||||||
|
<input class="form-control" asp-for="Email" placeholder="example@email.com" required type="email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-semibold text-dark small" asp-for="Password">비밀번호</label>
|
||||||
|
<div class="input-icon">
|
||||||
|
<span class="input-icon-addon">
|
||||||
|
<i class="bi bi-lock"></i>
|
||||||
|
</span>
|
||||||
|
<input class="form-control" asp-for="Password" type="password" placeholder="비밀번호 입력" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-4">
|
||||||
|
<button class="btn btn-primary w-100 py-2.5 fw-bold" type="submit">
|
||||||
|
<i class="bi bi-person-plus"></i> 가입하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-4 pt-2 border-top">
|
||||||
|
<p class="text-muted small mb-0">이미 계정이 있으신가요? <a href="/portal/login" class="text-primary fw-bold">로그인하기</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" class="row g-3">
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">이름</label>
|
|
||||||
<input class="form-control" asp-for="Name" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">연락처</label>
|
|
||||||
<input class="form-control" asp-for="Phone" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">이메일</label>
|
|
||||||
<input class="form-control" asp-for="Email" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">비밀번호</label>
|
|
||||||
<input class="form-control" asp-for="Password" type="password" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<button class="btn btn-dark" type="submit">가입하기</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -1,35 +1,40 @@
|
|||||||
@page "/rss.xml"
|
@page "/rss.xml"
|
||||||
@model TaxBaik.Web.Pages.RssModel
|
@model TaxBaik.Web.Pages.RssModel
|
||||||
@{
|
@{
|
||||||
|
Layout = null;
|
||||||
Response.ContentType = "application/rss+xml; charset=utf-8";
|
Response.ContentType = "application/rss+xml; charset=utf-8";
|
||||||
}@{
|
|
||||||
var rssContent = new System.Text.StringBuilder();
|
var rssContent = new System.Text.StringBuilder();
|
||||||
rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
|
rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
|
||||||
rssContent.AppendLine("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
|
rssContent.AppendLine("<rss version=\"2.0\">");
|
||||||
rssContent.AppendLine(" <channel>");
|
rssContent.AppendLine(" <channel>");
|
||||||
rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>");
|
rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>");
|
||||||
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
|
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
|
||||||
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
|
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
|
||||||
rssContent.AppendLine(" <language>ko-kr</language>");
|
rssContent.AppendLine(" <language>ko-KR</language>");
|
||||||
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
|
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
|
||||||
rssContent.AppendLine(" <ttl>60</ttl>");
|
rssContent.AppendLine(" <ttl>60</ttl>");
|
||||||
|
|
||||||
foreach (var post in Model.Posts)
|
foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
|
var itemUrl = item.Link;
|
||||||
|
var fullDescription = item.Description ?? string.Empty;
|
||||||
|
var safeDescription = fullDescription.Replace("]]>", "]]]]><![CDATA[>");
|
||||||
|
|
||||||
rssContent.AppendLine(" <item>");
|
rssContent.AppendLine(" <item>");
|
||||||
rssContent.AppendLine($" <title>{System.Net.WebUtility.HtmlEncode(post.Title)}</title>");
|
rssContent.AppendLine($" <title>{System.Net.WebUtility.HtmlEncode(item.Title)}</title>");
|
||||||
rssContent.AppendLine($" <link>https://www.taxbaik.com/blog/{post.Slug}</link>");
|
rssContent.AppendLine($" <link>{itemUrl}</link>");
|
||||||
rssContent.AppendLine($" <guid isPermaLink=\"true\">https://www.taxbaik.com/blog/{post.Slug}</guid>");
|
rssContent.AppendLine($" <guid isPermaLink=\"true\">{item.Guid}</guid>");
|
||||||
rssContent.AppendLine($" <pubDate>{post.PublishedAt?.ToString("R")}</pubDate>");
|
|
||||||
var desc = post.Content?.Substring(0, Math.Min(200, post.Content?.Length ?? 0)) ?? "";
|
if (item.PublishedAt is not null)
|
||||||
rssContent.AppendLine($" <description>{System.Net.WebUtility.HtmlEncode(desc)}</description>");
|
|
||||||
if (!string.IsNullOrEmpty(post.Content))
|
|
||||||
{
|
{
|
||||||
rssContent.AppendLine($" <content:encoded><![CDATA[{post.Content}]]></content:encoded>");
|
rssContent.AppendLine($" <pubDate>{item.PublishedAt.Value.ToUniversalTime():R}</pubDate>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rssContent.AppendLine($" <description><![CDATA[{safeDescription}]]></description>");
|
||||||
rssContent.AppendLine(" </item>");
|
rssContent.AppendLine(" </item>");
|
||||||
}
|
}
|
||||||
|
|
||||||
rssContent.AppendLine(" </channel>");
|
rssContent.AppendLine(" </channel>");
|
||||||
rssContent.AppendLine("</rss>");
|
rssContent.AppendLine("</rss>");
|
||||||
}@rssContent.ToString()
|
}
|
||||||
|
@Html.Raw(rssContent.ToString())
|
||||||
|
|||||||
@@ -7,30 +7,83 @@ namespace TaxBaik.Web.Pages;
|
|||||||
public class RssModel : PageModel
|
public class RssModel : PageModel
|
||||||
{
|
{
|
||||||
private readonly BlogService _blogService;
|
private readonly BlogService _blogService;
|
||||||
|
private readonly FaqService _faqService;
|
||||||
|
private readonly AnnouncementService _announcementService;
|
||||||
|
|
||||||
public List<BlogPost> Posts { get; set; } = [];
|
public List<RssItem> Items { get; set; } = [];
|
||||||
public string LastBuildDate { get; set; } = DateTime.UtcNow.ToString("R");
|
public string LastBuildDate { get; set; } = DateTime.UtcNow.ToString("R");
|
||||||
|
|
||||||
public RssModel(BlogService blogService)
|
public RssModel(
|
||||||
|
BlogService blogService,
|
||||||
|
FaqService faqService,
|
||||||
|
AnnouncementService announcementService)
|
||||||
{
|
{
|
||||||
_blogService = blogService;
|
_blogService = blogService;
|
||||||
|
_faqService = faqService;
|
||||||
|
_announcementService = announcementService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 최근 50개 블로그 포스트 (RSS는 일반적으로 최신 기사만 포함)
|
var blogTask = _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default);
|
||||||
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default);
|
var faqTask = _faqService.GetActiveAsync(HttpContext.RequestAborted);
|
||||||
Posts = posts.OrderByDescending(p => p.PublishedAt).ToList();
|
var announcementTask = _announcementService.GetActiveAsync(HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
await Task.WhenAll(blogTask, faqTask, announcementTask);
|
||||||
|
|
||||||
|
var blogPosts = (await blogTask).Item1
|
||||||
|
.OrderByDescending(p => p.PublishedAt)
|
||||||
|
.Select(post => new RssItem(
|
||||||
|
Title: post.Title,
|
||||||
|
Link: $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(post.Slug)}",
|
||||||
|
Description: post.Content ?? string.Empty,
|
||||||
|
PublishedAt: post.PublishedAt,
|
||||||
|
Guid: $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(post.Slug)}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var faqs = (await faqTask)
|
||||||
|
.OrderByDescending(f => f.UpdatedAt)
|
||||||
|
.Select(faq => new RssItem(
|
||||||
|
Title: $"FAQ: {faq.Question}",
|
||||||
|
Link: "https://www.taxbaik.com/#faq",
|
||||||
|
Description: faq.Answer,
|
||||||
|
PublishedAt: faq.UpdatedAt,
|
||||||
|
Guid: $"https://www.taxbaik.com/rss/faq/{faq.Id}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var announcements = (await announcementTask)
|
||||||
|
.OrderByDescending(a => a.UpdatedAt)
|
||||||
|
.Select(announcement => new RssItem(
|
||||||
|
Title: $"공지: {announcement.Title}",
|
||||||
|
Link: "https://www.taxbaik.com/#top",
|
||||||
|
Description: announcement.Content ?? announcement.Title,
|
||||||
|
PublishedAt: announcement.UpdatedAt,
|
||||||
|
Guid: $"https://www.taxbaik.com/rss/announcement/{announcement.Id}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Items = blogPosts
|
||||||
|
.Concat(faqs)
|
||||||
|
.Concat(announcements)
|
||||||
|
.OrderByDescending(item => item.PublishedAt ?? DateTime.MinValue)
|
||||||
|
.Take(50)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// DB 연결 실패: 빈 포스트 목록 반환 (정적 피드 구조는 유지)
|
// DB 연결 실패: 빈 피드가 되지 않도록 로컬 서버 시간 기준 단일 항목을 유지하지는 않는다.
|
||||||
Posts = [];
|
Items = [];
|
||||||
System.Diagnostics.Debug.WriteLine($"RSS Feed: Blog posts load failed: {ex.Message}");
|
System.Diagnostics.Debug.WriteLine($"RSS Feed: Blog posts load failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
LastBuildDate = Posts.FirstOrDefault()?.PublishedAt?.ToString("R") ?? DateTime.UtcNow.ToString("R");
|
LastBuildDate = Items.FirstOrDefault()?.PublishedAt?.ToString("R") ?? DateTime.UtcNow.ToString("R");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record RssItem(
|
||||||
|
string Title,
|
||||||
|
string Link,
|
||||||
|
string Description,
|
||||||
|
DateTime? PublishedAt,
|
||||||
|
string Guid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace TaxBaik.Web.Pages.Shared;
|
|||||||
public sealed record BlogEditorModel(
|
public sealed record BlogEditorModel(
|
||||||
string Title,
|
string Title,
|
||||||
string Content,
|
string Content,
|
||||||
int? CategoryId,
|
int CategoryId,
|
||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
string? ThumbnailUrl,
|
string? ThumbnailUrl,
|
||||||
string? SeoTitle,
|
string? SeoTitle,
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
@model (string Eyebrow, string Title, string Subtitle, string? ActionHref, string? ActionText)
|
@model TaxBaik.Web.Models.AdminPageHeaderModel
|
||||||
<div class="page-header d-print-none mb-4">
|
<div class="page-header d-print-none admin-page-header">
|
||||||
<div class="row align-items-center g-2">
|
<div class="row align-items-center g-2">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="page-pretitle text-uppercase">@Model.Eyebrow</div>
|
<div class="page-pretitle text-uppercase admin-page-header-eyebrow">@Model.Eyebrow</div>
|
||||||
<h2 class="page-title mb-1">@Model.Title</h2>
|
<h2 class="page-title mb-1 admin-page-header-title">@Model.Title</h2>
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.Subtitle))
|
@if (!string.IsNullOrWhiteSpace(Model.Subtitle))
|
||||||
{
|
{
|
||||||
<div class="text-muted small">@Model.Subtitle</div>
|
<div class="text-muted small admin-page-header-subtitle">@Model.Subtitle</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.ActionHref) && !string.IsNullOrWhiteSpace(Model.ActionText))
|
@if (!string.IsNullOrWhiteSpace(Model.ActionHref) && !string.IsNullOrWhiteSpace(Model.ActionText))
|
||||||
{
|
{
|
||||||
<div class="col-auto ms-auto">
|
<div class="col-auto ms-auto">
|
||||||
<a class="btn btn-primary btn-pill" href="@Model.ActionHref">@Model.ActionText</a>
|
<a class="btn btn-primary btn-sm btn-pill" href="@Model.ActionHref">@Model.ActionText</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
@model TaxBaik.Web.Pages.Shared.BlogEditorModel
|
@model TaxBaik.Web.Pages.Shared.BlogEditorModel
|
||||||
|
|
||||||
<section class="container-xl admin-page-shell admin-editor-root blog-editor-shell" data-blog-editor-root>
|
<section class="admin-page-shell admin-editor-root blog-editor-shell" data-blog-editor-root>
|
||||||
<div class="row g-3">
|
<div class="page-header d-print-none admin-page-header blog-editor-header">
|
||||||
<div class="col-12">
|
<div class="row align-items-center">
|
||||||
<div class="page-header d-print-none mb-4">
|
<div class="col">
|
||||||
<div class="row align-items-center">
|
<div class="page-pretitle admin-page-header-eyebrow">콘텐츠</div>
|
||||||
<div class="col">
|
<h2 class="page-title admin-page-header-title">@(Model.IsEdit ? "블로그 수정" : "블로그 등록")</h2>
|
||||||
<div class="page-pretitle">콘텐츠</div>
|
<p class="text-muted mb-0 admin-page-header-subtitle">@(Model.IsEdit ? "기존 글을 불러와 즉시 편집하고 미리볼 수 있습니다." : "에디터와 미리보기를 함께 제공하는 작성 화면입니다.")</p>
|
||||||
<h2 class="page-title">@(Model.IsEdit ? "블로그 수정" : "블로그 등록")</h2>
|
</div>
|
||||||
<p class="text-muted mb-0">@(Model.IsEdit ? "기존 글을 불러와 즉시 편집하고 미리볼 수 있습니다." : "에디터와 미리보기를 함께 제공하는 작성 화면입니다.")</p>
|
<div class="col-auto ms-auto">
|
||||||
</div>
|
<span class="badge bg-blue-lt text-blue badge-sm">실시간 미리보기</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<form method="post" class="card admin-page-card">
|
<form method="post" class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
@await Html.PartialAsync("_ValidationSummary")
|
||||||
@if (Model.IsEdit)
|
@if (Model.IsEdit)
|
||||||
{
|
{
|
||||||
<input type="hidden" name="Id" value="@ViewData["BlogEditorId"]" />
|
<input type="hidden" name="Id" value="@ViewData["BlogEditorId"]" />
|
||||||
@@ -42,11 +43,26 @@
|
|||||||
<div class="row g-3 blog-meta-grid">
|
<div class="row g-3 blog-meta-grid">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label" for="Input_CategoryId">카테고리</label>
|
<label class="form-label" for="Input_CategoryId">카테고리</label>
|
||||||
<input class="form-control" id="Input_CategoryId" name="Input.CategoryId" value="@Model.CategoryId" />
|
<select class="form-select" id="Input_CategoryId" name="Input.CategoryId" required>
|
||||||
|
<option value="">카테고리 선택</option>
|
||||||
|
@foreach (var category in (ViewData["BlogCategories"] as IEnumerable<TaxBaik.Domain.Entities.Category>) ?? [])
|
||||||
|
{
|
||||||
|
if (category.Id == Model.CategoryId)
|
||||||
|
{
|
||||||
|
<option value="@category.Id" selected>@category.Name</option>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<option value="@category.Id">@category.Name</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<span class="text-danger small" data-valmsg-for="Input.CategoryId" data-valmsg-replace="true"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label d-block" for="Input_IsPublished">발행 여부</label>
|
<label class="form-label d-block" for="Input_IsPublished">발행 여부</label>
|
||||||
<input class="form-check-input" id="Input_IsPublished" name="Input.IsPublished" type="checkbox" value="true" @(Model.IsPublished ? "checked" : null) />
|
<input type="hidden" name="Input.IsPublished" value="@(Model.IsPublished ? "true" : "false")" data-blog-published-value />
|
||||||
|
<input class="form-check-input" id="Input_IsPublished" type="checkbox" value="true" @(Model.IsPublished ? "checked" : null) data-blog-published-toggle />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label" for="Input_ThumbnailUrl">썸네일 URL</label>
|
<label class="form-label" for="Input_ThumbnailUrl">썸네일 URL</label>
|
||||||
@@ -62,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white d-flex flex-wrap justify-content-between gap-2">
|
<div class="card-footer d-flex flex-wrap justify-content-between gap-2">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
@if (Model.IsEdit)
|
@if (Model.IsEdit)
|
||||||
{
|
{
|
||||||
@@ -75,12 +91,24 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card admin-page-card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header d-flex align-items-center justify-content-between gap-2">
|
<div class="card-header d-flex align-items-center justify-content-between gap-2">
|
||||||
<h3 class="card-title mb-0">실시간 미리보기</h3>
|
<h3 class="card-title mb-0">실시간 미리보기</h3>
|
||||||
<span class="badge bg-secondary-lt text-secondary">공통 스타일</span>
|
<span class="badge bg-secondary-lt text-secondary">공통 스타일</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="admin-editor-source">
|
||||||
|
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
|
||||||
|
<div class="fw-semibold mb-2">@Model.Title</div>
|
||||||
|
<dl class="row g-2 mb-0 small">
|
||||||
|
<dt class="col-4 text-muted fw-normal">카테고리</dt>
|
||||||
|
<dd class="col-8 mb-0">@Model.CategoryId</dd>
|
||||||
|
<dt class="col-4 text-muted fw-normal">발행</dt>
|
||||||
|
<dd class="col-8 mb-0">@(Model.IsPublished ? "예" : "아니오")</dd>
|
||||||
|
<dt class="col-4 text-muted fw-normal">썸네일</dt>
|
||||||
|
<dd class="col-8 mb-0">@(string.IsNullOrWhiteSpace(Model.ThumbnailUrl) ? "-" : Model.ThumbnailUrl)</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
<article class="content-prose blog-preview-pane" data-blog-preview>@Html.Raw(Model.Content)</article>
|
<article class="content-prose blog-preview-pane" data-blog-preview>@Html.Raw(Model.Content)</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
@model string
|
@model string
|
||||||
|
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card">
|
||||||
<div class="card-body text-center py-5">
|
<div class="card-body text-center py-5">
|
||||||
<div class="text-muted mb-2">준비 중</div>
|
<div class="avatar avatar-xl bg-blue-lt text-blue mb-3 mx-auto">UI</div>
|
||||||
|
<div class="text-uppercase text-muted small mb-1">준비 중</div>
|
||||||
<h3 class="card-title mb-2">@Model</h3>
|
<h3 class="card-title mb-2">@Model</h3>
|
||||||
<p class="text-muted mb-0">이 페이지는 현재 구조를 맞춘 상태이며, 다음 단계에서 기능을 채우면 됩니다.</p>
|
<p class="text-muted mb-0">이 페이지는 구조만 맞춘 상태이며, 다음 단계에서 기능을 채우면 됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@model string
|
@model string
|
||||||
<div class="empty py-5">
|
@{
|
||||||
|
var emptyMessage = string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model;
|
||||||
|
}
|
||||||
|
<div class="empty admin-empty-state">
|
||||||
<div class="empty-img">
|
<div class="empty-img">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-database-off" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-database-off" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M4 6c0 1.657 4.03 3 9 3s9-1.343 9-3-4.03-3-9-3-9 1.343-9 3" />
|
<path d="M4 6c0 1.657 4.03 3 9 3s9-1.343 9-3-4.03-3-9-3-9 1.343-9 3" />
|
||||||
@@ -8,5 +11,5 @@
|
|||||||
<path d="M3 3l18 18" />
|
<path d="M3 3l18 18" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="empty-title">@string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model</p>
|
<p class="empty-title">@emptyMessage</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
{
|
{
|
||||||
<nav class="mt-4" aria-label="페이지네이션">
|
<nav class="mt-4" aria-label="페이지네이션">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
|
<li class="page-item @(Model.Page <= 1 ? "disabled" : "")">
|
||||||
|
<a class="page-link" href="@(Model.Page <= 1 ? "#" : Model.BuildPageUrl(Model.Page - 1))" tabindex="@(Model.Page <= 1 ? -1 : 0)" aria-disabled="@(Model.Page <= 1 ? "true" : "false")">이전</a>
|
||||||
|
</li>
|
||||||
@for (var i = 1; i <= Model.TotalPages; i++)
|
@for (var i = 1; i <= Model.TotalPages; i++)
|
||||||
{
|
{
|
||||||
<li class="page-item @(i == Model.Page ? "active" : "")">
|
<li class="page-item @(i == Model.Page ? "active" : "")">
|
||||||
<a class="page-link" href="@Model.BuildPageUrl(i)">@i</a>
|
<a class="page-link" href="@Model.BuildPageUrl(i)" @(i == Model.Page ? "aria-current=\"page\"" : "")>@i</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
<li class="page-item @(Model.Page >= Model.TotalPages ? "disabled" : "")">
|
||||||
|
<a class="page-link" href="@(Model.Page >= Model.TotalPages ? "#" : Model.BuildPageUrl(Model.Page + 1))" tabindex="@(Model.Page >= Model.TotalPages ? -1 : 0)" aria-disabled="@(Model.Page >= Model.TotalPages ? "true" : "false")">다음</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<span class="avatar avatar-xs bg-red-lt text-red">!</span>
|
<span class="avatar avatar-xs bg-red-lt text-red">!</span>
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<div class="fw-semibold mb-1">입력값을 확인하세요.</div>
|
<div class="fw-semibold mb-1">입력값을 확인하세요.</div>
|
||||||
<div asp-validation-summary="All" class="validation-summary text-sm"></div>
|
<div asp-validation-summary="All" class="text-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
@page "/sitemap.xml"
|
@page "/sitemap.xml"
|
||||||
@model TaxBaik.Web.Pages.SitemapModel
|
@model TaxBaik.Web.Pages.SitemapModel
|
||||||
@{
|
@{
|
||||||
|
Layout = null;
|
||||||
Response.ContentType = "application/xml; charset=utf-8";
|
Response.ContentType = "application/xml; charset=utf-8";
|
||||||
}<?xml version="1.0" encoding="utf-8"?>
|
}<?xml version="1.0" encoding="utf-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
@foreach (var url in Model.Urls)
|
@foreach (var url in Model.Urls)
|
||||||
{
|
{
|
||||||
<url>
|
<url>
|
||||||
<loc>@System.Net.WebUtility.HtmlEncode(url)</loc>
|
<loc>@url.Location</loc>
|
||||||
<lastmod>@DateTime.UtcNow:yyyy-MM-dd</lastmod>
|
@if (url.LastModified.HasValue)
|
||||||
|
{
|
||||||
|
<lastmod>@url.LastModified.Value.ToUniversalTime().ToString("yyyy-MM-dd")</lastmod>
|
||||||
|
}
|
||||||
</url>
|
</url>
|
||||||
}
|
}
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
@@ -5,48 +5,91 @@ namespace TaxBaik.Web.Pages;
|
|||||||
|
|
||||||
public class SitemapModel : PageModel
|
public class SitemapModel : PageModel
|
||||||
{
|
{
|
||||||
|
private const string BaseUrl = "https://www.taxbaik.com";
|
||||||
|
private const int BlogPageSize = 100;
|
||||||
|
|
||||||
private readonly BlogService _blogService;
|
private readonly BlogService _blogService;
|
||||||
|
private readonly FaqService _faqService;
|
||||||
|
private readonly AnnouncementService _announcementService;
|
||||||
|
private readonly ILogger<SitemapModel> _logger;
|
||||||
|
|
||||||
public List<string> Urls { get; set; } = [];
|
public List<SitemapUrl> Urls { get; } = [];
|
||||||
|
|
||||||
public SitemapModel(BlogService blogService)
|
public SitemapModel(
|
||||||
|
BlogService blogService,
|
||||||
|
FaqService faqService,
|
||||||
|
AnnouncementService announcementService,
|
||||||
|
ILogger<SitemapModel> logger)
|
||||||
{
|
{
|
||||||
_blogService = blogService;
|
_blogService = blogService;
|
||||||
|
_faqService = faqService;
|
||||||
|
_announcementService = announcementService;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
var baseUrl = "https://www.taxbaik.com";
|
// FAQ와 공지사항은 홈에만 표시되고 각각의 URL은 리다이렉트된다.
|
||||||
|
// 따라서 홈의 변경일에 합산하고 리다이렉트 URL은 sitemap에서 제외한다.
|
||||||
|
var homeLastModified = default(DateTime);
|
||||||
|
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
|
||||||
|
|
||||||
// 정적 페이지 (항상 포함)
|
|
||||||
Urls.AddRange(new[]
|
|
||||||
{
|
|
||||||
$"{baseUrl}",
|
|
||||||
$"{baseUrl}/about",
|
|
||||||
$"{baseUrl}/services",
|
|
||||||
$"{baseUrl}/contact",
|
|
||||||
$"{baseUrl}/privacy",
|
|
||||||
$"{baseUrl}/terms",
|
|
||||||
$"{baseUrl}/blog",
|
|
||||||
$"{baseUrl}/faq",
|
|
||||||
$"{baseUrl}/announcement",
|
|
||||||
$"{baseUrl}/inquiry"
|
|
||||||
});
|
|
||||||
|
|
||||||
// 동적 블로그 포스트 (DB 오류 처리)
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default);
|
var faqTask = _faqService.GetActiveAsync(HttpContext.RequestAborted);
|
||||||
foreach (var post in posts)
|
var announcementTask = _announcementService.GetActiveAsync(HttpContext.RequestAborted);
|
||||||
{
|
var postsTask = GetAllPublishedPostsAsync(HttpContext.RequestAborted);
|
||||||
Urls.Add($"{baseUrl}/blog/{post.Slug}");
|
|
||||||
}
|
await Task.WhenAll(faqTask, announcementTask, postsTask);
|
||||||
|
posts = await postsTask;
|
||||||
|
homeLastModified = (await faqTask)
|
||||||
|
.Select(faq => faq.UpdatedAt)
|
||||||
|
.Concat((await announcementTask).Select(announcement => announcement.UpdatedAt))
|
||||||
|
.Concat(posts.Take(3).Select(post => post.UpdatedAt))
|
||||||
|
.DefaultIfEmpty()
|
||||||
|
.Max();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// DB 연결 실패 등의 경우, 정적 페이지만으로도 sitemap 제공
|
// DB 문제 중에도 검색 엔진이 기존 핵심 페이지를 계속 발견할 수 있게 한다.
|
||||||
// 프로덕션에서는 DB가 있으므로 이 경로는 사용되지 않음
|
_logger.LogError(ex, "Failed to load dynamic sitemap content");
|
||||||
System.Diagnostics.Debug.WriteLine($"Sitemap: Blog posts load failed: {ex.Message}");
|
}
|
||||||
|
|
||||||
|
Urls.AddRange(new[]
|
||||||
|
{
|
||||||
|
new SitemapUrl($"{BaseUrl}/", homeLastModified == default ? null : homeLastModified),
|
||||||
|
new SitemapUrl($"{BaseUrl}/about"),
|
||||||
|
new SitemapUrl($"{BaseUrl}/services"),
|
||||||
|
new SitemapUrl($"{BaseUrl}/contact"),
|
||||||
|
new SitemapUrl($"{BaseUrl}/privacy"),
|
||||||
|
new SitemapUrl($"{BaseUrl}/terms"),
|
||||||
|
new SitemapUrl($"{BaseUrl}/blog")
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var post in posts)
|
||||||
|
{
|
||||||
|
Urls.Add(new SitemapUrl(
|
||||||
|
$"{BaseUrl}/blog/{Uri.EscapeDataString(post.Slug)}",
|
||||||
|
post.UpdatedAt == default ? post.PublishedAt : post.UpdatedAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<TaxBaik.Domain.Entities.BlogPost>> GetAllPublishedPostsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
|
||||||
|
var page = 1;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var (items, total) = await _blogService.GetPublishedPagedAsync(page, BlogPageSize, ct: ct);
|
||||||
|
posts.AddRange(items);
|
||||||
|
|
||||||
|
if (posts.Count >= total || !items.Any())
|
||||||
|
return posts;
|
||||||
|
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SitemapUrl(string Location, DateTime? LastModified = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
@{
|
||||||
|
const string SiteUrl = "https://www.taxbaik.com";
|
||||||
|
var canonicalUrl = ViewData["CanonicalUrl"] as string ?? $"{SiteUrl}{Context.Request.PathBase}{Context.Request.Path}";
|
||||||
|
var ogUrl = ViewData["OgUrl"] as string ?? canonicalUrl;
|
||||||
|
var robots = ViewData["Robots"] as string ?? "index, follow";
|
||||||
|
}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
@@ -12,30 +18,29 @@
|
|||||||
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||||
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||||
<meta property="og:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
|
<meta property="og:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
|
||||||
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "https://www.taxbaik.com/")" />
|
<meta property="og:url" content="@ogUrl" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
<meta name="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
|
||||||
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
<meta name="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
|
||||||
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
|
<meta name="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
|
||||||
|
|
||||||
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
|
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
|
||||||
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
|
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
|
||||||
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
|
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
|
||||||
|
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="@robots" />
|
||||||
<meta name="theme-color" content="#C89D6E" />
|
<meta name="theme-color" content="#C89D6E" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
<!-- RSS Feed -->
|
<!-- RSS Feed -->
|
||||||
<link rel="alternate" type="application/rss+xml" title="백원숙 세무회계 블로그" href="/rss.xml" />
|
<link rel="alternate" type="application/rss+xml" title="백원숙 세무회계 블로그" href="/rss.xml" />
|
||||||
<link rel="alternate" type="application/rss+xml" title="TaxBaik Blog Feed" href="/feed.xml" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "https://www.taxbaik.com/")" />
|
<link rel="canonical" href="@canonicalUrl" />
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="with-mobile-cta" data-kakao-url="https://pf.kakao.com/_xoxchTX">
|
<body class="with-mobile-cta" data-kakao-url="https://pf.kakao.com/_xoxchTX">
|
||||||
<partial name="_Header" />
|
@await Html.PartialAsync("_Header")
|
||||||
<main role="main" class="pb-5">
|
<main role="main" class="pb-5">
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@using TaxBaik.Web
|
||||||
|
@namespace TaxBaik.Web.Pages
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@@ -69,11 +69,12 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
options.AddPolicy("client-logs", httpContext =>
|
options.AddPolicy("client-logs", httpContext =>
|
||||||
{
|
{
|
||||||
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
var isDevelopment = builder.Environment.IsDevelopment();
|
||||||
return RateLimitPartition.GetFixedWindowLimiter(
|
return RateLimitPartition.GetFixedWindowLimiter(
|
||||||
partitionKey: $"client-logs:{ip}",
|
partitionKey: $"client-logs:{ip}",
|
||||||
factory: _ => new FixedWindowRateLimiterOptions
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
{
|
{
|
||||||
PermitLimit = 10,
|
PermitLimit = isDevelopment ? 200 : 10,
|
||||||
Window = TimeSpan.FromMinutes(1),
|
Window = TimeSpan.FromMinutes(1),
|
||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
AutoReplenishment = true
|
AutoReplenishment = true
|
||||||
@@ -82,11 +83,12 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
options.AddPolicy("admin-login", httpContext =>
|
options.AddPolicy("admin-login", httpContext =>
|
||||||
{
|
{
|
||||||
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
var isDevelopment = builder.Environment.IsDevelopment();
|
||||||
return RateLimitPartition.GetFixedWindowLimiter(
|
return RateLimitPartition.GetFixedWindowLimiter(
|
||||||
partitionKey: $"admin-login:{ip}",
|
partitionKey: $"admin-login:{ip}",
|
||||||
factory: _ => new FixedWindowRateLimiterOptions
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
{
|
{
|
||||||
PermitLimit = 5,
|
PermitLimit = isDevelopment ? 100 : 5,
|
||||||
Window = TimeSpan.FromMinutes(1),
|
Window = TimeSpan.FromMinutes(1),
|
||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
AutoReplenishment = true
|
AutoReplenishment = true
|
||||||
@@ -95,6 +97,7 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Razor Pages
|
// Razor Pages
|
||||||
|
builder.Services.AddSingleton<IIpLockoutService, IpLockoutService>();
|
||||||
builder.Services.AddRazorPages();
|
builder.Services.AddRazorPages();
|
||||||
|
|
||||||
// Session & TempData (쿠키 저장소)
|
// Session & TempData (쿠키 저장소)
|
||||||
@@ -262,6 +265,14 @@ builder.Services.AddScoped<TaxBaik.Web.Services.SitemapValidationService>();
|
|||||||
|
|
||||||
// Register version info
|
// Register version info
|
||||||
var versionInfo = new VersionInfo();
|
var versionInfo = new VersionInfo();
|
||||||
|
var assembly = typeof(Program).Assembly;
|
||||||
|
var assemblyVersion = assembly.GetName().Version?.ToString() ?? "unknown";
|
||||||
|
var informationalVersion = assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
|
||||||
|
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||||
|
.FirstOrDefault()?.InformationalVersion;
|
||||||
|
|
||||||
|
versionInfo.Version = informationalVersion ?? assemblyVersion;
|
||||||
|
versionInfo.Built = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'");
|
||||||
var versionJsonPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "version.json");
|
var versionJsonPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "version.json");
|
||||||
if (File.Exists(versionJsonPath))
|
if (File.Exists(versionJsonPath))
|
||||||
{
|
{
|
||||||
@@ -350,7 +361,7 @@ app.Use(async (context, next) =>
|
|||||||
context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none";
|
context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none";
|
||||||
if (!context.Response.Headers.ContainsKey("Content-Security-Policy"))
|
if (!context.Response.Headers.ContainsKey("Content-Security-Policy"))
|
||||||
{
|
{
|
||||||
context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://www.googletagmanager.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
|
context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://www.googletagmanager.com https://static.cloudflareinsights.com; connect-src 'self' https: https://cloudflareinsights.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ public class AuthService
|
|||||||
private readonly ILogger<AuthService> _logger;
|
private readonly ILogger<AuthService> _logger;
|
||||||
private readonly ITelegramNotificationService _telegramService;
|
private readonly ITelegramNotificationService _telegramService;
|
||||||
private readonly IHostEnvironment _environment;
|
private readonly IHostEnvironment _environment;
|
||||||
private readonly string _jwtSecretKey;
|
private readonly IIpLockoutService _ipLockoutService;
|
||||||
|
private readonly string? _jwtSecretKey;
|
||||||
private readonly string? _passwordResetToken;
|
private readonly string? _passwordResetToken;
|
||||||
private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상)
|
private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상)
|
||||||
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
|
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
|
||||||
@@ -24,18 +25,26 @@ public class AuthService
|
|||||||
ILogger<AuthService> logger,
|
ILogger<AuthService> logger,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ITelegramNotificationService telegramService,
|
ITelegramNotificationService telegramService,
|
||||||
IHostEnvironment environment)
|
IHostEnvironment environment,
|
||||||
|
IIpLockoutService ipLockoutService)
|
||||||
{
|
{
|
||||||
_adminUserRepository = adminUserRepository;
|
_adminUserRepository = adminUserRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_telegramService = telegramService;
|
_telegramService = telegramService;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
|
_ipLockoutService = ipLockoutService;
|
||||||
|
_jwtSecretKey = configuration["Jwt:SecretKey"];
|
||||||
_passwordResetToken = configuration["Admin:PasswordResetToken"];
|
_passwordResetToken = configuration["Admin:PasswordResetToken"];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResult> AuthenticateAndGenerateTokenPairAsync(string username, string password)
|
public async Task<AuthResult> AuthenticateAndGenerateTokenPairAsync(string username, string password, string ipAddress)
|
||||||
{
|
{
|
||||||
|
if (_ipLockoutService.IsLockedOut(ipAddress))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("로그인 시도 거부: 차단된 IP '{IpAddress}' (사용자: '{Username}')", ipAddress, username);
|
||||||
|
return AuthResult.InvalidCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
return AuthResult.InvalidCredentials();
|
return AuthResult.InvalidCredentials();
|
||||||
|
|
||||||
@@ -49,23 +58,24 @@ public class AuthService
|
|||||||
catch (Exception ex) when (_environment.IsDevelopment())
|
catch (Exception ex) when (_environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "❌ [개발 환경] 관리자 로그인 DB 조회 실패: {Username} | Exception Type: {ExType} | Message: {ExMessage}",
|
_logger.LogError(ex, "❌ [개발 환경] 관리자 로그인 DB 조회 실패: {Username} | Exception Type: {ExType} | Message: {ExMessage}",
|
||||||
username, ex.GetType().Name, ex.Message);
|
username, ex.GetType().Name, ex.Message);
|
||||||
if (ex.InnerException != null)
|
if (ex.InnerException != null)
|
||||||
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
|
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
|
||||||
return AuthResult.DatabaseUnavailable();
|
return AuthResult.DatabaseUnavailable();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "❌ 관리자 로그인 중 예상치 못한 오류: {Username} | Exception Type: {ExType} | Message: {ExMessage}",
|
_logger.LogError(ex, "❌ 관리자 로그인 중 예상치 못한 오류: {Username} | Exception Type: {ExType} | Message: {ExMessage}",
|
||||||
username, ex.GetType().Name, ex.Message);
|
username, ex.GetType().Name, ex.Message);
|
||||||
if (ex.InnerException != null)
|
if (ex.InnerException != null)
|
||||||
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
|
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
|
||||||
return AuthResult.DatabaseUnavailable();
|
return AuthResult.DatabaseUnavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username);
|
_logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username);
|
||||||
|
_ipLockoutService.RecordFailedAttempt(ipAddress);
|
||||||
return AuthResult.InvalidCredentials();
|
return AuthResult.InvalidCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,18 +88,26 @@ public class AuthService
|
|||||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
|
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
|
||||||
|
_ipLockoutService.RecordFailedAttempt(ipAddress);
|
||||||
// 실패한 로그인은 알림하지 않음 (로그만 남김)
|
// 실패한 로그인은 알림하지 않음 (로그만 남김)
|
||||||
return AuthResult.InvalidCredentials();
|
return AuthResult.InvalidCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("로그인 성공: {Username}", username);
|
_logger.LogInformation("로그인 성공: {Username} (IP: {IpAddress})", username, ipAddress);
|
||||||
|
_ipLockoutService.ResetAttempts(ipAddress);
|
||||||
// 로그인 알림은 제거 (로그만 남김)
|
// 로그인 알림은 제거 (로그만 남김)
|
||||||
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
|
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
|
||||||
return AuthResult.Success(GenerateTokenPair(user));
|
return AuthResult.Success(GenerateTokenPair(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AdminUser?> AuthenticateAsync(string username, string password)
|
public async Task<AdminUser?> AuthenticateAsync(string username, string password, string ipAddress)
|
||||||
{
|
{
|
||||||
|
if (_ipLockoutService.IsLockedOut(ipAddress))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("로그인 시도 거부: 차단된 IP '{IpAddress}' (사용자: '{Username}')", ipAddress, username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -97,11 +115,18 @@ public class AuthService
|
|||||||
{
|
{
|
||||||
var user = await _adminUserRepository.GetByUsernameAsync(username);
|
var user = await _adminUserRepository.GetByUsernameAsync(username);
|
||||||
if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash))
|
if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash))
|
||||||
|
{
|
||||||
|
_ipLockoutService.RecordFailedAttempt(ipAddress);
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
|
{
|
||||||
|
_ipLockoutService.RecordFailedAttempt(ipAddress);
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ipLockoutService.ResetAttempts(ipAddress);
|
||||||
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
|
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -184,7 +209,8 @@ public class AuthService
|
|||||||
|
|
||||||
private AuthTokenPair GenerateTokenPair(AdminUser user)
|
private AuthTokenPair GenerateTokenPair(AdminUser user)
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
|
var jwtSecretKey = GetJwtSecretKey();
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecretKey));
|
||||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
@@ -220,7 +246,7 @@ public class AuthService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecretKey()));
|
||||||
var handler = new JwtSecurityTokenHandler();
|
var handler = new JwtSecurityTokenHandler();
|
||||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||||
{
|
{
|
||||||
@@ -246,7 +272,7 @@ public class AuthService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecretKey()));
|
||||||
var handler = new JwtSecurityTokenHandler();
|
var handler = new JwtSecurityTokenHandler();
|
||||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||||
{
|
{
|
||||||
@@ -278,6 +304,14 @@ public class AuthService
|
|||||||
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes);
|
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetJwtSecretKey()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_jwtSecretKey))
|
||||||
|
throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
|
||||||
|
|
||||||
|
return _jwtSecretKey;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AuthTokenPair(string accessToken, string refreshToken, int expiresIn)
|
public class AuthTokenPair(string accessToken, string refreshToken, int expiresIn)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
public interface IIpLockoutService
|
||||||
|
{
|
||||||
|
bool IsLockedOut(string ipAddress);
|
||||||
|
void RecordFailedAttempt(string ipAddress);
|
||||||
|
void ResetAttempts(string ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IpLockoutService : IIpLockoutService
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly ILogger<IpLockoutService> _logger;
|
||||||
|
|
||||||
|
private const int MaxAttempts = 3;
|
||||||
|
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
|
||||||
|
private static readonly TimeSpan AttemptWindow = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
public IpLockoutService(IMemoryCache cache, ILogger<IpLockoutService> logger)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLockedOut(string ipAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var lockoutKey = GetLockoutKey(ipAddress);
|
||||||
|
if (_cache.TryGetValue(lockoutKey, out DateTime lockoutUntil))
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow < lockoutUntil)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lockout period expired, clean up
|
||||||
|
_cache.Remove(lockoutKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordFailedAttempt(string ipAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var attemptsKey = GetAttemptsKey(ipAddress);
|
||||||
|
int currentAttempts = _cache.GetOrCreate(attemptsKey, entry =>
|
||||||
|
{
|
||||||
|
entry.AbsoluteExpirationRelativeToNow = AttemptWindow;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
currentAttempts++;
|
||||||
|
_cache.Set(attemptsKey, currentAttempts, AttemptWindow);
|
||||||
|
|
||||||
|
_logger.LogWarning("IP {IpAddress} 로그인 실패 기록됨. 누적 실패 횟수: {Attempts}/{MaxAttempts}",
|
||||||
|
ipAddress, currentAttempts, MaxAttempts);
|
||||||
|
|
||||||
|
if (currentAttempts >= MaxAttempts)
|
||||||
|
{
|
||||||
|
var lockoutUntil = DateTime.UtcNow.Add(LockoutDuration);
|
||||||
|
var lockoutKey = GetLockoutKey(ipAddress);
|
||||||
|
_cache.Set(lockoutKey, lockoutUntil, LockoutDuration);
|
||||||
|
|
||||||
|
_logger.LogCritical("IP {IpAddress}의 로그인 시도가 {MaxAttempts}회 연속 실패하여 {Duration} 동안 로그인 차단됩니다. 차단 해제 예정 일시: {LockoutUntil} UTC",
|
||||||
|
ipAddress, MaxAttempts, LockoutDuration, lockoutUntil);
|
||||||
|
|
||||||
|
// Reset attempts count since they are now locked out
|
||||||
|
_cache.Remove(attemptsKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetAttempts(string ipAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cache.Remove(GetAttemptsKey(ipAddress));
|
||||||
|
_cache.Remove(GetLockoutKey(ipAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetAttemptsKey(string ipAddress) => $"IpLockout:Attempts:{ipAddress}";
|
||||||
|
private static string GetLockoutKey(string ipAddress) => $"IpLockout:Lockout:{ipAddress}";
|
||||||
|
}
|
||||||
@@ -46,8 +46,8 @@ public class SitemapValidationService
|
|||||||
result.TotalUrls = staticUrls.Count;
|
result.TotalUrls = staticUrls.Count;
|
||||||
|
|
||||||
// 2. 동적 블로그 포스트 검증
|
// 2. 동적 블로그 포스트 검증
|
||||||
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default);
|
var posts = await GetAllPublishedPostsAsync();
|
||||||
var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{p.Slug}").ToList();
|
var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(p.Slug)}").ToList();
|
||||||
|
|
||||||
ValidateUrls(blogUrls, result);
|
ValidateUrls(blogUrls, result);
|
||||||
|
|
||||||
@@ -251,10 +251,25 @@ public class SitemapValidationService
|
|||||||
$"{baseUrl}/contact",
|
$"{baseUrl}/contact",
|
||||||
$"{baseUrl}/privacy",
|
$"{baseUrl}/privacy",
|
||||||
$"{baseUrl}/terms",
|
$"{baseUrl}/terms",
|
||||||
$"{baseUrl}/blog",
|
$"{baseUrl}/blog"
|
||||||
$"{baseUrl}/faq",
|
|
||||||
$"{baseUrl}/announcement",
|
|
||||||
$"{baseUrl}/inquiry"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<TaxBaik.Domain.Entities.BlogPost>> GetAllPublishedPostsAsync()
|
||||||
|
{
|
||||||
|
const int pageSize = 100;
|
||||||
|
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
|
||||||
|
var page = 1;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var (items, total) = await _blogService.GetPublishedPagedAsync(page, pageSize);
|
||||||
|
posts.AddRange(items);
|
||||||
|
|
||||||
|
if (posts.Count >= total || !items.Any())
|
||||||
|
return posts;
|
||||||
|
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@
|
|||||||
function setupEditor(root) {
|
function setupEditor(root) {
|
||||||
const editor = root.querySelector('[data-blog-editor]');
|
const editor = root.querySelector('[data-blog-editor]');
|
||||||
const hiddenInput = root.querySelector('[data-blog-editor-value]');
|
const hiddenInput = root.querySelector('[data-blog-editor-value]');
|
||||||
|
const publishedToggle = root.querySelector('[data-blog-published-toggle]');
|
||||||
|
const publishedValue = root.querySelector('[data-blog-published-value]');
|
||||||
|
const form = root.querySelector('form');
|
||||||
const preview = root.querySelector('[data-blog-preview]');
|
const preview = root.querySelector('[data-blog-preview]');
|
||||||
const buttons = root.querySelectorAll('[data-blog-command]');
|
const buttons = root.querySelectorAll('[data-blog-command]');
|
||||||
|
|
||||||
@@ -32,6 +35,21 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncPublishedState = () => {
|
||||||
|
if (publishedValue && publishedToggle) {
|
||||||
|
publishedValue.value = publishedToggle.checked ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (publishedToggle) {
|
||||||
|
publishedToggle.addEventListener('change', syncPublishedState);
|
||||||
|
syncPublishedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', syncPublishedState);
|
||||||
|
}
|
||||||
|
|
||||||
syncEditor(editor, hiddenInput);
|
syncEditor(editor, hiddenInput);
|
||||||
renderPreview();
|
renderPreview();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,30 +7,6 @@ Allow: /
|
|||||||
Disallow: /admin/
|
Disallow: /admin/
|
||||||
Disallow: /portal/
|
Disallow: /portal/
|
||||||
Disallow: /manage/
|
Disallow: /manage/
|
||||||
Disallow: *.json$
|
|
||||||
Disallow: *.xml$ (sitemap 제외)
|
|
||||||
|
|
||||||
# 검색 엔진별 크롤링 속도 제한
|
|
||||||
User-agent: Googlebot
|
|
||||||
Crawl-delay: 1
|
|
||||||
|
|
||||||
User-agent: Bingbot
|
|
||||||
Crawl-delay: 2
|
|
||||||
|
|
||||||
User-agent: Yeti
|
|
||||||
Crawl-delay: 1
|
|
||||||
|
|
||||||
# Naver, Kakao 검색 엔진
|
|
||||||
User-agent: Naver
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: Kakaobot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
# Sitemap 위치
|
# Sitemap 위치
|
||||||
Sitemap: https://www.taxbaik.com/sitemap.xml
|
Sitemap: https://www.taxbaik.com/sitemap.xml
|
||||||
Sitemap: https://www.taxbaik.com/sitemap.xml
|
|
||||||
|
|
||||||
# RSS 피드
|
|
||||||
Sitemap: https://www.taxbaik.com/rss.xml
|
|
||||||
Sitemap: https://www.taxbaik.com/feed.xml
|
|
||||||
|
|||||||
Reference in New Issue
Block a user