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" echo "Expected short version: $SHORT_VERSION"
for i in $(seq 1 20); do for i in $(seq 1 20); do
# Suppress stderr and allow failures to handle transition/down periods cleanly # Suppress stderr and allow failures to handle transition/down periods cleanly
VERSION_BODY="$(curl -fsS "https://www.taxbaik.com/taxbaik/version.json" 2>/dev/null || true)" VERSION_BODY="$(curl -fsS "https://www.taxbaik.com/version.json" 2>/dev/null || true)"
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/taxbaik/blog/accountant-mistakes-5" || true)" BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/blog" || true)"
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/taxbaik/admin/login" || true)" LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://www.taxbaik.com/admin/login" || true)"
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)" echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
ROOT_URL="https://www.taxbaik.com/" \
ADMIN_URL="https://www.taxbaik.com/taxbaik/admin/login" \
PUBLIC_MARKER="백원숙 세무회계" \
PUBLIC_FORBIDDEN="관리자" \
ADMIN_MARKER="관리자 로그인" \
MAX_RETRIES=1 \
bash ./scripts/taxbaik-smoke.sh
exit 0 exit 0
fi fi
if [ $i -lt 20 ]; then if [ $i -lt 20 ]; then
@@ -76,8 +69,8 @@ jobs:
- name: Browser verifications in parallel - name: Browser verifications in parallel
env: env:
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅 # Nginx를 통해 root 경로로 라우팅 (PathBase 없음)
E2E_BASE_URL: https://www.taxbaik.com/taxbaik E2E_BASE_URL: https://www.taxbaik.com
# E2E 테스트는 운영에서 검증된 admin 계정 사용 # E2E 테스트는 운영에서 검증된 admin 계정 사용
E2E_ADMIN_USERNAME: admin E2E_ADMIN_USERNAME: admin
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
@@ -103,15 +96,15 @@ jobs:
run_job "browser-e2e" bash -lc 'npm run test:e2e:ci' run_job "browser-e2e" bash -lc 'npm run test:e2e:ci'
run_job "api-smoke" bash -lc ' run_job "api-smoke" bash -lc '
set -e set -e
TOKEN="$(curl -s -X POST "https://www.taxbaik.com/taxbaik/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c '\''import sys, json; print(json.load(sys.stdin)["accessToken"])'\'')" TOKEN="$(curl -s -X POST "https://www.taxbaik.com/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c '\''import sys, json; print(json.load(sys.stdin)["accessToken"])'\'')"
test -n "$TOKEN" test -n "$TOKEN"
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/blog/admin?page=1&pageSize=1" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/faq" >/dev/null curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/faq" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/announcement" >/dev/null curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/announcement" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/api/inquiry?page=1&pageSize=1" >/dev/null
curl -fsS "https://www.taxbaik.com/taxbaik/favicon.svg" >/dev/null curl -fsS "https://www.taxbaik.com/favicon.svg" >/dev/null
curl -fsS "https://www.taxbaik.com/taxbaik/favicon.ico" >/dev/null curl -fsS "https://www.taxbaik.com/favicon.ico" >/dev/null
curl -fsS "https://www.taxbaik.com/taxbaik/robots.txt" >/dev/null curl -fsS "https://www.taxbaik.com/robots.txt" >/dev/null
' '
failures=0 failures=0
+21 -7
View File
@@ -27,6 +27,9 @@ jobs:
set -e set -e
python3 scripts/validate_locked_db_connection.py python3 scripts/validate_locked_db_connection.py
- name: Validate SEO search guardrails
run: python3 scripts/validate_seo.py
- name: Build solution - name: Build solution
run: dotnet build src/TaxBaik.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true run: dotnet build src/TaxBaik.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true
@@ -85,16 +88,12 @@ jobs:
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}" TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}" TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}" TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}" # JWT는 현재 관리자 인증에 사용하지 않지만, 포털 인증/토큰 교환용 설정으로 유지한다.
if [ -z "$JWT_SECRET_KEY" ]; then # 없으면 런타임에서 AuthService가 실패하므로, CI 배포에서만 한 번 생성해 넣는다.
JWT_SECRET_KEY="$(openssl rand -base64 48 | tr -d '\n')"
echo "⚠️ TAXBAIK_JWT_SECRET_KEY is missing; generating a one-off production secret for this deploy." >&2
fi
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; } [ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; } [ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
[ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID" [ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480" [ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \ TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \ TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \ TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
@@ -106,7 +105,6 @@ jobs:
"ConnectionStrings": { "ConnectionStrings": {
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123" "Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=taxbaik123"
}, },
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
"Telegram": { "Telegram": {
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
"ChatId": os.environ["TELEGRAM_CHAT_ID"], "ChatId": os.environ["TELEGRAM_CHAT_ID"],
@@ -344,6 +342,22 @@ jobs:
fi fi
echo "✓ [6/6] 관리자 페이지 로드 완료" echo "✓ [6/6] 관리자 페이지 로드 완료"
# 검색 엔진은 Content-Type뿐 아니라 유효한 XML 본문을 요구한다.
# 공통 Razor 레이아웃이 다시 섞이는 회귀를 배포 직후 차단한다.
SITEMAP_BODY=\$(curl -fsS http://127.0.0.1:5001/sitemap.xml 2>/dev/null || true)
RSS_BODY=\$(curl -fsS http://127.0.0.1:5001/rss.xml 2>/dev/null || true)
if ! printf '%s' "\$SITEMAP_BODY" | grep -q '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' \
|| printf '%s' "\$SITEMAP_BODY" | grep -q '<!DOCTYPE html>'; then
echo "❌ sitemap.xml XML 응답 검증 실패" >&2
exit 1
fi
if ! printf '%s' "\$RSS_BODY" | grep -q '<rss version="2.0"' \
|| printf '%s' "\$RSS_BODY" | grep -q '<!DOCTYPE html>'; then
echo "❌ rss.xml XML 응답 검증 실패" >&2
exit 1
fi
echo "✓ [7/7] 사이트맵 및 RSS XML 응답 검증 완료"
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)" echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
# 구 배포 디렉토리 정리 (최근 5개 보존) # 구 배포 디렉토리 정리 (최근 5개 보존)
ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \ ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \
+1
View File
@@ -63,3 +63,4 @@ appsettings.Development.json
# Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md # Scratch / temporary work - never commit, see docs/ENGINEERING_HARNESS.md
.scratch/ .scratch/
tmp/
+25 -8
View File
@@ -1,14 +1,31 @@
# CLAUDE.md — TaxBaik 운영 메모 # CLAUDE.md — TaxBaik 운영 메모
## ⚠️ **중요: 2026-07-09 아키텍처 변경**
**현재 기준 (2026-07-09~):**
- 관리자 UI: **Razor Pages + Cookie Authentication** (서버 렌더링)
- UI 프레임워크: **Tabler** (Bootstrap 5 기반)
- 인증: **Cookie Authentication** (브라우저), JWT (외부 API)
- **자세한 기준은 아래 우선 기준의 문서들을 참고해주세요.**
**이전 기준 (2026-07-03 이전):**
- 관리자 UI: Blazor WebAssembly + MudBlazor (Phase 8/9 참고, 이제 레거시)
- 해당 섹션은 과거 이력으로만 참고 (기준이 아님)
---
## 우선 기준 ## 우선 기준
1. [docs/INDEX.md](./docs/INDEX.md) 1. [docs/INDEX.md](./docs/INDEX.md)
2. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md) 2. [docs/ADMIN_RAZORPAGES_ROADMAP.md](./docs/ADMIN_RAZORPAGES_ROADMAP.md) ⭐ **신규 어드민 표준**
3. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md) 3. [docs/ADMIN_LEGACY_BLAZOR.md](./docs/ADMIN_LEGACY_BLAZOR.md) (레거시 Blazor 관리)
4. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md) 4. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
5. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md) 5. [docs/STACK_TEMPLATE_WBS.md](./docs/STACK_TEMPLATE_WBS.md) (갱신됨)
6. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md) ⭐ **더존 회계시스템 고밀도 레이아웃 표준 (행 높이 32px, 조밀한 그리드, 대시보드 형태의 넓은 여백 배제)**
7. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
8. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)
이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보 기준은 위 문서를 따른다. 이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보/더존 고밀도 회계시스템 레이아웃 기준은 위 문서를 따른다. 대시보드와 같은 널찍한 여백 위주 화면 디자인은 특화 페이지(대시보드 홈 등)로 국한하며, 그 외 비즈니스/세무/활동 관리 데이터 그리드 및 폼 페이지는 '더존 회계시스템 스타일의 고밀도 레이아웃(32px 행 높이, 조밀한 셀 패딩, 낭비 없는 스크린 사용)'을 절대적으로 준수한다.
## 🎯 **개발 핵심 지침 (Development Standards 2026-07)** ## 🎯 **개발 핵심 지침 (Development Standards 2026-07)**
@@ -315,7 +332,7 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 모든 도메인 API-First 마이그레이션 완료 **완료**: 2026-06-28 / 모든 도메인 API-First 마이그레이션 완료
#### Phase 8: WebAssembly 렌더 모드 전환 ✅ (2026-07-03) #### Phase 8: [레거시] WebAssembly 렌더 모드 전환 ✅ (2026-07-03)
- [x] InteractiveWebAssemblyRenderMode 적용 (Blazor Server → WebAssembly) - [x] InteractiveWebAssemblyRenderMode 적용 (Blazor Server → WebAssembly)
- [x] Admin 컴포넌트 WebAssembly 클라이언트 전환 - [x] Admin 컴포넌트 WebAssembly 클라이언트 전환
- [x] 서버 상태 관리 제거 (Circuit 불필요) - [x] 서버 상태 관리 제거 (Circuit 불필요)
@@ -362,7 +379,7 @@ export DOTNET_PRINT_TELEMETRY_MESSAGE=false
**⚠️ Phase 8 알려진 한계 (Phase 9에서 수정됨)**: **⚠️ Phase 8 알려진 한계 (Phase 9에서 수정됨)**:
Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@rendermode`를 지정해 `prerender: false`로 고정했다. 그 결과 로그인 화면을 포함한 모든 어드민 페이지가 WASM 다운로드 완료 전까지 빈 화면/스피너만 보여주는 문제가 있었다(`scripts/validate_admin_render.sh`에 이 트레이드오프가 "기능 우선, 흰 화면 0.5~2초 감수"로 기록되어 있었음). 이는 `docs/ENGINEERING_HARNESS.md`의 "로그인 화면은 예외적으로 서버 프리렌더 허용" 규칙을 충족하지 못한 상태였다. Phase 9에서 페이지별 개별 렌더모드 지정으로 교체했다. Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@rendermode`를 지정해 `prerender: false`로 고정했다. 그 결과 로그인 화면을 포함한 모든 어드민 페이지가 WASM 다운로드 완료 전까지 빈 화면/스피너만 보여주는 문제가 있었다(`scripts/validate_admin_render.sh`에 이 트레이드오프가 "기능 우선, 흰 화면 0.5~2초 감수"로 기록되어 있었음). 이는 `docs/ENGINEERING_HARNESS.md`의 "로그인 화면은 예외적으로 서버 프리렌더 허용" 규칙을 충족하지 못한 상태였다. Phase 9에서 페이지별 개별 렌더모드 지정으로 교체했다.
#### Phase 9: 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03) #### Phase 9: [레거시] 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
- [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음) - [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음)
- [x] `Login.razor``@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨 - [x] `Login.razor``@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨
- [x] 나머지 `[Authorize]` 어드민 페이지는 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`로 명시 유지 → 인증 컨텍스트 없이 prerender될 때 `AuthorizeRouteView`가 빈 화면을 그리는 문제(Phase 8 초기에 겪었던 문제) 재발 방지 - [x] 나머지 `[Authorize]` 어드민 페이지는 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`로 명시 유지 → 인증 컨텍스트 없이 prerender될 때 `AuthorizeRouteView`가 빈 화면을 그리는 문제(Phase 8 초기에 겪었던 문제) 재발 방지
@@ -372,7 +389,7 @@ Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@r
**완료**: 2026-07-03 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지 **완료**: 2026-07-03 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지
#### Phase 13: FastEndpoints 마이그레이션 ✅ (2026-07-03) #### Phase 13: [레거시] FastEndpoints 마이그레이션 ✅ (2026-07-03)
- [x] AdminDashboardController → FastEndpoints 마이그레이션 - [x] AdminDashboardController → FastEndpoints 마이그레이션
- GetSummaryEndpoint.cs (GET /api/admin-dashboard/summary) - GetSummaryEndpoint.cs (GET /api/admin-dashboard/summary)
- GetUpcomingFilingsEndpoint.cs (GET /api/admin-dashboard/upcoming-filings) - GetUpcomingFilingsEndpoint.cs (GET /api/admin-dashboard/upcoming-filings)
+5 -5
View File
@@ -18,12 +18,12 @@
| 영역 | 권장 방식 | 비고 | | 영역 | 권장 방식 | 비고 |
| --- | --- | --- | | --- | --- | --- |
| Public Home | Static SSR + 필요한 부분만 Interactive WebAssembly | 초기 로딩과 SEO 우선 | | Public Home | Static SSR + 필요한 부분만 Interactive WebAssembly | 초기 로딩과 SEO 우선 |
| Login | Static SSR 또는 Razor Page form | MudBlazor 의존 금지 | | Login | Razor Pages form + Cookie Authentication | MudBlazor 의존 금지 (레거시: Interactive WebAssembly 참고) |
| Home/Dashboard | Interactive WebAssembly + MudBlazor | 사용자 인터랙션 중심 | | Home/Dashboard | Razor Pages + Cookie Authentication | 서버 렌더링 우선 (레거시: Interactive WebAssembly + MudBlazor 참고) |
| Admin CRUD | Interactive WebAssembly + API-first | BrowserClient 경유 | | Admin CRUD | Razor Pages + Repository Pattern + Tabler UI | Cookie Authentication 기준 (레거시: Interactive WebAssembly + API-first 참고) |
| API | Minimal API 또는 Controller + OpenAPI | 계약 우선 | | API | Controller + OpenAPI (외부 클라이언트) | 계약 우선, JWT 기반 |
| Persistence | Dapper + `NpgsqlDataSource` singleton | connection pool 직접 관리 금지 | | Persistence | Dapper + `NpgsqlDataSource` singleton | connection pool 직접 관리 금지 |
| Auth | 브라우저는 HttpOnly Secure Cookie 우선, 외부 클라이언트만 JWT | localStorage 저장 금지 | | Auth | 브라우저는 Cookie Authentication, 외부 클라이언트만 JWT | localStorage 저장 금지 |
현재 저장소 기준 예외: 현재 저장소 기준 예외:
+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(): def main():
port = 5001 port = 5001
base_url = f"http://localhost:{port}/taxbaik" base_url = f"http://localhost:{port}"
health_url = f"{base_url}/healthz" health_url = f"{base_url}/healthz"
server_process = None server_process = None
+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] [Fact]
public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException() public async Task CreateAsync_WhenPublishedWithoutSeoTitle_ThrowsValidationException()
{ {
var service = new BlogService(new FakeBlogPostRepository(), new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>()); var service = new BlogService(
new FakeBlogPostRepository(),
new FakeCategoryRepository(),
new MemoryCache(new MemoryCacheOptions()),
new PassThroughValidator<CreateBlogPostDto>());
await Assert.ThrowsAsync<TaxBaik.Application.Services.ValidationException>(() => service.CreateAsync(new CreateBlogPostDto await Assert.ThrowsAsync<TaxBaik.Application.Services.ValidationException>(() => service.CreateAsync(new CreateBlogPostDto
{ {
@@ -34,7 +38,11 @@ public class BlogServiceTests
new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" } new BlogPost { Id = 1, Title = "같은 제목", Content = "본문", Slug = "같은-제목" }
] ]
}; };
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>()); var service = new BlogService(
repository,
new FakeCategoryRepository(),
new MemoryCache(new MemoryCacheOptions()),
new PassThroughValidator<CreateBlogPostDto>());
var post = await service.CreateAsync(new CreateBlogPostDto var post = await service.CreateAsync(new CreateBlogPostDto
{ {
@@ -55,7 +63,11 @@ public class BlogServiceTests
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true } new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
] ]
}; };
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()), new PassThroughValidator<CreateBlogPostDto>()); var service = new BlogService(
repository,
new FakeCategoryRepository(),
new MemoryCache(new MemoryCacheOptions()),
new PassThroughValidator<CreateBlogPostDto>());
await service.DeleteAsync(1); await service.DeleteAsync(1);
@@ -131,6 +143,34 @@ public class BlogServiceTests
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
} }
private sealed class FakeCategoryRepository : ICategoryRepository
{
private readonly List<Category> _categories =
[
new Category { Id = 1, Name = "사업자 세무", Slug = "business-tax", SortOrder = 1 }
];
public Task<IEnumerable<Category>> GetAllAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IEnumerable<Category>>(_categories);
public Task<Category?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
Task.FromResult(_categories.FirstOrDefault(x => x.Slug == slug));
public Task<Category?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
Task.FromResult(_categories.FirstOrDefault(x => x.Id == id));
public Task<int> CreateAsync(Category category, CancellationToken cancellationToken = default)
{
category.Id = _categories.Count + 1;
_categories.Add(category);
return Task.FromResult(category.Id);
}
public Task UpdateAsync(Category category, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DeleteAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class PassThroughValidator<T> : IValidator<T> private sealed class PassThroughValidator<T> : IValidator<T>
{ {
public FluentValidation.Results.ValidationResult Validate(T instance) => new(); public FluentValidation.Results.ValidationResult Validate(T instance) => new();
@@ -4,7 +4,7 @@ public class CreateBlogPostDto
{ {
public required string Title { get; set; } public required string Title { get; set; }
public required string Content { get; set; } public required string Content { get; set; }
public int? CategoryId { get; set; } public int CategoryId { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
public string? SeoTitle { get; set; } public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; } public string? SeoDescription { get; set; }
@@ -18,7 +18,7 @@ public class BlogPostResponseDto
public int Id { get; set; } public int Id { get; set; }
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty; public string Content { get; set; } = string.Empty;
public int? CategoryId { get; set; } public int CategoryId { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
public string? SeoTitle { get; set; } public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; } public string? SeoDescription { get; set; }
+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.Title).NotEmpty().MaximumLength(ValidationRules.TitleMaxLength);
RuleFor(x => x.Content).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.ContentMaxLength); RuleFor(x => x.Content).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.ContentMaxLength);
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.Tags).MaximumLength(ValidationRules.TagsMaxLength); RuleFor(x => x.Tags).MaximumLength(ValidationRules.TagsMaxLength);
RuleFor(x => x.SeoTitle).MaximumLength(ValidationRules.SeoTitleMaxLength); RuleFor(x => x.SeoTitle)
RuleFor(x => x.SeoDescription).MaximumLength(ValidationRules.SeoDescriptionMaxLength); .NotEmpty().When(x => x.IsPublished)
.MaximumLength(ValidationRules.SeoTitleMaxLength);
RuleFor(x => x.SeoDescription)
.NotEmpty().When(x => x.IsPublished)
.MaximumLength(ValidationRules.SeoDescriptionMaxLength);
} }
} }
@@ -8,7 +8,11 @@ using TaxBaik.Domain.Interfaces;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache, IValidator<CreateBlogPostDto> validator) public class BlogService(
IBlogPostRepository repository,
ICategoryRepository categoryRepository,
IMemoryCache memoryCache,
IValidator<CreateBlogPostDto> validator)
{ {
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) => public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct); await repository.GetByIdAsync(id, ct);
@@ -50,6 +54,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default) public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
{ {
ValidatePost(post); ValidatePost(post);
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
post.Title = post.Title.Trim(); post.Title = post.Title.Trim();
post.Content = post.Content.Trim(); post.Content = post.Content.Trim();
post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct); post.Slug = await GenerateUniqueSlugAsync(post.Title, ct: ct);
@@ -62,6 +67,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default) public async Task<BlogPost> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
{ {
validator.ValidateAndThrow(dto); validator.ValidateAndThrow(dto);
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
var post = new BlogPost var post = new BlogPost
{ {
Title = dto.Title, Title = dto.Title,
@@ -83,6 +89,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) public async Task UpdateAsync(BlogPost post, CancellationToken ct = default)
{ {
post.CategoryId ??= await EnsureCategoryIdAsync(0, ct);
await repository.UpdateAsync(post, ct); await repository.UpdateAsync(post, ct);
memoryCache.Remove(AdminDashboardService.CacheKey); memoryCache.Remove(AdminDashboardService.CacheKey);
} }
@@ -90,6 +97,7 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default) public async Task<BlogPost?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
{ {
validator.ValidateAndThrow(dto); validator.ValidateAndThrow(dto);
dto.CategoryId = await EnsureCategoryIdAsync(dto.CategoryId, ct);
var post = await repository.GetByIdAsync(id, ct); var post = await repository.GetByIdAsync(id, ct);
if (post == null) if (post == null)
return null; return null;
@@ -178,6 +186,15 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100); private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
private async Task<int> EnsureCategoryIdAsync(int categoryId, CancellationToken ct)
{
if (categoryId > 0)
return categoryId;
var firstCategory = (await categoryRepository.GetAllAsync(ct)).FirstOrDefault();
return firstCategory?.Id ?? throw new ValidationException("블로그 카테고리를 먼저 생성하세요.");
}
public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default) public async Task<(int TotalPosts, int PublishedPosts)> GetStatsAsync(CancellationToken ct = default)
{ {
var posts = (await repository.GetAllForAdminAsync(ct)).ToList(); var posts = (await repository.GetAllForAdminAsync(ct)).ToList();
+145 -111
View File
@@ -1,6 +1,8 @@
using System.Data;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text.RegularExpressions;
using Npgsql; using DbUp;
using DbUp.Engine;
using TaxBaik.Domain.Interfaces; using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Infrastructure.Data; namespace TaxBaik.Infrastructure.Data;
@@ -18,159 +20,191 @@ public class MigrationRunner
public async Task RunAsync() public async Task RunAsync()
{ {
await EnsureMigrationTableAsync(); await Task.Run(() => RunMigrations());
await ExecutePendingMigrationsAsync();
} }
private async Task EnsureMigrationTableAsync() private void RunMigrations()
{ {
using var conn = new NpgsqlConnection(_connectionString); // Try file system first (for deployment), then embedded resources
await conn.OpenAsync(); var migrationDirs = new[]
{
"./migrations", // relative
"/home/kjh2064/taxbaik_active/migrations", // deployment
"./db/migrations",
"../db/migrations"
};
using var cmd = conn.CreateCommand(); var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
var upgradeEngineBuilder = DeployChanges.To
.PostgresqlDatabase(_connectionString)
.JournalTo(new DbUpCustomJournal(_connectionString))
.LogToConsole();
if (migrationPath != null && Directory.Exists(migrationPath))
{
Console.WriteLine($"[DbUp] Loading migration scripts from filesystem path: {migrationPath}");
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsFromFileSystem(migrationPath);
}
else
{
Console.WriteLine("[DbUp] Loading migration scripts embedded in Assembly.");
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsEmbeddedInAssembly(
Assembly.GetExecutingAssembly(),
name => name.Contains(".Migrations.V") && name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)
);
}
var upgradeEngine = upgradeEngineBuilder.Build();
if (upgradeEngine.IsUpgradeRequired())
{
Console.WriteLine("[DbUp] Database upgrade is required. Running migrations...");
var result = upgradeEngine.PerformUpgrade();
if (!result.Successful)
{
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.");
}
}
}
public class DbUpCustomJournal : IJournal
{
private readonly string _connectionString;
public DbUpCustomJournal(string connectionString)
{
_connectionString = connectionString;
}
public string[] GetExecutedScripts()
{
var executedVersions = new HashSet<string>();
using (var conn = new Npgsql.NpgsqlConnection(_connectionString))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = @" cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(50) PRIMARY KEY, version VARCHAR(50) PRIMARY KEY,
description VARCHAR(500), description VARCHAR(500),
installed_on TIMESTAMPTZ DEFAULT NOW() installed_on TIMESTAMPTZ DEFAULT NOW()
);"; );";
await cmd.ExecuteNonQueryAsync(); cmd.ExecuteNonQuery();
} }
private async Task ExecutePendingMigrationsAsync() using (var cmd = conn.CreateCommand())
{ {
var executedMigrations = await GetExecutedMigrationsAsync(); cmd.CommandText = "SELECT version FROM schema_migrations;";
var migrations = GetAvailableMigrations(); using (var reader = cmd.ExecuteReader())
foreach (var migration in migrations.OrderBy(x => x.Version))
{ {
if (!executedMigrations.Contains(migration.Version)) while (reader.Read())
{ {
await ExecuteMigrationAsync(migration); executedVersions.Add(reader.GetString(0));
}
} }
} }
} }
private async Task<HashSet<string>> GetExecutedMigrationsAsync() // Collect all possible script names from both embedded resources and filesystem
{ var allScriptNames = new List<string>();
var executed = new HashSet<string>();
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand(); // 1. Assembly resources
cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;"; var assembly = Assembly.GetExecutingAssembly();
var resourceNames = assembly.GetManifestResourceNames()
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase));
allScriptNames.AddRange(resourceNames);
using var reader = await cmd.ExecuteReaderAsync(); // 2. Filesystem
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[] var migrationDirs = new[]
{ {
"./migrations", // relative "./migrations",
"/home/kjh2064/taxbaik_active/migrations" // deployment "/home/kjh2064/taxbaik_active/migrations",
"./db/migrations",
"../db/migrations"
}; };
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists); 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)!);
}
if (migrationPath != null && Directory.Exists(migrationPath)) var executedScriptNames = new List<string>();
foreach (var scriptName in allScriptNames.Distinct())
{ {
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x); var match = Regex.Match(scriptName, @"[Vv](?<version>\d+)__");
foreach (var file in files) if (match.Success)
{ {
var fileName = Path.GetFileNameWithoutExtension(file); var ver = match.Groups["version"].Value;
if (fileName.StartsWith("V")) if (executedVersions.Contains(ver))
{ {
var version = fileName.Substring(1, fileName.IndexOf('_') - 1); executedScriptNames.Add(scriptName);
var description = fileName.Substring(fileName.IndexOf('_') + 2);
var sql = File.ReadAllText(file);
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
} }
} }
} }
return executedScriptNames.ToArray();
}
public void StoreExecutedScript(SqlScript script, Func<IDbCommand> dbCommandFactory)
{
var match = Regex.Match(script.Name, @"[Vv](?<version>\d+)__(?<desc>.*)$");
string version;
string description;
if (match.Success)
{
version = match.Groups["version"].Value;
description = match.Groups["desc"].Value.Replace(".sql", "").Replace("_", " ");
}
else else
{ {
var assembly = Assembly.GetExecutingAssembly(); version = script.Name;
var resourceNames = assembly.GetManifestResourceNames() description = "Executed via DbUp";
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) }
.OrderBy(x => x);
foreach (var resourceName in resourceNames) using (var cmd = dbCommandFactory())
{ {
using var stream = assembly.GetManifestResourceStream(resourceName); cmd.CommandText = @"
if (stream == null) INSERT INTO schema_migrations (version, description)
continue; VALUES (@version, @description)
ON CONFLICT (version) DO NOTHING;";
using var reader = new StreamReader(stream); var pVersion = cmd.CreateParameter();
var sql = reader.ReadToEnd(); pVersion.ParameterName = "@version";
var fileName = Path.GetFileNameWithoutExtension(resourceName); pVersion.Value = version;
var versionStart = fileName.IndexOf('V'); cmd.Parameters.Add(pVersion);
var versionEnd = fileName.IndexOf('_', versionStart + 1);
if (versionStart < 0 || versionEnd < 0)
continue;
var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1); var pDesc = cmd.CreateParameter();
var description = fileName.Substring(versionEnd + 1); pDesc.ParameterName = "@description";
pDesc.Value = description;
cmd.Parameters.Add(pDesc);
migrations.Add(new Migration { Version = version, Description = description, Sql = sql }); cmd.ExecuteNonQuery();
} }
} }
return migrations; public void EnsureTableExistsAndIsLatestVersion(Func<IDbCommand> dbCommandFactory)
}
private async Task ExecuteMigrationAsync(Migration migration)
{ {
using var conn = new NpgsqlConnection(_connectionString); using (var cmd = dbCommandFactory())
await conn.OpenAsync();
try
{ {
using var cmd = conn.CreateCommand(); cmd.CommandText = @"
cmd.CommandText = migration.Sql; CREATE TABLE IF NOT EXISTS schema_migrations (
await cmd.ExecuteNonQueryAsync(); version VARCHAR(50) PRIMARY KEY,
description VARCHAR(500),
using var insertCmd = conn.CreateCommand(); installed_on TIMESTAMPTZ DEFAULT NOW()
insertCmd.CommandText = );";
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description);"; cmd.ExecuteNonQuery();
insertCmd.Parameters.AddWithValue("@version", migration.Version);
insertCmd.Parameters.AddWithValue("@description", migration.Description);
await insertCmd.ExecuteNonQueryAsync();
Console.WriteLine($"✓ Migration {migration.Version} executed");
}
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
{
// 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();
}
catch (Exception ex)
{
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
throw;
} }
} }
private class Migration
{
public required string Version { get; set; }
public required string Description { get; set; }
public required string Sql { get; set; }
}
} }
@@ -4,6 +4,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.1.15" /> <PackageReference Include="Dapper" Version="2.1.15" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Npgsql" Version="10.0.3" /> <PackageReference Include="Npgsql" Version="10.0.3" />
@@ -0,0 +1,8 @@
namespace TaxBaik.Web.Models;
public sealed record AdminPageHeaderModel(
string Eyebrow,
string Title,
string? Subtitle,
string? ActionHref,
string? ActionText);
@@ -0,0 +1,65 @@
@page
@model TaxBaik.Web.Pages.Admin.Announcements.CreateModel
@{ ViewData["Title"] = "공지사항 작성"; }
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 작성", "Tabler 표준 폼으로 공지사항을 등록합니다.", null, null))
<form method="post" class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_ValidationSummary")
<div class="row g-2">
<div class="col-lg-8">
<label asp-for="Input.Title" class="form-label form-label-sm"></label>
<input asp-for="Input.Title" class="form-control form-control-sm" />
<span asp-validation-for="Input.Title" class="text-danger small"></span>
</div>
<div class="col-lg-4">
<label asp-for="Input.DisplayType" class="form-label form-label-sm"></label>
<select asp-for="Input.DisplayType" class="form-select form-select-sm">
<option value="info">info</option>
<option value="success">success</option>
<option value="warning">warning</option>
<option value="danger">danger</option>
</select>
</div>
<div class="col-12">
<label asp-for="Input.Content" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Content" class="form-control form-control-sm" rows="8"></textarea>
<span asp-validation-for="Input.Content" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
</div>
<div class="col-md-4">
<label asp-for="Input.StartsAt" class="form-label form-label-sm"></label>
<input asp-for="Input.StartsAt" class="form-control form-control-sm" type="datetime-local" />
</div>
<div class="col-md-4">
<label asp-for="Input.EndsAt" class="form-label form-label-sm"></label>
<input asp-for="Input.EndsAt" class="form-control form-control-sm" type="datetime-local" />
</div>
<div class="col-12">
<label class="form-check">
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
<span class="form-check-label form-label-sm">활성</span>
</label>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/admin/announcements">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</form>
</section>
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Announcements;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel(AnnouncementService announcementService) : PageModel
{
[BindProperty]
public AnnouncementDto Input { get; set; } = new();
public IActionResult OnGet() => Page();
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
await announcementService.CreateAsync(Input, ct);
return RedirectToPage("/Admin/Announcements/Index");
}
}
@@ -0,0 +1,58 @@
@page "/admin/announcements/{id:int}"
@model TaxBaik.Web.Pages.Admin.Announcements.DetailModel
@{ ViewData["Title"] = "공지사항 상세"; }
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 상세", "공지사항의 노출 정보를 확인합니다.", null, null))
@if (Model.Item is null)
{
@await Html.PartialAsync("_EmptyState", "공지사항을 찾을 수 없습니다.")
}
else
{
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<dl class="row details-grid mb-0">
<dt class="col-sm-3">제목</dt>
<dd class="col-sm-9">@Model.Item.Title</dd>
<dt class="col-sm-3">유형</dt>
<dd class="col-sm-9">@Model.Item.DisplayType</dd>
<dt class="col-sm-3">내용</dt>
<dd class="col-sm-9"><div class="pre-wrap">@Model.Item.Content</div></dd>
<dt class="col-sm-3">활성</dt>
<dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Item.IsActive ? "active" : "inactive")</dd>
<dt class="col-sm-3">정렬</dt>
<dd class="col-sm-9">@Model.Item.SortOrder</dd>
<dt class="col-sm-3">시작일</dt>
<dd class="col-sm-9">@Model.Item.StartsAt?.ToString("yyyy-MM-dd HH:mm")</dd>
<dt class="col-sm-3">종료일</dt>
<dd class="col-sm-9">@Model.Item.EndsAt?.ToString("yyyy-MM-dd HH:mm")</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-body p-2">
<div class="d-flex gap-2">
<a class="btn btn-primary btn-sm flex-fill" href="/admin/announcements/@Model.Item.Id/edit">수정</a>
<a class="btn btn-outline-secondary btn-sm flex-fill" href="/admin/announcements">목록</a>
</div>
</div>
</div>
</div>
</div>
}
</section>
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Announcements;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class DetailModel(AnnouncementService announcementService) : PageModel
{
public Announcement? Item { get; private set; }
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
{
Item = await announcementService.GetByIdAsync(id, ct);
return Page();
}
}
@@ -0,0 +1,77 @@
@page "/admin/announcements/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Announcements.EditModel
@{ ViewData["Title"] = "공지사항 수정"; }
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 수정", "기존 공지사항을 Tabler 표준 폼으로 수정합니다.", null, null))
<form method="post" class="card">
<div class="card-header p-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
<div class="fw-semibold small">@Model.Input.Title</div>
<div class="text-muted small">유형: @Model.Input.DisplayType</div>
</div>
<span class="badge bg-secondary-lt text-secondary small">원본 확인 후 수정</span>
</div>
<div class="card-body p-2">
@await Html.PartialAsync("_ValidationSummary")
<input type="hidden" asp-for="Input.Id" />
<div class="row g-2">
<div class="col-lg-8">
<label asp-for="Input.Title" class="form-label form-label-sm"></label>
<input asp-for="Input.Title" class="form-control form-control-sm" />
<span asp-validation-for="Input.Title" class="text-danger small"></span>
</div>
<div class="col-lg-4">
<label asp-for="Input.DisplayType" class="form-label form-label-sm"></label>
<select asp-for="Input.DisplayType" class="form-select form-select-sm">
<option value="info">info</option>
<option value="success">success</option>
<option value="warning">warning</option>
<option value="danger">danger</option>
</select>
</div>
<div class="col-12">
<label asp-for="Input.Content" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Content" class="form-control form-control-sm" rows="8"></textarea>
<span asp-validation-for="Input.Content" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
</div>
<div class="col-md-4">
<label asp-for="Input.StartsAt" class="form-label form-label-sm"></label>
<input asp-for="Input.StartsAt" class="form-control form-control-sm" type="datetime-local" />
</div>
<div class="col-md-4">
<label asp-for="Input.EndsAt" class="form-label form-label-sm"></label>
<input asp-for="Input.EndsAt" class="form-control form-control-sm" type="datetime-local" />
</div>
<div class="col-12">
<label class="form-check">
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
<span class="form-check-label form-label-sm">활성</span>
</label>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between gap-2">
<div class="d-flex gap-2">
<button class="btn btn-outline-danger btn-sm" type="submit" formaction="?handler=Delete" formmethod="post" onclick="return confirm('정말 삭제하시겠습니까?');">삭제</button>
<a class="btn btn-outline-secondary btn-sm" href="/admin/announcements">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</div>
</form>
</section>
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Announcements;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel(AnnouncementService announcementService) : PageModel
{
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
[BindProperty]
public AnnouncementDto Input { get; set; } = new();
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
var item = await announcementService.GetByIdAsync(Id, ct);
if (item is null)
return NotFound();
Input = new AnnouncementDto
{
Id = item.Id,
Title = item.Title,
Content = item.Content,
DisplayType = item.DisplayType,
IsActive = item.IsActive,
StartsAt = item.StartsAt,
EndsAt = item.EndsAt,
SortOrder = item.SortOrder
};
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
await announcementService.UpdateAsync(Input, ct);
return RedirectToPage("/Admin/Announcements/Index");
}
public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
{
await announcementService.DeleteAsync(Id, ct);
return RedirectToPage("/Admin/Announcements/Index");
}
}
@@ -2,33 +2,58 @@
@model TaxBaik.Web.Pages.Admin.Announcements.IndexModel @model TaxBaik.Web.Pages.Admin.Announcements.IndexModel
@{ ViewData["Title"] = "공지사항"; } @{ ViewData["Title"] = "공지사항"; }
<section class="container-fluid py-4"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("Content", "공지사항 관리", "공지 목록을 서버 렌더링으로 표시합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Content", "공지사항 관리", "공지 목록을 서버 렌더링으로 표시합니다.", "/admin/announcements/create", "공지 작성"))
<div class="card">
<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) @if (Model.Items.Count == 0)
{ {
<tr><td colspan="4"><partial name="_EmptyState" model="공지사항이 없습니다." /></td></tr> <div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_EmptyState", "공지사항이 없습니다.")
</div>
</div>
} }
else else
{ {
foreach (var item in Model.Items) <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> <tr>
<td>@item.Title</td> <td class="fw-semibold">@item.Title</td>
<td>@item.DisplayType</td> <td>@item.DisplayType</td>
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td> <td>@await Html.PartialAsync("_StatusBadge", item.IsActive ? "active" : "inactive")</td>
<td>@item.SortOrder</td> <td>@item.SortOrder</td>
<td class="text-end">
<div class="btn-list flex-nowrap justify-content-end">
<a class="btn btn-sm btn-outline-secondary" href="/admin/announcements/@item.Id">상세</a>
<a class="btn btn-sm btn-primary" href="/admin/announcements/@item.Id/edit">수정</a>
</div>
</td>
</tr> </tr>
} }
}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
}
</section> </section>
+20 -2
View File
@@ -2,9 +2,15 @@
@model TaxBaik.Web.Pages.Admin.Blog.CreateModel @model TaxBaik.Web.Pages.Admin.Blog.CreateModel
@{ @{
ViewData["Title"] = "블로그 등록"; ViewData["Title"] = "블로그 등록";
ViewData["BlogCategories"] = Model.Categories;
} }
<partial name="_BlogEditor" model="new TaxBaik.Web.Pages.Shared.BlogEditorModel( <section class="admin-page-shell">
@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.Title,
Model.Input.Content, Model.Input.Content,
Model.Input.CategoryId, Model.Input.CategoryId,
@@ -12,8 +18,20 @@
Model.Input.ThumbnailUrl, Model.Input.ThumbnailUrl,
Model.Input.SeoTitle, Model.Input.SeoTitle,
Model.Input.SeoDescription, Model.Input.SeoDescription,
false)" /> false))
</div>
</div>
</section>
@section Scripts { @section Scripts {
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script> <script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
} }
@@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs; using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog; namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] [Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel(BlogService blogService) : PageModel public class CreateModel(BlogService blogService, CategoryService categoryService) : PageModel
{ {
[BindProperty] [BindProperty]
public CreateBlogPostDto Input { get; set; } = new() public CreateBlogPostDto Input { get; set; } = new()
@@ -17,14 +18,47 @@ public class CreateModel(BlogService blogService) : PageModel
Title = string.Empty Title = string.Empty
}; };
public IActionResult OnGet() => Page(); public IReadOnlyList<Category> Categories { get; private set; } = [];
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (Categories.Count > 0)
Input.CategoryId = Categories[0].Id;
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct) public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{ {
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Page(); return Page();
await blogService.CreateAsync(Input, ct); await blogService.CreateAsync(Input, ct);
return RedirectToPage("/Admin/Blog/Index"); return RedirectToPage("/Admin/Blog/Index");
} }
private async Task<IEnumerable<Category>> EnsureCategoriesAsync(CancellationToken ct)
{
var categories = (await categoryService.GetAllAsync(ct)).ToList();
if (categories.Count > 0)
return categories;
var defaults = new[]
{
("사업자 세무", "business-tax", 1),
("부동산 세금", "real-estate-tax", 2),
("종합소득세", "income-tax", 3),
("부가가치세", "vat", 4),
("가족자산·증여", "family-asset", 5)
};
foreach (var (name, slug, sortOrder) in defaults)
{
await categoryService.CreateAsync(name, null, ct);
}
return await categoryService.GetAllAsync(ct);
}
} }
+21 -3
View File
@@ -1,11 +1,17 @@
@page "{id:int}" @page "/admin/blog/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Blog.EditModel @model TaxBaik.Web.Pages.Admin.Blog.EditModel
@{ @{
ViewData["Title"] = "블로그 수정"; ViewData["Title"] = "블로그 수정";
ViewData["BlogEditorId"] = Model.Id; ViewData["BlogEditorId"] = Model.Id;
ViewData["BlogCategories"] = Model.Categories;
} }
<partial name="_BlogEditor" model='new TaxBaik.Web.Pages.Shared.BlogEditorModel( <section class="admin-page-shell">
@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.Title,
Model.Input.Content, Model.Input.Content,
Model.Input.CategoryId, Model.Input.CategoryId,
@@ -14,8 +20,20 @@
Model.Input.SeoTitle, Model.Input.SeoTitle,
Model.Input.SeoDescription, Model.Input.SeoDescription,
true, true,
"?handler=Delete")' /> "?handler=Delete"))
</div>
</div>
</section>
@section Scripts { @section Scripts {
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script> <script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
} }
@@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs; using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog; namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] [Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel(BlogService blogService) : PageModel public class EditModel(BlogService blogService, CategoryService categoryService) : PageModel
{ {
[BindProperty(SupportsGet = true)] [BindProperty(SupportsGet = true)]
public int Id { get; set; } public int Id { get; set; }
@@ -20,17 +21,20 @@ public class EditModel(BlogService blogService) : PageModel
Title = string.Empty Title = string.Empty
}; };
public IReadOnlyList<Category> Categories { get; private set; } = [];
public async Task<IActionResult> OnGetAsync(CancellationToken ct) public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{ {
var post = await blogService.GetByIdAsync(Id, ct); var post = await blogService.GetByIdAsync(Id, ct);
if (post is null) if (post is null)
return NotFound(); return NotFound();
Categories = (await EnsureCategoriesAsync(ct)).ToList();
Input = new CreateBlogPostDto Input = new CreateBlogPostDto
{ {
Title = post.Title, Title = post.Title,
Content = post.Content, Content = post.Content,
CategoryId = post.CategoryId, CategoryId = post.CategoryId ?? 0,
Tags = post.Tags, Tags = post.Tags,
SeoTitle = post.SeoTitle, SeoTitle = post.SeoTitle,
SeoDescription = post.SeoDescription, SeoDescription = post.SeoDescription,
@@ -44,6 +48,7 @@ public class EditModel(BlogService blogService) : PageModel
public async Task<IActionResult> OnPostAsync(CancellationToken ct) public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{ {
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Page(); return Page();
@@ -59,4 +64,27 @@ public class EditModel(BlogService blogService) : PageModel
await blogService.DeleteAsync(Id, ct); await blogService.DeleteAsync(Id, ct);
return RedirectToPage("/Admin/Blog/Index"); return RedirectToPage("/Admin/Blog/Index");
} }
private async Task<IEnumerable<Category>> EnsureCategoriesAsync(CancellationToken ct)
{
var categories = (await categoryService.GetAllAsync(ct)).ToList();
if (categories.Count > 0)
return categories;
var defaults = new[]
{
("사업자 세무", "business-tax", 1),
("부동산 세금", "real-estate-tax", 2),
("종합소득세", "income-tax", 3),
("부가가치세", "vat", 4),
("가족자산·증여", "family-asset", 5)
};
foreach (var (name, slug, sortOrder) in defaults)
{
await categoryService.CreateAsync(name, null, ct);
}
return await categoryService.GetAllAsync(ct);
}
} }
+43 -29
View File
@@ -4,29 +4,34 @@
ViewData["Title"] = "블로그"; ViewData["Title"] = "블로그";
} }
<section class="container-xl admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("콘텐츠", "블로그 관리", "검색, 발행 상태, 작성일 기준의 블로그 목록입니다.", "/admin/blog/create", "새 글 작성")' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("콘텐츠", "블로그 관리", "검색, 발행 상태, 작성일 기준의 블로그 목록입니다.", "/admin/blog/create", "새 글 작성"))
<form method="get" class="card admin-page-card mb-3"> <div class="card">
<div class="card-body"> <div class="card-body p-2" style="min-height: auto;">
<div class="row g-3 align-items-end"> <form method="get">
<div class="col-md-6"> <div class="row g-2 align-items-end">
<label class="form-label" for="SearchQuery">검색</label> <div class="col-md-4">
<input class="form-control" id="SearchQuery" name="searchQuery" value="@Model.SearchQuery" placeholder="제목 또는 본문 검색" /> <label class="form-label form-label-sm" for="SearchQuery">검색</label>
<input class="form-control form-control-sm" id="SearchQuery" name="searchQuery" value="@Model.SearchQuery" placeholder="제목 또는 본문" />
</div> </div>
<div class="col-md-6 d-flex gap-2"> <div class="col-auto">
<button class="btn btn-primary" type="submit">검색</button> <button class="btn btn-primary btn-sm" type="submit">검색</button>
<a class="btn btn-outline-secondary" href="/admin/blog">초기화</a>
<a class="btn btn-outline-secondary" href="/admin/blog?showArchived=true">내린 글 보기</a>
</div> </div>
<div class="col-auto">
<a class="btn btn-outline-secondary btn-sm" href="/admin/blog">초기화</a>
</div>
<div class="col-auto">
<a class="btn btn-outline-secondary btn-sm" href="/admin/blog?showArchived=true">내린 글</a>
</div> </div>
</div> </div>
</form> </form>
</div>
<div class="card admin-page-card"> @if (Model.Items.Count > 0)
<div class="table-responsive"> {
<table class="table table-hover align-middle mb-0"> <div class="table-responsive table-responsive--separator">
<thead class="table-light"> <table class="table table-vcenter card-table table-hover">
<thead>
<tr> <tr>
<th>제목</th> <th>제목</th>
<th>발행</th> <th>발행</th>
@@ -36,33 +41,42 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (Model.Items.Count == 0) @foreach (var post in Model.Items)
{
<tr>
<td colspan="5"><partial name="_EmptyState" model="블로그 글이 없습니다." /></td>
</tr>
}
else
{
foreach (var post in Model.Items)
{ {
<tr> <tr>
<td> <td>
<div class="fw-semibold">@post.Title</div> <div class="fw-semibold">@post.Title</div>
<div class="text-muted small">@post.Slug</div> <div class="text-muted small">@post.Slug</div>
</td> </td>
<td><partial name="_StatusBadge" model="@(post.IsPublished ? "active" : "inactive")" /></td> <td>@await Html.PartialAsync("_StatusBadge", post.IsPublished ? "active" : "inactive")</td>
<td>@post.ViewCount</td> <td>@post.ViewCount</td>
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td> <td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
<td class="text-end"> <td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/admin/blog/@post.Id/edit">수정</a> <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> <a class="btn btn-sm btn-outline-secondary" href="/blog/@post.Slug" target="_blank">보기</a>
</div>
</td> </td>
</tr> </tr>
} }
}
</tbody> </tbody>
</table> </table>
</div> </div>
}
else
{
<div class="card-body py-4">
@await Html.PartialAsync("_EmptyState", "블로그 글이 없습니다.")
</div>
}
</div> </div>
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.ClientLogs.IndexModel @model TaxBaik.Web.Pages.Admin.ClientLogs.IndexModel
@{ ViewData["Title"] = "클라이언트 로그"; } @{ ViewData["Title"] = "클라이언트 로그"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("운영 로그", "클라이언트 로그", "고객별 작업 이력을 확인합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 로그", "클라이언트 로그", "고객별 작업 이력을 확인합니다.", null, null))
<partial name="_ComingSoon" model="클라이언트 로그" /> @await Html.PartialAsync("_ComingSoon", "클라이언트 로그")
</section> </section>
@@ -4,55 +4,64 @@
ViewData["Title"] = "고객 등록"; ViewData["Title"] = "고객 등록";
} }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 등록", "FluentValidation과 Tabler 표준 폼으로 신규 고객을 등록합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 등록", "FluentValidation과 Tabler 표준 폼으로 신규 고객을 등록합니다.", null, null))
<form method="post" class="card shadow-sm border-0"> <form method="post" class="card">
<div class="card-body"> <div class="card-body p-2">
<partial name="_ValidationSummary" /> @await Html.PartialAsync("_ValidationSummary")
<div class="row g-3"> <div class="row g-2">
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.Name" class="form-label"></label> <label asp-for="Input.Name" class="form-label form-label-sm"></label>
<input asp-for="Input.Name" class="form-control" /> <input asp-for="Input.Name" class="form-control form-control-sm" />
<span asp-validation-for="Input.Name" class="text-danger small"></span> <span asp-validation-for="Input.Name" class="text-danger small"></span>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.CompanyName" class="form-label"></label> <label asp-for="Input.CompanyName" class="form-label form-label-sm"></label>
<input asp-for="Input.CompanyName" class="form-control" /> <input asp-for="Input.CompanyName" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.Phone" class="form-label"></label> <label asp-for="Input.Phone" class="form-label form-label-sm"></label>
<input asp-for="Input.Phone" class="form-control" /> <input asp-for="Input.Phone" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.Email" class="form-label"></label> <label asp-for="Input.Email" class="form-label form-label-sm"></label>
<input asp-for="Input.Email" class="form-control" /> <input asp-for="Input.Email" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="Input.Status" class="form-label"></label> <label asp-for="Input.Status" class="form-label form-label-sm"></label>
<input asp-for="Input.Status" class="form-control" /> <input asp-for="Input.Status" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="Input.ServiceType" class="form-label"></label> <label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
<input asp-for="Input.ServiceType" class="form-control" /> <input asp-for="Input.ServiceType" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="Input.TaxType" class="form-label"></label> <label asp-for="Input.TaxType" class="form-label form-label-sm"></label>
<input asp-for="Input.TaxType" class="form-control" /> <input asp-for="Input.TaxType" class="form-control form-control-sm" />
</div> </div>
<div class="col-12"> <div class="col-12">
<label asp-for="Input.Source" class="form-label"></label> <label asp-for="Input.Source" class="form-label form-label-sm"></label>
<input asp-for="Input.Source" class="form-control" /> <input asp-for="Input.Source" class="form-control form-control-sm" />
</div> </div>
<div class="col-12"> <div class="col-12">
<label asp-for="Input.Memo" class="form-label"></label> <label asp-for="Input.Memo" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Memo" class="form-control" rows="5"></textarea> <textarea asp-for="Input.Memo" class="form-control form-control-sm" rows="5"></textarea>
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer bg-white d-flex justify-content-end gap-2"> <div class="card-footer d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="/admin/clients">취소</a> <a class="btn btn-outline-secondary btn-sm" href="/admin/clients">취소</a>
<button class="btn btn-primary" type="submit">저장</button> <button class="btn btn-primary btn-sm" type="submit">저장</button>
</div> </div>
</form> </form>
</section> </section>
@@ -1,23 +1,23 @@
@page "{id:int}" @page "/admin/clients/{id:int}"
@model TaxBaik.Web.Pages.Admin.Clients.DetailModel @model TaxBaik.Web.Pages.Admin.Clients.DetailModel
@{ @{
ViewData["Title"] = "고객 상세"; ViewData["Title"] = "고객 상세";
} }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 상세", "고객의 기본 정보와 수정 경로를 확인합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 상세", "고객의 기본 정보와 수정 경로를 확인합니다.", null, null))
@if (Model.Client is null) @if (Model.Client is null)
{ {
<partial name="_EmptyState" model="조회한 고객을 찾을 수 없습니다." /> @await Html.PartialAsync("_EmptyState", "조회한 고객을 찾을 수 없습니다.")
} }
else else
{ {
<div class="row g-4"> <div class="row g-4">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card shadow-sm border-0"> <div class="card">
<div class="card-body"> <div class="card-body">
<dl class="row mb-0"> <dl class="row details-grid mb-0">
<dt class="col-sm-3">고객명</dt> <dt class="col-sm-3">고객명</dt>
<dd class="col-sm-9">@Model.Client.Name</dd> <dd class="col-sm-9">@Model.Client.Name</dd>
<dt class="col-sm-3">회사명</dt> <dt class="col-sm-3">회사명</dt>
@@ -27,7 +27,7 @@
<dt class="col-sm-3">이메일</dt> <dt class="col-sm-3">이메일</dt>
<dd class="col-sm-9">@Model.Client.Email</dd> <dd class="col-sm-9">@Model.Client.Email</dd>
<dt class="col-sm-3">상태</dt> <dt class="col-sm-3">상태</dt>
<dd class="col-sm-9"><partial name="_StatusBadge" model="Model.Client.Status" /></dd> <dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Client.Status)</dd>
<dt class="col-sm-3">서비스 유형</dt> <dt class="col-sm-3">서비스 유형</dt>
<dd class="col-sm-9">@Model.Client.ServiceType</dd> <dd class="col-sm-9">@Model.Client.ServiceType</dd>
<dt class="col-sm-3">세금 유형</dt> <dt class="col-sm-3">세금 유형</dt>
@@ -45,13 +45,24 @@
</div> </div>
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card shadow-sm border-0"> <div class="card">
<div class="card-body d-grid gap-2"> <div class="card-body p-2">
<a class="btn btn-primary" href="/admin/clients/@Model.Client.Id/edit">수정</a> <div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="/admin/clients">목록</a> <a class="btn btn-primary btn-sm flex-fill" href="/admin/clients/@Model.Client.Id/edit">수정</a>
<a class="btn btn-outline-secondary btn-sm flex-fill" href="/admin/clients">목록</a>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
} }
</section> </section>
+55 -30
View File
@@ -1,62 +1,87 @@
@page "{id:int}" @page "/admin/clients/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Clients.EditModel @model TaxBaik.Web.Pages.Admin.Clients.EditModel
@{ @{
ViewData["Title"] = "고객 수정"; ViewData["Title"] = "고객 수정";
} }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 수정", "기존 고객 정보를 불러와 Tabler 표준 폼으로 수정합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 수정", "기존 고객 정보를 불러와 Tabler 표준 폼으로 수정합니다.", null, null))
<form method="post" class="card shadow-sm border-0"> <div class="card mb-2">
<div class="card-body"> <div class="card-body p-2">
<partial name="_ValidationSummary" /> <div class="text-muted small text-uppercase mb-1">불러온 원본</div>
<div class="row g-3"> <div class="fw-semibold">@Model.Input.Name</div>
<div class="text-muted small">@Model.Input.CompanyName / @Model.Input.Phone</div>
</div>
</div>
<form method="post" class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_ValidationSummary")
<div class="row g-2">
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.Name" class="form-label"></label> <label asp-for="Input.Name" class="form-label form-label-sm"></label>
<input asp-for="Input.Name" class="form-control" /> <input asp-for="Input.Name" class="form-control form-control-sm" />
<span asp-validation-for="Input.Name" class="text-danger small"></span> <span asp-validation-for="Input.Name" class="text-danger small"></span>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.CompanyName" class="form-label"></label> <label asp-for="Input.CompanyName" class="form-label form-label-sm"></label>
<input asp-for="Input.CompanyName" class="form-control" /> <input asp-for="Input.CompanyName" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.Phone" class="form-label"></label> <label asp-for="Input.Phone" class="form-label form-label-sm"></label>
<input asp-for="Input.Phone" class="form-control" /> <input asp-for="Input.Phone" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Input.Email" class="form-label"></label> <label asp-for="Input.Email" class="form-label form-label-sm"></label>
<input asp-for="Input.Email" class="form-control" /> <input asp-for="Input.Email" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="Input.Status" class="form-label"></label> <label asp-for="Input.Status" class="form-label form-label-sm"></label>
<input asp-for="Input.Status" class="form-control" /> <input asp-for="Input.Status" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="Input.ServiceType" class="form-label"></label> <label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
<input asp-for="Input.ServiceType" class="form-control" /> <input asp-for="Input.ServiceType" class="form-control form-control-sm" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="Input.TaxType" class="form-label"></label> <label asp-for="Input.TaxType" class="form-label form-label-sm"></label>
<input asp-for="Input.TaxType" class="form-control" /> <input asp-for="Input.TaxType" class="form-control form-control-sm" />
</div> </div>
<div class="col-12"> <div class="col-12">
<label asp-for="Input.Source" class="form-label"></label> <label asp-for="Input.Source" class="form-label form-label-sm"></label>
<input asp-for="Input.Source" class="form-control" /> <input asp-for="Input.Source" class="form-control form-control-sm" />
</div> </div>
<div class="col-12"> <div class="col-12">
<label asp-for="Input.Memo" class="form-label"></label> <label asp-for="Input.Memo" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Memo" class="form-control" rows="5"></textarea> <textarea asp-for="Input.Memo" class="form-control form-control-sm" rows="5"></textarea>
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer bg-white d-flex justify-content-end gap-2"> <div class="card-footer d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="/admin/clients/@Model.Id">취소</a> <a class="btn btn-outline-secondary btn-sm" href="/admin/clients/@Model.Id">취소</a>
<button class="btn btn-primary" type="submit">저장</button> <button class="btn btn-primary btn-sm" type="submit">저장</button>
</div> </div>
</form> </form>
</section> </section>
<form method="post" asp-page-handler="Delete" class="mt-3" onsubmit="return confirm('정말 삭제하시겠습니까?');"> <div class="card mt-2">
<button class="btn btn-outline-danger" type="submit">삭제</button> <div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
<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> </form>
</div>
</div>
@@ -4,44 +4,47 @@
ViewData["Title"] = "고객 관리"; ViewData["Title"] = "고객 관리";
} }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("고객 관리", "고객 목록", "검색, 상태 필터, 페이지네이션 기준의 고객 목록입니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("고객 관리", "고객 목록", "검색, 상태 필터, 페이지네이션 기준의 고객 목록입니다.", "/admin/clients/create", "고객 등록"))
<form method="get" class="card shadow-sm border-0 mb-4"> <div class="card">
<div class="card-body"> <div class="card-body p-2" style="min-height: auto;">
<div class="row g-3 align-items-end"> <form method="get">
<div class="row g-2 align-items-end">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="Search">검색</label> <label class="form-label form-label-sm" for="Search">검색</label>
<input class="form-control" id="Search" name="search" value="@Model.Search" placeholder="이름, 전화, 회사" /> <input class="form-control form-control-sm" id="Search" name="search" value="@Model.Search" placeholder="이름, 전화, 회사" />
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label" for="Status">상태</label> <label class="form-label form-label-sm" for="Status">상태</label>
<select class="form-select" id="Status" name="status"> <select class="form-select form-select-sm" id="Status" name="status">
<option value="" selected="@(string.IsNullOrWhiteSpace(Model.Status))">전체</option> <option value="" selected="@(string.IsNullOrWhiteSpace(Model.Status))">전체</option>
<option value="active" selected="@(Model.Status == "active")">active</option> <option value="active" selected="@(Model.Status == "active")">active</option>
<option value="inactive" selected="@(Model.Status == "inactive")">inactive</option> <option value="inactive" selected="@(Model.Status == "inactive")">inactive</option>
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-auto">
<label class="form-label" for="PageSize">페이지 크기</label> <label class="form-label form-label-sm" for="PageSize">건수</label>
<select class="form-select" id="PageSize" name="pageSize"> <select class="form-select form-select-sm" id="PageSize" name="pageSize">
<option value="10" selected="@(Model.PageSize == 10)">10</option> <option value="10" selected="@(Model.PageSize == 10)">10</option>
<option value="20" selected="@(Model.PageSize == 20)">20</option> <option value="20" selected="@(Model.PageSize == 20)">20</option>
<option value="50" selected="@(Model.PageSize == 50)">50</option> <option value="50" selected="@(Model.PageSize == 50)">50</option>
</select> </select>
</div> </div>
<div class="col-md-3 d-grid d-sm-flex gap-2"> <div class="col-auto">
<button class="btn btn-primary" type="submit">검색</button> <button class="btn btn-primary btn-sm" type="submit">검색</button>
<a class="btn btn-outline-secondary" href="/admin/clients">초기화</a>
</div> </div>
<div class="col-auto">
<a class="btn btn-outline-secondary btn-sm" href="/admin/clients">초기화</a>
</div> </div>
</div> </div>
</form> </form>
</div>
<div class="card shadow-sm border-0"> @if (Model.Items.Count > 0)
<div class="table-responsive"> {
<table class="table table-hover align-middle mb-0"> <div class="table-responsive table-responsive--separator">
<thead class="table-light"> <table class="table table-vcenter card-table table-hover">
<thead>
<tr> <tr>
<th>고객명</th> <th>고객명</th>
<th>회사명</th> <th>회사명</th>
@@ -52,35 +55,42 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (Model.Items.Count == 0) @foreach (var item in Model.Items)
{
<tr>
<td colspan="6">
<partial name="_EmptyState" model="검색 결과가 없습니다." />
</td>
</tr>
}
else
{
foreach (var item in Model.Items)
{ {
<tr> <tr>
<td class="fw-semibold">@item.Name</td> <td class="fw-semibold">@item.Name</td>
<td>@item.CompanyName</td> <td>@item.CompanyName</td>
<td>@item.Phone</td> <td>@item.Phone</td>
<td><partial name="_StatusBadge" model="item.Status" /></td> <td>@await Html.PartialAsync("_StatusBadge", item.Status)</td>
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td> <td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
<td class="text-end"> <td class="text-end">
<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-outline-secondary" href="/admin/clients/@item.Id">상세</a>
<a class="btn btn-sm btn-outline-primary" href="/admin/clients/@item.Id/edit">수정</a> <a class="btn btn-sm btn-primary" href="/admin/clients/@item.Id/edit">수정</a>
</div>
</td> </td>
</tr> </tr>
} }
}
</tbody> </tbody>
</table> </table>
</div> </div>
}
else
{
<div class="card-body">
@await Html.PartialAsync("_EmptyState", "검색 결과가 없습니다.")
</div>
}
</div> </div>
<partial name="_Pagination" model="new TaxBaik.Web.Pages.Shared.PaginationModel(Model.CurrentPage, Model.TotalPages, Model.BuildPageUrl)" /> @await Html.PartialAsync("_Pagination", new TaxBaik.Web.Pages.Shared.PaginationModel(Model.CurrentPage, Model.TotalPages, Model.BuildPageUrl))
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.CommonCodes.IndexModel @model TaxBaik.Web.Pages.Admin.CommonCodes.IndexModel
@{ ViewData["Title"] = "공통코드"; } @{ ViewData["Title"] = "공통코드"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("운영 설정", "공통 코드", "코드성 데이터를 표준화해 관리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 설정", "공통 코드", "코드성 데이터를 표준화해 관리합니다.", null, null))
<partial name="_ComingSoon" model="공통 코드" /> @await Html.PartialAsync("_ComingSoon", "공통 코드")
</section> </section>
@@ -2,33 +2,46 @@
@model TaxBaik.Web.Pages.Admin.Companies.IndexModel @model TaxBaik.Web.Pages.Admin.Companies.IndexModel
@{ ViewData["Title"] = "회사"; } @{ ViewData["Title"] = "회사"; }
<section class="container-fluid py-4"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("CRM", "회사 관리", "고객사 회사를 관리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("CRM", "회사 관리", "고객사 회사를 관리합니다.", null, null))
@if (Model.Items.Count == 0)
{
<div class="card"> <div class="card">
<div class="table-responsive"> <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"> <table class="table table-vcenter card-table">
<thead> <thead>
<tr><th>코드</th><th>회사명</th><th>담당자</th><th>활성</th></tr> <tr><th>코드</th><th>회사명</th><th>담당자</th><th>활성</th></tr>
</thead> </thead>
<tbody> <tbody>
@if (Model.Items.Count == 0) @foreach (var item in Model.Items)
{
<tr><td colspan="4"><partial name="_EmptyState" model="회사가 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{ {
<tr> <tr>
<td>@item.CompanyCode</td> <td>@item.CompanyCode</td>
<td>@item.CompanyName</td> <td>@item.CompanyName</td>
<td>@item.ContactPerson</td> <td>@item.ContactPerson</td>
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td> <td>@await Html.PartialAsync("_StatusBadge", item.IsActive ? "active" : "inactive")</td>
</tr> </tr>
} }
}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
}
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.ConsultingActivities.IndexModel @model TaxBaik.Web.Pages.Admin.ConsultingActivities.IndexModel
@{ ViewData["Title"] = "상담 활동"; } @{ ViewData["Title"] = "상담 활동"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "상담 활동", "상담 히스토리와 후속 조치를 관리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "상담 활동", "상담 히스토리와 후속 조치를 관리합니다.", null, null))
<partial name="_ComingSoon" model="상담 활동" /> @await Html.PartialAsync("_ComingSoon", "상담 활동")
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.Contracts.IndexModel @model TaxBaik.Web.Pages.Admin.Contracts.IndexModel
@{ ViewData["Title"] = "계약"; } @{ ViewData["Title"] = "계약"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "계약 관리", "계약 상태와 만료일을 추적합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "계약 관리", "계약 상태와 만료일을 추적합니다.", null, null))
<partial name="_ComingSoon" model="계약 관리" /> @await Html.PartialAsync("_ComingSoon", "계약 관리")
</section> </section>
@@ -1,2 +1,11 @@
@page @page
@model TaxBaik.Web.Pages.Admin.Customers.CreateModel @model TaxBaik.Web.Pages.Admin.Customers.CreateModel
@@ -1,2 +1,11 @@
@page "{id:int}" @page "/admin/customers/{id:int}"
@model TaxBaik.Web.Pages.Admin.Customers.DetailModel @model TaxBaik.Web.Pages.Admin.Customers.DetailModel
@@ -1,2 +1,11 @@
@page "{id:int}" @page "/admin/customers/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Customers.EditModel @model TaxBaik.Web.Pages.Admin.Customers.EditModel
@@ -1,2 +1,11 @@
@page @page
@model TaxBaik.Web.Pages.Admin.Customers.IndexModel @model TaxBaik.Web.Pages.Admin.Customers.IndexModel
+124 -86
View File
@@ -6,115 +6,153 @@
<section class="admin-page-shell"> <section class="admin-page-shell">
<div class="page-header d-print-none mb-4"> <div class="page-header d-print-none mb-4">
<div class="row align-items-center"> <div class="row align-items-center g-3">
<div class="col"> <div class="col">
<div class="page-pretitle">TaxBaik Admin</div> <div class="page-pretitle text-uppercase font-numeric text-primary fw-semibold" style="letter-spacing: 1px;">TAXBAIK ADMIN SYSTEM</div>
<h2 class="page-title">대시보드</h2> <h2 class="page-title text-dark fw-bold h2 mb-1">대시보드</h2>
<p class="text-muted mb-0">관리자 업무 흐름의 시작점입니다.</p> <p class="text-muted small mb-0">관리자 업무 흐름을 관리하는 중앙 컨트롤 타워입니다.</p>
</div> </div>
<div class="col-auto ms-auto"> <div class="col-auto ms-auto">
<div class="text-end small text-muted"> <div class="card shadow-sm border-0 bg-white px-3 py-2 rounded-3 d-flex align-items-center flex-row gap-2">
<div>로그인 사용자</div> <span class="avatar avatar-sm rounded bg-primary-lt text-primary"><i class="bi bi-person-fill"></i></span>
<div class="fw-semibold text-body">@User.Identity?.Name</div>
</div>
</div>
</div>
</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> <div>
<h2 class="h4 mb-1">고객 목록</h2> <div class="text-muted small" style="font-size: 0.7rem; line-height: 1;">접속 계정</div>
<p class="text-muted mb-0">검색/상세/수정/삭제</p> <div class="fw-semibold text-dark font-numeric" style="font-size: 0.85rem;">@User.Identity?.Name</div>
</div>
<span class="avatar avatar-rounded bg-primary-lt text-primary">01</span>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card card-sm">
<div class="card-body">
<div class="text-muted small text-uppercase mb-1">DB 관리</div>
<div class="d-flex align-items-center justify-content-between gap-3">
<div>
<h2 class="h4 mb-1">Flyway</h2>
<p class="text-muted mb-0">SQL migration 추적</p>
</div>
<span class="avatar avatar-rounded bg-indigo-lt text-indigo">02</span>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card card-sm">
<div class="card-body">
<div class="text-muted small text-uppercase mb-1">인증</div>
<div class="d-flex align-items-center justify-content-between gap-3">
<div>
<h2 class="h4 mb-1">Cookie Auth</h2>
<p class="text-muted mb-0">서버 세션 유지</p>
</div>
<span class="avatar avatar-rounded bg-green-lt text-green">03</span>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card card-sm">
<div class="card-body">
<div class="text-muted small text-uppercase mb-1">콘텐츠</div>
<div class="d-flex align-items-center justify-content-between gap-3">
<div>
<h2 class="h4 mb-1">블로그 편집</h2>
<p class="text-muted mb-0">에디터 + 미리보기</p>
</div>
<span class="avatar avatar-rounded bg-yellow-lt text-yellow">04</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="row row-deck row-cards g-3"> <!-- Quick Stats Grid -->
<div class="col-12 col-xl-8"> <div class="row g-3 mb-4">
<div class="card"> <div class="col-md-6 col-xl-3">
<div class="card-header"> <div class="card shadow-sm border-0 rounded-3">
<h3 class="card-title">빠른 작업</h3> <div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<span class="text-muted small text-uppercase fw-semibold" style="letter-spacing: 0.5px;">고객 관리</span>
<span class="avatar avatar-md rounded-circle bg-blue-lt text-blue"><i class="bi bi-people-fill"></i></span>
</div> </div>
<div class="card-body"> <h3 class="fw-bold text-dark mb-1 font-numeric">고객 목록</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-indigo-lt text-indigo"><i class="bi bi-database-fill-check"></i></span>
</div>
<h3 class="fw-bold text-dark mb-1 font-numeric">DbUp Migration</h3>
<p class="text-muted small mb-0">SQL 스키마 형상 버전 실시간 추적</p>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card shadow-sm border-0 rounded-3">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<span class="text-muted small text-uppercase fw-semibold" style="letter-spacing: 0.5px;">보안 및 세션</span>
<span class="avatar avatar-md rounded-circle bg-green-lt text-green"><i class="bi bi-shield-lock-fill"></i></span>
</div>
<h3 class="fw-bold text-dark mb-1 font-numeric">Cookie Auth</h3>
<p class="text-muted small mb-0">서버 사이드 세션 무결성 보호</p>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="card shadow-sm border-0 rounded-3">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<span class="text-muted small text-uppercase fw-semibold" style="letter-spacing: 0.5px;">콘텐츠 관리</span>
<span class="avatar avatar-md rounded-circle bg-yellow-lt text-yellow"><i class="bi bi-journal-text"></i></span>
</div>
<h3 class="fw-bold text-dark mb-1 font-numeric">블로그 & 공지</h3>
<p class="text-muted small mb-0">웹 에디터를 통한 정보 업데이트</p>
</div>
</div>
</div>
</div>
<!-- Main Section Grid -->
<div class="row g-3">
<div class="col-lg-8">
<div class="card shadow-sm border-0 rounded-3">
<div class="card-header bg-transparent py-3 border-light">
<h3 class="card-title text-dark fw-bold mb-0" style="font-size: 1rem;">
<i class="bi bi-lightning-charge text-primary me-2"></i> 바로가기 링크
</h3>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/customers"> <a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 px-4 border-0 border-bottom" href="/admin/customers">
<span>고객 목록으로 이동</span> <div class="d-flex align-items-center gap-3">
<span class="text-muted">관리</span> <span class="avatar avatar-xs bg-primary-lt text-primary rounded"><i class="bi bi-people"></i></span>
<div>
<div class="fw-semibold text-dark">고객 정보 조회</div>
<div class="text-muted small">등록된 개인 고객의 기본 상세 데이터 보기</div>
</div>
</div>
<span class="badge bg-secondary-subtle text-secondary px-2.5 py-1.5 fs-8 fw-semibold">바로가기</span>
</a> </a>
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/blog"> <a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 px-4 border-0 border-bottom" href="/admin/blog">
<span>블로그 글 작성</span> <div class="d-flex align-items-center gap-3">
<span class="text-muted">에디터</span> <span class="avatar avatar-xs bg-success-lt text-success rounded"><i class="bi bi-pencil-square"></i></span>
<div>
<div class="fw-semibold text-dark">새 블로그 포스트 작성</div>
<div class="text-muted small">웹 에디터 모드 및 즉시 발행</div>
</div>
</div>
<span class="badge bg-secondary-subtle text-secondary px-2.5 py-1.5 fs-8 fw-semibold">바로가기</span>
</a> </a>
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/admin/inquiries"> <a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 px-4" href="/admin/inquiries">
<span>문의 처리</span> <div class="d-flex align-items-center gap-3">
<span class="text-muted">업무</span> <span class="avatar avatar-xs bg-info-lt text-info rounded"><i class="bi bi-chat-left-dots"></i></span>
<div>
<div class="fw-semibold text-dark">신규 문의 처리</div>
<div class="text-muted small">고객이 홈페이지를 통해 접수한 세무 관련 상담 대응</div>
</div>
</div>
<span class="badge bg-secondary-subtle text-secondary px-2.5 py-1.5 fs-8 fw-semibold">바로가기</span>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-xl-4"> <div class="col-lg-4">
<div class="card"> <div class="card shadow-sm border-0 rounded-3 bg-white h-100">
<div class="card-header"> <div class="card-header bg-transparent py-3 border-light">
<h3 class="card-title">운영 노트</h3> <h3 class="card-title text-dark fw-bold mb-0" style="font-size: 1rem;">
<i class="bi bi-info-circle text-primary me-2"></i> 아키텍처 노하우
</h3>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<div class="alert alert-info mb-0"> <div class="alert alert-info d-flex align-items-start gap-2 mb-3" role="alert">
Razor Pages, Dapper, Flyway, Cookie Auth, Tabler 기준으로 서버 렌더링 중심의 어드민을 유지합니다. <i class="bi bi-info-circle-fill text-info mt-1"></i>
<div class="small leading-relaxed text-muted">
본 시스템은 Razor Pages, Dapper, DbUp, Cookie Auth 및 Tabler CSS 라이브러리를 탑재한 서버 사이드 렌더링 프레임워크로 가볍고 안전합니다.
</div>
</div>
<div class="card bg-light border border-light-subtle rounded-3 p-3 mt-3">
<div class="fw-bold text-dark small mb-2"><i class="bi bi-shield-check text-success me-1"></i> 개발 보안 규칙</div>
<ul class="list-unstyled small text-muted mb-0 vstack gap-2">
<li><i class="bi bi-check-circle-fill text-primary me-1"></i> 애플리케이션 레이어 Audit 처리</li>
<li><i class="bi bi-check-circle-fill text-primary me-1"></i> DB 트리거(Trigger)의 사용 금지</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
@@ -0,0 +1,56 @@
@page
@model TaxBaik.Web.Pages.Admin.Faqs.CreateModel
@{ ViewData["Title"] = "FAQ 작성"; }
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 작성", "Tabler 표준 폼으로 FAQ를 등록합니다.", null, null))
<form method="post" class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_ValidationSummary")
<div class="row g-2">
<div class="col-lg-8">
<label asp-for="Input.Question" class="form-label form-label-sm"></label>
<input asp-for="Input.Question" class="form-control form-control-sm" />
</div>
<div class="col-lg-4">
<label asp-for="Input.Category" class="form-label form-label-sm"></label>
<input asp-for="Input.Category" class="form-control form-control-sm" list="faq-categories" />
<datalist id="faq-categories">
@foreach (var category in Model.Categories)
{
<option value="@category"></option>
}
</datalist>
</div>
<div class="col-12">
<label asp-for="Input.Answer" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Answer" class="form-control form-control-sm" rows="10"></textarea>
</div>
<div class="col-md-4">
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
</div>
<div class="col-12">
<label class="form-check">
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
<span class="form-check-label form-label-sm">활성</span>
</label>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/admin/faqs">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</form>
</section>
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Domain.Entities;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Faqs;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel(FaqService faqService) : PageModel
{
[BindProperty]
public Faq Input { get; set; } = new();
public IReadOnlyList<string> Categories => FaqService.Categories;
public IActionResult OnGet()
{
Input.IsActive = true;
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
await faqService.CreateAsync(Input, ct);
return RedirectToPage("/Admin/Faqs/Index");
}
}
@@ -0,0 +1,54 @@
@page "/admin/faqs/{id:int}"
@model TaxBaik.Web.Pages.Admin.Faqs.DetailModel
@{ ViewData["Title"] = "FAQ 상세"; }
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 상세", "FAQ의 질문, 답변, 상태를 확인합니다.", null, null))
@if (Model.Item is null)
{
@await Html.PartialAsync("_EmptyState", "FAQ를 찾을 수 없습니다.")
}
else
{
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<dl class="row details-grid mb-0">
<dt class="col-sm-3">질문</dt>
<dd class="col-sm-9">@Model.Item.Question</dd>
<dt class="col-sm-3">답변</dt>
<dd class="col-sm-9"><div class="pre-wrap">@Model.Item.Answer</div></dd>
<dt class="col-sm-3">카테고리</dt>
<dd class="col-sm-9">@Model.Item.Category</dd>
<dt class="col-sm-3">활성</dt>
<dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Item.IsActive ? "active" : "inactive")</dd>
<dt class="col-sm-3">정렬</dt>
<dd class="col-sm-9">@Model.Item.SortOrder</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-body p-2">
<div class="d-flex gap-2">
<a class="btn btn-primary btn-sm flex-fill" href="/admin/faqs/@Model.Item.Id/edit">수정</a>
<a class="btn btn-outline-secondary btn-sm flex-fill" href="/admin/faqs">목록</a>
</div>
</div>
</div>
</div>
</div>
}
</section>
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Faqs;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class DetailModel(FaqService faqService) : PageModel
{
public Faq? Item { get; private set; }
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
{
Item = await faqService.GetByIdAsync(id, ct);
return Page();
}
}
@@ -0,0 +1,68 @@
@page "/admin/faqs/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Faqs.EditModel
@{ ViewData["Title"] = "FAQ 수정"; }
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 수정", "기존 FAQ를 Tabler 표준 폼으로 수정합니다.", null, null))
<form method="post" class="card">
<div class="card-header p-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
<div class="fw-semibold small">@Model.Input.Question</div>
<div class="text-muted small">카테고리: @Model.Input.Category</div>
</div>
<span class="badge bg-secondary-lt text-secondary small">원본 확인 후 수정</span>
</div>
<div class="card-body p-2">
@await Html.PartialAsync("_ValidationSummary")
<input type="hidden" asp-for="Input.Id" />
<div class="row g-2">
<div class="col-lg-8">
<label asp-for="Input.Question" class="form-label form-label-sm"></label>
<input asp-for="Input.Question" class="form-control form-control-sm" />
</div>
<div class="col-lg-4">
<label asp-for="Input.Category" class="form-label form-label-sm"></label>
<input asp-for="Input.Category" class="form-control form-control-sm" list="faq-categories" />
<datalist id="faq-categories">
@foreach (var category in Model.Categories)
{
<option value="@category"></option>
}
</datalist>
</div>
<div class="col-12">
<label asp-for="Input.Answer" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Answer" class="form-control form-control-sm" rows="10"></textarea>
</div>
<div class="col-md-4">
<label asp-for="Input.SortOrder" class="form-label form-label-sm"></label>
<input asp-for="Input.SortOrder" class="form-control form-control-sm" type="number" />
</div>
<div class="col-12">
<label class="form-check">
<input asp-for="Input.IsActive" class="form-check-input" type="checkbox" />
<span class="form-check-label form-label-sm">활성</span>
</label>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between gap-2">
<button class="btn btn-outline-danger btn-sm" type="submit" formaction="?handler=Delete" formmethod="post" onclick="return confirm('정말 삭제하시겠습니까?');">삭제</button>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/admin/faqs">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</div>
</form>
</section>
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Domain.Entities;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Faqs;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel(FaqService faqService) : PageModel
{
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
[BindProperty]
public Faq Input { get; set; } = new();
public IReadOnlyList<string> Categories => FaqService.Categories;
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
var faq = await faqService.GetByIdAsync(Id, ct);
if (faq is null)
return NotFound();
Input = new Faq
{
Id = faq.Id,
Question = faq.Question,
Answer = faq.Answer,
Category = faq.Category,
SortOrder = faq.SortOrder,
IsActive = faq.IsActive
};
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
await faqService.UpdateAsync(Input, ct);
return RedirectToPage("/Admin/Faqs/Index");
}
public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
{
await faqService.DeleteAsync(Id, ct);
return RedirectToPage("/Admin/Faqs/Index");
}
}
+32 -13
View File
@@ -2,33 +2,52 @@
@model TaxBaik.Web.Pages.Admin.Faqs.IndexModel @model TaxBaik.Web.Pages.Admin.Faqs.IndexModel
@{ ViewData["Title"] = "FAQ"; } @{ ViewData["Title"] = "FAQ"; }
<section class="container-fluid py-4"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("Support", "FAQ 관리", "질문과 답변을 관리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Support", "FAQ 관리", "질문과 답변을 관리합니다.", "/admin/faqs/create", "FAQ 작성"))
<div class="card">
<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) @if (Model.Items.Count == 0)
{ {
<tr><td colspan="4"><partial name="_EmptyState" model="FAQ가 없습니다." /></td></tr> <div class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_EmptyState", "FAQ가 없습니다.")
</div>
</div>
} }
else else
{ {
foreach (var item in Model.Items) <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> <tr>
<td>@item.Question</td> <td>@item.Question</td>
<td>@item.Category</td> <td>@item.Category</td>
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td> <td>@await Html.PartialAsync("_StatusBadge", item.IsActive ? "active" : "inactive")</td>
<td>@item.SortOrder</td> <td>@item.SortOrder</td>
<td class="text-end">
<div class="btn-list flex-nowrap justify-content-end">
<a class="btn btn-sm btn-outline-secondary" href="/admin/faqs/@item.Id">상세</a>
<a class="btn btn-sm btn-primary" href="/admin/faqs/@item.Id/edit">수정</a>
</div>
</td>
</tr> </tr>
} }
}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
}
</section> </section>
+51 -14
View File
@@ -4,25 +4,62 @@
ViewData["Title"] = "관리자 홈"; ViewData["Title"] = "관리자 홈";
} }
<section class="container py-5"> <section class="admin-page-shell">
<div class="row align-items-center g-4"> <div class="page-header d-print-none mb-4">
<div class="col-lg-8"> <div class="row align-items-center">
<p class="text-uppercase text-muted small mb-2">TaxBaik Admin</p> <div class="col">
<h1 class="display-6 fw-bold mb-3">고객관리 어드민</h1> <div class="page-pretitle">TaxBaik Admin</div>
<p class="lead text-muted mb-0"> <h2 class="page-title">고객관리 어드민</h2>
Razor Pages + Cookie Authentication + Dapper + Flyway 기준으로 정리하는 서버 렌더링 어드민입니다. <p class="text-muted mb-0">Razor Pages + Cookie Authentication + Dapper + DbUp 기준으로 정리하는 서버 렌더링 어드민입니다.</p>
</p>
</div> </div>
<div 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> </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> </div>
</div> </div>
</section> </section>
@@ -1,4 +1,56 @@
@page @page
@model TaxBaik.Web.Pages.Admin.Inquiries.CreateModel @model TaxBaik.Web.Pages.Admin.Inquiries.CreateModel
@{ ViewData["Title"] = "문의 등록"; } @{ ViewData["Title"] = "문의 등록"; }
<section class="container py-5"><h1 class="h2 fw-bold">문의 등록</h1></section>
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 등록", "관리자가 직접 상담 요청을 등록할 수 있습니다.", null, null))
<form method="post" class="card">
<div class="card-body p-2">
@await Html.PartialAsync("_ValidationSummary")
<div class="row g-2">
<div class="col-md-6">
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
<input asp-for="Input.Name" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.Phone" class="form-label form-label-sm"></label>
<input asp-for="Input.Phone" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.Email" class="form-label form-label-sm"></label>
<input asp-for="Input.Email" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
<input asp-for="Input.ServiceType" class="form-control form-control-sm" />
</div>
<div class="col-12">
<label asp-for="Input.Message" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Message" class="form-control form-control-sm" rows="8"></textarea>
</div>
<div class="col-md-6">
<label asp-for="Input.Status" class="form-label form-label-sm"></label>
<input asp-for="Input.Status" class="form-control form-control-sm" />
</div>
<div class="col-12">
<label asp-for="Input.AdminMemo" class="form-label form-label-sm"></label>
<textarea asp-for="Input.AdminMemo" class="form-control form-control-sm" rows="4"></textarea>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/admin/inquiries">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</form>
</section>
@@ -1,7 +1,44 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel(InquiryService inquiryService) : PageModel
{
[BindProperty]
public UpdateInquiryDto Input { get; set; } = new();
public IActionResult OnGet()
{
Input.Status = "new";
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
var inquiryId = await inquiryService.SubmitAsync(
Input.Name,
Input.Phone,
string.IsNullOrWhiteSpace(Input.ServiceType) ? "기타" : Input.ServiceType,
Input.Message,
Input.Email,
suppressNotification: true,
ct: ct);
if (!string.IsNullOrWhiteSpace(Input.Status))
await inquiryService.UpdateStatusAsync(inquiryId, Input.Status, changedBy: User.Identity?.Name, ct: ct);
if (!string.IsNullOrWhiteSpace(Input.AdminMemo))
await inquiryService.UpdateAdminMemoAsync(inquiryId, Input.AdminMemo, ct);
return RedirectToPage("/Admin/Inquiries/Index");
}
}
@@ -1,4 +1,65 @@
@page "{id:int}" @page "/admin/inquiries/{id:int}"
@model TaxBaik.Web.Pages.Admin.Inquiries.DetailModel @model TaxBaik.Web.Pages.Admin.Inquiries.DetailModel
@{ ViewData["Title"] = "문의 상세"; } @{ ViewData["Title"] = "문의 상세"; }
<section class="container py-5"><h1 class="h2 fw-bold">문의 상세</h1></section>
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 상세", "문의 요청과 처리 내역을 확인합니다.", null, null))
@if (Model.Inquiry is null)
{
@await Html.PartialAsync("_EmptyState", "문의 정보를 찾을 수 없습니다.")
}
else
{
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<dl class="row details-grid mb-0">
<dt class="col-sm-3">이름</dt>
<dd class="col-sm-9">@Model.Inquiry.Name</dd>
<dt class="col-sm-3">전화</dt>
<dd class="col-sm-9">@Model.Inquiry.Phone</dd>
<dt class="col-sm-3">이메일</dt>
<dd class="col-sm-9">@Model.Inquiry.Email</dd>
<dt class="col-sm-3">서비스</dt>
<dd class="col-sm-9">@Model.Inquiry.ServiceType</dd>
<dt class="col-sm-3">상태</dt>
<dd class="col-sm-9">@await Html.PartialAsync("_StatusBadge", Model.Inquiry.Status)</dd>
<dt class="col-sm-3">메시지</dt>
<dd class="col-sm-9"><div class="pre-wrap">@Model.Inquiry.Message</div></dd>
<dt class="col-sm-3">관리 메모</dt>
<dd class="col-sm-9">@Model.Inquiry.AdminMemo</dd>
<dt class="col-sm-3">등록일</dt>
<dd class="col-sm-9">@Model.Inquiry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</dd>
<dt class="col-sm-3">수정일</dt>
<dd class="col-sm-9">@Model.Inquiry.UpdatedAt?.ToString("yyyy-MM-dd HH:mm")</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-body p-2">
<div class="d-flex gap-2 flex-column">
<a class="btn btn-primary btn-sm" href="/admin/inquiries/@Model.Inquiry.Id/edit">수정</a>
<form method="post" asp-page-handler="Delete" onsubmit="return confirm('정말 삭제하시겠습니까?');">
<button class="btn btn-outline-danger btn-sm w-100" type="submit">삭제</button>
</form>
<a class="btn btn-outline-secondary btn-sm" href="/admin/inquiries">목록</a>
</div>
</div>
</div>
</div>
</div>
}
</section>
@@ -1,7 +1,26 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class DetailModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class DetailModel(InquiryService inquiryService) : PageModel
{
public Inquiry? Inquiry { get; private set; }
public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
{
Inquiry = await inquiryService.GetByIdAsync(id, ct);
return Page();
}
public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
{
await inquiryService.DeleteAsync(id, ct);
return RedirectToPage("/Admin/Inquiries/Index");
}
}
@@ -1,4 +1,68 @@
@page "{id:int}" @page "/admin/inquiries/{id:int}/edit"
@model TaxBaik.Web.Pages.Admin.Inquiries.EditModel @model TaxBaik.Web.Pages.Admin.Inquiries.EditModel
@{ ViewData["Title"] = "문의 수정"; } @{ ViewData["Title"] = "문의 수정"; }
<section class="container py-5"><h1 class="h2 fw-bold">문의 수정</h1></section>
<section class="admin-page-shell">
@await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 수정", "문의 내용을 수정하고 상태를 갱신합니다.", null, null))
<form method="post" class="card">
<div class="card-header p-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
<div class="fw-semibold small">@Model.Input.Name</div>
<div class="text-muted small">@Model.Input.Phone / @Model.Input.ServiceType</div>
</div>
<span class="badge bg-secondary-lt text-secondary small">원본 확인 후 수정</span>
</div>
<div class="card-body p-2">
@await Html.PartialAsync("_ValidationSummary")
<input type="hidden" asp-for="Id" />
<div class="row g-2">
<div class="col-md-6">
<label asp-for="Input.Name" class="form-label form-label-sm"></label>
<input asp-for="Input.Name" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.Phone" class="form-label form-label-sm"></label>
<input asp-for="Input.Phone" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.Email" class="form-label form-label-sm"></label>
<input asp-for="Input.Email" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.ServiceType" class="form-label form-label-sm"></label>
<input asp-for="Input.ServiceType" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label asp-for="Input.Status" class="form-label form-label-sm"></label>
<input asp-for="Input.Status" class="form-control form-control-sm" />
</div>
<div class="col-12">
<label asp-for="Input.Message" class="form-label form-label-sm"></label>
<textarea asp-for="Input.Message" class="form-control form-control-sm" rows="8"></textarea>
</div>
<div class="col-12">
<label asp-for="Input.AdminMemo" class="form-label form-label-sm"></label>
<textarea asp-for="Input.AdminMemo" class="form-control form-control-sm" rows="4"></textarea>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between gap-2">
<button class="btn btn-outline-danger btn-sm" type="submit" formaction="?handler=Delete" formmethod="post" onclick="return confirm('정말 삭제하시겠습니까?');">삭제</button>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/admin/inquiries/@Model.Id">취소</a>
<button class="btn btn-primary btn-sm" type="submit">저장</button>
</div>
</div>
</form>
</section>
@@ -1,7 +1,56 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel(InquiryService inquiryService) : PageModel
{
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
[BindProperty]
public UpdateInquiryDto Input { get; set; } = new();
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
var inquiry = await inquiryService.GetByIdAsync(Id, ct);
if (inquiry is null)
return NotFound();
Input = new UpdateInquiryDto
{
Name = inquiry.Name,
Phone = inquiry.Phone,
Email = inquiry.Email,
ServiceType = inquiry.ServiceType,
Message = inquiry.Message,
Status = inquiry.Status,
AdminMemo = inquiry.AdminMemo
};
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
var updated = await inquiryService.UpdateAsync(Id, Input, ct);
if (updated is null)
return NotFound();
return RedirectToPage("/Admin/Inquiries/Detail", new { id = Id });
}
public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
{
await inquiryService.DeleteAsync(Id, ct);
return RedirectToPage("/Admin/Inquiries/Index");
}
}
@@ -2,24 +2,26 @@
@model TaxBaik.Web.Pages.Admin.Inquiries.IndexModel @model TaxBaik.Web.Pages.Admin.Inquiries.IndexModel
@{ ViewData["Title"] = "문의"; } @{ ViewData["Title"] = "문의"; }
<section class="container-fluid py-4"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("Customer Requests", "문의 관리", "상담 요청을 상태별로 확인합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Customer Requests", "문의 관리", "상담 요청을 상태별로 확인합니다.", "/admin/inquiries/create", "문의 등록"))
<form method="get" class="card mb-3"> <div class="card">
<div class="card-body"> <div class="card-body p-2" style="min-height: auto;">
<div class="row g-3 align-items-end"> <form method="get">
<div class="col-md-4"> <div class="row g-2 align-items-end">
<label class="form-label" for="Status">상태</label> <div class="col-md-3">
<input class="form-control" id="Status" name="status" value="@Model.Status" placeholder="new / consulting / contracted ..." /> <label class="form-label form-label-sm" for="Status">상태</label>
</div> <input class="form-control form-control-sm" id="Status" name="status" value="@Model.Status" placeholder="new, consulting..." />
<div class="col-md-2 d-grid">
<button class="btn btn-primary" type="submit">필터</button>
</div> </div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" type="submit">필터</button>
</div> </div>
</div> </div>
</form> </form>
<div class="card"> </div>
<div class="table-responsive"> @if (Model.Items.Count > 0)
<table class="table table-vcenter card-table"> {
<div class="table-responsive table-responsive--separator">
<table class="table table-vcenter card-table table-hover">
<thead> <thead>
<tr> <tr>
<th>이름</th> <th>이름</th>
@@ -27,28 +29,44 @@
<th>서비스</th> <th>서비스</th>
<th>상태</th> <th>상태</th>
<th>등록일</th> <th>등록일</th>
<th class="text-end">작업</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (Model.Items.Count == 0) @foreach (var item in Model.Items)
{
<tr><td colspan="5"><partial name="_EmptyState" model="문의 목록이 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{ {
<tr> <tr>
<td>@item.Name</td> <td>@item.Name</td>
<td>@item.Phone</td> <td>@item.Phone</td>
<td>@item.ServiceType</td> <td>@item.ServiceType</td>
<td><partial name="_StatusBadge" model="@(item.Status)" /></td> <td>@await Html.PartialAsync("_StatusBadge", item.Status)</td>
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td> <td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<div class="btn-list flex-nowrap justify-content-end">
<a class="btn btn-sm btn-outline-secondary" href="/admin/inquiries/@item.Id">상세</a>
<a class="btn btn-sm btn-primary" href="/admin/inquiries/@item.Id/edit">수정</a>
</div>
</td>
</tr> </tr>
} }
}
</tbody> </tbody>
</table> </table>
</div> </div>
}
else
{
<div class="card-body">
@await Html.PartialAsync("_EmptyState", "문의 목록이 없습니다.")
</div>
}
</div> </div>
</section> </section>
+9
View File
@@ -71,3 +71,12 @@
</div> </div>
</section> </section>
+17 -2
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) if (!ModelState.IsValid)
{ {
return Page(); return Page();
} }
var user = await authService.AuthenticateAsync(Username, Password); var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (lockoutService.IsLockedOut(ip))
{
ErrorMessage = "보안 정책에 의해 연속 로그인 실패로 인한 임시 접근이 제한되었습니다. 15분 후 다시 시도해 주세요.";
return Page();
}
var user = await authService.AuthenticateAsync(Username, Password, ip);
if (user is null) if (user is null)
{
if (lockoutService.IsLockedOut(ip))
{
ErrorMessage = "로그인 실패 횟수가 초과하여 15분간 로그인이 제한됩니다.";
}
else
{ {
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다."; ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
}
return Page(); return Page();
} }
@@ -13,3 +13,12 @@
</section> </section>
<meta http-equiv="refresh" content="0;url=/admin/login" /> <meta http-equiv="refresh" content="0;url=/admin/login" />
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.RevenueTrackings.IndexModel @model TaxBaik.Web.Pages.Admin.RevenueTrackings.IndexModel
@{ ViewData["Title"] = "매출 추적"; } @{ ViewData["Title"] = "매출 추적"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "매출 추적", "월별 매출 추이를 확인하는 분석 화면입니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "매출 추적", "월별 매출 추이를 확인하는 분석 화면입니다.", null, null))
<partial name="_ComingSoon" model="매출 추적" /> @await Html.PartialAsync("_ComingSoon", "매출 추적")
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.SeasonSimulator.IndexModel @model TaxBaik.Web.Pages.Admin.SeasonSimulator.IndexModel
@{ ViewData["Title"] = "시즌 시뮬레이터"; } @{ ViewData["Title"] = "시즌 시뮬레이터"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("운영 도구", "시즌 시뮬레이터", "정책 변경 시 영향을 미리 확인하는 도구입니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("운영 도구", "시즌 시뮬레이터", "정책 변경 시 영향을 미리 확인하는 도구입니다.", null, null))
<partial name="_ComingSoon" model="시즌 시뮬레이터" /> @await Html.PartialAsync("_ComingSoon", "시즌 시뮬레이터")
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.Settings.IndexModel @model TaxBaik.Web.Pages.Admin.Settings.IndexModel
@{ ViewData["Title"] = "설정"; } @{ ViewData["Title"] = "설정"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("환경 설정", "설정", "시스템 기본값과 관리자 운영 정책을 정리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("환경 설정", "설정", "시스템 기본값과 관리자 운영 정책을 정리합니다.", null, null))
<partial name="_ComingSoon" model="설정 기능" /> @await Html.PartialAsync("_ComingSoon", "설정 기능")
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.TaxFilingSchedules.IndexModel @model TaxBaik.Web.Pages.Admin.TaxFilingSchedules.IndexModel
@{ ViewData["Title"] = "신고 일정"; } @{ ViewData["Title"] = "신고 일정"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "세금 신고 일정", "세무 신고 마감 일정과 상태를 관리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "세금 신고 일정", "세무 신고 마감 일정과 상태를 관리합니다.", null, null))
<partial name="_ComingSoon" model="세금 신고 일정" /> @await Html.PartialAsync("_ComingSoon", "세금 신고 일정")
</section> </section>
@@ -1,7 +1,16 @@
@page @page
@model TaxBaik.Web.Pages.Admin.TaxFilings.IndexModel @model TaxBaik.Web.Pages.Admin.TaxFilings.IndexModel
@{ ViewData["Title"] = "세금 신고"; } @{ ViewData["Title"] = "세금 신고"; }
<section class="container admin-page-shell"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("세무 운영", "세금 신고", "신고 상태를 한 화면에서 관리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("세무 운영", "세금 신고", "신고 상태를 한 화면에서 관리합니다.", null, null))
<partial name="_ComingSoon" model="세금 신고 관리" /> @await Html.PartialAsync("_ComingSoon", "세금 신고 관리")
</section> </section>
@@ -2,33 +2,46 @@
@model TaxBaik.Web.Pages.Admin.TaxProfiles.IndexModel @model TaxBaik.Web.Pages.Admin.TaxProfiles.IndexModel
@{ ViewData["Title"] = "세무 프로필"; } @{ ViewData["Title"] = "세무 프로필"; }
<section class="container-fluid py-4"> <section class="admin-page-shell">
<partial name="_AdminPageHeader" model='("Tax", "세무 프로필 관리", "고객별 세무 속성을 관리합니다.", null, null)' /> @await Html.PartialAsync("_AdminPageHeader", new TaxBaik.Web.Models.AdminPageHeaderModel("Tax", "세무 프로필 관리", "고객별 세무 속성을 관리합니다.", null, null))
@if (Model.Items.Count == 0)
{
<div class="card"> <div class="card">
<div class="table-responsive"> <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"> <table class="table table-vcenter card-table">
<thead> <thead>
<tr><th>고객ID</th><th>사업유형</th><th>세무위험</th><th>다음신고일</th></tr> <tr><th>고객ID</th><th>사업유형</th><th>세무위험</th><th>다음신고일</th></tr>
</thead> </thead>
<tbody> <tbody>
@if (Model.Items.Count == 0) @foreach (var item in Model.Items)
{
<tr><td colspan="4"><partial name="_EmptyState" model="세무 프로필이 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{ {
<tr> <tr>
<td>@item.ClientId</td> <td>@item.ClientId</td>
<td>@item.BusinessType</td> <td>@item.BusinessType</td>
<td><partial name="_StatusBadge" model="@(item.TaxRiskLevel ?? "normal")" /></td> <td>@await Html.PartialAsync("_StatusBadge", item.TaxRiskLevel ?? "normal")</td>
<td>@item.NextFilingDueDate?.ToString("yyyy-MM-dd")</td> <td>@item.NextFilingDueDate?.ToString("yyyy-MM-dd")</td>
</tr> </tr>
} }
}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
}
</section> </section>
+218 -91
View File
@@ -14,123 +14,250 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "TaxBaik Admin")</title> <title>@(ViewData["Title"] ?? "TaxBaik Admin")</title>
<meta name="theme-color" content="#111827" /> <meta name="theme-color" content="#1F4E79" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Tabler Core & Icons -->
<link href="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/css/tabler.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/css/tabler.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
<!-- Fast head theme load script to prevent flashing -->
<script>
(function () {
var theme = localStorage.getItem('tablerTheme') || 'light';
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
</head> </head>
<body class="admin-app"> <body class="layout-fluid">
<div class="page admin-shell"> <div class="page">
<header class="navbar navbar-expand-md navbar-light d-print-none admin-topbar"> <!-- Sidebar Panel (Tabler Native Vertical Navbar) -->
<aside class="navbar navbar-vertical navbar-expand-lg navbar-dark bg-dark d-print-none" data-bs-theme="dark">
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex align-items-center gap-3"> <!-- Toggle Button for Mobile -->
<div class="navbar-brand navbar-brand-autodark"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
<a href="/admin/dashboard" class="text-decoration-none text-reset fw-semibold">TaxBaik Admin</a> <span class="navbar-toggler-icon"></span>
</div>
<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>
</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>
</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> </button>
</h2>
<div id="adminMenuCore" class="accordion-collapse collapse @(coreOpen ? "show" : string.Empty)" data-bs-parent="#adminSidebarAccordion"> <!-- Logo & Branding -->
<div class="list-group list-group-flush list-group-transparent"> <h1 class="navbar-brand navbar-brand-autodark px-2">
<a class="list-group-item list-group-item-action @activeClass("/admin/dashboard")" href="/admin/dashboard">▣ 대시보드</a> <a href="/admin/dashboard" class="text-decoration-none text-white fw-bold d-flex align-items-center gap-2">
<a class="list-group-item list-group-item-action @activeClass("/admin/settings")" href="/admin/settings">⚙ 설정</a> <span class="avatar avatar-sm bg-primary text-white rounded-3"><i class="bi bi-safe-fill"></i></span>
<a class="list-group-item list-group-item-action @activeClass("/admin/clientlogs")" href="/admin/clientlogs">⌘ 클라이언트 로그</a> <span>TaxBaik <span class="text-primary-light small">Admin</span></span>
</a>
</h1>
<!-- Mobile User Dropdown -->
<div class="navbar-nav flex-row d-lg-none">
<div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<span class="avatar avatar-sm rounded-circle"><i class="bi bi-person-circle"></i></span>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<div class="dropdown-item small text-muted font-numeric">@User.Identity?.Name</div>
<div class="dropdown-divider"></div>
<a href="/admin/logout" class="dropdown-item text-danger"><i class="bi bi-box-arrow-right me-1"></i> 로그아웃</a>
</div> </div>
</div> </div>
</div> </div>
<div class="accordion-item">
<h2 class="accordion-header"> <!-- Navigation Sidebar Links -->
<button class="accordion-button @(customerOpen ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuCustomer" aria-expanded="@(customerOpen ? "true" : "false")"> <div class="collapse navbar-collapse" id="sidebar-menu">
고객/상담 <ul class="navbar-nav pt-lg-3">
</button> <!-- 기본 관리 -->
</h2> <li class="nav-item dropdown @(coreOpen ? "active" : "")">
<div id="adminMenuCustomer" class="accordion-collapse collapse @(customerOpen ? "show" : string.Empty)" data-bs-parent="#adminSidebarAccordion"> <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")">
<div class="list-group list-group-flush list-group-transparent"> <span class="nav-link-icon d-md-none d-lg-inline-block"><i class="bi bi-speedometer2"></i></span>
<a class="list-group-item list-group-item-action @activeClass("/admin/customers")" href="/admin/customers">◫ 고객</a> <span class="nav-link-title fw-semibold">기본 관리</span>
<a class="list-group-item list-group-item-action @activeClass("/admin/clients")" href="/admin/clients">◫ 고객사</a> </a>
<a class="list-group-item list-group-item-action @activeClass("/admin/inquiries")" href="/admin/inquiries">◫ 문의</a> <div class="dropdown-menu @(coreOpen ? "show" : "")">
<a class="list-group-item list-group-item-action @activeClass("/admin/consultingactivities")" href="/admin/consultingactivities">◫ 상담 활동</a> <a class="dropdown-item @activeClass("/admin/dashboard")" href="/admin/dashboard">
</div> <i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>대시보드
</div> </a>
</div> <a class="dropdown-item @activeClass("/admin/settings")" href="/admin/settings">
<div class="accordion-item"> <i class="bi bi-circle-fill small me-2" style="font-size: 0.35rem;"></i>설정
<h2 class="accordion-header"> </a>
<button class="accordion-button @(contentOpen ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuContent" aria-expanded="@(contentOpen ? "true" : "false")"> <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>클라이언트 로그
</button> </a>
</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>
</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> </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> </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> </div>
</li>
</ul>
</div> </div>
</div> </div>
</aside> </aside>
<main class="col-12 col-lg-9 col-xxl-10">
<div class="card shadow-sm border-0 admin-content-card admin-main-surface"> <!-- Main Wrapper Panel -->
<div class="card-body p-3 p-md-4 p-xl-5"> <div class="page-wrapper d-flex flex-column min-vh-100">
<nav aria-label="breadcrumb" class="mb-4"> <!-- Topbar (Search, Breadcrumbs, Theme Switcher, User Profiles) -->
<ol class="breadcrumb breadcrumb-muted"> <header class="navbar navbar-expand-md navbar-light d-none d-lg-flex d-print-none sticky-top border-bottom">
<li class="breadcrumb-item"><a href="/admin/dashboard">관리자</a></li> <div class="container-xl justify-content-between">
<li class="breadcrumb-item active" aria-current="page">@breadcrumbText</li> <!-- 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> </ol>
</nav> </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>
<!-- 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>
<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() @RenderBody()
</div> </div>
</div> </div>
</main> </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> </div>
</div> </div>
</footer>
</div> </div>
</div>
<!-- Tabler Javascript -->
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js" defer></script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
<!-- Theme Manager Javascript -->
<script>
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('tablerTheme', theme);
var iconLight = document.querySelector('.theme-icon-light');
var iconDark = document.querySelector('.theme-icon-dark');
if (iconLight && iconDark) {
if (theme === 'dark') {
iconLight.classList.remove('d-none');
iconDark.classList.add('d-none');
} else {
iconLight.classList.add('d-none');
iconDark.classList.remove('d-none');
}
}
}
function toggleTheme() {
var currentTheme = localStorage.getItem('tablerTheme') || 'light';
var newTheme = currentTheme === 'dark' ? 'light' : 'dark';
applyTheme(newTheme);
}
// Initialize Theme Icons on Load
document.addEventListener('DOMContentLoaded', function() {
var currentTheme = localStorage.getItem('tablerTheme') || 'light';
applyTheme(currentTheme);
});
</script>
</body> </body>
</html> </html>
@@ -0,0 +1,12 @@
@using TaxBaik.Web
@namespace TaxBaik.Web.Pages.Admin
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -2,3 +2,12 @@
Layout = "_AdminLayout"; Layout = "_AdminLayout";
} }
+6
View File
@@ -2,6 +2,12 @@
@model TaxBaik.Web.Pages.Blog.BlogIndexModel @model TaxBaik.Web.Pages.Blog.BlogIndexModel
@{ @{
ViewData["Title"] = "블로그 | 백원숙 세무회계"; ViewData["Title"] = "블로그 | 백원숙 세무회계";
ViewData["Description"] = "세금 신고, 부동산 세금, 증여·상속 관련 최신 세무 정보와 절세 팁을 확인하세요.";
var canonicalQuery = Model.SelectedCategoryId.HasValue
? $"?categoryId={Model.SelectedCategoryId.Value}" + (Model.CurrentPage > 1 ? $"&page={Model.CurrentPage}" : string.Empty)
: Model.CurrentPage > 1 ? $"?page={Model.CurrentPage}" : string.Empty;
ViewData["CanonicalUrl"] = $"https://www.taxbaik.com/blog{canonicalQuery}";
ViewData["OgUrl"] = ViewData["CanonicalUrl"];
} }
<div class="container py-5"> <div class="container py-5">
+1 -1
View File
@@ -4,7 +4,7 @@
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title; ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
ViewData["Description"] = Model.Post?.SeoDescription ?? ""; ViewData["Description"] = Model.Post?.SeoDescription ?? "";
ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? ""; ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? "";
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/blog/{Model.Post?.Slug}"; var canonicalUrl = $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(Model.Post?.Slug ?? string.Empty)}";
ViewData["CanonicalUrl"] = canonicalUrl; ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["OgUrl"] = canonicalUrl; ViewData["OgUrl"] = canonicalUrl;
} }
+3 -32
View File
@@ -1,35 +1,6 @@
@page "/feed.xml" @page "/feed.xml"
@model TaxBaik.Web.Pages.RssModel
@{ @{
Response.ContentType = "application/rss+xml; charset=utf-8"; Layout = null;
}@{ Response.StatusCode = StatusCodes.Status308PermanentRedirect;
var rssContent = new System.Text.StringBuilder(); Response.Headers.Location = "/rss.xml";
rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
rssContent.AppendLine("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
rssContent.AppendLine(" <channel>");
rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>");
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
rssContent.AppendLine(" <language>ko-kr</language>");
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
rssContent.AppendLine(" <ttl>60</ttl>");
foreach (var post in Model.Posts)
{
rssContent.AppendLine(" <item>");
rssContent.AppendLine($" <title>{System.Net.WebUtility.HtmlEncode(post.Title)}</title>");
rssContent.AppendLine($" <link>https://www.taxbaik.com/blog/{post.Slug}</link>");
rssContent.AppendLine($" <guid isPermaLink=\"true\">https://www.taxbaik.com/blog/{post.Slug}</guid>");
rssContent.AppendLine($" <pubDate>{post.PublishedAt?.ToString("R")}</pubDate>");
var desc = post.Content?.Substring(0, Math.Min(200, post.Content?.Length ?? 0)) ?? "";
rssContent.AppendLine($" <description>{System.Net.WebUtility.HtmlEncode(desc)}</description>");
if (!string.IsNullOrEmpty(post.Content))
{
rssContent.AppendLine($" <content:encoded><![CDATA[{post.Content}]]></content:encoded>");
} }
rssContent.AppendLine(" </item>");
}
rssContent.AppendLine(" </channel>");
rssContent.AppendLine("</rss>");
}@rssContent.ToString()
+56 -42
View File
@@ -5,46 +5,57 @@
ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다."; ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
} }
<div class="bg-light py-5"> <div class="bg-light py-5 min-vh-75">
<div class="container"> <div class="container">
<!-- 상단 헤더 & 환영 문구 --> <!-- 상단 헤더 & 환영 문구 -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom"> <div class="card border-0 shadow-sm mb-4 rounded-4 overflow-hidden" style="background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);">
<div 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> <div>
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p> <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 text-dark">안녕하세요, @(User.Identity?.Name)님!</h1> <h1 class="display-6 fw-bold mb-2 text-white">안녕하세요, @(User.Identity?.Name)님!</h1>
@if (Model.ClientInfo != null) @if (Model.ClientInfo != null)
{ {
<p class="text-muted mb-0"> <div class="d-flex flex-wrap gap-3 align-items-center opacity-90 small">
<i class="bi bi-building"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName) <span><i class="bi bi-building me-1"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)</span>
| <i class="bi bi-telephone"></i> @Model.ClientInfo.Phone <span class="d-none d-sm-inline">|</span>
</p> <span><i class="bi bi-telephone me-1"></i> @Model.ClientInfo.Phone</span>
</div>
} }
</div> </div>
<div class="mt-3 mt-sm-0"> <div class="mt-3 mt-sm-0">
<form method="post" action="/portal/logout" class="d-inline"> <form method="post" action="/portal/logout" class="d-inline">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm"> <button type="submit" class="btn btn-sm btn-outline-light px-3 py-2 fw-semibold">
<i class="bi bi-box-arrow-right"></i> 로그아웃 <i class="bi bi-box-arrow-right"></i> 로그아웃
</button> </button>
</form> </form>
</div> </div>
</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) @if (Model.ClientInfo == null)
{ {
<!-- 연동 대기 경고 --> <!-- 연동 대기 경고 -->
<div class="card border-warning shadow-sm mb-5"> <div class="card border-0 shadow-lg rounded-4 mb-5">
<div class="card-body p-5 text-center"> <div class="card-body p-5 text-center">
<div class="mb-4"> <div class="mb-4">
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span> <span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
</div> </div>
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3> <h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
<p class="text-muted max-width-md mx-auto mb-4"> <p class="text-muted max-width-md mx-auto mb-4 leading-relaxed">
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 레코드를 찾지 못했습니다.<br /> 가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 정보를 찾지 못했습니다.<br />
세무사 측에서 고객 등록을 완료하거나 관리자 백오피스에서 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다. 세무사 측에서 고객 등록을 완료하거나 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
</p> </p>
<a href="/contact" class="btn btn-primary px-4 py-2"> <a href="/contact" class="btn btn-primary px-4 py-2.5 fw-bold">
<i class="bi bi-chat-dots"></i> 세무사에게 문의하기 <i class="bi bi-chat-dots-fill"></i> 세무사에게 문의하기
</a> </a>
</div> </div>
</div> </div>
@@ -54,19 +65,19 @@
<div class="row g-4"> <div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) --> <!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card glass-card mb-4"> <div class="card border-0 shadow-sm rounded-4 h-100">
<div class="card-body p-4"> <div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4 pb-2 border-bottom">
<h3 class="h5 fw-bold text-dark mb-0"> <h3 class="h5 fw-bold text-dark mb-0">
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황 <i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
</h3> </h3>
<span class="badge bg-secondary">총 @(Model.Filings.Count)건</span> <span class="badge bg-primary-badge font-numeric">총 @(Model.Filings.Count)건</span>
</div> </div>
@if (!Model.Filings.Any()) @if (!Model.Filings.Any())
{ {
<div class="text-center py-5 text-muted"> <div class="text-center py-5 text-muted">
<i class="bi bi-folder-x display-4 d-block mb-3 text-secondary"></i> <i class="bi bi-folder-x display-4 d-block mb-3 text-secondary" style="opacity: 0.5;"></i>
등록된 세무 신고 일정이 없습니다. 등록된 세무 신고 일정이 없습니다.
</div> </div>
} }
@@ -76,10 +87,10 @@
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th scope="col">신고 종류</th> <th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">신고 종류</th>
<th scope="col">신고 기한</th> <th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">신고 기한</th>
<th scope="col">진행 상태</th> <th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">진행 상태</th>
<th scope="col">메모</th> <th scope="col" class="py-3 border-0 small text-uppercase fw-bold text-muted">메모</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -88,28 +99,28 @@
var dDay = (filing.DueDate - DateTime.Today).Days; var dDay = (filing.DueDate - DateTime.Today).Days;
var statusClass = filing.Status switch var statusClass = filing.Status switch
{ {
"filed" => "bg-success-subtle text-success", "filed" => "bg-success-subtle text-success border border-success-subtle",
"overdue" => "bg-danger-subtle text-danger", "overdue" => "bg-danger-subtle text-danger border border-danger-subtle",
_ => "bg-warning-subtle text-warning-emphasis" _ => "bg-warning-subtle text-warning-emphasis border border-warning-subtle"
}; };
var statusLabel = filing.Status switch var statusLabel = filing.Status switch
{ {
"filed" => "신고 완료", "filed" => "신고 완료",
"overdue" => "기한 초과", "overdue" => "기한 초과",
_ => $"D-{dDay}" _ => dDay == 0 ? "D-Day" : dDay < 0 ? $"기한 지남 ({Math.Abs(dDay)}일)" : $"D-{dDay}"
}; };
<tr> <tr class="admin-row-clickable">
<td> <td class="py-3">
<span class="fw-bold text-dark">@filing.FilingType</span> <span class="fw-bold text-dark">@filing.FilingType</span>
</td> </td>
<td> <td class="py-3">
<span>@filing.DueDate.ToString("yyyy-MM-dd")</span> <span class="font-numeric text-muted">@filing.DueDate.ToString("yyyy-MM-dd")</span>
</td> </td>
<td> <td class="py-3">
<span class="badge @statusClass px-2.5 py-1.5 fs-7">@statusLabel</span> <span class="badge @statusClass px-2.5 py-1.5 fs-8 fw-bold">@statusLabel</span>
</td> </td>
<td class="text-muted small"> <td class="py-3 text-muted small">
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo) @(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
</td> </td>
</tr> </tr>
@@ -124,16 +135,16 @@
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) --> <!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card glass-card"> <div class="card border-0 shadow-sm rounded-4 h-100">
<div class="card-body p-4"> <div class="card-body p-4">
<h3 class="h5 fw-bold text-dark mb-4"> <h3 class="h5 fw-bold text-dark mb-4 pb-2 border-bottom">
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력 <i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
</h3> </h3>
@if (!Model.Consultations.Any()) @if (!Model.Consultations.Any())
{ {
<div class="text-center py-5 text-muted"> <div class="text-center py-5 text-muted">
<i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary"></i> <i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary" style="opacity: 0.5;"></i>
최근 상담 이력이 없습니다. 최근 상담 이력이 없습니다.
</div> </div>
} }
@@ -142,15 +153,18 @@
<div class="timeline ps-2"> <div class="timeline ps-2">
@foreach (var activity in Model.Consultations) @foreach (var activity in Model.Consultations)
{ {
<div class="timeline-item-modern"> <div class="position-relative ps-4 pb-4 border-start border-2 border-light-subtle">
<!-- Timeline Dot -->
<div class="position-absolute start-0 translate-middle-x rounded-circle bg-primary" style="width: 12px; height: 12px; top: 6px; border: 3px solid #fff;"></div>
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span> <span class="badge bg-primary-badge px-2 py-1 fs-8">@activity.ActivityType</span>
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small> <small class="text-muted font-numeric">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
</div> </div>
<p class="text-dark small mb-1 fw-semibold">@activity.Description</p> <p class="text-dark small mb-1 fw-semibold leading-normal">@activity.Description</p>
@if (!string.IsNullOrEmpty(activity.Outcome)) @if (!string.IsNullOrEmpty(activity.Outcome))
{ {
<div class="bg-light p-2 rounded small text-muted mt-1"> <div class="bg-light p-2.5 rounded-3 small text-muted mt-1.5 border border-light">
<strong>결과:</strong> @activity.Outcome <strong>결과:</strong> @activity.Outcome
</div> </div>
} }
+60 -18
View File
@@ -6,35 +6,77 @@
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/login"; ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/login";
} }
<section class="container py-5" style="max-width: 560px;"> <div class="bg-light py-5 min-vh-75 d-flex align-items-center">
<h1 class="h3 fw-bold mb-4">고객 포털 로그인</h1> <div class="container" style="max-width: 480px;">
<div class="alert alert-secondary"> <div class="card shadow-lg border-0 rounded-4 overflow-hidden">
포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다. <div class="card-body p-4 p-md-5">
<div 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>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) @if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{ {
<div class="alert alert-danger">@Model.ErrorMessage</div> <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"> <form method="post" class="vstack gap-3">
<div> <div>
<label class="form-label">이메일</label> <label class="form-label fw-semibold text-dark small" asp-for="Email">이메일 주소</label>
<input class="form-control" asp-for="Email" /> <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>
<div> <div>
<label class="form-label">비밀번호</label> <label class="form-label fw-semibold text-dark small" asp-for="Password">비밀번호</label>
<input class="form-control" asp-for="Password" type="password" /> <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-dark" type="submit">로그인</button> </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> </form>
<div class="d-grid gap-2 mt-4">
<form method="post" asp-page-handler="Google"> <div class="hr-text my-4"><span class="text-muted small">또는 소셜 계정으로 로그인</span></div>
<button class="btn btn-outline-dark w-100" type="submit">Google로 로그인</button>
<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>
<form method="post" asp-page-handler="Naver"> <form method="post" asp-page-handler="Naver" class="w-100">
<button class="btn btn-outline-success w-100" type="submit">Naver로 로그인</button> <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>
<form method="post" asp-page-handler="Kakao"> <form method="post" asp-page-handler="Kakao" class="w-100">
<button class="btn btn-outline-warning w-100" type="submit">Kakao로 로그인</button> <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> </form>
</div> </div>
</section>
<div class="text-center mt-4 pt-2 border-top">
<p class="text-muted small mb-0">아직 회원이 아니신가요? <a href="/portal/register" class="text-primary fw-bold">회원가입하기</a></p>
</div>
</div>
</div>
</div>
</div>
+56 -15
View File
@@ -6,30 +6,71 @@
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/register"; ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/portal/register";
} }
<section class="container py-5" style="max-width: 640px;"> <div class="bg-light py-5 min-vh-75 d-flex align-items-center">
<h1 class="h3 fw-bold mb-4">고객 포털 회원가입</h1> <div class="container" style="max-width: 560px;">
<div class="alert alert-secondary"> <div class="card shadow-lg border-0 rounded-4 overflow-hidden">
가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다. <div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<span class="badge bg-primary-badge px-3 py-2 mb-2 fs-7 font-numeric">TAXBAIK MY PORTAL</span>
<h1 class="h3 fw-bold text-dark mb-2">고객 포털 회원가입</h1>
<p class="text-muted small">포털 가입 후 세무 대리 고객 정보와 연동하여 편리한 서비스를 이용하세요.</p>
</div> </div>
<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"> <form method="post" class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">이름</label> <label class="form-label fw-semibold text-dark small" asp-for="Name">이름</label>
<input class="form-control" asp-for="Name" /> <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>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">연락처</label> <label class="form-label fw-semibold text-dark small" asp-for="Phone">연락처</label>
<input class="form-control" asp-for="Phone" /> <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>
<div class="col-12"> <div class="col-12">
<label class="form-label">이메일</label> <label class="form-label fw-semibold text-dark small" asp-for="Email">이메일 주소</label>
<input class="form-control" asp-for="Email" /> <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>
<div class="col-12"> <div class="col-12">
<label class="form-label">비밀번호</label> <label class="form-label fw-semibold text-dark small" asp-for="Password">비밀번호</label>
<input class="form-control" asp-for="Password" type="password" /> <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"> </div>
<button class="btn btn-dark" type="submit">가입하기</button> <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> </div>
</form> </form>
</section>
<div class="text-center mt-4 pt-2 border-top">
<p class="text-muted small mb-0">이미 계정이 있으신가요? <a href="/portal/login" class="text-primary fw-bold">로그인하기</a></p>
</div>
</div>
</div>
</div>
</div>
+18 -13
View File
@@ -1,35 +1,40 @@
@page "/rss.xml" @page "/rss.xml"
@model TaxBaik.Web.Pages.RssModel @model TaxBaik.Web.Pages.RssModel
@{ @{
Layout = null;
Response.ContentType = "application/rss+xml; charset=utf-8"; Response.ContentType = "application/rss+xml; charset=utf-8";
}@{
var rssContent = new System.Text.StringBuilder(); var rssContent = new System.Text.StringBuilder();
rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>"); rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
rssContent.AppendLine("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">"); rssContent.AppendLine("<rss version=\"2.0\">");
rssContent.AppendLine(" <channel>"); rssContent.AppendLine(" <channel>");
rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>"); rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>");
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>"); rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>"); rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
rssContent.AppendLine(" <language>ko-kr</language>"); rssContent.AppendLine(" <language>ko-KR</language>");
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>"); rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
rssContent.AppendLine(" <ttl>60</ttl>"); rssContent.AppendLine(" <ttl>60</ttl>");
foreach (var post in Model.Posts) foreach (var item in Model.Items)
{ {
var itemUrl = item.Link;
var fullDescription = item.Description ?? string.Empty;
var safeDescription = fullDescription.Replace("]]>", "]]]]><![CDATA[>");
rssContent.AppendLine(" <item>"); rssContent.AppendLine(" <item>");
rssContent.AppendLine($" <title>{System.Net.WebUtility.HtmlEncode(post.Title)}</title>"); rssContent.AppendLine($" <title>{System.Net.WebUtility.HtmlEncode(item.Title)}</title>");
rssContent.AppendLine($" <link>https://www.taxbaik.com/blog/{post.Slug}</link>"); rssContent.AppendLine($" <link>{itemUrl}</link>");
rssContent.AppendLine($" <guid isPermaLink=\"true\">https://www.taxbaik.com/blog/{post.Slug}</guid>"); rssContent.AppendLine($" <guid isPermaLink=\"true\">{item.Guid}</guid>");
rssContent.AppendLine($" <pubDate>{post.PublishedAt?.ToString("R")}</pubDate>");
var desc = post.Content?.Substring(0, Math.Min(200, post.Content?.Length ?? 0)) ?? ""; if (item.PublishedAt is not null)
rssContent.AppendLine($" <description>{System.Net.WebUtility.HtmlEncode(desc)}</description>");
if (!string.IsNullOrEmpty(post.Content))
{ {
rssContent.AppendLine($" <content:encoded><![CDATA[{post.Content}]]></content:encoded>"); rssContent.AppendLine($" <pubDate>{item.PublishedAt.Value.ToUniversalTime():R}</pubDate>");
} }
rssContent.AppendLine($" <description><![CDATA[{safeDescription}]]></description>");
rssContent.AppendLine(" </item>"); rssContent.AppendLine(" </item>");
} }
rssContent.AppendLine(" </channel>"); rssContent.AppendLine(" </channel>");
rssContent.AppendLine("</rss>"); rssContent.AppendLine("</rss>");
}@rssContent.ToString() }
@Html.Raw(rssContent.ToString())
+61 -8
View File
@@ -7,30 +7,83 @@ namespace TaxBaik.Web.Pages;
public class RssModel : PageModel public class RssModel : PageModel
{ {
private readonly BlogService _blogService; private readonly BlogService _blogService;
private readonly FaqService _faqService;
private readonly AnnouncementService _announcementService;
public List<BlogPost> Posts { get; set; } = []; public List<RssItem> Items { get; set; } = [];
public string LastBuildDate { get; set; } = DateTime.UtcNow.ToString("R"); public string LastBuildDate { get; set; } = DateTime.UtcNow.ToString("R");
public RssModel(BlogService blogService) public RssModel(
BlogService blogService,
FaqService faqService,
AnnouncementService announcementService)
{ {
_blogService = blogService; _blogService = blogService;
_faqService = faqService;
_announcementService = announcementService;
} }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
try try
{ {
// 최근 50개 블로그 포스트 (RSS는 일반적으로 최신 기사만 포함) var blogTask = _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default);
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default); var faqTask = _faqService.GetActiveAsync(HttpContext.RequestAborted);
Posts = posts.OrderByDescending(p => p.PublishedAt).ToList(); var announcementTask = _announcementService.GetActiveAsync(HttpContext.RequestAborted);
await Task.WhenAll(blogTask, faqTask, announcementTask);
var blogPosts = (await blogTask).Item1
.OrderByDescending(p => p.PublishedAt)
.Select(post => new RssItem(
Title: post.Title,
Link: $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(post.Slug)}",
Description: post.Content ?? string.Empty,
PublishedAt: post.PublishedAt,
Guid: $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(post.Slug)}"))
.ToList();
var faqs = (await faqTask)
.OrderByDescending(f => f.UpdatedAt)
.Select(faq => new RssItem(
Title: $"FAQ: {faq.Question}",
Link: "https://www.taxbaik.com/#faq",
Description: faq.Answer,
PublishedAt: faq.UpdatedAt,
Guid: $"https://www.taxbaik.com/rss/faq/{faq.Id}"))
.ToList();
var announcements = (await announcementTask)
.OrderByDescending(a => a.UpdatedAt)
.Select(announcement => new RssItem(
Title: $"공지: {announcement.Title}",
Link: "https://www.taxbaik.com/#top",
Description: announcement.Content ?? announcement.Title,
PublishedAt: announcement.UpdatedAt,
Guid: $"https://www.taxbaik.com/rss/announcement/{announcement.Id}"))
.ToList();
Items = blogPosts
.Concat(faqs)
.Concat(announcements)
.OrderByDescending(item => item.PublishedAt ?? DateTime.MinValue)
.Take(50)
.ToList();
} }
catch (Exception ex) catch (Exception ex)
{ {
// DB 연결 실패: 빈 포스트 목록 반환 (정적 피드 구조는 유지) // DB 연결 실패: 빈 피드가 되지 않도록 로컬 서버 시간 기준 단일 항목을 유지하지는 않는다.
Posts = []; Items = [];
System.Diagnostics.Debug.WriteLine($"RSS Feed: Blog posts load failed: {ex.Message}"); System.Diagnostics.Debug.WriteLine($"RSS Feed: Blog posts load failed: {ex.Message}");
} }
LastBuildDate = Posts.FirstOrDefault()?.PublishedAt?.ToString("R") ?? DateTime.UtcNow.ToString("R"); LastBuildDate = Items.FirstOrDefault()?.PublishedAt?.ToString("R") ?? DateTime.UtcNow.ToString("R");
} }
public sealed record RssItem(
string Title,
string Link,
string Description,
DateTime? PublishedAt,
string Guid);
} }
@@ -3,7 +3,7 @@ namespace TaxBaik.Web.Pages.Shared;
public sealed record BlogEditorModel( public sealed record BlogEditorModel(
string Title, string Title,
string Content, string Content,
int? CategoryId, int CategoryId,
bool IsPublished, bool IsPublished,
string? ThumbnailUrl, string? ThumbnailUrl,
string? SeoTitle, string? SeoTitle,
@@ -1,18 +1,18 @@
@model (string Eyebrow, string Title, string Subtitle, string? ActionHref, string? ActionText) @model TaxBaik.Web.Models.AdminPageHeaderModel
<div class="page-header d-print-none mb-4"> <div class="page-header d-print-none admin-page-header">
<div class="row align-items-center g-2"> <div class="row align-items-center g-2">
<div class="col"> <div class="col">
<div class="page-pretitle text-uppercase">@Model.Eyebrow</div> <div class="page-pretitle text-uppercase admin-page-header-eyebrow">@Model.Eyebrow</div>
<h2 class="page-title mb-1">@Model.Title</h2> <h2 class="page-title mb-1 admin-page-header-title">@Model.Title</h2>
@if (!string.IsNullOrWhiteSpace(Model.Subtitle)) @if (!string.IsNullOrWhiteSpace(Model.Subtitle))
{ {
<div class="text-muted small">@Model.Subtitle</div> <div class="text-muted small admin-page-header-subtitle">@Model.Subtitle</div>
} }
</div> </div>
@if (!string.IsNullOrWhiteSpace(Model.ActionHref) && !string.IsNullOrWhiteSpace(Model.ActionText)) @if (!string.IsNullOrWhiteSpace(Model.ActionHref) && !string.IsNullOrWhiteSpace(Model.ActionText))
{ {
<div class="col-auto ms-auto"> <div class="col-auto ms-auto">
<a class="btn btn-primary btn-pill" href="@Model.ActionHref">@Model.ActionText</a> <a class="btn btn-primary btn-sm btn-pill" href="@Model.ActionHref">@Model.ActionText</a>
</div> </div>
} }
</div> </div>
+43 -15
View File
@@ -1,22 +1,23 @@
@model TaxBaik.Web.Pages.Shared.BlogEditorModel @model TaxBaik.Web.Pages.Shared.BlogEditorModel
<section class="container-xl admin-page-shell admin-editor-root blog-editor-shell" data-blog-editor-root> <section class="admin-page-shell admin-editor-root blog-editor-shell" data-blog-editor-root>
<div class="row g-3"> <div class="page-header d-print-none admin-page-header blog-editor-header">
<div class="col-12">
<div class="page-header d-print-none mb-4">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col"> <div class="col">
<div class="page-pretitle">콘텐츠</div> <div class="page-pretitle admin-page-header-eyebrow">콘텐츠</div>
<h2 class="page-title">@(Model.IsEdit ? "블로그 수정" : "블로그 등록")</h2> <h2 class="page-title admin-page-header-title">@(Model.IsEdit ? "블로그 수정" : "블로그 등록")</h2>
<p class="text-muted mb-0">@(Model.IsEdit ? "기존 글을 불러와 즉시 편집하고 미리볼 수 있습니다." : "에디터와 미리보기를 함께 제공하는 작성 화면입니다.")</p> <p class="text-muted mb-0 admin-page-header-subtitle">@(Model.IsEdit ? "기존 글을 불러와 즉시 편집하고 미리볼 수 있습니다." : "에디터와 미리보기를 함께 제공하는 작성 화면입니다.")</p>
</div> </div>
<div class="col-auto ms-auto">
<span class="badge bg-blue-lt text-blue badge-sm">실시간 미리보기</span>
</div> </div>
</div> </div>
</div> </div>
<div class="row g-3">
<div class="col-lg-8"> <div class="col-lg-8">
<form method="post" class="card admin-page-card"> <form method="post" class="card">
<div class="card-body"> <div class="card-body">
<div asp-validation-summary="All" class="alert alert-danger"></div> @await Html.PartialAsync("_ValidationSummary")
@if (Model.IsEdit) @if (Model.IsEdit)
{ {
<input type="hidden" name="Id" value="@ViewData["BlogEditorId"]" /> <input type="hidden" name="Id" value="@ViewData["BlogEditorId"]" />
@@ -42,11 +43,26 @@
<div class="row g-3 blog-meta-grid"> <div class="row g-3 blog-meta-grid">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="Input_CategoryId">카테고리</label> <label class="form-label" for="Input_CategoryId">카테고리</label>
<input class="form-control" id="Input_CategoryId" name="Input.CategoryId" value="@Model.CategoryId" /> <select class="form-select" id="Input_CategoryId" name="Input.CategoryId" required>
<option value="">카테고리 선택</option>
@foreach (var category in (ViewData["BlogCategories"] as IEnumerable<TaxBaik.Domain.Entities.Category>) ?? [])
{
if (category.Id == Model.CategoryId)
{
<option value="@category.Id" selected>@category.Name</option>
}
else
{
<option value="@category.Id">@category.Name</option>
}
}
</select>
<span class="text-danger small" data-valmsg-for="Input.CategoryId" data-valmsg-replace="true"></span>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label d-block" for="Input_IsPublished">발행 여부</label> <label class="form-label d-block" for="Input_IsPublished">발행 여부</label>
<input class="form-check-input" id="Input_IsPublished" name="Input.IsPublished" type="checkbox" value="true" @(Model.IsPublished ? "checked" : null) /> <input type="hidden" name="Input.IsPublished" value="@(Model.IsPublished ? "true" : "false")" data-blog-published-value />
<input class="form-check-input" id="Input_IsPublished" type="checkbox" value="true" @(Model.IsPublished ? "checked" : null) data-blog-published-toggle />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="Input_ThumbnailUrl">썸네일 URL</label> <label class="form-label" for="Input_ThumbnailUrl">썸네일 URL</label>
@@ -62,7 +78,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer bg-white d-flex flex-wrap justify-content-between gap-2"> <div class="card-footer d-flex flex-wrap justify-content-between gap-2">
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
@if (Model.IsEdit) @if (Model.IsEdit)
{ {
@@ -75,12 +91,24 @@
</form> </form>
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card admin-page-card h-100"> <div class="card h-100">
<div class="card-header d-flex align-items-center justify-content-between gap-2"> <div class="card-header d-flex align-items-center justify-content-between gap-2">
<h3 class="card-title mb-0">실시간 미리보기</h3> <h3 class="card-title mb-0">실시간 미리보기</h3>
<span class="badge bg-secondary-lt text-secondary">공통 스타일</span> <span class="badge bg-secondary-lt text-secondary">공통 스타일</span>
</div> </div>
<div class="card-body"> <div class="card-body d-grid gap-3">
<div class="admin-editor-source">
<div class="text-muted small text-uppercase mb-1">불러온 원본</div>
<div class="fw-semibold mb-2">@Model.Title</div>
<dl class="row g-2 mb-0 small">
<dt class="col-4 text-muted fw-normal">카테고리</dt>
<dd class="col-8 mb-0">@Model.CategoryId</dd>
<dt class="col-4 text-muted fw-normal">발행</dt>
<dd class="col-8 mb-0">@(Model.IsPublished ? "예" : "아니오")</dd>
<dt class="col-4 text-muted fw-normal">썸네일</dt>
<dd class="col-8 mb-0">@(string.IsNullOrWhiteSpace(Model.ThumbnailUrl) ? "-" : Model.ThumbnailUrl)</dd>
</dl>
</div>
<article class="content-prose blog-preview-pane" data-blog-preview>@Html.Raw(Model.Content)</article> <article class="content-prose blog-preview-pane" data-blog-preview>@Html.Raw(Model.Content)</article>
</div> </div>
</div> </div>
@@ -1,9 +1,10 @@
@model string @model string
<div class="card shadow-sm border-0"> <div class="card">
<div class="card-body text-center py-5"> <div class="card-body text-center py-5">
<div class="text-muted mb-2">준비 중</div> <div class="avatar avatar-xl bg-blue-lt text-blue mb-3 mx-auto">UI</div>
<div class="text-uppercase text-muted small mb-1">준비 중</div>
<h3 class="card-title mb-2">@Model</h3> <h3 class="card-title mb-2">@Model</h3>
<p class="text-muted mb-0">이 페이지는 현재 구조 맞춘 상태이며, 다음 단계에서 기능을 채우면 됩니다.</p> <p class="text-muted mb-0">이 페이지는 구조 맞춘 상태이며, 다음 단계에서 기능을 채우면 됩니다.</p>
</div> </div>
</div> </div>
@@ -1,5 +1,8 @@
@model string @model string
<div class="empty py-5"> @{
var emptyMessage = string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model;
}
<div class="empty admin-empty-state">
<div class="empty-img"> <div class="empty-img">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-database-off" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-database-off" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6c0 1.657 4.03 3 9 3s9-1.343 9-3-4.03-3-9-3-9 1.343-9 3" /> <path d="M4 6c0 1.657 4.03 3 9 3s9-1.343 9-3-4.03-3-9-3-9 1.343-9 3" />
@@ -8,5 +11,5 @@
<path d="M3 3l18 18" /> <path d="M3 3l18 18" />
</svg> </svg>
</div> </div>
<p class="empty-title">@string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model</p> <p class="empty-title">@emptyMessage</p>
</div> </div>
@@ -3,12 +3,18 @@
{ {
<nav class="mt-4" aria-label="페이지네이션"> <nav class="mt-4" aria-label="페이지네이션">
<ul class="pagination"> <ul class="pagination">
<li class="page-item @(Model.Page <= 1 ? "disabled" : "")">
<a class="page-link" href="@(Model.Page <= 1 ? "#" : Model.BuildPageUrl(Model.Page - 1))" tabindex="@(Model.Page <= 1 ? -1 : 0)" aria-disabled="@(Model.Page <= 1 ? "true" : "false")">이전</a>
</li>
@for (var i = 1; i <= Model.TotalPages; i++) @for (var i = 1; i <= Model.TotalPages; i++)
{ {
<li class="page-item @(i == Model.Page ? "active" : "")"> <li class="page-item @(i == Model.Page ? "active" : "")">
<a class="page-link" href="@Model.BuildPageUrl(i)">@i</a> <a class="page-link" href="@Model.BuildPageUrl(i)" @(i == Model.Page ? "aria-current=\"page\"" : "")>@i</a>
</li> </li>
} }
<li class="page-item @(Model.Page >= Model.TotalPages ? "disabled" : "")">
<a class="page-link" href="@(Model.Page >= Model.TotalPages ? "#" : Model.BuildPageUrl(Model.Page + 1))" tabindex="@(Model.Page >= Model.TotalPages ? -1 : 0)" aria-disabled="@(Model.Page >= Model.TotalPages ? "true" : "false")">다음</a>
</li>
</ul> </ul>
</nav> </nav>
} }
@@ -8,7 +8,7 @@
<span class="avatar avatar-xs bg-red-lt text-red">!</span> <span class="avatar avatar-xs bg-red-lt text-red">!</span>
<div class="flex-fill"> <div class="flex-fill">
<div class="fw-semibold mb-1">입력값을 확인하세요.</div> <div class="fw-semibold mb-1">입력값을 확인하세요.</div>
<div asp-validation-summary="All" class="validation-summary text-sm"></div> <div asp-validation-summary="All" class="text-sm"></div>
</div> </div>
</div> </div>
</div> </div>
+6 -2
View File
@@ -1,14 +1,18 @@
@page "/sitemap.xml" @page "/sitemap.xml"
@model TaxBaik.Web.Pages.SitemapModel @model TaxBaik.Web.Pages.SitemapModel
@{ @{
Layout = null;
Response.ContentType = "application/xml; charset=utf-8"; Response.ContentType = "application/xml; charset=utf-8";
}<?xml version="1.0" encoding="utf-8"?> }<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach (var url in Model.Urls) @foreach (var url in Model.Urls)
{ {
<url> <url>
<loc>@System.Net.WebUtility.HtmlEncode(url)</loc> <loc>@url.Location</loc>
<lastmod>@DateTime.UtcNow:yyyy-MM-dd</lastmod> @if (url.LastModified.HasValue)
{
<lastmod>@url.LastModified.Value.ToUniversalTime().ToString("yyyy-MM-dd")</lastmod>
}
</url> </url>
} }
</urlset> </urlset>
+70 -27
View File
@@ -5,48 +5,91 @@ namespace TaxBaik.Web.Pages;
public class SitemapModel : PageModel public class SitemapModel : PageModel
{ {
private const string BaseUrl = "https://www.taxbaik.com";
private const int BlogPageSize = 100;
private readonly BlogService _blogService; private readonly BlogService _blogService;
private readonly FaqService _faqService;
private readonly AnnouncementService _announcementService;
private readonly ILogger<SitemapModel> _logger;
public List<string> Urls { get; set; } = []; public List<SitemapUrl> Urls { get; } = [];
public SitemapModel(BlogService blogService) public SitemapModel(
BlogService blogService,
FaqService faqService,
AnnouncementService announcementService,
ILogger<SitemapModel> logger)
{ {
_blogService = blogService; _blogService = blogService;
_faqService = faqService;
_announcementService = announcementService;
_logger = logger;
} }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
var baseUrl = "https://www.taxbaik.com"; // FAQ와 공지사항은 홈에만 표시되고 각각의 URL은 리다이렉트된다.
// 따라서 홈의 변경일에 합산하고 리다이렉트 URL은 sitemap에서 제외한다.
var homeLastModified = default(DateTime);
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
// 정적 페이지 (항상 포함)
Urls.AddRange(new[]
{
$"{baseUrl}",
$"{baseUrl}/about",
$"{baseUrl}/services",
$"{baseUrl}/contact",
$"{baseUrl}/privacy",
$"{baseUrl}/terms",
$"{baseUrl}/blog",
$"{baseUrl}/faq",
$"{baseUrl}/announcement",
$"{baseUrl}/inquiry"
});
// 동적 블로그 포스트 (DB 오류 처리)
try try
{ {
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default); var faqTask = _faqService.GetActiveAsync(HttpContext.RequestAborted);
foreach (var post in posts) var announcementTask = _announcementService.GetActiveAsync(HttpContext.RequestAborted);
{ var postsTask = GetAllPublishedPostsAsync(HttpContext.RequestAborted);
Urls.Add($"{baseUrl}/blog/{post.Slug}");
} await Task.WhenAll(faqTask, announcementTask, postsTask);
posts = await postsTask;
homeLastModified = (await faqTask)
.Select(faq => faq.UpdatedAt)
.Concat((await announcementTask).Select(announcement => announcement.UpdatedAt))
.Concat(posts.Take(3).Select(post => post.UpdatedAt))
.DefaultIfEmpty()
.Max();
} }
catch (Exception ex) catch (Exception ex)
{ {
// DB 연결 실패 등의 경우, 정적 페이지만으로도 sitemap 제공 // DB 문제 중에도 검색 엔진이 기존 핵심 페이지를 계속 발견할 수 있게 한다.
// 프로덕션에서는 DB가 있으므로 이 경로는 사용되지 않음 _logger.LogError(ex, "Failed to load dynamic sitemap content");
System.Diagnostics.Debug.WriteLine($"Sitemap: Blog posts load failed: {ex.Message}"); }
Urls.AddRange(new[]
{
new SitemapUrl($"{BaseUrl}/", homeLastModified == default ? null : homeLastModified),
new SitemapUrl($"{BaseUrl}/about"),
new SitemapUrl($"{BaseUrl}/services"),
new SitemapUrl($"{BaseUrl}/contact"),
new SitemapUrl($"{BaseUrl}/privacy"),
new SitemapUrl($"{BaseUrl}/terms"),
new SitemapUrl($"{BaseUrl}/blog")
});
foreach (var post in posts)
{
Urls.Add(new SitemapUrl(
$"{BaseUrl}/blog/{Uri.EscapeDataString(post.Slug)}",
post.UpdatedAt == default ? post.PublishedAt : post.UpdatedAt));
} }
} }
private async Task<List<TaxBaik.Domain.Entities.BlogPost>> GetAllPublishedPostsAsync(CancellationToken ct)
{
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
var page = 1;
while (true)
{
var (items, total) = await _blogService.GetPublishedPagedAsync(page, BlogPageSize, ct: ct);
posts.AddRange(items);
if (posts.Count >= total || !items.Any())
return posts;
page++;
}
}
public sealed record SitemapUrl(string Location, DateTime? LastModified = null);
} }
+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> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
@@ -12,30 +18,29 @@
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" /> <meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" /> <meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="og:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" /> <meta property="og:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "https://www.taxbaik.com/")" /> <meta property="og:url" content="@ogUrl" />
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" /> <meta name="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" /> <meta name="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" /> <meta name="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) --> <!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> --> <!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> --> <!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" /> <meta name="robots" content="@robots" />
<meta name="theme-color" content="#C89D6E" /> <meta name="theme-color" content="#C89D6E" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- RSS Feed --> <!-- RSS Feed -->
<link rel="alternate" type="application/rss+xml" title="백원숙 세무회계 블로그" href="/rss.xml" /> <link rel="alternate" type="application/rss+xml" title="백원숙 세무회계 블로그" href="/rss.xml" />
<link rel="alternate" type="application/rss+xml" title="TaxBaik Blog Feed" href="/feed.xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" /> <link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "https://www.taxbaik.com/")" /> <link rel="canonical" href="@canonicalUrl" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@@ -71,7 +76,7 @@
</script> </script>
</head> </head>
<body class="with-mobile-cta" data-kakao-url="https://pf.kakao.com/_xoxchTX"> <body class="with-mobile-cta" data-kakao-url="https://pf.kakao.com/_xoxchTX">
<partial name="_Header" /> @await Html.PartialAsync("_Header")
<main role="main" class="pb-5"> <main role="main" class="pb-5">
@RenderBody() @RenderBody()
</main> </main>
@@ -0,0 +1,3 @@
@using TaxBaik.Web
@namespace TaxBaik.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+14 -3
View File
@@ -69,11 +69,12 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("client-logs", httpContext => options.AddPolicy("client-logs", httpContext =>
{ {
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var isDevelopment = builder.Environment.IsDevelopment();
return RateLimitPartition.GetFixedWindowLimiter( return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"client-logs:{ip}", partitionKey: $"client-logs:{ip}",
factory: _ => new FixedWindowRateLimiterOptions factory: _ => new FixedWindowRateLimiterOptions
{ {
PermitLimit = 10, PermitLimit = isDevelopment ? 200 : 10,
Window = TimeSpan.FromMinutes(1), Window = TimeSpan.FromMinutes(1),
QueueLimit = 0, QueueLimit = 0,
AutoReplenishment = true AutoReplenishment = true
@@ -82,11 +83,12 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("admin-login", httpContext => options.AddPolicy("admin-login", httpContext =>
{ {
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var isDevelopment = builder.Environment.IsDevelopment();
return RateLimitPartition.GetFixedWindowLimiter( return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"admin-login:{ip}", partitionKey: $"admin-login:{ip}",
factory: _ => new FixedWindowRateLimiterOptions factory: _ => new FixedWindowRateLimiterOptions
{ {
PermitLimit = 5, PermitLimit = isDevelopment ? 100 : 5,
Window = TimeSpan.FromMinutes(1), Window = TimeSpan.FromMinutes(1),
QueueLimit = 0, QueueLimit = 0,
AutoReplenishment = true AutoReplenishment = true
@@ -95,6 +97,7 @@ builder.Services.AddRateLimiter(options =>
}); });
// Razor Pages // Razor Pages
builder.Services.AddSingleton<IIpLockoutService, IpLockoutService>();
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
// Session & TempData (쿠키 저장소) // Session & TempData (쿠키 저장소)
@@ -262,6 +265,14 @@ builder.Services.AddScoped<TaxBaik.Web.Services.SitemapValidationService>();
// Register version info // Register version info
var versionInfo = new VersionInfo(); var versionInfo = new VersionInfo();
var assembly = typeof(Program).Assembly;
var assemblyVersion = assembly.GetName().Version?.ToString() ?? "unknown";
var informationalVersion = assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
.FirstOrDefault()?.InformationalVersion;
versionInfo.Version = informationalVersion ?? assemblyVersion;
versionInfo.Built = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'");
var versionJsonPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "version.json"); var versionJsonPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "version.json");
if (File.Exists(versionJsonPath)) if (File.Exists(versionJsonPath))
{ {
@@ -350,7 +361,7 @@ app.Use(async (context, next) =>
context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none"; context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none";
if (!context.Response.Headers.ContainsKey("Content-Security-Policy")) if (!context.Response.Headers.ContainsKey("Content-Security-Policy"))
{ {
context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://www.googletagmanager.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"; context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://www.googletagmanager.com https://static.cloudflareinsights.com; connect-src 'self' https: https://cloudflareinsights.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
} }
await next(); await next();
+43 -9
View File
@@ -14,7 +14,8 @@ public class AuthService
private readonly ILogger<AuthService> _logger; private readonly ILogger<AuthService> _logger;
private readonly ITelegramNotificationService _telegramService; private readonly ITelegramNotificationService _telegramService;
private readonly IHostEnvironment _environment; private readonly IHostEnvironment _environment;
private readonly string _jwtSecretKey; private readonly IIpLockoutService _ipLockoutService;
private readonly string? _jwtSecretKey;
private readonly string? _passwordResetToken; private readonly string? _passwordResetToken;
private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상) private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상)
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일 private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
@@ -24,18 +25,26 @@ public class AuthService
ILogger<AuthService> logger, ILogger<AuthService> logger,
IConfiguration configuration, IConfiguration configuration,
ITelegramNotificationService telegramService, ITelegramNotificationService telegramService,
IHostEnvironment environment) IHostEnvironment environment,
IIpLockoutService ipLockoutService)
{ {
_adminUserRepository = adminUserRepository; _adminUserRepository = adminUserRepository;
_logger = logger; _logger = logger;
_telegramService = telegramService; _telegramService = telegramService;
_environment = environment; _environment = environment;
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration."); _ipLockoutService = ipLockoutService;
_jwtSecretKey = configuration["Jwt:SecretKey"];
_passwordResetToken = configuration["Admin:PasswordResetToken"]; _passwordResetToken = configuration["Admin:PasswordResetToken"];
} }
public async Task<AuthResult> AuthenticateAndGenerateTokenPairAsync(string username, string password) public async Task<AuthResult> AuthenticateAndGenerateTokenPairAsync(string username, string password, string ipAddress)
{ {
if (_ipLockoutService.IsLockedOut(ipAddress))
{
_logger.LogWarning("로그인 시도 거부: 차단된 IP '{IpAddress}' (사용자: '{Username}')", ipAddress, username);
return AuthResult.InvalidCredentials();
}
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return AuthResult.InvalidCredentials(); return AuthResult.InvalidCredentials();
@@ -66,6 +75,7 @@ public class AuthService
if (user == null) if (user == null)
{ {
_logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username); _logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username);
_ipLockoutService.RecordFailedAttempt(ipAddress);
return AuthResult.InvalidCredentials(); return AuthResult.InvalidCredentials();
} }
@@ -78,18 +88,26 @@ public class AuthService
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{ {
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username); _logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
_ipLockoutService.RecordFailedAttempt(ipAddress);
// 실패한 로그인은 알림하지 않음 (로그만 남김) // 실패한 로그인은 알림하지 않음 (로그만 남김)
return AuthResult.InvalidCredentials(); return AuthResult.InvalidCredentials();
} }
_logger.LogInformation("로그인 성공: {Username}", username); _logger.LogInformation("로그인 성공: {Username} (IP: {IpAddress})", username, ipAddress);
_ipLockoutService.ResetAttempts(ipAddress);
// 로그인 알림은 제거 (로그만 남김) // 로그인 알림은 제거 (로그만 남김)
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id); await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
return AuthResult.Success(GenerateTokenPair(user)); return AuthResult.Success(GenerateTokenPair(user));
} }
public async Task<AdminUser?> AuthenticateAsync(string username, string password) public async Task<AdminUser?> AuthenticateAsync(string username, string password, string ipAddress)
{ {
if (_ipLockoutService.IsLockedOut(ipAddress))
{
_logger.LogWarning("로그인 시도 거부: 차단된 IP '{IpAddress}' (사용자: '{Username}')", ipAddress, username);
return null;
}
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return null; return null;
@@ -97,11 +115,18 @@ public class AuthService
{ {
var user = await _adminUserRepository.GetByUsernameAsync(username); var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash)) if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash))
{
_ipLockoutService.RecordFailedAttempt(ipAddress);
return null; return null;
}
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
_ipLockoutService.RecordFailedAttempt(ipAddress);
return null; return null;
}
_ipLockoutService.ResetAttempts(ipAddress);
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id); await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
return user; return user;
} }
@@ -184,7 +209,8 @@ public class AuthService
private AuthTokenPair GenerateTokenPair(AdminUser user) private AuthTokenPair GenerateTokenPair(AdminUser user)
{ {
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); var jwtSecretKey = GetJwtSecretKey();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[] var claims = new[]
@@ -220,7 +246,7 @@ public class AuthService
{ {
try try
{ {
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecretKey()));
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, new TokenValidationParameters var principal = handler.ValidateToken(token, new TokenValidationParameters
{ {
@@ -246,7 +272,7 @@ public class AuthService
{ {
try try
{ {
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecretKey()));
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, new TokenValidationParameters var principal = handler.ValidateToken(token, new TokenValidationParameters
{ {
@@ -278,6 +304,14 @@ public class AuthService
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes); && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes);
} }
private string GetJwtSecretKey()
{
if (string.IsNullOrWhiteSpace(_jwtSecretKey))
throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
return _jwtSecretKey;
}
} }
public class AuthTokenPair(string accessToken, string refreshToken, int expiresIn) public class AuthTokenPair(string accessToken, string refreshToken, int expiresIn)
@@ -0,0 +1,92 @@
using System;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace TaxBaik.Web.Services;
public interface IIpLockoutService
{
bool IsLockedOut(string ipAddress);
void RecordFailedAttempt(string ipAddress);
void ResetAttempts(string ipAddress);
}
public class IpLockoutService : IIpLockoutService
{
private readonly IMemoryCache _cache;
private readonly ILogger<IpLockoutService> _logger;
private const int MaxAttempts = 3;
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
private static readonly TimeSpan AttemptWindow = TimeSpan.FromHours(1);
public IpLockoutService(IMemoryCache cache, ILogger<IpLockoutService> logger)
{
_cache = cache;
_logger = logger;
}
public bool IsLockedOut(string ipAddress)
{
if (string.IsNullOrWhiteSpace(ipAddress))
return false;
var lockoutKey = GetLockoutKey(ipAddress);
if (_cache.TryGetValue(lockoutKey, out DateTime lockoutUntil))
{
if (DateTime.UtcNow < lockoutUntil)
{
return true;
}
// Lockout period expired, clean up
_cache.Remove(lockoutKey);
}
return false;
}
public void RecordFailedAttempt(string ipAddress)
{
if (string.IsNullOrWhiteSpace(ipAddress))
return;
var attemptsKey = GetAttemptsKey(ipAddress);
int currentAttempts = _cache.GetOrCreate(attemptsKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = AttemptWindow;
return 0;
});
currentAttempts++;
_cache.Set(attemptsKey, currentAttempts, AttemptWindow);
_logger.LogWarning("IP {IpAddress} 로그인 실패 기록됨. 누적 실패 횟수: {Attempts}/{MaxAttempts}",
ipAddress, currentAttempts, MaxAttempts);
if (currentAttempts >= MaxAttempts)
{
var lockoutUntil = DateTime.UtcNow.Add(LockoutDuration);
var lockoutKey = GetLockoutKey(ipAddress);
_cache.Set(lockoutKey, lockoutUntil, LockoutDuration);
_logger.LogCritical("IP {IpAddress}의 로그인 시도가 {MaxAttempts}회 연속 실패하여 {Duration} 동안 로그인 차단됩니다. 차단 해제 예정 일시: {LockoutUntil} UTC",
ipAddress, MaxAttempts, LockoutDuration, lockoutUntil);
// Reset attempts count since they are now locked out
_cache.Remove(attemptsKey);
}
}
public void ResetAttempts(string ipAddress)
{
if (string.IsNullOrWhiteSpace(ipAddress))
return;
_cache.Remove(GetAttemptsKey(ipAddress));
_cache.Remove(GetLockoutKey(ipAddress));
}
private static string GetAttemptsKey(string ipAddress) => $"IpLockout:Attempts:{ipAddress}";
private static string GetLockoutKey(string ipAddress) => $"IpLockout:Lockout:{ipAddress}";
}
@@ -46,8 +46,8 @@ public class SitemapValidationService
result.TotalUrls = staticUrls.Count; result.TotalUrls = staticUrls.Count;
// 2. 동적 블로그 포스트 검증 // 2. 동적 블로그 포스트 검증
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default); var posts = await GetAllPublishedPostsAsync();
var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{p.Slug}").ToList(); var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(p.Slug)}").ToList();
ValidateUrls(blogUrls, result); ValidateUrls(blogUrls, result);
@@ -251,10 +251,25 @@ public class SitemapValidationService
$"{baseUrl}/contact", $"{baseUrl}/contact",
$"{baseUrl}/privacy", $"{baseUrl}/privacy",
$"{baseUrl}/terms", $"{baseUrl}/terms",
$"{baseUrl}/blog", $"{baseUrl}/blog"
$"{baseUrl}/faq",
$"{baseUrl}/announcement",
$"{baseUrl}/inquiry"
}; };
} }
private async Task<List<TaxBaik.Domain.Entities.BlogPost>> GetAllPublishedPostsAsync()
{
const int pageSize = 100;
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
var page = 1;
while (true)
{
var (items, total) = await _blogService.GetPublishedPagedAsync(page, pageSize);
posts.AddRange(items);
if (posts.Count >= total || !items.Any())
return posts;
page++;
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,9 @@
function setupEditor(root) { function setupEditor(root) {
const editor = root.querySelector('[data-blog-editor]'); const editor = root.querySelector('[data-blog-editor]');
const hiddenInput = root.querySelector('[data-blog-editor-value]'); const hiddenInput = root.querySelector('[data-blog-editor-value]');
const publishedToggle = root.querySelector('[data-blog-published-toggle]');
const publishedValue = root.querySelector('[data-blog-published-value]');
const form = root.querySelector('form');
const preview = root.querySelector('[data-blog-preview]'); const preview = root.querySelector('[data-blog-preview]');
const buttons = root.querySelectorAll('[data-blog-command]'); const buttons = root.querySelectorAll('[data-blog-command]');
@@ -32,6 +35,21 @@
}); });
}); });
const syncPublishedState = () => {
if (publishedValue && publishedToggle) {
publishedValue.value = publishedToggle.checked ? 'true' : 'false';
}
};
if (publishedToggle) {
publishedToggle.addEventListener('change', syncPublishedState);
syncPublishedState();
}
if (form) {
form.addEventListener('submit', syncPublishedState);
}
syncEditor(editor, hiddenInput); syncEditor(editor, hiddenInput);
renderPreview(); renderPreview();
} }
-24
View File
@@ -7,30 +7,6 @@ Allow: /
Disallow: /admin/ Disallow: /admin/
Disallow: /portal/ Disallow: /portal/
Disallow: /manage/ Disallow: /manage/
Disallow: *.json$
Disallow: *.xml$ (sitemap 제외)
# 검색 엔진별 크롤링 속도 제한
User-agent: Googlebot
Crawl-delay: 1
User-agent: Bingbot
Crawl-delay: 2
User-agent: Yeti
Crawl-delay: 1
# Naver, Kakao 검색 엔진
User-agent: Naver
Allow: /
User-agent: Kakaobot
Allow: /
# Sitemap 위치 # Sitemap 위치
Sitemap: https://www.taxbaik.com/sitemap.xml Sitemap: https://www.taxbaik.com/sitemap.xml
Sitemap: https://www.taxbaik.com/sitemap.xml
# RSS 피드
Sitemap: https://www.taxbaik.com/rss.xml
Sitemap: https://www.taxbaik.com/feed.xml