Compare commits

..

71 Commits

Author SHA1 Message Date
kjh2064 959a4ea00b style: upgrade admin panel theme to Tabler native vertical layout with light/dark mode support
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-07-11 22:42:55 +09:00
kjh2064 eccbd00902 Configure development rate limiter limit and fix base url in local tests runner
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m36s
2026-07-11 17:55:12 +09:00
kjh2064 dce8e23676 Use DbUp for schema migrations with custom journal for compatibility
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-07-11 17:31:49 +09:00
kjh2064 fb3ee4aad6 Use combined RSS feed items
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m22s
2026-07-11 15:37:50 +09:00
kjh2064 228dc6683d Simplify RSS for Naver compatibility
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-07-11 15:33:05 +09:00
kjh2064 262046628d Harden RSS feed for Naver
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-07-11 15:31:06 +09:00
kjh2064 bcc3d6e680 Improve sitemap, RSS, and SEO guardrails
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-07-11 13:09:57 +09:00
kjh2064 e0945f46a6 feat(admin/security): implement IP Lockout service for brute-force defense and compact administrative UI density
TaxBaik CI/CD / build-and-deploy (push) Successful in 57s
2026-07-10 17:06:00 +09:00
kjh2064 4d3933f22e style: apply navy background to entire sidebar panel with card body and header overrides
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m30s
2026-07-09 23:41:14 +09:00
kjh2064 a781aa5ecb style: upgrade accordion sidebar to Tabler demo level with navy background, white text, and proper hover states
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m21s
2026-07-09 23:37:45 +09:00
kjh2064 c569f32c03 fix: revert navbar-vertical to accordion structure to prevent dropdown closure
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m15s
2026-07-09 23:34:12 +09:00
kjh2064 5e0a4c07d2 ci: update browser-e2e workflow URLs to match current codebase (remove PathBase /taxbaik)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m7s
2026-07-09 23:29:38 +09:00
kjh2064 22a1a4c4ad fix: prevent dropdown menu from closing when selected by using javascript:void(0) href
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m4s
2026-07-09 23:27:44 +09:00
kjh2064 a80651cd15 style: update sidebar to navy background with white text for better contrast and readability
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m5s
2026-07-09 23:20:35 +09:00
kjh2064 7389d5aeb3 style: add Tabler navbar-vertical CSS for admin sidebar with proper typography and colors
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m6s
2026-07-09 23:08:47 +09:00
kjh2064 4f012fa1ad refactor: convert admin sidebar from accordion to Tabler navbar-vertical pattern with SVG icons
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-07-09 23:05:52 +09:00
kjh2064 66d7256d72 fix: add Cloudflare Insights to CSP script-src and connect-src
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m4s
2026-07-09 22:51:55 +09:00
kjh2064 066cac0ce2 refactor: fix action button UX on detail pages (col-lg-4 d-grid to d-flex layout)
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-07-09 22:40:05 +09:00
kjh2064 505bd9f663 style: Fix search form height - add min-height: auto override
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
Search form card-body inherits min-height: calc(100vh - 140px) from .admin-content-card .card-body, causing it to occupy full screen. Add inline min-height: auto to override and allow natural height for search forms only.

Affected pages: Blog, Clients, Inquiries

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:32:45 +09:00
kjh2064 8812e7e5d2 style: Fix CSS selector conflict for table-responsive margin
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
Change from .card > .table-responsive to .card > .card-body + .table-responsive to avoid overriding border-top from .table-responsive--separator class. Ensures margin removal doesn't conflict with separator border styling.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:16:58 +09:00
kjh2064 bc1373b6b8 style: Standardize all Edit pages with form compaction
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m10s
Apply consistent styling to all Edit pages:
- card-header p-2 + small typography
- card-body p-2 with g-2 spacing
- form-control-sm, form-select-sm, form-label-sm
- btn-sm for all buttons
- Consistent badge and delete button styling

Affected pages: Clients, Announcements, FAQs, Inquiries, Blog

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:14:04 +09:00
kjh2064 16d74c1299 style: Remove gap between card-body and table-responsive
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m25s
Add CSS rules to eliminate margin between card-body (form) and table-responsive (table) in card containers. Achieves compact spacing without whitespace gaps between search form and data table.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:12:25 +09:00
kjh2064 d94edc5a30 style: Apply form compaction to Create/Edit pages
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m13s
Standardize all Create and Edit forms with:
- card-body p-2 padding
- form-control-sm, form-select-sm, form-label-sm for compact layout
- btn-sm for buttons
- row g-2 spacing instead of g-3
- Consistent empty state and danger action styling

Affected pages: Clients, Announcements, FAQs, Inquiries, Blog

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:11:35 +09:00
kjh2064 51beea51b6 style: Standardize empty state card-body padding to p-2
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m9s
Add p-2 padding to card-body elements in empty state blocks across all admin pages (Announcements, FAQs, TaxProfiles, Companies) for consistent spacing and compact layout.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:08:59 +09:00
kjh2064 d891f3f491 style: Add separator class to all table-responsive containers
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m6s
Apply .table-responsive--separator to all pages with table containers (Announcements, FAQs, TaxProfiles, Companies) for consistent spacing and visual hierarchy.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:07:17 +09:00
kjh2064 59811cee52 style: Replace inline border-top styles with reusable CSS class
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m7s
Move border-top styling from inline attributes to .table-responsive--separator class in admin.css. Removes duplication and maintains consistent design system across all search+table pages (Blog, Clients, Inquiries).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:05:09 +09:00
kjh2064 fd74240a72 style: Remove hr separators, use CSS borders for compact spacing
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m10s
Replace <hr class="my-0" /> with <div class="table-responsive" style="border-top: 1px solid var(--border-color-light);"> across all search+table pages (Blog, Clients, Inquiries). This eliminates the large gap created by hr element's default margin, achieving Tabler demo-level density.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 22:03:56 +09:00
kjh2064 da085a7700 style: Compact search/filter forms across all pages
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m7s
- Reduce card-body padding to p-2
- Change gap from g-3 to g-2
- Convert row columns to col-auto for compact layout
- Add form-control-sm and form-select-sm
- Shorten placeholder text and labels
- Applied to: Blog, Clients, Inquiries

Result: Search sections now take minimal vertical space,
table data dominates the view for better data density.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 21:59:36 +09:00
kjh2064 29a2d65065 style: Integrate search form and table into single card container
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
- Consolidate filter/search form with data table in one card
- Separate with <hr class='my-0'> for visual hierarchy
- Remove redundant card-mb spacing and gaps
- Unified layout for Blog, Clients, Inquiries pages
- Cleaner, more cohesive data display

Applied to:
- Blog/Index.cshtml
- Clients/Index.cshtml
- Inquiries/Index.cshtml

Announcements and Faqs already have integrated structure (no search form)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 21:56:16 +09:00
kjh2064 2215c8ddb5 style: Remove unnecessary spacing between page header and content
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m4s
- Reduce admin-page-shell gap from 0.25rem to 0 (eliminate double spacing)
- Remove all card margins in admin-page-shell (margin-top/bottom: 0)
- Keep only page-header margin-bottom for section separation (0.5rem)
- Achieve seamless content flow with minimal visual breaks

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 21:52:59 +09:00
kjh2064 05dfa17dde style: Extreme density optimization - ultra-compact admin UI
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
- Reduce admin-page-shell gap from 0.5rem to 0.25rem
- Reduce page header title from 0.9rem to 0.78rem
- Reduce header margins: eyebrow (0.2rem→0.1rem), subtitle (0.2rem→0.1rem), title (0.3rem→0.15rem)
- Reduce page-header margin-bottom from 0.4rem to 0.25rem
- Reduce card-body padding to p-2 across all breakpoints
- Reduce admin-page-shell padding from 1rem to 0.5rem
- Achieve Douzone-level data density

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 21:48:49 +09:00
kjh2064 d95a88bc3d style: Compact admin UI density - reduce card padding and header size
TaxBaik CI/CD / build-and-deploy (push) Successful in 57s
- Reduce card-body padding from p-3 p-md-4 p-xl-4 to p-2 p-md-3
- Decrease admin-page-shell gap from 1rem to 0.5rem
- Reduce page header title size from 1.02rem to 0.9rem
- Reduce header margin-bottom from 0.5rem to 0.4rem
- Improve visual hierarchy with tighter spacing

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 21:45:33 +09:00
kjh2064 a508b08509 style: Apply Tabler layout to Blog create/edit pages
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
- Add admin-page-shell wrapper and _AdminPageHeader partial
- Wrap editor content in card component
- Consistent with other admin pages (Index.cshtml pattern)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 21:31:53 +09:00
kjh2064 81b2b1ebdf docs: Update architecture guidelines for Razor Pages + Cookie Auth (2026-07-09)
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
- Add architecture change notice in CLAUDE.md (Blazor WASM → Razor Pages)
- Mark Phase 8/9/13 as [레거시] (no longer current standard)
- Update priority guidelines: add ADMIN_RAZORPAGES_ROADMAP.md and ADMIN_LEGACY_BLAZOR.md
- Fix STACK_TEMPLATE_WBS.md Target Template table: Admin CRUD now uses Razor Pages + Cookie Authentication
- Add legacy references for previous WebAssembly/MudBlazor approach

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-09 21:23:53 +09:00
kjh2064 0cf881c652 Align admin home with Tabler tone
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m8s
2026-07-09 18:05:35 +09:00
kjh2064 a49a75dd36 Flatten admin theme toward Tabler
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m6s
2026-07-09 18:03:45 +09:00
kjh2064 26ea7e17d0 Fix blog publish checkbox binding
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-07-09 17:44:21 +09:00
kjh2064 bbb5d8dc55 Reduce admin title density
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-07-09 16:58:55 +09:00
kjh2064 dfbc077679 Compress admin sidebar and dashboard
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-07-09 16:55:28 +09:00
kjh2064 5e1ebd8448 Move admin breadcrumb to topbar
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-07-09 16:53:55 +09:00
kjh2064 3c4905a249 Tighten admin top density
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m6s
2026-07-09 16:52:37 +09:00
kjh2064 bfc19adf47 Compress admin breadcrumb and sidebar
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-07-09 16:50:54 +09:00
kjh2064 1cb35810b6 Unify admin edit source summaries
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-07-09 16:48:11 +09:00
kjh2064 d4ea2c18c1 Refine announcement edit source summary
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m0s
2026-07-09 16:46:42 +09:00
kjh2064 ccbc1d3698 Improve blog editor source summary
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-07-09 16:45:16 +09:00
kjh2064 946e305e2c Fix blog editor default selection and checkbox binding
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-07-09 16:43:58 +09:00
kjh2064 a2e2064056 Normalize remaining admin list pages
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-07-09 16:29:40 +09:00
kjh2064 91424bd8a2 Standardize admin empty states
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-07-09 16:24:39 +09:00
kjh2064 b9466b30a2 Tighten admin empty state spacing
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-07-09 16:19:16 +09:00
kjh2064 8acc988174 Fix blog category fallback and empty card
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-07-09 16:09:54 +09:00
kjh2064 89d457c549 Align admin list empty states
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m0s
2026-07-09 16:06:42 +09:00
kjh2064 1441d6b306 Require SEO fields for published blogs
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-07-09 15:59:34 +09:00
kjh2064 7ca47e792b Fix blog service tests and admin empty states
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-07-09 15:44:52 +09:00
kjh2064 44777f4a1f Align admin list empty states and blog category guard
TaxBaik CI/CD / build-and-deploy (push) Failing after 35s
2026-07-09 15:41:43 +09:00
kjh2064 2f72834317 Fix blog category selection and empty state rendering
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-07-09 15:32:13 +09:00
kjh2064 c5e6e4ba57 Stabilize admin rendering and deploy auth
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-07-09 15:21:13 +09:00
kjh2064 f7813450d7 Standardize admin entry buttons
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
2026-07-09 14:30:13 +09:00
kjh2064 2f4ec5efea Add inquiry action controls
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-07-09 14:28:12 +09:00
kjh2064 a53372e4f1 Seed version info from assembly
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-07-09 14:23:16 +09:00
kjh2064 4348de0707 Show loaded values on edit screens
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m0s
2026-07-09 14:20:07 +09:00
kjh2064 3831f52253 Fix customer admin routes
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m7s
2026-07-09 14:17:55 +09:00
kjh2064 6b6359007c Fix remaining admin page routes
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-07-09 14:17:01 +09:00
kjh2064 f5249afbd0 Fix admin action routes
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-07-09 14:13:26 +09:00
kjh2064 e3addc1a79 Implement admin CRUD for announcements inquiries and faqs
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-07-09 14:06:14 +09:00
kjh2064 bfc4d70ebd Tighten shared admin components
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-07-09 13:59:17 +09:00
kjh2064 6407afece5 Normalize remaining admin page shells
TaxBaik CI/CD / build-and-deploy (push) Successful in 57s
2026-07-09 13:55:54 +09:00
kjh2064 f4deff4e78 Trim legacy admin dashboard styles
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-07-09 13:53:54 +09:00
kjh2064 58eb297865 Remove leftover admin topbar overrides
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-07-09 13:48:18 +09:00
kjh2064 e1c4630159 Remove admin css overrides that masked Tabler
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m0s
2026-07-09 13:45:49 +09:00
kjh2064 00521668be Tabler polish for client and blog editors
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
2026-07-09 13:44:26 +09:00
kjh2064 8c0c30525d Align list pages with Tabler table patterns
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m0s
2026-07-09 13:42:44 +09:00
94 changed files with 3471 additions and 1594 deletions
+13 -20
View File
@@ -52,18 +52,11 @@ jobs:
echo "Expected short version: $SHORT_VERSION"
for i in $(seq 1 20); do
# Suppress stderr and allow failures to handle transition/down periods cleanly
VERSION_BODY="$(curl -fsS "https://www.taxbaik.com/taxbaik/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)"
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/taxbaik/admin/login" || 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/blog" || 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
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
fi
if [ $i -lt 20 ]; then
@@ -76,8 +69,8 @@ jobs:
- name: Browser verifications in parallel
env:
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
E2E_BASE_URL: https://www.taxbaik.com/taxbaik
# Nginx를 통해 root 경로로 라우팅 (PathBase 없음)
E2E_BASE_URL: https://www.taxbaik.com
# E2E 테스트는 운영에서 검증된 admin 계정 사용
E2E_ADMIN_USERNAME: admin
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 "api-smoke" bash -lc '
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"
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/taxbaik/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/taxbaik/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/taxbaik/favicon.ico" >/dev/null
curl -fsS "https://www.taxbaik.com/taxbaik/robots.txt" >/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/api/faq" >/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/api/inquiry?page=1&pageSize=1" >/dev/null
curl -fsS "https://www.taxbaik.com/favicon.svg" >/dev/null
curl -fsS "https://www.taxbaik.com/favicon.ico" >/dev/null
curl -fsS "https://www.taxbaik.com/robots.txt" >/dev/null
'
failures=0
+21 -7
View File
@@ -27,6 +27,9 @@ jobs:
set -e
python3 scripts/validate_locked_db_connection.py
- name: Validate SEO search guardrails
run: python3 scripts/validate_seo.py
- name: Build solution
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_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
if [ -z "$JWT_SECRET_KEY" ]; then
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
# JWT는 현재 관리자 인증에 사용하지 않지만, 포털 인증/토큰 교환용 설정으로 유지한다.
# 없으면 런타임에서 AuthService가 실패하므로, CI 배포에서만 한 번 생성해 넣는다.
[ -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_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
@@ -106,7 +105,6 @@ jobs:
"ConnectionStrings": {
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
},
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
"Telegram": {
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
@@ -344,6 +342,22 @@ jobs:
fi
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)"
# 구 배포 디렉토리 정리 (최근 5개 보존)
ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \
+1
View File
@@ -63,3 +63,4 @@ appsettings.Development.json
# Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md
.scratch/
tmp/
+25 -8
View File
@@ -1,14 +1,31 @@
# 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)
2. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
3. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md)
4. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
5. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)
2. [docs/ADMIN_RAZORPAGES_ROADMAP.md](./docs/ADMIN_RAZORPAGES_ROADMAP.md) ⭐ **신규 어드민 표준**
3. [docs/ADMIN_LEGACY_BLAZOR.md](./docs/ADMIN_LEGACY_BLAZOR.md) (레거시 Blazor 관리)
4. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.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)**
@@ -315,7 +332,7 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 모든 도메인 API-First 마이그레이션 완료
#### Phase 8: WebAssembly 렌더 모드 전환 ✅ (2026-07-03)
#### Phase 8: [레거시] WebAssembly 렌더 모드 전환 ✅ (2026-07-03)
- [x] InteractiveWebAssemblyRenderMode 적용 (Blazor Server → WebAssembly)
- [x] Admin 컴포넌트 WebAssembly 클라이언트 전환
- [x] 서버 상태 관리 제거 (Circuit 불필요)
@@ -362,7 +379,7 @@ export DOTNET_PRINT_TELEMETRY_MESSAGE=false
**⚠️ 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 9: 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
#### Phase 9: [레거시] 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
- [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음)
- [x] `Login.razor``@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨
- [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 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지
#### Phase 13: FastEndpoints 마이그레이션 ✅ (2026-07-03)
#### Phase 13: [레거시] FastEndpoints 마이그레이션 ✅ (2026-07-03)
- [x] AdminDashboardController → FastEndpoints 마이그레이션
- GetSummaryEndpoint.cs (GET /api/admin-dashboard/summary)
- GetUpcomingFilingsEndpoint.cs (GET /api/admin-dashboard/upcoming-filings)
+5 -5
View File
@@ -18,12 +18,12 @@
| 영역 | 권장 방식 | 비고 |
| --- | --- | --- |
| Public Home | Static SSR + 필요한 부분만 Interactive WebAssembly | 초기 로딩과 SEO 우선 |
| Login | Static SSR 또는 Razor Page form | MudBlazor 의존 금지 |
| Home/Dashboard | Interactive WebAssembly + MudBlazor | 사용자 인터랙션 중심 |
| Admin CRUD | Interactive WebAssembly + API-first | BrowserClient 경유 |
| API | Minimal API 또는 Controller + OpenAPI | 계약 우선 |
| Login | Razor Pages form + Cookie Authentication | MudBlazor 의존 금지 (레거시: Interactive WebAssembly 참고) |
| Home/Dashboard | Razor Pages + Cookie Authentication | 서버 렌더링 우선 (레거시: Interactive WebAssembly + MudBlazor 참고) |
| Admin CRUD | Razor Pages + Repository Pattern + Tabler UI | Cookie Authentication 기준 (레거시: Interactive WebAssembly + API-first 참고) |
| API | Controller + OpenAPI (외부 클라이언트) | 계약 우선, JWT 기반 |
| Persistence | Dapper + `NpgsqlDataSource` singleton | connection pool 직접 관리 금지 |
| Auth | 브라우저는 HttpOnly Secure Cookie 우선, 외부 클라이언트만 JWT | localStorage 저장 금지 |
| Auth | 브라우저는 Cookie Authentication, 외부 클라이언트만 JWT | localStorage 저장 금지 |
현재 저장소 기준 예외:
+17
View File
@@ -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
```
+1 -1
View File
@@ -32,7 +32,7 @@ def is_server_healthy(url):
def main():
port = 5001
base_url = f"http://localhost:{port}/taxbaik"
base_url = f"http://localhost:{port}"
health_url = f"{base_url}/healthz"
server_process = None
+54
View File
@@ -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]
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
{
@@ -34,7 +38,11 @@ public class BlogServiceTests
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
{
@@ -55,7 +63,11 @@ public class BlogServiceTests
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);
@@ -131,6 +143,34 @@ public class BlogServiceTests
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>
{
public FluentValidation.Results.ValidationResult Validate(T instance) => new();
@@ -4,7 +4,7 @@ public class CreateBlogPostDto
{
public required string Title { 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? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
@@ -18,7 +18,7 @@ public class BlogPostResponseDto
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public int? CategoryId { get; set; }
public int CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
+7 -2
View File
@@ -8,9 +8,14 @@ public sealed class CreateBlogPostDtoValidator : AbstractValidator<CreateBlogPos
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(ValidationRules.TitleMaxLength);
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.SeoTitle).MaximumLength(ValidationRules.SeoTitleMaxLength);
RuleFor(x => x.SeoDescription).MaximumLength(ValidationRules.SeoDescriptionMaxLength);
RuleFor(x => x.SeoTitle)
.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;
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) =>
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)
{
ValidatePost(post);
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
post.Title = post.Title.Trim();
post.Content = post.Content.Trim();
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)
{
validator.ValidateAndThrow(dto);
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
var post = new BlogPost
{
Title = dto.Title,
@@ -83,6 +89,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
{
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
await repository.UpdateAsync(post, ct);
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)
{
validator.ValidateAndThrow(dto);
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
var post = await repository.GetByIdAsync(id, ct);
if (post == 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 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)
{
var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
+153 -119
View File
@@ -1,6 +1,8 @@
using System.Data;
using System.Reflection;
using System.Text;
using Npgsql;
using System.Text.RegularExpressions;
using DbUp;
using DbUp.Engine;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Infrastructure.Data;
@@ -18,159 +20,191 @@ public class MigrationRunner
public async Task RunAsync()
{
await EnsureMigrationTableAsync();
await ExecutePendingMigrationsAsync();
await Task.Run(() => RunMigrations());
}
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
var migrationDirs = new[]
{
"./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 upgradeEngineBuilder = DeployChanges.To
.PostgresqlDatabase(_connectionString)
.JournalTo(new DbUpCustomJournal(_connectionString))
.LogToConsole();
if (migrationPath != null && Directory.Exists(migrationPath))
{
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x);
foreach (var file in files)
{
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 });
}
}
Console.WriteLine($"[DbUp] Loading migration scripts from filesystem path: {migrationPath}");
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsFromFileSystem(migrationPath);
}
else
{
var assembly = Assembly.GetExecutingAssembly();
var resourceNames = assembly.GetManifestResourceNames()
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x);
Console.WriteLine("[DbUp] Loading migration scripts embedded in Assembly.");
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsEmbeddedInAssembly(
Assembly.GetExecutingAssembly(),
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);
if (stream == null)
continue;
Console.WriteLine($"[DbUp] Database migration failed: {result.Error}");
throw result.Error;
}
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);
var sql = reader.ReadToEnd();
var fileName = Path.GetFileNameWithoutExtension(resourceName);
var versionStart = fileName.IndexOf('V');
var versionEnd = fileName.IndexOf('_', versionStart + 1);
if (versionStart < 0 || versionEnd < 0)
continue;
public class DbUpCustomJournal : IJournal
{
private readonly string _connectionString;
var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1);
var description = fileName.Substring(versionEnd + 1);
public DbUpCustomJournal(string connectionString)
{
_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);
await conn.OpenAsync();
var match = Regex.Match(script.Name, @"[Vv](?<version>\d+)__(?<desc>.*)$");
string version;
string description;
try
if (match.Success)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = migration.Sql;
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");
version = match.Groups["version"].Value;
description = match.Groups["desc"].Value.Replace(".sql", "").Replace("_", " ");
}
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
else
{
// Already executed previously; mark as done
Console.WriteLine($" Migration {migration.Version} already applied");
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();
version = script.Name;
description = "Executed via DbUp";
}
catch (Exception ex)
using (var cmd = dbCommandFactory())
{
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
throw;
cmd.CommandText = @"
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; }
public required string Description { get; set; }
public required string Sql { get; set; }
using (var cmd = dbCommandFactory())
{
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>
<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.DependencyInjection" Version="8.0.0" />
<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
@{ ViewData["Title"] = "공지사항"; }
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Content", "공지사항 관리", "공지 목록을 서버 렌더링으로 표시합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>제목</th><th>유형</th><th>활성</th><th>정렬</th></tr>
</thead>
<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>
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 관리", "공지 목록을 서버 렌더링으로 표시합니다.", "/admin/announcements/create", "공지 작성"))
@if (Model.Items.Count == 0)
{
<div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_EmptyState", "공지사항이 없습니다.")
</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>
+27 -9
View File
@@ -2,18 +2,36 @@
@model TaxBaik.Web.Pages.Admin.Blog.CreateModel
@{
ViewData["Title"] = "블로그 등록";
ViewData["BlogCategories"] = Model.Categories;
}
<partial name="_BlogEditor" model="new TaxBaik.Web.Pages.Shared.BlogEditorModel(
Model.Input.Title,
Model.Input.Content,
Model.Input.CategoryId,
Model.Input.IsPublished,
Model.Input.ThumbnailUrl,
Model.Input.SeoTitle,
Model.Input.SeoDescription,
false)" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("콘텐츠", "새 글 작성", "블로그 글을 등록합니다.", null, null))
<div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel(
Model.Input.Title,
Model.Input.Content,
Model.Input.CategoryId,
Model.Input.IsPublished,
Model.Input.ThumbnailUrl,
Model.Input.SeoTitle,
Model.Input.SeoDescription,
false))
</div>
</div>
</section>
@section Scripts {
<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 TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel(BlogService blogService) : PageModel
public class CreateModel(BlogService blogService, CategoryService categoryService) : PageModel
{
[BindProperty]
public CreateBlogPostDto Input { get; set; } = new()
@@ -17,14 +18,47 @@ public class CreateModel(BlogService blogService) : PageModel
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)
{
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (!ModelState.IsValid)
return Page();
await blogService.CreateAsync(Input, ct);
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);
}
}
+29 -11
View File
@@ -1,21 +1,39 @@
@page "{id:int}"
@page "/admin/blog/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Blog.EditModel
@{
ViewData["Title"] = "블로그 수정";
ViewData["BlogEditorId"] = Model.Id;
ViewData["BlogCategories"] = Model.Categories;
}
<partial name="_BlogEditor" model='new TaxBaik.Web.Pages.Shared.BlogEditorModel(
Model.Input.Title,
Model.Input.Content,
Model.Input.CategoryId,
Model.Input.IsPublished,
Model.Input.ThumbnailUrl,
Model.Input.SeoTitle,
Model.Input.SeoDescription,
true,
"?handler=Delete")' />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("콘텐츠", "글 수정", "블로그 글을 수정합니다.", null, null))
<div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel(
Model.Input.Title,
Model.Input.Content,
Model.Input.CategoryId,
Model.Input.IsPublished,
Model.Input.ThumbnailUrl,
Model.Input.SeoTitle,
Model.Input.SeoDescription,
true,
"?handler=Delete"))
</div>
</div>
</section>
@section Scripts {
<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 TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel(BlogService blogService) : PageModel
public class EditModel(BlogService blogService, CategoryService categoryService) : PageModel
{
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
@@ -20,17 +21,20 @@ public class EditModel(BlogService blogService) : PageModel
Title = string.Empty
};
public IReadOnlyList<Category> Categories { get; private set; } = [];
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
var post = await blogService.GetByIdAsync(Id, ct);
if (post is null)
return NotFound();
Categories = (await EnsureCategoriesAsync(ct)).ToList();
Input = new CreateBlogPostDto
{
Title = post.Title,
Content = post.Content,
CategoryId = post.CategoryId,
CategoryId = post.CategoryId ?? 0,
Tags = post.Tags,
SeoTitle = post.SeoTitle,
SeoDescription = post.SeoDescription,
@@ -44,6 +48,7 @@ public class EditModel(BlogService blogService) : PageModel
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (!ModelState.IsValid)
return Page();
@@ -59,4 +64,27 @@ public class EditModel(BlogService blogService) : PageModel
await blogService.DeleteAsync(Id, ct);
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);
}
}
+69 -55
View File
@@ -4,65 +4,79 @@
ViewData["Title"] = "블로그";
}
<section class="container-xl admin-page-shell">
<partial name="_AdminPageHeader" model='("콘텐츠", "블로그 관리", "검색, 발행 상태, 작성일 기준의 블로그 목록입니다.", "/admin/blog/create", "새 글 작성")' />
<section class="admin-page-shell">
@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-body">
<div class="row g-3 align-items-end">
<div class="col-md-6">
<label class="form-label" for="SearchQuery">검색</label>
<input class="form-control" id="SearchQuery" name="searchQuery" value="@Model.SearchQuery" placeholder="제목 또는 본문 검색" />
<div class="card">
<div class="card-body p-2" style="min-height: auto;">
<form method="get">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<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 class="col-md-6 d-flex gap-2">
<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>
</form>
</div>
</form>
<div class="card admin-page-card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<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)
{
@if (Model.Items.Count > 0)
{
<div class="table-responsive table-responsive--separator">
<table class="table table-vcenter card-table table-hover">
<thead>
<tr>
<td>
<div class="fw-semibold">@post.Title</div>
<div class="text-muted small">@post.Slug</div>
</td>
<td><partial name="_StatusBadge" model="@(post.IsPublished ? "active" : "inactive")" /></td>
<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>
<th>제목</th>
<th>발행</th>
<th>조회수</th>
<th>작성일</th>
<th class="text-end">작업</th>
</tr>
}
}
</tbody>
</table>
</div>
</thead>
<tbody>
@foreach (var post in Model.Items)
{
<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>
</section>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.ClientLogs.IndexModel
@{ ViewData["Title"] = "클라이언트 로그"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("운영 로그", "클라이언트 로그", "고객별 작업 이력을 확인합니다.", null, null)' />
<partial name="_ComingSoon" model="클라이언트 로그" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 로그", "클라이언트 로그", "고객별 작업 이력을 확인합니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "클라이언트 로그")
</section>
@@ -4,55 +4,64 @@
ViewData["Title"] = "고객 등록";
}
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 등록", "FluentValidation과 Tabler 표준 폼으로 신규 고객을 등록합니다.", null, null)' />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 등록", "FluentValidation과 Tabler 표준 폼으로 신규 고객을 등록합니다.", null, null))
<form method="post" class="card shadow-sm border-0">
<div class="card-body">
<partial name="_ValidationSummary" />
<div class="row g-3">
<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"></label>
<input asp-for="Input.Name" class="form-control" />
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
<input asp-for="Input.Name" class="form-control form-control-sm" />
<span asp-validation-for="Input.Name" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Input.CompanyName" class="form-label"></label>
<input asp-for="Input.CompanyName" class="form-control" />
<label asp-for="Input.CompanyName" class="form-label form-label-sm"></label>
<input asp-for="Input.CompanyName" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.Phone" class="form-label"></label>
<input asp-for="Input.Phone" class="form-control" />
<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"></label>
<input asp-for="Input.Email" class="form-control" />
<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-4">
<label asp-for="Input.Status" class="form-label"></label>
<input asp-for="Input.Status" class="form-control" />
<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-md-4">
<label asp-for="Input.ServiceType" class="form-label"></label>
<input asp-for="Input.ServiceType" class="form-control" />
<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-4">
<label asp-for="Input.TaxType" class="form-label"></label>
<input asp-for="Input.TaxType" class="form-control" />
<label asp-for="Input.TaxType" class="form-label form-label-sm"></label>
<input asp-for="Input.TaxType" class="form-control form-control-sm" />
</div>
<div class="col-12">
<label asp-for="Input.Source" class="form-label"></label>
<input asp-for="Input.Source" class="form-control" />
<label asp-for="Input.Source" class="form-label form-label-sm"></label>
<input asp-for="Input.Source" class="form-control form-control-sm" />
</div>
<div class="col-12">
<label asp-for="Input.Memo" class="form-label"></label>
<textarea asp-for="Input.Memo" class="form-control" rows="5"></textarea>
<label asp-for="Input.Memo" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Memo" class="form-control form-control-sm" rows="5"></textarea>
</div>
</div>
</div>
<div class="card-footer bg-white d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="/admin/clients">취소</a>
<button class="btn btn-primary" type="submit">저장</button>
<div class="card-footer d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/admin/clients">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</form>
</section>
@@ -1,23 +1,23 @@
@page "{id:int}"
@page "/admin/clients/{id:int}"
@model TaxBaik.Web.Pages.Admin.Clients.DetailModel
@{
ViewData["Title"] = "고객 상세";
}
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 상세", "고객의 기본 정보와 수정 경로를 확인합니다.", null, null)' />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 상세", "고객의 기본 정보와 수정 경로를 확인합니다.", null, null))
@if (Model.Client is null)
{
<partial name="_EmptyState" model="조회한 고객을 찾을 수 없습니다." />
@await Html.PartialAsync("_EmptyState", "조회한 고객을 찾을 수 없습니다.")
}
else
{
<div class="row g-4">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card">
<div class="card-body">
<dl class="row mb-0">
<dl class="row details-grid mb-0">
<dt class="col-sm-3">고객명</dt>
<dd class="col-sm-9">@Model.Client.Name</dd>
<dt class="col-sm-3">회사명</dt>
@@ -27,7 +27,7 @@
<dt class="col-sm-3">이메일</dt>
<dd class="col-sm-9">@Model.Client.Email</dd>
<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>
<dd class="col-sm-9">@Model.Client.ServiceType</dd>
<dt class="col-sm-3">세금 유형</dt>
@@ -45,13 +45,24 @@
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-body d-grid gap-2">
<a class="btn btn-primary" href="/admin/clients/@Model.Client.Id/edit">수정</a>
<a class="btn btn-outline-secondary" href="/admin/clients">목록</a>
<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/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>
}
</section>
+56 -31
View File
@@ -1,62 +1,87 @@
@page "{id:int}"
@page "/admin/clients/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Clients.EditModel
@{
ViewData["Title"] = "고객 수정";
}
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 수정", "기존 고객 정보를 불러와 Tabler 표준 폼으로 수정합니다.", null, null)' />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 수정", "기존 고객 정보를 불러와 Tabler 표준 폼으로 수정합니다.", null, null))
<form method="post" class="card shadow-sm border-0">
<div class="card-body">
<partial name="_ValidationSummary" />
<div class="row g-3">
<div class="card mb-2">
<div class="card-body p-2">
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
<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">
<label asp-for="Input.Name" class="form-label"></label>
<input asp-for="Input.Name" class="form-control" />
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
<input asp-for="Input.Name" class="form-control form-control-sm" />
<span asp-validation-for="Input.Name" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Input.CompanyName" class="form-label"></label>
<input asp-for="Input.CompanyName" class="form-control" />
<label asp-for="Input.CompanyName" class="form-label form-label-sm"></label>
<input asp-for="Input.CompanyName" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.Phone" class="form-label"></label>
<input asp-for="Input.Phone" class="form-control" />
<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"></label>
<input asp-for="Input.Email" class="form-control" />
<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-4">
<label asp-for="Input.Status" class="form-label"></label>
<input asp-for="Input.Status" class="form-control" />
<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-md-4">
<label asp-for="Input.ServiceType" class="form-label"></label>
<input asp-for="Input.ServiceType" class="form-control" />
<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-4">
<label asp-for="Input.TaxType" class="form-label"></label>
<input asp-for="Input.TaxType" class="form-control" />
<label asp-for="Input.TaxType" class="form-label form-label-sm"></label>
<input asp-for="Input.TaxType" class="form-control form-control-sm" />
</div>
<div class="col-12">
<label asp-for="Input.Source" class="form-label"></label>
<input asp-for="Input.Source" class="form-control" />
<label asp-for="Input.Source" class="form-label form-label-sm"></label>
<input asp-for="Input.Source" class="form-control form-control-sm" />
</div>
<div class="col-12">
<label asp-for="Input.Memo" class="form-label"></label>
<textarea asp-for="Input.Memo" class="form-control" rows="5"></textarea>
<label asp-for="Input.Memo" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Memo" class="form-control form-control-sm" rows="5"></textarea>
</div>
</div>
</div>
<div class="card-footer bg-white d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="/admin/clients/@Model.Id">취소</a>
<button class="btn btn-primary" type="submit">저장</button>
<div class="card-footer d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/admin/clients/@Model.Id">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</form>
</section>
<form method="post" asp-page-handler="Delete" class="mt-3" onsubmit="return confirm('정말 삭제하시겠습니까?');">
<button class="btn btn-outline-danger" type="submit">삭제</button>
</form>
<div class="card mt-2">
<div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
<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"] = "고객 관리";
}
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 목록", "검색, 상태 필터, 페이지네이션 기준의 고객 목록입니다.", null, null)' />
<section class="admin-page-shell">
@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-body">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label" for="Search">검색</label>
<input class="form-control" id="Search" name="search" value="@Model.Search" placeholder="이름, 전화, 회사명" />
<div class="card">
<div class="card-body p-2" style="min-height: auto;">
<form method="get">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<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 class="col-md-3">
<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>
</form>
</div>
</form>
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<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)
{
@if (Model.Items.Count > 0)
{
<div class="table-responsive table-responsive--separator">
<table class="table table-vcenter card-table table-hover">
<thead>
<tr>
<td colspan="6">
<partial name="_EmptyState" model="검색 결과가 없습니다." />
</td>
<th>고객명</th>
<th>회사명</th>
<th>전화</th>
<th>상태</th>
<th>등록일</th>
<th class="text-end">작업</th>
</tr>
}
else
{
foreach (var item in Model.Items)
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td class="fw-semibold">@item.Name</td>
<td>@item.CompanyName</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 class="text-end">
<a class="btn btn-sm btn-outline-secondary" href="/admin/clients/@item.Id">상세</a>
<a class="btn btn-sm btn-outline-primary" href="/admin/clients/@item.Id/edit">수정</a>
<div class="btn-list flex-nowrap justify-content-end">
<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>
</tr>
}
}
</tbody>
</table>
</div>
</tbody>
</table>
</div>
}
else
{
<div class="card-body">
@await Html.PartialAsync("_EmptyState", "검색 결과가 없습니다.")
</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>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.CommonCodes.IndexModel
@{ ViewData["Title"] = "공통코드"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("운영 설정", "공통 코드", "코드성 데이터를 표준화해 관리합니다.", null, null)' />
<partial name="_ComingSoon" model="공통 코드" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 설정", "공통 코드", "코드성 데이터를 표준화해 관리합니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "공통 코드")
</section>
@@ -2,33 +2,46 @@
@model TaxBaik.Web.Pages.Admin.Companies.IndexModel
@{ ViewData["Title"] = "회사"; }
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("CRM", "회사 관리", "고객사 회사를 관리합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>코드</th><th>회사명</th><th>담당자</th><th>활성</th></tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="4"><partial name="_EmptyState" model="회사가 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("CRM", "회사 관리", "고객사 회사를 관리합니다.", null, null))
@if (Model.Items.Count == 0)
{
<div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_EmptyState", "회사가 없습니다.")
</div>
</div>
}
else
{
<div class="card">
<div class="table-responsive table-responsive--separator">
<table class="table table-vcenter card-table">
<thead>
<tr><th>코드</th><th>회사명</th><th>담당자</th><th>활성</th></tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.CompanyCode</td>
<td>@item.CompanyName</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>
}
}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
}
</section>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.ConsultingActivities.IndexModel
@{ ViewData["Title"] = "상담 활동"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "상담 활동", "상담 히스토리와 후속 조치를 관리합니다.", null, null)' />
<partial name="_ComingSoon" model="상담 활동" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "상담 활동", "상담 히스토리와 후속 조치를 관리합니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "상담 활동")
</section>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.Contracts.IndexModel
@{ ViewData["Title"] = "계약"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "계약 관리", "계약 상태와 만료일을 추적합니다.", null, null)' />
<partial name="_ComingSoon" model="계약 관리" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "계약 관리", "계약 상태와 만료일을 추적합니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "계약 관리")
</section>
@@ -1,2 +1,11 @@
@page
@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
@@ -1,2 +1,11 @@
@page "{id:int}"
@page "/admin/customers/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Customers.EditModel
@@ -1,2 +1,11 @@
@page
@model TaxBaik.Web.Pages.Admin.Customers.IndexModel
+125 -87
View File
@@ -6,115 +6,153 @@
<section class="admin-page-shell">
<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="page-pretitle">TaxBaik Admin</div>
<h2 class="page-title">대시보드</h2>
<p class="text-muted mb-0">관리자 업무 흐름의 시작점입니다.</p>
<div class="page-pretitle text-uppercase font-numeric text-primary fw-semibold" style="letter-spacing: 1px;">TAXBAIK ADMIN SYSTEM</div>
<h2 class="page-title text-dark fw-bold h2 mb-1">대시보드</h2>
<p class="text-muted small mb-0">관리자 업무 흐름을 관리하는 중앙 컨트롤 타워입니다.</p>
</div>
<div class="col-auto ms-auto">
<div class="text-end small text-muted">
<div>로그인 사용자</div>
<div class="fw-semibold text-body">@User.Identity?.Name</div>
</div>
</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 class="card shadow-sm border-0 bg-white px-3 py-2 rounded-3 d-flex align-items-center flex-row gap-2">
<span class="avatar avatar-sm rounded bg-primary-lt text-primary"><i class="bi bi-person-fill"></i></span>
<div>
<div class="text-muted small" style="font-size: 0.7rem; line-height: 1;">접속 계정</div>
<div class="fw-semibold text-dark font-numeric" style="font-size: 0.85rem;">@User.Identity?.Name</div>
</div>
</div>
</div>
</div>
</div>
<div class="row row-deck row-cards g-3">
<div class="col-12 col-xl-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">빠른 작업</h3>
<!-- Quick Stats Grid -->
<div class="row g-3 mb-4">
<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-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 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">
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/customers">
<span>고객 목록으로 이동</span>
<span class="text-muted">관리</span>
<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">
<div class="d-flex align-items-center gap-3">
<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 class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/blog">
<span>블로그 글 작성</span>
<span class="text-muted">에디터</span>
<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">
<div class="d-flex align-items-center gap-3">
<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 class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/inquiries">
<span>문의 처리</span>
<span class="text-muted">업무</span>
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 px-4" href="/admin/inquiries">
<div class="d-flex align-items-center gap-3">
<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>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">운영 노트</h3>
<div class="col-lg-4">
<div class="card shadow-sm border-0 rounded-3 bg-white h-100">
<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-info-circle text-primary me-2"></i> 아키텍처 노하우
</h3>
</div>
<div class="card-body">
<div class="alert alert-info mb-0">
Razor Pages, Dapper, Flyway, Cookie Auth, Tabler 기준으로 서버 렌더링 중심의 어드민을 유지합니다.
<div class="card-body p-4">
<div class="alert alert-info d-flex align-items-start gap-2 mb-3" role="alert">
<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>
</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");
}
}
+40 -21
View File
@@ -2,33 +2,52 @@
@model TaxBaik.Web.Pages.Admin.Faqs.IndexModel
@{ ViewData["Title"] = "FAQ"; }
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Support", "FAQ 관리", "질문과 답변을 관리합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>질문</th><th>카테고리</th><th>활성</th><th>정렬</th></tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="4"><partial name="_EmptyState" model="FAQ가 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 관리", "질문과 답변을 관리합니다.", "/admin/faqs/create", "FAQ 작성"))
@if (Model.Items.Count == 0)
{
<div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_EmptyState", "FAQ가 없습니다.")
</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>@item.Question</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 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>
}
}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
}
</section>
+52 -15
View File
@@ -4,25 +4,62 @@
ViewData["Title"] = "관리자 홈";
}
<section class="container py-5">
<div class="row align-items-center g-4">
<div class="col-lg-8">
<p class="text-uppercase text-muted small mb-2">TaxBaik Admin</p>
<h1 class="display-6 fw-bold mb-3">고객관리 어드민</h1>
<p class="lead text-muted mb-0">
Razor Pages + Cookie Authentication + Dapper + Flyway 기준으로 정리하는 서버 렌더링 어드민입니다.
</p>
<section class="admin-page-shell">
<div class="page-header d-print-none mb-4">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">TaxBaik Admin</div>
<h2 class="page-title">고객관리 어드민</h2>
<p class="text-muted mb-0">Razor Pages + Cookie Authentication + Dapper + DbUp 기준으로 정리하는 서버 렌더링 어드민입니다.</p>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<div class="d-grid gap-2">
<a class="btn btn-dark" href="/admin/dashboard">대시보드</a>
<a class="btn btn-outline-secondary" href="/admin/login?returnUrl=%2Fadmin%2Fdashboard">다시 로그인</a>
</div>
</div>
<div class="row row-deck row-cards g-3">
<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-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>
</section>
@@ -1,4 +1,56 @@
@page
@model TaxBaik.Web.Pages.Admin.Inquiries.CreateModel
@{ 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.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.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
@{ 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.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
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
@{ 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.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.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
@{ ViewData["Title"] = "문의"; }
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Customer Requests", "문의 관리", "상담 요청을 상태별로 확인합니다.", null, null)' />
<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>
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 관리", "상담 요청을 상태별로 확인합니다.", "/admin/inquiries/create", "문의 등록"))
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>이름</th>
<th>전화</th>
<th>서비스</th>
<th>상태</th>
<th>등록일</th>
</tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="5"><partial name="_EmptyState" model="문의 목록이 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
<div class="card-body p-2" style="min-height: auto;">
<form method="get">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label form-label-sm" for="Status">상태</label>
<input class="form-control form-control-sm" id="Status" name="status" value="@Model.Status" placeholder="new, consulting..." />
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" type="submit">필터</button>
</div>
</div>
</form>
</div>
@if (Model.Items.Count > 0)
{
<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>등록일</th>
<th class="text-end">작업</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.Name</td>
<td>@item.Phone</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 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>
}
}
</tbody>
</table>
</div>
</tbody>
</table>
</div>
}
else
{
<div class="card-body">
@await Html.PartialAsync("_EmptyState", "문의 목록이 없습니다.")
</div>
}
</div>
</section>
+9
View File
@@ -71,3 +71,12 @@
</div>
</section>
+18 -3
View File
@@ -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)
{
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)
{
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
if (lockoutService.IsLockedOut(ip))
{
ErrorMessage = "로그인 실패 횟수가 초과하여 15분간 로그인이 제한됩니다.";
}
else
{
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
}
return Page();
}
@@ -13,3 +13,12 @@
</section>
<meta http-equiv="refresh" content="0;url=/admin/login" />
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.RevenueTrackings.IndexModel
@{ ViewData["Title"] = "매출 추적"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "매출 추적", "월별 매출 추이를 확인하는 분석 화면입니다.", null, null)' />
<partial name="_ComingSoon" model="매출 추적" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "매출 추적", "월별 매출 추이를 확인하는 분석 화면입니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "매출 추적")
</section>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.SeasonSimulator.IndexModel
@{ ViewData["Title"] = "시즌 시뮬레이터"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("운영 도구", "시즌 시뮬레이터", "정책 변경 시 영향을 미리 확인하는 도구입니다.", null, null)' />
<partial name="_ComingSoon" model="시즌 시뮬레이터" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 도구", "시즌 시뮬레이터", "정책 변경 시 영향을 미리 확인하는 도구입니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "시즌 시뮬레이터")
</section>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.Settings.IndexModel
@{ ViewData["Title"] = "설정"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("환경 설정", "설정", "시스템 기본값과 관리자 운영 정책을 정리합니다.", null, null)' />
<partial name="_ComingSoon" model="설정 기능" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("환경 설정", "설정", "시스템 기본값과 관리자 운영 정책을 정리합니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "설정 기능")
</section>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.TaxFilingSchedules.IndexModel
@{ ViewData["Title"] = "신고 일정"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "세금 신고 일정", "세무 신고 마감 일정과 상태를 관리합니다.", null, null)' />
<partial name="_ComingSoon" model="세금 신고 일정" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "세금 신고 일정", "세무 신고 마감 일정과 상태를 관리합니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "세금 신고 일정")
</section>
@@ -1,7 +1,16 @@
@page
@model TaxBaik.Web.Pages.Admin.TaxFilings.IndexModel
@{ ViewData["Title"] = "세금 신고"; }
<section class="container admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "세금 신고", "신고 상태를 한 화면에서 관리합니다.", null, null)' />
<partial name="_ComingSoon" model="세금 신고 관리" />
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "세금 신고", "신고 상태를 한 화면에서 관리합니다.", null, null))
@await Html.PartialAsync("_ComingSoon", "세금 신고 관리")
</section>
@@ -2,33 +2,46 @@
@model TaxBaik.Web.Pages.Admin.TaxProfiles.IndexModel
@{ ViewData["Title"] = "세무 프로필"; }
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Tax", "세무 프로필 관리", "고객별 세무 속성을 관리합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>고객ID</th><th>사업유형</th><th>세무위험</th><th>다음신고일</th></tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="4"><partial name="_EmptyState" model="세무 프로필이 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Tax", "세무 프로필 관리", "고객별 세무 속성을 관리합니다.", null, null))
@if (Model.Items.Count == 0)
{
<div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_EmptyState", "세무 프로필이 없습니다.")
</div>
</div>
}
else
{
<div class="card">
<div class="table-responsive table-responsive--separator">
<table class="table table-vcenter card-table">
<thead>
<tr><th>고객ID</th><th>사업유형</th><th>세무위험</th><th>다음신고일</th></tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.ClientId</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>
</tr>
}
}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
}
</section>
+228 -101
View File
@@ -14,123 +14,250 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
<!-- Tabler Core & Icons -->
<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" />
<!-- 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>
<body class="admin-app">
<div class="page admin-shell">
<header class="navbar navbar-expand-md navbar-light d-print-none admin-topbar">
<body class="layout-fluid">
<div class="page">
<!-- 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="d-flex align-items-center gap-3">
<div class="navbar-brand navbar-brand-autodark">
<a href="/admin/dashboard" class="text-decoration-none text-reset fw-semibold">TaxBaik Admin</a>
</div>
<div class="d-none d-lg-flex flex-column">
<span class="text-uppercase text-muted small">Admin Console</span>
<span class="fw-semibold">Razor Pages + Tabler</span>
<!-- Toggle Button for Mobile -->
<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">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Logo & Branding -->
<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 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>
<a class="btn btn-outline-secondary btn-sm" href="/admin/logout">로그아웃</a>
<!-- Navigation Sidebar Links -->
<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>
</header>
<div class="page-wrapper">
<div class="container-fluid">
<div class="row g-3">
<aside class="col-12 col-lg-3 col-xxl-2">
<div class="card shadow-sm border-0 admin-sidebar-card sticky-top">
<div class="card-header admin-sidebar-header">
<div>
<div class="text-uppercase text-muted small">Navigation</div>
<h3 class="card-title mb-0">관리 메뉴</h3>
</div>
</div>
<div class="card-body p-0 admin-sidebar-body">
<div class="accordion accordion-flush" id="adminSidebarAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button @(coreOpen ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuCore" aria-expanded="@(coreOpen ? "true" : "false")">
기본
</button>
</h2>
<div id="adminMenuCore" class="accordion-collapse collapse @(coreOpen ? "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/dashboard")" href="/admin/dashboard">▣ 대시보드</a>
<a class="list-group-item list-group-item-action @activeClass("/admin/settings")" href="/admin/settings">⚙ 설정</a>
<a class="list-group-item list-group-item-action @activeClass("/admin/clientlogs")" href="/admin/clientlogs">⌘ 클라이언트 로그</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>
</aside>
<!-- Main Wrapper Panel -->
<div class="page-wrapper d-flex flex-column min-vh-100">
<!-- Topbar (Search, Breadcrumbs, Theme Switcher, User Profiles) -->
<header class="navbar navbar-expand-md navbar-light d-none d-lg-flex d-print-none sticky-top border-bottom">
<div class="container-xl justify-content-between">
<!-- Left breadcrumb -->
<div class="d-flex align-items-center">
<nav aria-label="breadcrumb" class="d-inline-block">
<ol class="breadcrumb breadcrumb-muted mb-0 small">
<li class="breadcrumb-item"><a href="/admin/dashboard" class="text-muted"><i class="bi bi-house-door-fill"></i></a></li>
<li class="breadcrumb-item active text-dark fw-semibold" aria-current="page">@breadcrumbText</li>
</ol>
</nav>
</div>
<!-- Right Actions -->
<div class="navbar-nav flex-row align-items-center gap-3">
<!-- Dark/Light Theme Switcher Button -->
<div class="nav-item">
<a href="#" class="nav-link px-0 text-muted" onclick="toggleTheme(); return false;" title="테마 전환" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="bi bi-sun fs-3 theme-icon-light d-none"></i>
<i class="bi bi-moon-stars-fill fs-3 theme-icon-dark"></i>
</a>
</div>
</aside>
<main class="col-12 col-lg-9 col-xxl-10">
<div class="card shadow-sm border-0 admin-content-card admin-main-surface">
<div class="card-body p-3 p-md-4 p-xl-5">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb breadcrumb-muted">
<li class="breadcrumb-item"><a href="/admin/dashboard">관리자</a></li>
<li class="breadcrumb-item active" aria-current="page">@breadcrumbText</li>
</ol>
</nav>
@RenderBody()
</div>
<!-- Divider -->
<div class="vr bg-light opacity-50" style="height: 1.5rem;"></div>
<!-- Profile & Logout -->
<div class="d-flex align-items-center gap-2">
<span class="avatar avatar-xs rounded-circle bg-secondary-lt text-secondary"><i class="bi bi-person-circle"></i></span>
<span class="text-dark small fw-semibold font-numeric me-2">@User.Identity?.Name</span>
</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>
<!-- 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">
&copy; 2026 TaxBaik. All rights reserved.
</div>
</div>
</div>
</footer>
</div>
</div>
<!-- Tabler Javascript -->
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js" defer></script>
@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>
</html>
@@ -0,0 +1,12 @@
@using TaxBaik.Web
@namespace TaxBaik.Web.Pages.Admin
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -2,3 +2,12 @@
Layout = "_AdminLayout";
}
+6
View File
@@ -2,6 +2,12 @@
@model TaxBaik.Web.Pages.Blog.BlogIndexModel
@{
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">
+1 -1
View File
@@ -4,7 +4,7 @@
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
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["OgUrl"] = canonicalUrl;
}
+4 -33
View File
@@ -1,35 +1,6 @@
@page "/feed.xml"
@model TaxBaik.Web.Pages.RssModel
@{
Response.ContentType = "application/rss+xml; charset=utf-8";
}@{
var rssContent = new System.Text.StringBuilder();
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()
Layout = null;
Response.StatusCode = StatusCodes.Status308PermanentRedirect;
Response.Headers.Location = "/rss.xml";
}
+67 -53
View File
@@ -5,46 +5,57 @@
ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
}
<div class="bg-light py-5">
<div class="bg-light py-5 min-vh-75">
<div class="container">
<!-- 상단 헤더 & 환영 문구 -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom">
<div>
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p>
<h1 class="display-6 fw-bold text-dark">안녕하세요, @(User.Identity?.Name)님!</h1>
@if (Model.ClientInfo != null)
{
<p class="text-muted mb-0">
<i class="bi bi-building"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)
| <i class="bi bi-telephone"></i> @Model.ClientInfo.Phone
</p>
}
</div>
<div class="mt-3 mt-sm-0">
<form method="post" action="/portal/logout" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-box-arrow-right"></i> 로그아웃
</button>
</form>
<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 class="card-body p-4 p-md-5 text-white position-relative">
<div class="d-flex flex-wrap justify-content-between align-items-center position-relative" style="z-index: 2;">
<div>
<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>
@if (Model.ClientInfo != null)
{
<div class="d-flex flex-wrap gap-3 align-items-center opacity-90 small">
<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>
<span><i class="bi bi-telephone me-1"></i> @Model.ClientInfo.Phone</span>
</div>
}
</div>
<div class="mt-3 mt-sm-0">
<form method="post" action="/portal/logout" class="d-inline">
@Html.AntiForgeryToken()
<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>
@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="mb-4">
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
</div>
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
<p class="text-muted max-width-md mx-auto mb-4">
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 레코드를 찾지 못했습니다.<br />
세무사 측에서 고객 등록을 완료하거나 관리자 백오피스에서 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
<p class="text-muted max-width-md mx-auto mb-4 leading-relaxed">
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 정보를 찾지 못했습니다.<br />
세무사 측에서 고객 등록을 완료하거나 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
</p>
<a href="/contact" class="btn btn-primary px-4 py-2">
<i class="bi bi-chat-dots"></i> 세무사에게 문의하기
<a href="/contact" class="btn btn-primary px-4 py-2.5 fw-bold">
<i class="bi bi-chat-dots-fill"></i> 세무사에게 문의하기
</a>
</div>
</div>
@@ -54,19 +65,19 @@
<div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<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="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">
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
</h3>
<span class="badge bg-secondary">총 @(Model.Filings.Count)건</span>
<span class="badge bg-primary-badge font-numeric">총 @(Model.Filings.Count)건</span>
</div>
@if (!Model.Filings.Any())
{
<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>
}
@@ -76,10 +87,10 @@
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th scope="col">신고 종류</th>
<th scope="col">신고 기한</th>
<th scope="col">진행 상태</th>
<th scope="col">메모</th>
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">신고 종류</th>
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">신고 기한</th>
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">진행 상태</th>
<th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">메모</th>
</tr>
</thead>
<tbody>
@@ -88,28 +99,28 @@
var dDay = (filing.DueDate - DateTime.Today).Days;
var statusClass = filing.Status switch
{
"filed" => "bg-success-subtle text-success",
"overdue" => "bg-danger-subtle text-danger",
_ => "bg-warning-subtle text-warning-emphasis"
"filed" => "bg-success-subtle text-success border border-success-subtle",
"overdue" => "bg-danger-subtle text-danger border border-danger-subtle",
_ => "bg-warning-subtle text-warning-emphasis border border-warning-subtle"
};
var statusLabel = filing.Status switch
{
"filed" => "신고 완료",
"overdue" => "기한 초과",
_ => $"D-{dDay}"
_ => dDay == 0 ? "D-Day" : dDay < 0 ? $"기한 지남 ({Math.Abs(dDay)}일)" : $"D-{dDay}"
};
<tr>
<td>
<tr class="admin-row-clickable">
<td class="py-3">
<span class="fw-bold text-dark">@filing.FilingType</span>
</td>
<td>
<span>@filing.DueDate.ToString("yyyy-MM-dd")</span>
<td class="py-3">
<span class="font-numeric text-muted">@filing.DueDate.ToString("yyyy-MM-dd")</span>
</td>
<td>
<span class="badge @statusClass px-2.5 py-1.5 fs-7">@statusLabel</span>
<td class="py-3">
<span class="badge @statusClass px-2.5 py-1.5 fs-8 fw-bold">@statusLabel</span>
</td>
<td class="text-muted small">
<td class="py-3 text-muted small">
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
</td>
</tr>
@@ -124,16 +135,16 @@
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<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">
<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> 최근 상담 및 지원 이력
</h3>
@if (!Model.Consultations.Any())
{
<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>
}
@@ -142,15 +153,18 @@
<div class="timeline ps-2">
@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">
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
<span class="badge bg-primary-badge px-2 py-1 fs-8">@activity.ActivityType</span>
<small class="text-muted font-numeric">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
</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))
{
<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
</div>
}
+72 -30
View File
@@ -6,35 +6,77 @@
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/login";
}
<section class="container py-5" style="max-width: 560px;">
<h1 class="h3 fw-bold mb-4">고객 포털 로그인</h1>
<div class="alert alert-secondary">
포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다.
</div>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-danger">@Model.ErrorMessage</div>
}
<form method="post" class="vstack gap-3">
<div>
<label class="form-label">이메일</label>
<input class="form-control" asp-for="Email" />
<div class="bg-light py-5 min-vh-75 d-flex align-items-center">
<div class="container" style="max-width: 480px;">
<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>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<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>
<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>
</section>
</div>
+67 -26
View File
@@ -6,30 +6,71 @@
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/register";
}
<section class="container py-5" style="max-width: 640px;">
<h1 class="h3 fw-bold mb-4">고객 포털 회원가입</h1>
<div class="alert alert-secondary">
가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다.
<div class="bg-light py-5 min-vh-75 d-flex align-items-center">
<div class="container" style="max-width: 560px;">
<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>
<form method="post" class="row g-3">
<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>
</div>
+18 -13
View File
@@ -1,35 +1,40 @@
@page "/rss.xml"
@model TaxBaik.Web.Pages.RssModel
@{
Layout = null;
Response.ContentType = "application/rss+xml; charset=utf-8";
}@{
var rssContent = new System.Text.StringBuilder();
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(" <title>백원숙 세무회계 - 블로그</title>");
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
rssContent.AppendLine(" <language>ko-kr</language>");
rssContent.AppendLine(" <language>ko-KR</language>");
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
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($" <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($" <title>{System.Net.WebUtility.HtmlEncode(item.Title)}</title>");
rssContent.AppendLine($" <link>{itemUrl}</link>");
rssContent.AppendLine($" <guid isPermaLink=\"true\">{item.Guid}</guid>");
if (item.PublishedAt is not null)
{
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(" </channel>");
rssContent.AppendLine("</rss>");
}@rssContent.ToString()
}
@Html.Raw(rssContent.ToString())
+61 -8
View File
@@ -7,30 +7,83 @@ namespace TaxBaik.Web.Pages;
public class RssModel : PageModel
{
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 RssModel(BlogService blogService)
public RssModel(
BlogService blogService,
FaqService faqService,
AnnouncementService announcementService)
{
_blogService = blogService;
_faqService = faqService;
_announcementService = announcementService;
}
public async Task OnGetAsync()
{
try
{
// 최근 50개 블로그 포스트 (RSS는 일반적으로 최신 기사만 포함)
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default);
Posts = posts.OrderByDescending(p => p.PublishedAt).ToList();
var blogTask = _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default);
var faqTask = _faqService.GetActiveAsync(HttpContext.RequestAborted);
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)
{
// DB 연결 실패: 빈 포스트 목록 반환 (정적 피드 구조는 유지)
Posts = [];
// DB 연결 실패: 빈 피드가 되지 않도록 로컬 서버 시간 기준 단일 항목을 유지하지는 않는다.
Items = [];
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(
string Title,
string Content,
int? CategoryId,
int CategoryId,
bool IsPublished,
string? ThumbnailUrl,
string? SeoTitle,
@@ -1,18 +1,18 @@
@model (string Eyebrow, string Title, string Subtitle, string? ActionHref, string? ActionText)
<div class="page-header d-print-none mb-4">
@model TaxBaik.Web.Models.AdminPageHeaderModel
<div class="page-header d-print-none admin-page-header">
<div class="row align-items-center g-2">
<div class="col">
<div class="page-pretitle text-uppercase">@Model.Eyebrow</div>
<h2 class="page-title mb-1">@Model.Title</h2>
<div class="page-pretitle text-uppercase admin-page-header-eyebrow">@Model.Eyebrow</div>
<h2 class="page-title mb-1 admin-page-header-title">@Model.Title</h2>
@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>
@if (!string.IsNullOrWhiteSpace(Model.ActionHref) && !string.IsNullOrWhiteSpace(Model.ActionText))
{
<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>
+46 -18
View File
@@ -1,22 +1,23 @@
@model TaxBaik.Web.Pages.Shared.BlogEditorModel
<section class="container-xl admin-page-shell admin-editor-root blog-editor-shell" data-blog-editor-root>
<div class="row g-3">
<div class="col-12">
<div class="page-header d-print-none mb-4">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">콘텐츠</div>
<h2 class="page-title">@(Model.IsEdit ? "블로그 수정" : "블로그 등록")</h2>
<p class="text-muted mb-0">@(Model.IsEdit ? "기존 글을 불러와 즉시 편집하고 미리볼 수 있습니다." : "에디터와 미리보기를 함께 제공하는 작성 화면입니다.")</p>
</div>
</div>
<section class="admin-page-shell admin-editor-root blog-editor-shell" data-blog-editor-root>
<div class="page-header d-print-none admin-page-header blog-editor-header">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle admin-page-header-eyebrow">콘텐츠</div>
<h2 class="page-title admin-page-header-title">@(Model.IsEdit ? "블로그 수정" : "블로그 등록")</h2>
<p class="text-muted mb-0 admin-page-header-subtitle">@(Model.IsEdit ? "기존 글을 불러와 즉시 편집하고 미리볼 수 있습니다." : "에디터와 미리보기를 함께 제공하는 작성 화면입니다.")</p>
</div>
<div class="col-auto ms-auto">
<span class="badge bg-blue-lt text-blue badge-sm">실시간 미리보기</span>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-8">
<form method="post" class="card admin-page-card">
<form method="post" class="card">
<div class="card-body">
<div asp-validation-summary="All" class="alert alert-danger"></div>
@await Html.PartialAsync("_ValidationSummary")
@if (Model.IsEdit)
{
<input type="hidden" name="Id" value="@ViewData["BlogEditorId"]" />
@@ -42,11 +43,26 @@
<div class="row g-3 blog-meta-grid">
<div class="col-md-4">
<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 class="col-md-4">
<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 class="col-md-4">
<label class="form-label" for="Input_ThumbnailUrl">썸네일 URL</label>
@@ -62,7 +78,7 @@
</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">
@if (Model.IsEdit)
{
@@ -75,12 +91,24 @@
</form>
</div>
<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">
<h3 class="card-title mb-0">실시간 미리보기</h3>
<span class="badge bg-secondary-lt text-secondary">공통 스타일</span>
</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>
</div>
</div>
@@ -1,9 +1,10 @@
@model string
<div class="card shadow-sm border-0">
<div class="card">
<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>
<p class="text-muted mb-0">이 페이지는 현재 구조 맞춘 상태이며, 다음 단계에서 기능을 채우면 됩니다.</p>
<p class="text-muted mb-0">이 페이지는 구조 맞춘 상태이며, 다음 단계에서 기능을 채우면 됩니다.</p>
</div>
</div>
@@ -1,5 +1,8 @@
@model string
<div class="empty py-5">
@{
var emptyMessage = string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model;
}
<div class="empty admin-empty-state">
<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">
<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" />
</svg>
</div>
<p class="empty-title">@string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model</p>
<p class="empty-title">@emptyMessage</p>
</div>
@@ -3,12 +3,18 @@
{
<nav class="mt-4" aria-label="페이지네이션">
<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++)
{
<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 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>
</nav>
}
@@ -8,7 +8,7 @@
<span class="avatar avatar-xs bg-red-lt text-red">!</span>
<div class="flex-fill">
<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>
+6 -2
View File
@@ -1,14 +1,18 @@
@page "/sitemap.xml"
@model TaxBaik.Web.Pages.SitemapModel
@{
Layout = null;
Response.ContentType = "application/xml; charset=utf-8";
}<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach (var url in Model.Urls)
{
<url>
<loc>@System.Net.WebUtility.HtmlEncode(url)</loc>
<lastmod>@DateTime.UtcNow:yyyy-MM-dd</lastmod>
<loc>@url.Location</loc>
@if (url.LastModified.HasValue)
{
<lastmod>@url.LastModified.Value.ToUniversalTime().ToString("yyyy-MM-dd")</lastmod>
}
</url>
}
</urlset>
+70 -27
View File
@@ -5,48 +5,91 @@ namespace TaxBaik.Web.Pages;
public class SitemapModel : PageModel
{
private const string BaseUrl = "https://www.taxbaik.com";
private const int BlogPageSize = 100;
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;
_faqService = faqService;
_announcementService = announcementService;
_logger = logger;
}
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
{
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default);
foreach (var post in posts)
{
Urls.Add($"{baseUrl}/blog/{post.Slug}");
}
var faqTask = _faqService.GetActiveAsync(HttpContext.RequestAborted);
var announcementTask = _announcementService.GetActiveAsync(HttpContext.RequestAborted);
var postsTask = GetAllPublishedPostsAsync(HttpContext.RequestAborted);
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)
{
// DB 연결 실패 등의 경우, 정적 페이지만으로도 sitemap 제공
// 프로덕션에서는 DB가 있으므로 이 경로는 사용되지 않음
System.Diagnostics.Debug.WriteLine($"Sitemap: Blog posts load failed: {ex.Message}");
// DB 문제 중에도 검색 엔진이 기존 핵심 페이지를 계속 발견할 수 있게 한다.
_logger.LogError(ex, "Failed to load dynamic sitemap content");
}
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);
}
+14 -9
View File
@@ -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>
<html lang="ko">
<head>
@@ -12,30 +18,29 @@
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<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:url" content="@(ViewData["OgUrl"] ?? "https://www.taxbaik.com/")" />
<meta property="og:url" content="@ogUrl" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta name="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta name="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" />
<meta name="robots" content="@robots" />
<meta name="theme-color" content="#C89D6E" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- RSS Feed -->
<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.gstatic.com" crossorigin />
<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 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 rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@@ -71,7 +76,7 @@
</script>
</head>
<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">
@RenderBody()
</main>
@@ -0,0 +1,3 @@
@using TaxBaik.Web
@namespace TaxBaik.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+14 -3
View File
@@ -69,11 +69,12 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("client-logs", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var isDevelopment = builder.Environment.IsDevelopment();
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"client-logs:{ip}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
PermitLimit = isDevelopment ? 200 : 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
@@ -82,11 +83,12 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("admin-login", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var isDevelopment = builder.Environment.IsDevelopment();
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"admin-login:{ip}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
PermitLimit = isDevelopment ? 100 : 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
@@ -95,6 +97,7 @@ builder.Services.AddRateLimiter(options =>
});
// Razor Pages
builder.Services.AddSingleton<IIpLockoutService, IpLockoutService>();
builder.Services.AddRazorPages();
// Session & TempData (쿠키 저장소)
@@ -262,6 +265,14 @@ builder.Services.AddScoped<TaxBaik.Web.Services.SitemapValidationService>();
// Register version info
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");
if (File.Exists(versionJsonPath))
{
@@ -350,7 +361,7 @@ app.Use(async (context, next) =>
context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none";
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();
+47 -13
View File
@@ -14,7 +14,8 @@ public class AuthService
private readonly ILogger<AuthService> _logger;
private readonly ITelegramNotificationService _telegramService;
private readonly IHostEnvironment _environment;
private readonly string _jwtSecretKey;
private readonly IIpLockoutService _ipLockoutService;
private readonly string? _jwtSecretKey;
private readonly string? _passwordResetToken;
private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상)
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
@@ -24,18 +25,26 @@ public class AuthService
ILogger<AuthService> logger,
IConfiguration configuration,
ITelegramNotificationService telegramService,
IHostEnvironment environment)
IHostEnvironment environment,
IIpLockoutService ipLockoutService)
{
_adminUserRepository = adminUserRepository;
_logger = logger;
_telegramService = telegramService;
_environment = environment;
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
_ipLockoutService = ipLockoutService;
_jwtSecretKey = configuration["Jwt:SecretKey"];
_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))
return AuthResult.InvalidCredentials();
@@ -49,23 +58,24 @@ public class AuthService
catch (Exception ex) when (_environment.IsDevelopment())
{
_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)
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
return AuthResult.DatabaseUnavailable();
}
catch (Exception ex)
{
_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)
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
_logger.LogError(ex.InnerException, " Inner Exception: {InnerExMessage}", ex.InnerException.Message);
return AuthResult.DatabaseUnavailable();
}
if (user == null)
{
_logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username);
_ipLockoutService.RecordFailedAttempt(ipAddress);
return AuthResult.InvalidCredentials();
}
@@ -78,18 +88,26 @@ public class AuthService
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
_ipLockoutService.RecordFailedAttempt(ipAddress);
// 실패한 로그인은 알림하지 않음 (로그만 남김)
return AuthResult.InvalidCredentials();
}
_logger.LogInformation("로그인 성공: {Username}", username);
_logger.LogInformation("로그인 성공: {Username} (IP: {IpAddress})", username, ipAddress);
_ipLockoutService.ResetAttempts(ipAddress);
// 로그인 알림은 제거 (로그만 남김)
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
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))
return null;
@@ -97,11 +115,18 @@ public class AuthService
{
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash))
{
_ipLockoutService.RecordFailedAttempt(ipAddress);
return null;
}
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
_ipLockoutService.RecordFailedAttempt(ipAddress);
return null;
}
_ipLockoutService.ResetAttempts(ipAddress);
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
return user;
}
@@ -184,7 +209,8 @@ public class AuthService
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 claims = new[]
@@ -220,7 +246,7 @@ public class AuthService
{
try
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecretKey()));
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
@@ -246,7 +272,7 @@ public class AuthService
{
try
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecretKey()));
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
@@ -278,6 +304,14 @@ public class AuthService
&& 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)
@@ -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;
// 2. 동적 블로그 포스트 검증
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default);
var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{p.Slug}").ToList();
var posts = await GetAllPublishedPostsAsync();
var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(p.Slug)}").ToList();
ValidateUrls(blogUrls, result);
@@ -251,10 +251,25 @@ public class SitemapValidationService
$"{baseUrl}/contact",
$"{baseUrl}/privacy",
$"{baseUrl}/terms",
$"{baseUrl}/blog",
$"{baseUrl}/faq",
$"{baseUrl}/announcement",
$"{baseUrl}/inquiry"
$"{baseUrl}/blog"
};
}
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) {
const editor = root.querySelector('[data-blog-editor]');
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 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);
renderPreview();
}
-24
View File
@@ -7,30 +7,6 @@ Allow: /
Disallow: /admin/
Disallow: /portal/
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: 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