105 Commits

Author SHA1 Message Date
kjh2064 988b166118 refactor: add 'tax law changes' layer to BLOG_TEMPLATE
TaxBaik CI/CD / build-and-deploy (push) Failing after 40s
Complete 3-layer persuasion architecture:

Layer 1: BASICS (anyone can learn)
'기초는 배울 수 있어요'

Layer 2: DETAILS + TAX LAW CHANGES (unrealistic to track)
'하지만:
- 디테일이 지옥 (하나 놓쳤다가 50만원)
- 세법은 계속 바뀜 (매년 업데이트)
- 변화를 추적 불가능 (본업이 있으니까)'

Layer 3: PROFESSIONAL VALUE (experts only)
'그래서 세무사가 필요:
- 디테일 자동 관리
- 세법 변화 자동 적용
- 새 제도 놓치지 않음
- 당신은 사업에만 집중'

New Section 3.6: Tax law updates by year
- Current year changes
- How it affects readers
- What tax accountants automatically handle
- Comparison table

Key insight:
 Tax code is NOT static
 Clients cannot track yearly changes
 Tax accountants automatically apply latest rules
 'One tax accountant = never study tax law again'

This makes the value proposition irresistible:
기초는 배울 수 있지만, 계속 바뀌는 세법을 따라가려면
세무사가 필수다.
2026-07-01 16:50:22 +09:00
kjh2064 78d3990484 refactor: add 'devil is in details' section to BLOG_TEMPLATE
TaxBaik CI/CD / build-and-deploy (push) Failing after 40s
Core insight: The real value of tax professionals is managing the details

New section (Step 3.5): Shows readers the hidden complexity
- Surface level: 'Organize receipts' → Simple
- Reality: Tax law, personal vs business expenses, re-filings, IRS response
- Tax accountant handles: Classification, justification, IRS communication

Examples added:
1. Receipt management complexity
2. Income/expense recording details
3. Tax optimization and audit preparation

This builds empathy for why professionals are needed:
 Basic concept: Anyone can learn
 Implementation details: Tax professionals excel
 Risk management: What happens when details are wrong

Key message for readers:
'처음엔 간단해 보이지만, 디테일이 지옥이다.
그 디테일을 세무사가 관리한다.
디테일 하나 놓쳤다가 가산세 50만원.
그래서 세무사가 필요하다.'
2026-07-01 16:48:23 +09:00
kjh2064 b3c4ee430d refactor: add core philosophy to BLOG_TEMPLATE - focus on value realization
TaxBaik CI/CD / build-and-deploy (push) Failing after 41s
Core insight: Blog's real purpose is to make readers understand WHY professionals matter

4-step progression:
1. Teach basic concepts (readers understand)
2. Show complexity (readers realize it's hard)
3. Reveal professional value (readers get why expert needed)
4. Make it emotionally resonate (time saved + money saved + stress gone)

Key message for blog writers:
 'Basic concepts anyone can learn'
 'But complexity grows → professionals save time/money/stress'
 'Tax accountant cost < tax savings + time savings + stress reduction'

Example calculation:
- Tax savings: +200만원
- Professional cost: -100만원
- Time value: 월 9시간 자유
- Stress: 무조건 감소
- Net benefit: +100만원 + 심리적 안정

Goal: Readers conclude: '돈을 쓰는 이유가 있네. 세무사를 고용하자.'
2026-07-01 16:46:33 +09:00
kjh2064 7b27f748de refactor: simplify CLAUDE.md + create BLOG_TEMPLATE.md
TaxBaik CI/CD / build-and-deploy (push) Failing after 40s
- Reduce CLAUDE.md blog section to essential guidelines only (10 lines)
- Move detailed templates and checklists to new BLOG_TEMPLATE.md
- Update core philosophy: education + natural professional referral
- Not 'hire me' but 'understand concepts → know when to consult'

BLOG_TEMPLATE.md includes:
 Complete 5-step blog post template
 Real persona examples (name, age, job, income)
 Before/After case structure
 Step-by-step calculation with tables
 Tone guidance (empowerment + professional value)
 Checklist for writers
 Do's & Don'ts
 Seasonal content ideas

Philosophy:
- Basic tax knowledge: anyone can do it
- Complex cases: professionals add efficiency
- Goal: grow customer understanding + increase professional service value
2026-07-01 16:45:47 +09:00
kjh2064 abad1630b6 feat: add EasyMDE markdown editor for blog creation/editing
TaxBaik CI/CD / build-and-deploy (push) Failing after 41s
- Add EasyMDE 2.18.0 CDN to App.razor
- Add Marked.js for markdown preview rendering
- Replace MudTextField with EasyMDE editor in BlogCreate.razor
- Replace MudTextField with EasyMDE editor in BlogEdit.razor
- Add JavaScript interop for editor initialization and content sync
- Support markdown syntax highlighting and formatting toolbar

Features:
 Bold, italic, strikethrough
 Headings (H1-H6)
 Code blocks and inline code
 Lists (ordered/unordered)
 Links and images
 Tables
 Quotes
 Horizontal rules
 Real-time preview (side-by-side mode)
 Full-screen editing
 Markdown guide

The editor syncs content with Blazor form on save.
Markdown syntax is preserved in database and rendered as HTML on blog pages.
2026-07-01 16:37:30 +09:00
kjh2064 6ffff70ece feat: add Markdig markdown rendering for blog posts
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m47s
- Add Markdig NuGet package (0.38.0)
- Convert blog content from markdown to HTML in Post.cshtml.cs
- Display rendered HTML content instead of raw text
- Add comprehensive markdown styling (h1-h6, lists, tables, code, etc.)
- Use TaxBaik color scheme for markdown elements

Blog posts now render properly:
 Headings (#, ##, ###)
 Bold/italic text (**text**, *text*)
 Lists (-, *, ordered)
 Tables
 Code blocks
 Blockquotes
 Links

Styling follows TaxBaik brand:
- Primary color for headings
- Warm typography (Noto Sans KR)
- Consistent spacing and borders
- Mobile-responsive design
2026-07-01 16:34:09 +09:00
kjh2064 ed8ac34542 fix: replace V018 with V019 (fixed SQL quote escaping)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m46s
V018 had PostgreSQL quote escaping issues with long content strings.
V019 uses 1256 quoting to avoid escaping problems and cleanly inserts
all 12 blog posts (5 updates + 7 new) with middle-school level language.

Deletes V018, commits V019 as replacement.
2026-07-01 16:31:40 +09:00
kjh2064 6b14ce929e refactor: move blog posts to migration system (V018)
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m45s
- Convert db/blog_posts_update.sql → db/migrations/V018__UpdateBlogPostsWithCases.sql
- Enable automatic migration on app startup
- 12 blog posts (5 updated + 7 new) will auto-apply when deployed
- Covers real customer cases: accounting, tax savings, seasonal guidance
2026-07-01 16:29:12 +09:00
kjh2064 e830c08263 feat: add comprehensive blog posts + content guidelines
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m48s
- Add 7 new blog posts (total 12): practical tax cases
- Update 5 existing posts with real examples and calculations
- Add 'Blog Content Writing Guidelines' to CLAUDE.md
  * 5-step structure, checklist, evaluation criteria (5-star system)
  * Seasonal content prioritization by tax calendar
  * Middle-school level language standard
- All posts include concrete examples, tax savings calculations
- Focus on customer pain points + practical solutions
- Blog posts: accounting mistakes, capital gains tax, freelancer income,
  VAT reporting, gift tax, business registration, VAT schedule

Posts cover real scenarios:
1. 사업자 기장 실수 5가지 (business accounting mistakes)
2. 부동산 양도세 (capital gains tax calculation)
3. 프리랜서 종합소득세 (freelancer income tax)
4. 부가가치세 간이/일반과세 (VAT simplified vs general)
5. 증여세 절세 (gift tax savings)
6. 스마트스토어 기장 (smartstore accounting)
7. 프리랜서 놓친 경비 (forgotten freelancer expenses)
8. 월세 신고 (rental income reporting)
9. 자녀 증여세 (child gift tax)
10. 사업자 등록 타이밍 (business registration timing)
11. 소상공인 간단 기장 (simple accounting for SMB)
12. 부가세 신고 (VAT monthly reporting)

All content follows: real case → concrete calculation → tax savings
tips → "꼭 기억하세요!" summary

Generated SQL: db/blog_posts_update.sql
2026-07-01 16:26:18 +09:00
kjh2064 a1065e8233 ux: improve site-wide navigation with breadcrumbs and footer sitemap
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m54s
- Add breadcrumb navigation to About and Services pages
- Add back-to-home buttons on all secondary pages
- Enhance footer with full site menu (Home, About, Services, Blog, Contact)
- Add related-pages section at bottom of Services page
- Improve visual hierarchy and page interconnection

Makes it easy for users to navigate between all major sections and always
know how to return to home or explore related pages.
2026-07-01 16:10:15 +09:00
kjh2064 7cdb0bf8e9 ux: improve about page navigation with breadcrumb and back button
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m25s
Add breadcrumb navigation and back-to-home button to make About page
navigation clear and user-friendly. Also add related-pages section at
bottom linking to Home, Services, and Blog.

Addresses: users getting lost on About page with no clear way back.
2026-07-01 16:02:37 +09:00
kjh2064 8bea85df96 refactor: redesign homepage for clarity and SEO focus
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m48s
Replace bloated multi-section layout with streamlined structure:
- Remove online-trust, About, customer-type sections from homepage
- Restore 3-service-group simplicity (business-tax, real-estate-tax, family-asset)
- Elevate blog section for SEO priority (post homepage hero)
- Move full About content to dedicated /about page (linked from hero banner)
- Replace customer-type segmentation with blog category tagging

Improves mobile readability, reduces scrolling fatigue, and aligns homepage
to core business goals (blog SEO + service clarity). About page now hosts
the full story with expertise details.
2026-07-01 15:55:19 +09:00
kjh2064 127490906b feat: enrich homepage with online trust bar, about narrative, and customer segments
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m45s
Adds an online-consulting trust strip below the hero, replaces the plain
credential row with a personal bio + expertise section, expands the
service cards from 3 broad categories to 5 specific offerings plus a
consult CTA card, and adds a customer-segment section so visitors can
self-identify their situation. Layout follows existing Bootstrap
responsive grid conventions used elsewhere on the page.
2026-07-01 15:38:57 +09:00
kjh2064 ada05e254d fix: add missing public route redirects
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m38s
2026-07-01 14:55:18 +09:00
kjh2064 7602f5be59 fix: use deploy package for ci blue-green settings
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m42s
2026-07-01 14:35:24 +09:00
kjh2064 777cdcd918 fix: restore admin login submit handler
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m40s
2026-07-01 14:31:10 +09:00
kjh2064 0f6ba33af3 fix: stabilize admin login and ci versioning
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m40s
2026-07-01 14:24:59 +09:00
kjh2064 6d263c20bf fix: admin login overlay and version footer
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m53s
2026-07-01 13:53:42 +09:00
kjh2064 c9bf4f4f6f fix: render admin login as static form
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m50s
2026-07-01 13:43:57 +09:00
kjh2064 b12d2ae0c6 fix: bind admin login via static js
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m47s
2026-07-01 13:38:43 +09:00
kjh2064 f9cbafdb3d chore: remove version txt fallback
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m29s
2026-07-01 13:30:46 +09:00
kjh2064 64de7d2304 fix: write both version files for deployment
TaxBaik CI/CD / build-and-deploy (push) Failing after 3m19s
2026-07-01 13:28:48 +09:00
kjh2064 1f628b49a8 fix: admin login submit without blazor hydration
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m45s
2026-07-01 13:17:38 +09:00
kjh2064 a4a2499c7d fix: pass ci flag to remote deploy
TaxBaik CI/CD / build-and-deploy (push) Failing after 3m10s
2026-07-01 13:14:06 +09:00
kjh2064 6b11b64135 fix: admin login interactivity and proxy publish
TaxBaik CI/CD / build-and-deploy (push) Failing after 3m15s
2026-07-01 13:09:42 +09:00
kjh2064 a60451b95f fix: favicon and ci deployment checks
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m37s
2026-07-01 12:58:21 +09:00
kjh2064 2a046d0393 feat(admin): restore Blazor WebAssembly architecture for admin pages with hybrid Server routing
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m40s
2026-07-01 11:21:10 +09:00
kjh2064 62ce89359a Merge branch 'master' of http://gitea.taxbaik.com/kjh2064/taxbaik
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m11s
2026-07-01 11:04:29 +09:00
kjh2064 32c5a3d042 fix(admin): MudBlazor duplicate popover warning exception disable 2026-07-01 11:04:08 +09:00
kjh2064 68291867f9 fix(nginx): add redirect from /admin to /taxbaik/admin for Blazor base path alignment 2026-07-01 10:56:23 +09:00
kjh2064 d24f3f58db feat(admin): 공지사항, FAQ, 블로그 목록 검색 필터 추가 및 블로그 미리보기 탭 탑재, FAQ 순서 조정 기능 구현 2026-07-01 10:53:55 +09:00
kjh2064 71cd2c1129 Merge pull request '[infra] 서버 도메인 설정 변경 및 SSL(HTTPS) 적용' (#12) from codex/taxbaik-wasm-theme into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m52s
Reviewed-on: #12
2026-07-01 10:45:52 +09:00
kjh2064 24ecf89028 docs: 도메인 기반 가상 호스트 및 HTTPS 적용에 따른 지침 최신화 2026-07-01 10:40:28 +09:00
kjh2064 ff6651c4f2 feat(nginx): 도메인 기반 가상 호스트 및 SSL 설정 파일 추가 2026-07-01 10:40:00 +09:00
kjh2064 f892b85b7e fix: relocate MudPopoverProvider and dialog/snackbar providers to MainLayout to enable interactive Blazor circuit operations
TaxBaik CI/CD / build-and-deploy (push) Successful in 52s
2026-06-30 23:02:27 +09:00
kjh2064 62a7b2f2ef test: restore input element target clicking for select combos in E2E tests
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m16s
2026-06-30 22:56:17 +09:00
kjh2064 184ff2259b design: compact admin topbar to high density desktop ERP layout
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-30 22:55:15 +09:00
kjh2064 163812e964 feat: implement Enter key autofocus keyboard navigation and robust E2E selector clicking
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-30 22:51:58 +09:00
kjh2064 ba158f9824 fix: change FullWidth string literals to boolean expressions on MudBlazor inputs to resolve circuit cast exceptions
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-06-30 22:47:54 +09:00
kjh2064 b2477d977b feat: implement ERP-style split pane master-detail layout for tax profiles, schedules, and contracts backoffice pages
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m31s
2026-06-30 22:44:32 +09:00
kjh2064 80c97fba96 test: adjust minimum font size threshold to 10px in responsive tests to align with ERP density
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-06-30 22:43:17 +09:00
kjh2064 1fb3a3c329 test: align fallback base URL in responsive E2E tests with other test suites
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-30 22:38:53 +09:00
kjh2064 abd7bbf016 style: revert aggressive wildcard overrides in CSS and restore stable Blazor theme configuration
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-30 22:34:34 +09:00
kjh2064 c765db37b3 style: implement complete Douzone ERP style overhaul for high-density desktop backoffice UI
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m58s
2026-06-30 22:24:57 +09:00
kjh2064 967a784d6e feat: implement database-driven Common Code system for admin comboboxes
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m48s
2026-06-30 22:24:04 +09:00
kjh2064 03809bbf26 test: make combobox dropdown choices E2E tests robust against Blazor rendering lag
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m24s
2026-06-30 22:21:24 +09:00
kjh2064 c626c164f8 style: reduce typography and spacing design tokens for higher layout density in admin panel
TaxBaik CI/CD / build-and-deploy (push) Successful in 52s
2026-06-30 22:19:16 +09:00
kjh2064 15f5dcf4ea docs: update CLAUDE.md guidelines for TCP proxy Green-Blue deployment
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m19s
2026-06-30 22:13:25 +09:00
kjh2064 a84f842490 feat: implement zero-downtime Green/Blue deployment using local TCP proxy
TaxBaik CI/CD / build-and-deploy (push) Successful in 51s
2026-06-30 22:11:09 +09:00
kjh2064 8999e51d4e style: refactor dashboard metrics cards to use admin.css design system
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m53s
2026-06-30 22:01:12 +09:00
kjh2064 f98405b791 feat: revamp UI/UX of homepage & portal, clean warnings, and update ROADMAP_WBS
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-30 21:59:22 +09:00
kjh2064 ee964457d9 Merge pull request 'revert: rollback Fluent UI and Blazor homepage to last successful state (3be3794)' (#11) from refactor/rollback-fluent-ui into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/11
2026-06-30 20:33:06 +09:00
kjh2064 54c179b1eb revert: rollback Fluent UI and Blazor homepage to last successful state (3be3794) 2026-06-30 20:29:42 +09:00
kjh2064 488b8d11b7 Merge pull request '[codex] 홈페이지 테마 개편 및 Blazor WebAssembly 클라이언트 추가' (#10) from codex/taxbaik-wasm-theme into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m38s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/10
2026-06-30 18:29:20 +09:00
kjh2064 65c5f19a2f feat: Blazor WebAssembly 클라이언트 추가 2026-06-30 18:27:45 +09:00
kjh2064 eaacbc8d7f Merge pull request '[codex] 스크롤 흐름 복원' (#9) from codex/scroll-unlock into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/9
2026-06-30 18:20:38 +09:00
kjh2064 ac8a70a2ca 스크롤 흐름 복원 2026-06-30 00:21:23 +09:00
kjh2064 203e674c3f 스크롤 잠금 해제
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m11s
2026-06-30 00:15:24 +09:00
kjh2064 0c014d0bdf 홈 화면 프리렌더 복구
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m0s
2026-06-30 00:11:34 +09:00
kjh2064 904c0972ca 공개 홈 Razor Pages 프리렌더 수정
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
2026-06-30 00:06:49 +09:00
kjh2064 7e75aeeec7 공개 홈 Razor Pages 렌더 모드 정리 2026-06-30 00:06:49 +09:00
kjh2064 b13eed7b7e 홈과 관리자 로그인 화면 테마 및 제목 정리 2026-06-30 00:06:49 +09:00
kjh2064 4647b049b8 지침의 레거시 정책과 우선순위 정리 2026-06-30 00:06:49 +09:00
kjh2064 1a5ebb45bc 지침의 MudDataGrid와 MudDialog 예시 정리 2026-06-30 00:06:49 +09:00
kjh2064 f197663101 MudDataGrid와 MudDialog 폐기 기준 명시 2026-06-30 00:06:49 +09:00
kjh2064 70b57f1d4c Merge pull request 'Fluent UI v5 기준 Blazor 하네스 및 라우팅 정리' (#8) from refactor/fluentui-v5-harness into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m6s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/8
2026-06-29 23:32:02 +09:00
kjh2064 428eeb6fd8 관리자 CSS 레거시 추가 정리 2026-06-29 23:25:12 +09:00
kjh2064 dd68a237a1 Blazor 호스팅을 Fluent UI v5 단일 엔트리로 통합 2026-06-29 23:13:48 +09:00
kjh2064 ef9fd523c6 관리자 및 사이트 UI 토큰 정리 2026-06-29 23:13:47 +09:00
kjh2064 f2ab78dea2 수익 추적 조회 API 복원 2026-06-29 23:13:46 +09:00
kjh2064 1e0c0b7e1c refactor: 홈 라우팅 충돌 해결 및 임시 구현 정리
TaxBaik CI/CD / build-and-deploy (push) Failing after 53s
2026-06-29 22:49:12 +09:00
kjh2064 1b173376ee refactor: admin ui를 fluent v5와 html 기반으로 전환
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m53s
2026-06-29 22:37:40 +09:00
kjh2064 1a7bc9e209 docs: fluent v5와 skeleton 기준 반영 2026-06-29 22:37:39 +09:00
kjh2064 3be379431f lite blazor 데이터 갱신 정리
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 18:18:04 +09:00
kjh2064 682e2db3a3 fix: CRM 다이얼로그의 ClientId 바인딩을 Nullable int? 로 변경하고 CompanyName null 대비 Fallback 이름을 Name으로 매핑하여 MudSelect 초기 렌더링 Circuit 크래시 원천 차단
TaxBaik CI/CD / build-and-deploy (push) Failing after 37s
2026-06-29 17:14:07 +09:00
kjh2064 d9766cb5ef fix: E2E 내비게이션 시 Blazor Dynamic Spinner 감지 및 MudDialog 고유 식별자 기반 native click 연동을 적용하여 비동기 클릭 유실 원천 차단
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 17:03:32 +09:00
kjh2064 6bcb9effa8 fix: E2E 콤보박스 검증 테스트가 mud-popover-open 및 getByLabel을 사용하여 안정적(Robust)으로 동작하도록 전면 리팩토링하여 CI 실패 해결
TaxBaik CI/CD / build-and-deploy (push) Successful in 58s
2026-06-29 16:30:31 +09:00
kjh2064 186c6ef7a4 fix: 텔레그램 알림 예외에서 브라우저 강제 종료(JSDisconnectedException, TaskCanceledException) 필터링 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m18s
2026-06-29 16:20:10 +09:00
kjh2064 c2e8e08f09 test: E2E 테스트에 세무 프로필, 신고 일정, 계약 관리의 콤보 데이터 목록(Dropdown choices) 노출 검증 케이스 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-06-29 16:18:17 +09:00
kjh2064 3f7cd7cd84 fix: 기존 모든 목록 페이지들의 데이터 로드 생명주기를 OnAfterRenderAsync로 수정하여 Prerendering 401 오류 및 CRUD 마비 현상 완벽 해결
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-06-29 16:15:42 +09:00
kjh2064 4b352df408 fix: 기존 모든 브라우저 클라이언트의 TokenRefreshHandler 의존성 제거 및 수동 토큰 직접 주입 패턴 일괄 일치화 적용 (콤보 데이터 유실 문제 완벽 해결)
TaxBaik CI/CD / build-and-deploy (push) Successful in 52s
2026-06-29 16:07:23 +09:00
kjh2064 a4b1234900 fix: CRM 페이지 다이얼로그의 콤보박스 기본 고객 바인딩 수정 및 폼 유효성 검사(Validation) 보강
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m23s
2026-06-29 16:00:42 +09:00
kjh2064 a3c81c4f70 fix: TaxFilingBrowserClient의 이중 api/prefix 조립 문제 해결 (BaseUrl에 이미 포함되어 있으므로 상대경로에서 걷어냄)
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:51:09 +09:00
kjh2064 6e8b4e76ac fix: TaxFilingBrowserClient의 API 라우트 경로 오타 및 prefix 누락 오류 수정 (tax-filing -> api/taxfiling)
TaxBaik CI/CD / build-and-deploy (push) Successful in 57s
2026-06-29 15:47:07 +09:00
kjh2064 5807e1b35e fix: HttpClientFactory 생명주기 불일치(Scope Capture) 문제를 회피하기 위해 CRM API 클라이언트에 직접 토큰 주입하도록 전면 개편
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
2026-06-29 15:43:15 +09:00
kjh2064 3e1097f585 fix: DelegatingHandler와 TokenStore의 생명주기 불일치(Scope Capture) 문제 해결을 위한 IServiceProvider 동적 해석(Resolve) 적용
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:39:07 +09:00
kjh2064 917600a793 fix: 인증 로컬스토리지 복구 흐름에서 TokenStore 적재가 보장되지 않은 상태로 인증 통과 처리되는 보안 누수 현상 수정 (401 오류 원천 차단)
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:35:32 +09:00
kjh2064 0d3615b44d fix: Blazor 인증 공급자의 비동기 로딩 지연에 의한 API 호출 레이스 컨디션 해결 (CascadingParameter Task<AuthenticationState> 대기 추가)
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s
2026-06-29 15:30:14 +09:00
kjh2064 fc339ca9e7 fix: Blazor Server Prerendering 시점의 401 에러 방지를 위해 CRM 화면 API 로드 수명 주기를 OnAfterRenderAsync로 일괄 개선
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:27:03 +09:00
kjh2064 da1226994f fix: E2E 테스트 시 Blazor 인증 상태 복원을 위한 로컬스토리지 토큰 세트(accessToken, refreshToken, tokenExpiry) 주입 보강
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m18s
2026-06-29 15:23:21 +09:00
kjh2064 6bc03ce3d9 fix: CI E2E 테스트용 로컬스토리지 인증 토큰 키 불일치 수정 (auth_token -> accessToken)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m28s
2026-06-29 15:20:36 +09:00
kjh2064 ecfbfc7cac feat: 검색엔진 노출 강화를 위한 SEO 설정(sitemap.xml, JSON-LD 구조화 데이터, 메타 태그) 추가 및 개선
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-06-29 15:18:44 +09:00
kjh2064 46cb508bdf fix: Contract, TaxProfile, TaxFilingSchedule에 대해 선제적으로 GetAllAsync API 및 구현체 추가
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-29 15:16:08 +09:00
kjh2064 ecabe8d9cc fix: ConsultingActivity 전체 조회 API 및 리포지토리/서비스 구현체 구현
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-06-29 15:12:23 +09:00
kjh2064 55c65810c1 fix: RevenueTracking 전체 조회 API 및 리포지토리/서비스 구현체 구현
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m3s
2026-06-29 15:09:21 +09:00
kjh2064 7054d397e4 fix: AdminDashboardController의 라우트 매핑 오류 수정 (api/admin-dashboard)
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m2s
2026-06-29 15:05:59 +09:00
kjh2064 11fb596fc2 Merge branch 'feature/telegram-logging'
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-29 15:02:54 +09:00
kjh2064 a04592499c fix: 블로그 작성/수정 시 카테고리 MudSelect 타입 캐스팅 오류 수정 2026-06-29 14:52:09 +09:00
kjh2064 ea9478f2f1 Merge pull request 'feat: Serilog 기반 실시간 텔레그램 에러 알림 연동' (#6) from feature/telegram-logging into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/6
2026-06-29 11:41:38 +09:00
kjh2064 f569211967 feat: Serilog 기반 실시간 텔레그램 에러 알림 연동 2026-06-29 11:35:27 +09:00
kjh2064 c8306e2ac7 Merge pull request 'docs: ROADMAP_WBS.md 내 텔레그램 및 고객 포털 태스크 완료 상태 체크 업데이트' (#5) from docs/roadmap-update into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m26s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/5
2026-06-29 00:08:07 +09:00
kjh2064 bad2f47ffe Merge pull request 'feat: 고객 포털 세무 신고 및 상담 요약 실시간 대시보드 화면 고도화 및 어드민 UX 리사이징 보완' (#4) from feature/client-portal into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m31s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/4
2026-06-29 00:07:57 +09:00
kjh2064 943fe9c819 Merge pull request 'feat: TelegramNotificationService 내에 SendReportAsync 추가 및 백그라운드 리포팅 로직 개선' (#3) from feature/telegram-reports into master
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m20s
Reviewed-on: http://178.104.200.7/kjh2064/taxbaik/pulls/3
2026-06-29 00:07:47 +09:00
kjh2064 7b819f4ab0 docs: ROADMAP_WBS.md 내 텔레그램 및 고객 포털 태스크 완료 상태 체크 업데이트 2026-06-29 00:05:52 +09:00
kjh2064 3c8f30af6d feat: TelegramNotificationService 내에 SendReportAsync 추가 및 백그라운드 리포팅 로직 개선 2026-06-29 00:05:14 +09:00
357 changed files with 10868 additions and 6270 deletions
+20 -2
View File
@@ -49,12 +49,13 @@ jobs:
# Suppress stderr and allow failures to handle transition/down periods cleanly
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.json" 2>/dev/null || true)"
BLOG_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/blog/accountant-mistakes-5" || true)"
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ]; then
LOGIN_STATUS="$(curl -s -o /dev/null -w '%{http_code}' "http://${DEPLOY_HOST}/taxbaik/admin/login" || true)"
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
exit 0
fi
if [ $i -lt 20 ]; then
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, login=${LOGIN_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
sleep 3
fi
done
@@ -72,6 +73,23 @@ jobs:
echo "Running E2E tests on Desktop Chrome (production verification)"
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
- name: API smoke verification
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
E2E_ADMIN_USERNAME: test_admin
E2E_ADMIN_PASSWORD: TestAdmin@123456
run: |
set -e
TOKEN="$(curl -s -X POST "http://${DEPLOY_HOST}/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"])')"
test -n "$TOKEN"
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/faq" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/announcement" >/dev/null
curl -fsS -H "Authorization: Bearer $TOKEN" "http://${DEPLOY_HOST}/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.svg" >/dev/null
curl -fsS "http://${DEPLOY_HOST}/taxbaik/favicon.ico" >/dev/null
curl -fsS "http://${DEPLOY_HOST}/taxbaik/robots.txt" >/dev/null
- name: Browser E2E summary
if: always()
run: |
+25 -9
View File
@@ -1,7 +1,6 @@
name: TaxBaik CI/CD
on:
workflow_dispatch:
push:
branches:
- master
@@ -33,6 +32,9 @@ jobs:
- name: Publish Web
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore
- name: Publish Proxy
run: dotnet publish TaxBaik.Proxy/ -c Release -o ./publish/proxy
- name: Write production secrets
run: |
set -e
@@ -67,6 +69,11 @@ jobs:
)'
test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; }
- name: Verify proxy artifact
run: |
test -s ./publish/proxy/TaxBaik.Proxy.dll || { echo "TaxBaik.Proxy.dll missing" >&2; exit 1; }
test -s ./publish/proxy/TaxBaik.Proxy.runtimeconfig.json || { echo "TaxBaik.Proxy.runtimeconfig.json missing" >&2; exit 1; }
- name: Copy migrations
run: cp -r db/migrations ./publish/migrations || true
@@ -100,12 +107,14 @@ jobs:
- name: Package artifact
run: |
cp deploy_gb.sh ./publish/deploy_gb.sh
tar -czf taxbaik_deploy.tgz -C ./publish .
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
- name: Deploy & verify on server
run: |
set -e
export TAXBAIK_DEPLOY_FROM_CI=1
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
COMMIT=$(git rev-parse --short HEAD)
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
@@ -148,7 +157,7 @@ jobs:
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
-o ServerAliveInterval=10 \
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
"$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE
set -e
DEPLOY_HOME="/home/kjh2064"
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
@@ -162,12 +171,12 @@ jobs:
echo "--- [2/5] 운영 설정 검증 ---"
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
test -s "\$DEPLOY_DIR/proxy/TaxBaik.Proxy.dll" \
|| { echo "FATAL: TaxBaik.Proxy.dll 없음" >&2; exit 1; }
echo "--- [3/5] 심볼릭 링크 전환 ---"
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
echo "--- [4/5] 서비스 재시작 ---"
sudo /usr/bin/systemctl restart taxbaik
echo "--- [3/4] Green-Blue 배포 실행 ---"
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
ATTEMPTS=20
@@ -191,13 +200,20 @@ jobs:
fi
echo "✓ [3/4] 버전 정보 확인 완료"
# 검증 3: 관리자 로그인 페이지
# 검증 4: 5001 프록시 확인
if ! ss -tlnp | grep -q ':5001 '; then
echo "❌ 5001 프록시가 실행 중이 아님" >&2
exit 1
fi
echo "✓ [4/5] 5001 프록시 확인 완료"
# 검증 5: 관리자 로그인 페이지
LOGIN_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000")
if [ "\$LOGIN_STATUS" != "200" ]; then
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
exit 1
fi
echo "✓ [4/4] 관리자 페이지 로드 완료"
echo "✓ [5/5] 관리자 페이지 로드 완료"
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
# 구 배포 디렉토리 정리 (최근 5개 보존)
+416
View File
@@ -0,0 +1,416 @@
# 블로그 포스트 작성 템플릿
## 🎯 핵심 철학
**블로그의 진정한 목적 - 3층 구조**:
### 1층: 기초 교육 (누구나 배울 수 있음)
"이 정도는 자신이 할 수 있어요"
- 기본 개념 설명
- 단계별 방법론
### 2층: 디테일 & 세법 변화 (추적 불가능)
**"하지만 악마는 디테일... 그리고 세법은 계속 바뀌어요"**
- 겹겹이 쌓인 디테일들
- 매년 변경되는 세법
- 고객이 추적 불가능한 영역
### 3층: 세무사의 가치 (전문가만 가능)
**"그래서 전문가가 필요합니다"**
- 디테일 관리
- 세법 변화 자동 추적
- 리스크 관리
- 시간/돈/스트레스 절약
---
**최종 메시지**:
```
기초는 배울 수 있어요.
하지만:
- 디테일이 지옥이고 (50만원 실수 가능)
- 세법은 계속 바뀌고 (매년 업데이트 필요)
- 변화를 추적하기는 불가능해요 (본업이 있으니까)
그래서 세무사가 있으면:
- 디테일은 자동 관리
- 세법 변화도 자동 적용
- 새 제도도 놓치지 않음
- 당신은 사업에만 집중
이래서 세무사 비용이 아깝지 않은 거죠.
```
---
## 📝 템플릿 (복사해서 사용)
### 📌 Step 1: 도입부 (공감)
```markdown
# [제목]
"[구체적 상황]?"
"많은 [직업]들이 이 상황을 겪습니다."
→ 독자가 자신의 상황을 발견하도록
```
**예시**:
```markdown
# 동네 카페 월세 낼 때 세금이 안 나와요 - 정말 그럴까?
"사업을 시작했는데 세금을 낸 적이 없어요"
"많은 소규모 사업자들이 이렇게 생각합니다."
```
---
### 👤 Step 2: 실제 사례 (구체적 페르소나)
**필수 정보**:
- 이름, 나이, 직업, 사업 경력
- 월/연간 매출 (현실적 수치)
- 실제 겪은 문제/성공 사례
```markdown
### 상황: [지역] [카테고리]를 운영하는 [이름]님 ([나이]세, 사업 [년]차)
**기본 정보**:
- 위치: [구체적 위치]
- 월 매출: [금액]
- 월 경비: [주요 항목들]
### 원래는 이렇게 했어요 (실패 사례)
→ [실제 실수 1]
→ [실제 실수 2]
**결과**: 세금을 [X만 원] 더 내게 됨 (또는 세무조사 대상)
### 바뀐 후 (성공 사례)
→ [해결책 1]
→ [해결책 2]
**결과**: 세금을 [X만 원] 절약함 (또는 안정적인 운영)
```
**예시**:
```markdown
### 상황: 강남 역삼동에서 카페를 운영하는 김민수님 (34세, 사업 3년차)
**기본 정보**:
- 위치: 강남역 3번 출구 근처
- 월 매출: 약 600만 원 (평일 200만, 주말 400만)
- 월 경비: 월세 150만, 재료비 180만, 직원급여 100만 원
### 원래는 이렇게 했어요
→ "세금은 큰 회사나 내는 거라고 생각했어요"
→ 영수증도 대충 정리하고
**결과**: 세무청에서 3년치를 추징받고 가산세까지...손해 70만 원
### 바뀐 후
→ 매달 영수증을 정리해서
→ 세무사와 년 1회 기장 상담
**결과**: 세금도 명확하고, 추징도 없음. 심플하고 안전
```
---
### 📊 Step 3: 계산 & 설명
**구조**:
1. **기본 정보 확인** (위에서 제시한 사례 요약)
2. **단계별 계산** (Step 1, Step 2, ... 명확히)
3. **표로 시각화**
```markdown
## 계산 방법
### Step 1️⃣: 매출 정리
월 600만 원 × 12개월 = 연 7,200만 원
### Step 2️⃣: 경비 계산
| 항목 | 월 | 연간 |
|------|-----|------|
| 월세 | 150만 | 1,800만 |
| 재료비 | 180만 | 2,160만 |
| 직원급여 | 100만 | 1,200만 |
| 기타 | 20만 | 240만 |
| **합계** | **450만** | **5,400만** |
### Step 3️⃣: 순이익
7,200만 - 5,400만 = **1,800만 원**
### Step 4️⃣: 세금
1,800만 원 × 약 6% = **약 108만 원/년**
```
---
### 🎭 Step 3.5: 악마는 디테일이다 (선택사항이지만 강력함)
**구조**: "간단해 보이지만, 실제로는..."
```markdown
## 겉으로는 간단해 보여요... 하지만
### 📄 "영수증을 정리하세요"라고 했는데
**겉으로는**:
→ 영수증을 모으기만 하면 돼
**현실의 디테일**:
→ 이 영수증은 인정되고, 이건 안 됨 (세법)
→ 이건 개인비? 사업비? (판단)
→ 카드값이랑 현금값이랑 다르면? (대사)
→ 3년 지났는데 영수증을 못 찾으면? (소송)
→ 세무청이 불인정하면? (항의 절차)
**세무사가 처리하는 것**:
✅ 어떤 영수증이 인정될지 사전에 판단
✅ 개인비와 사업비의 경계 명확히
✅ 세법 변경사항 적용
✅ 세무청 부인시 대응 준비
---
### 📊 "매출과 경비를 기록하세요"라고 했는데
**겉으로는**:
→ 엑셀에 숫자만 입력하면 돼
**현실의 디테일**:
→ 카드 명세서와 입금액이 안 맞음 (환불? 수수료?)
→ 한 달간 매출을 빼먹음 (추가 계산)
→ 같은 카테고리인데 세법상 다르게 분류돼야 함 (부가세/소득세 다름)
→ 작년에 잘못 입력한 게 발견됨 (수정신고)
→ 월별로 편차가 커서 세무청이 의심함 (설명 준비)
**세무사가 처리하는 것**:
✅ 카드명세서 vs 입금액 정산
✅ 누락된 부분 찾아서 추가
✅ 세법상 올바른 분류
✅ 이전년도 오류 수정신고
✅ 세무청 질의에 대한 근거 제시
---
### ✅ "정확하게 기장하면 세금이 확정돼요"라고 했는데
**겉으로는**:
→ 기장만 잘하면 세금 끝
**현실의 디테일**:
→ 같은 사업도 절세 방법이 다양함 (어떤 게 맞나?)
→ 올해는 이렇게, 내년은 저렇게? (일관성)
→ 부가세와 소득세 둘 다 고려해야 함 (연계 계산)
→ 세무조사가 오면 3년치를 모두 봄 (소급 처리)
→ 이의신청/항소하려면? (법적 절차)
**세무사가 처리하는 것**:
✅ 최적의 절세 전략 제시
✅ 연도별 일관된 기장 방식 유지
✅ 부가세/소득세 동시 최적화
✅ 세무조사 대비 사전 정리
✅ 이의신청/항소 등 법적 대응
```
**💡 핵심**:
- 기초는 누구나 배울 수 있어요
- **하지만 디테일을 모두 처리하려면?**
- **그 디테일들이 바로 세무사가 하는 일**
- **디테일 하나 놓쳤다가 가산세 50만 원... 이래서 세무사 비용이 아깝지 않음**
---
### 🔄 Step 3.6: 세법은 계속 바뀐다 (매년 업데이트)
**구조**: "올해의 세법 변화"를 포스트 작성 시점에 맞춰 갱신
```markdown
## 그런데 세법은 해마다 바뀝니다
### 📋 [연도] 변경사항들 (꼭 알아야 할 것들)
**✅ 2025년 부가세 변화**:
- 신고 기한이 [날짜]로 변경됨
- 영세사업자 기준이 [금액]로 상향조정됨
- 새로운 공제 항목이 추가됨: [항목들]
**✅ 2025년 소득세 변화**:
- 기본공제가 [금액]에서 [금액]로 증가
- 자녀 공제 조건이 변경됨
- 월급 원천징수 기준이 조정됨
**✅ 2025년 새로운 제도**:
- 소상공인 세금 감면 확대
- 청년사업자 지원 강화
- 부가가치세 간편신청 범위 확대
---
**혼자서 할 때의 문제**:
❌ "작년 기준으로 기장했는데 올해 기준이 바뀐 거야?"
❌ "이 공제가 되는 건지 안 되는 건지 모르겠어"
❌ "새로운 제도가 나왔다는 것도 몰랐어"
❌ "처음 다시 계산해야 하나?"
**세무사가 처리하는 것**:
✅ 매년 변경사항 자동 추적
✅ 당신의 상황에 맞는 새로운 공제 적용
✅ 이전년도 재계산 필요시 수정신고
✅ 연중 세법 개정 소식 안내
✅ 새로운 지원 정책 놓치지 않게 관리
---
## 왜 세무사를 고용해야 하나요?
### 📈 "혼자하기" vs "세무사와 함께"
| 항목 | 혼자할 때 | 세무사와 함께 |
|------|----------|-----------|
| **세법 추적** | 부분적 (인터넷 검색) | 자동 (전문가 업데이트) |
| **새 제도 활용** | 놓칠 확률 높음 | 100% 적용 |
| **변경사항 대응** | 재계산 필요 | 자동 반영 |
| **신뢰도** | 불안감 | 확신 |
| **업데이트 비용** | 당신의 시간 | 포함됨 |
**세법이 계속 바뀐다는 것 자체가 세무사가 필요한 이유**
```
**💡 강조점**:
- 세법은 **정적이지 않음** (계속 변함)
- 고객은 **변화를 추적할 수 없음** (본업이 있으니까)
- 세무사는 **자동으로 최신 기준 적용** (전문가니까)
- 결과: **"세무사 한 명이면 내가 평생 세법 공부 안 해도 돼"**
---
### 💡 Step 4: 실무 팁 (3~5개)
**구조**: ✅ 이렇게 하세요 / ❌ 이렇게 하면 안 돼요
```markdown
## 이렇게 하면 세금이 명확해요
### ✅ 해야 할 것
1. **영수증 정리** - 매달 봉투에 모아두기
2. **기본 기록** - 엑셀에 간단히 기입
3. **연 1회 점검** - 세무사와 기본 상담
4. **투명성** - 세무청 신고는 정확하게
### ❌ 하면 안 되는 것
1. **영수증 버리기** - 나중에 증거 없음
2. **개인비와 섞기** - 기장 혼란
3. **신고 늦추기** - 가산세 발생
4. **과하게 깎기** - 세무조사 리스크
```
---
### 🎓 Step 5: 결론 (임파워먼트 + 세무사 자연스러운 유도)
**구조**:
1. "기초 수준이면 이 정도면 충분"
2. "하지만 이렇게 복잡해지면 전문가 도움이 효율적"
3. "세무사와 함께 하는 게 장기적으로 낫다"
```markdown
## 기초는 누구나 할 수 있어요
**이 정도면 자신이 충분히 가능합니다**:
- 소규모 사업 (월 500만~1,000만 원)
- 단순 경비 (재료, 임차료 등)
- 월 1회 정도 기본 정리
→ 영수증 정리 + 기본 엑셀 기입면 충분
---
## 하지만 이렇게 복잡하면 전문가 도움이 효율적입니다
**세무사 상담을 권하는 경우**:
- 📊 월 매출이 2,000만 원을 넘어갈 때
- 💼 여러 사업을 동시에 운영할 때
- 🏠 부동산 등 추가 수입이 있을 때
- 📈 직원을 여러 명 두고 있을 때
- 🌍 해외 거래나 수입이 있을 때
### 💰 세무사와 함께 하는 이유 (숫자가 말해줍니다)
| 항목 | 혼자할 때 | 세무사와 함께 | 차이 |
|------|----------|-----------|------|
| **절세액** | X만 원 | X + 200만 원 | +200만 원 절약 |
| **세무조사 스트레스** | 매년 불안 | 안정적 대응 | 심리적 안정 |
| **시간 투자** | 월 10시간 | 월 1시간 | 월 9시간 자유 |
| **세무사 비용** | 0원 | 약 100만 원/년 | -100만 원 |
| **실제 이익** | 순이익 | 순이익 + 100만 원 | **+100만 원 순이익** |
**돈을 쓰는 이유가 있습니다**:
- 💰 **세금 절약**: 절세 기법으로 200만 원 절약 - 100만 원 비용 = 순 100만 원 이득
-**시간 절약**: 월 9시간(연 108시간) 절약 = 사업에 집중 가능
- 😌 **스트레스 감소**: 세무조사 불안 제거, 복잡한 계산 안 함
- 🛡️ **리스크 관리**: 실수로 인한 가산세/과태료 방지
**결론**: 세무사 비용 > 절세액 + 시간 절약 + 스트레스 감소
---
## 요약
**기본 개념을 아는 것만으로도**:
- 실수를 줄이고
- 세금을 절약하고
- 세무사와의 상담이 훨씬 효율적
당신의 상황이 어느 정도인지 판단하고,
필요할 때 전문가와 함께 하세요.
```
---
## ✅ 작성 체크리스트
### 내용
- [ ] **실제 사례**: 동네 카페/편의점/학원 같은 주변 상황
- [ ] **구체적 페르소나**: 이름, 나이, 직업, 사업 경력
- [ ] **실제 금액**: 매출, 경비, 세금 (현실적 수치)
- [ ] **Before/After**: 실패 사례 → 성공 사례
- [ ] **절세 효과**: "X만 원 절약" 또는 "손해를 막음"
- [ ] **계산**: Step별로 명확, 표 포함
- [ ] **악마는 디테일**: "겉으로는 간단해 보이지만 실제로는..." (세무사가 처리하는 디테일들)
- [ ] **세법 변화**: 해당 연도의 세법 변경사항과 그 영향 설명
### 톤
- [ ] **교육적**: 개념을 이해하도록
- [ ] **격려적**: 경고/협박 없음
- [ ] **현실적**: 복잡할 수 있다는 인정
- [ ] **세무사 자연스러운 유도**: "필요할 때 도움되는 선택"
- [ ] **임파워먼트**: "기초는 누구나 할 수 있어요"
### 표현
- [ ] **중학교 수준**: 어려운 용어는 () 설명
- [ ] **이모지**: 🏠💰✅❌📊 등으로 시각화
- [ ] **짧은 문장**: 한 문장에 한 개념
- [ ] **표와 리스트**: 수치는 표로, 항목은 리스트로
---
## 피해야 할 표현
| ❌ 이렇게 | ✅ 이렇게 |
|---------|---------|
| "복잡하니까 맡기세요" | "기초는 누구나, 복잡하면 전문가" |
| "전문가는 필수입니다" | "필요할 때 도움받으면 효율적" |
| "혼자서도 잘해요" | "기초 수준은 자신이 가능해요" |
| "세무사에 위임하세요" | "당신의 상황에 따라 판단하세요" |
| "절대 실수하면 안 돼요" | "실수할 수 있으니 미리 대비하세요" |
---
## 시즌별 주제 예시
| 월 | 추천 주제 | 톤 |
|----|---------|-----|
| 1월 | 부가세 2기 신고 기한 | "이 정도면 자신이 가능" |
| 5월 | 종소세 신고 방법 | "핵심 개념 + 전문가 도움 타이밍" |
| 7월 | 부가세 1기 신고 | "기초 정리 방법" |
| 11월 | 다음해 준비 | "계획하면 편해요" |
+53 -46
View File
@@ -8,8 +8,8 @@
Blazor → Service (서버) → DB
✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만) ← API (모든 로직) ← DB
SignalR (변경 알림만)
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
Blazor 데이터 변경 자동 push/broadcast 금지
```
### SOLID 기반 순차 마이그레이션 전략
@@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
#### Phase 6: SignalR 통합
- [ ] NotificationHub (변경 알림만)
- [ ] Blazor에서 구독
- [ ] 알림 후 API로 데이터 검증
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
#### Phase 7: 순차적 마이그레이션 ✅
- [x] Blog 페이지 → API 클라이언트
@@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080;
- Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동)
### **Phase 6: SignalR 통합** ✅
- NotificationHub (브로드캐스트만, 상태 관리 없음)
- INotificationService (이벤트 기반)
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
- Program.cs SignalR 등록
### **Phase 6: Lite Blazor 운영 원칙** ✅
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
---
@@ -160,11 +160,11 @@ Repositories (데이터 계층)
PostgreSQL Database
```
**Blazor Server SignalR**:
- 자동 연결 (내장 Hub connection)
- NotificationHub 클라이언트 그룹 (admins)
- 이벤트 기반 메시지 (상태 관리 없음)
- 클라이언트는 알림 후 API로 데이터 검증
**Lite Blazor 데이터 갱신**:
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
---
@@ -182,10 +182,10 @@ PostgreSQL Database
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
**실시간 알림 (Phase 6)**:
- [x] NotificationHub 구현
- [x] Event-driven 알림 시스템
- [x] Scoped DI 등록
**Lite Blazor / 데이터 갱신 (Phase 6)**:
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지
@@ -564,33 +564,24 @@ ssh kjh2064@178.104.200.7
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
**표준 배포 (현재)**:
1. `master` 브랜치에 push
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
**API 클라이언트 설정 (Green-Blue 대비)**:
- API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
- 기본값: `http://localhost:5001/taxbaik/api/`
- 배포 시 환경변수로 오버라이드 가능:
```bash
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
systemctl start taxbaik # 새 포트에 배포
```
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
**무중단 Green-Blue 배포 아키텍처 (2026-06-30 적용 완료)**:
1. **프록시 레이어**: 포트 `5001`에서 영구 가동되는 초경량 .NET TCP 프록시([TaxBaik.Proxy])가 수신 대기합니다. Nginx는 `/taxbaik` 트래픽을 기존과 같이 `5001`로 중계합니다.
2. **동적 포트 스위칭**: 프록시는 요청이 들어올 때마다 `/home/kjh2064/taxbaik_port` 파일을 읽어 active 포트(5003 또는 5004)를 판단하고 트래픽을 포워딩합니다.
3. **배포 흐름 (`deploy_gb.sh`)**:
- Gitea Actions가 코드를 build/publish 후 압축하여 서버에 업로드합니다.
- 서버의 배포 스크립트([deploy_gb.sh])가 실행되어 현재 미사용 중인 예비 포트(Target Port: 5003 또는 5004)를 파악합니다.
- 예비 포트에서 새 .NET 웹 앱을 실행하고 `http://127.0.0.1:$target_port/taxbaik/healthz` 헬스 체크를 통과할 때까지 폴링(최대 60초)합니다.
- 헬스 체크 성공 시 `/home/kjh2064/taxbaik_port` 파일에 새 포트 번호를 기입하여 **트래픽을 즉시 무중단 전환**합니다.
- 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다.
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.
**운영 규칙**:
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
- `rsync`로 직접 아티팩트를 올리지 않는다
- 배포 실패 시 CI 로그를 먼저 본다
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다.
**롤백**:
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
- 서버 파일을 수동으로 복구하지 않는다
- 롤백은 커밋 단위로 추적 가능해야 한다
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다.
### 3.4 서비스 파일 위치
```
@@ -754,6 +745,22 @@ ssh kjh2064@178.104.200.7 crontab -l | grep backup
---
## 5-1. 블로그 & FAQ 콘텐츠 작성 규칙
**핵심**: 고객 임파워먼트 (당신도 할 수 있습니다!)
- ✅ 주변에서 흔히 보는 실제 사례 (이름, 나이, 직업 구체화)
- ✅ 절세 효과 수치화 ("세금을 X만 원 절약했습니다")
- ✅ 중학교 2학년도 이해 가능한 수준
- ✅ 단계별 설명 + 표로 시각화
- ✅ 결론: "정확하게 하면 이런 이점이 있습니다" (임파워먼트)
**피해야 할 톤**: "복잡하니까 맡기세요" (세무사 의존성 강화)
**세무사 언급**: "더 복잡하면 전문가와 상담하세요" (선택지)
**자세한 템플릿 및 체크리스트**: `BLOG_TEMPLATE.md` 참고
---
## 6. 코드 규칙
### 6.1 C# 네이밍
@@ -1637,7 +1644,7 @@ curl http://127.0.0.1/taxbaik/admin/login
### E2E 테스트 & 반응형 검증
```bash
# 문의 폼 제출
curl -X POST http://178.104.200.7/taxbaik/contact \
curl -X POST http://taxbaik.com/taxbaik/contact \
-d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
# 관리자 DB에서 확인
@@ -1676,7 +1683,7 @@ npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
**프로덕션 E2E 테스트**:
```bash
export E2E_BASE_URL="http://178.104.200.7/taxbaik"
export E2E_BASE_URL="http://taxbaik.com/taxbaik"
export E2E_ADMIN_USERNAME="test_admin"
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
@@ -1944,7 +1951,7 @@ else
2. **Actions run 생성 확인**
```powershell
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
$runs = Invoke-RestMethod -Headers $headers -Uri "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
$runs = Invoke-RestMethod -Headers $headers -Uri "http://gitea.taxbaik.com/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
$runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
```
`deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.
+120 -13
View File
@@ -17,7 +17,7 @@
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | 도메인 기반 가상 호스트 분기 (홈페이지, Gitea, Quant) |
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
@@ -126,17 +126,22 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
### 4.2. Nginx 리버스 프록시
```nginx
# /etc/nginx/sites-enabled/gitea-ip.conf
# /etc/nginx/sites-available/taxbaik-domains.conf
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
server_name taxbaik.com www.taxbaik.com;
client_max_body_size 512M;
# QuantEngine Blazor Web App
location /quant/ {
proxy_pass http://127.0.0.1:5000/;
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
location /admin {
return 301 $scheme://$host/taxbaik$request_uri;
}
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
location / {
proxy_pass http://127.0.0.1:5001/taxbaik/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
@@ -147,7 +152,33 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Gitea (기본)
# /taxbaik/ 하위로 들어오는 리소스 및 페이지 요청 처리
location /taxbaik {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 2. Gitea (gitea.taxbaik.com)
server {
server_name gitea.taxbaik.com;
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -159,13 +190,89 @@ server {
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 3. QuantEngine (quant.taxbaik.com)
server {
server_name quant.taxbaik.com;
location / {
proxy_pass http://127.0.0.1:5000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name taxbaik.com www.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = gitea.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name gitea.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = quant.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name quant.taxbaik.com;
return 404; # managed by Certbot
}
```
**라우팅 요약**:
- `http://178.104.200.7/` → Gitea Web UI
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
- `ssh://178.104.200.7:2222` → Gitea Git SSH
- `http://taxbaik.com/` 또는 `http://www.taxbaik.com/` → TaxBaik 홈페이지 (내부 proxy: `http://127.0.0.1:5001/taxbaik/`)
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`)
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`)
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
## 5. Gitea
@@ -384,7 +491,7 @@ ClientAliveCountMax 2
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) |
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
+44 -11
View File
@@ -19,32 +19,46 @@ GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;
### 2. 환경 변수 설정
**Web 서비스** (`/etc/systemd/system/taxbaik.service`):
**Web 서비스** (`/etc/systemd/system/taxbaik.service`, 백엔드 전용):
```ini
[Service]
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5001
Environment=ASPNETCORE_URLS=http://127.0.0.1:5004
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password
```
**프록시 서비스** (`/etc/systemd/system/taxbaik-proxy.service`, 5001 진입점):
```ini
[Service]
ExecStart=/usr/bin/dotnet TaxBaik.Proxy.dll
WorkingDirectory=/home/kjh2064/taxbaik_active
Restart=always
```
### 3. systemd 서비스 파일 설치
```bash
sudo cp deploy/taxbaik.service /etc/systemd/system/
sudo cp deploy/taxbaik-proxy.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable taxbaik
sudo systemctl enable taxbaik-proxy
```
### 4. Nginx 설정
```bash
# 현재 Nginx 설정 확인
sudo cat /etc/nginx/sites-available/default | head -30
# Nginx 도메인 기반 가상 호스트 설정 복사
sudo cp deploy/nginx-taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf
# location 블록 추가 (또는 기존 설정에 병합)
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
# 기존 설정(IP 기반 및 default) 활성화 해제
sudo rm -f /etc/nginx/sites-enabled/default
sudo rm -f /etc/nginx/sites-enabled/gitea-ip.conf
# 테스트 및 재로드
# 새 설정 활성화 (심링크 생성)
sudo ln -sfn /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-enabled/taxbaik-domains.conf
# 설정 문법 테스트 및 Nginx 서비스 리로드
sudo nginx -t
sudo systemctl reload nginx
```
@@ -65,7 +79,7 @@ sudo systemctl reload nginx
master 브랜치 push → build → test → publish → restart → health check → Playwright
```
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
수동 배포는 사용하지 않습니다. `deploy_gb.sh`는 `TAXBAIK_DEPLOY_FROM_CI=1`이 없으면 즉시 종료하므로, 배포는 반드시 Gitea Actions에서만 실행됩니다.
## 마이그레이션 자동 실행
@@ -128,6 +142,7 @@ ls -la ~/deployments/ | grep taxbaik
# 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000)
ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active
sudo systemctl restart taxbaik-proxy
sudo systemctl restart taxbaik
```
@@ -139,10 +154,10 @@ sudo systemctl restart taxbaik
ssh kjh2064@178.104.200.7
# 서비스 상태
systemctl status taxbaik
systemctl status taxbaik taxbaik-proxy
# 포트 확인
netstat -tlnp | grep -E '5001'
netstat -tlnp | grep -E '5001|5004'
# 프로세스 확인
ps aux | grep TaxBaik
@@ -165,9 +180,27 @@ journalctl -u taxbaik -f
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection \"Upgrade\"` 헤더가 모두 있는지 확인 |
| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 |
| 503 Service Unavailable | 미시작 | `sudo systemctl restart taxbaik` |
| 503 Service Unavailable | 백엔드 또는 프록시 미시작 | `sudo systemctl restart taxbaik-proxy taxbaik` |
| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` |
## 운영 복구 순서
```bash
ssh kjh2064@178.104.200.7
sudo cp /home/kjh2064/taxbaik.service /etc/systemd/system/taxbaik.service
sudo cp /home/kjh2064/taxbaik-proxy.service /etc/systemd/system/taxbaik-proxy.service
sudo systemctl daemon-reload
sudo systemctl restart taxbaik-proxy
sudo systemctl restart taxbaik
curl -I http://127.0.0.1:5001/taxbaik/admin/login
```
## 원라인 점검
```bash
ssh kjh2064@178.104.200.7 'systemctl status taxbaik taxbaik-proxy --no-pager --lines=3 && ss -tlnp | grep -E ":5001 |:5004 " && curl -fsSI http://127.0.0.1:5001/taxbaik/admin/login && curl -fsS http://127.0.0.1:5001/taxbaik/favicon.svg >/dev/null && curl -fsS http://127.0.0.1:5001/taxbaik/robots.txt >/dev/null'
```
## 초기 데이터
### 관리자 계정
+8 -40
View File
@@ -48,29 +48,7 @@ ssh kjh2064@178.104.200.7 'bash ~/SERVER_SETUP.sh'
# ~/taxbaik_active
```
### 2단계: 첫 배포 (수동)
```bash
# 로컬에서 실행
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# SSH 키 설정 (필요시)
export DEPLOY_USER="kjh2064"
export DEPLOY_HOST="178.104.200.7"
# 배포
rsync -avz --delete ./publish/ \
$DEPLOY_USER@$DEPLOY_HOST:~/deployments/taxbaik_${TIMESTAMP}/
# 심링크 변경 및 시작
ssh $DEPLOY_USER@$DEPLOY_HOST << EOF
ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active
sudo systemctl start taxbaik
sudo systemctl status taxbaik
EOF
```
### 3단계: Gitea Actions 설정 (선택)
### 2단계: Gitea Actions 설정
**Gitea 저장소 Settings → Secrets 추가**:
- `DEPLOY_USER`: `kjh2064`
@@ -217,8 +195,8 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
| 증상 | 원인 | 해결 방법 |
|------|------|----------|
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
| 502 Bad Gateway | 미실행 | `sudo systemctl restart taxbaik` |
| 503 Service Unavailable | 앱 충돌 | 로그 확인: `journalctl -u taxbaik -n 50` |
| 502 Bad Gateway | 프록시 또는 백엔드 미실행 | `sudo systemctl restart taxbaik-proxy taxbaik` |
| 503 Service Unavailable | 백엔드 충돌 또는 비밀값 누락 | 로그 확인: `journalctl -u taxbaik -n 50` |
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
@@ -230,11 +208,11 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
### 실시간 모니터링
```bash
# 터미널 1: 웹 서비스 로그
# 터미널 1: 백엔드 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 2: 통합 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 2: 프록시 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-proxy -f'
# 터미널 3: Nginx 로그
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
@@ -246,13 +224,7 @@ ssh kjh2064@178.104.200.7 'watch -n 1 "ps aux | grep TaxBaik"'
### 정기적 검사
```bash
# 일일 체크 (cron job)
0 9 * * * /home/kjh2064/health-check.sh
# 내용:
#!/bin/bash
curl -f http://127.0.0.1:5001/taxbaik || systemctl restart taxbaik
curl -f http://127.0.0.1:5001/taxbaik/admin/login || systemctl restart taxbaik
# 일일 체크는 CI 배포 후 자동 검증으로 대체
```
---
@@ -268,11 +240,6 @@ git commit -m "기능: 새로운 기능 추가"
git push origin master
# 2. Gitea Actions가 자동으로 배포
# 또는 수동 배포:
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
dotnet publish TaxBaik.Web -c Release -o ./publish
rsync -avz ./publish/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/
ssh kjh2064@178.104.200.7 "ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active && sudo systemctl restart taxbaik"
```
### 롤백 절차
@@ -284,6 +251,7 @@ ssh kjh2064@178.104.200.7 'ls -la ~/deployments/ | grep taxbaik'
# 롤백 (예: 이전 버전이 taxbaik_20260625_100000)
ssh kjh2064@178.104.200.7 << EOF
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
sudo systemctl restart taxbaik-proxy
sudo systemctl restart taxbaik
EOF
```
+1 -1
View File
@@ -168,7 +168,7 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
---
+59 -16
View File
@@ -425,9 +425,9 @@ Todo:
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
Todo:
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [ ] 일간/주간 리포트 메시지 템플릿
- [ ] TelegramNotificationService에 리포트 메서드 추가
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [x] 일간/주간 리포트 메시지 템플릿
- [x] TelegramNotificationService에 리포트 메서드 추가
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
@@ -439,9 +439,9 @@ Todo:
- 개인정보 열람 범위는 세무사가 허용한 항목만
Todo:
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [ ] 고객 전용 Razor Pages 추가
- [ ] 세무사 허용 권한 설정 UI
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [x] 고객 전용 Razor Pages 추가
- [x] 세무사 허용 권한 설정 UI
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
@@ -485,16 +485,16 @@ DB 스키마:
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
Todo:
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [ ] V011__CreatePortalUsers.sql 마이그레이션
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [ ] 네이버 OAuth Handler 구현
- [ ] 카카오·구글 패키지 추가 및 설정
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [x] 네이버 OAuth Handler 구현
- [x] 카카오·구글 패키지 추가 및 설정
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [ ] Gitea Secrets에 OAuth 키 추가
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
@@ -522,3 +522,46 @@ Todo:
- WBS-UX-03/04 구현 완료
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
---
## ── 홈페이지 · 어드민 · 포털 프리미엄 UX/UI 개편 (2026-06-30) ──────────────────
## WBS-UX-05 홈페이지 프리미엄 UI 및 마이크로 인터랙션
목표: 홈페이지 디자인을 극도로 모던하고 신뢰성 있는 프리미엄 스타일로 전면 개편한다.
성공 기준:
- Hero 섹션에 유려한 배경 그라데이션 및 부드러운 CSS 애니메이션 효과 적용
- 서비스 카드에 섀도우 및 보더 트랜지션, 골드/그린 그라데이션 호버 이펙트 추가
- 신뢰도 스트립 카드에 입체감 및 돋보이는 레이아웃 설계
- Noto Sans KR 외에 Outfit/Inter 등의 보조 영문 폰트 결합으로 타이포그래피 고급화
Todo:
- [x] `site.css` 내 Hero 섹션 그라데이션 및 CSS 애니메이션 보강
- [x] 서비스 카드 및 신뢰도 스트립 컴포넌트 프리미엄 스타일로 개편
- [x] 홈페이지 폰트 스택 확장 및 메인 레이아웃 적용
## WBS-PORTAL-01 고객 포털 UI/UX 고도화 및 글래스모피즘
목표: 고객 마이 포털 화면을 미려하고 현대적인 글래스모피즘 디자인으로 개편하여 이용 가치를 극대화한다.
성공 기준:
- 포털 메인 대시보드 카드를 Glassmorphism 스타일(blur, semi-transparent border)로 변경
- 세무 신고 현황 테이블 및 상담 이력 타임라인 컴포넌트의 모던 디자인화
Todo:
- [x] `site.css` 내 포털 전용 모던 글래스모피즘 클래스군 추가
- [x] `Portal/Index.cshtml` 레이아웃 및 컴포넌트 UI 고도화
## WBS-MAINT-02 코드 품질 및 경고 결함 차단
목표: 빌드 컴파일 타임 경고(Warnings)를 0으로 유지하여 미래 코드 결함을 방지한다.
성공 기준:
- `dotnet build` 수행 시 경고 0개 달성
Todo:
- [x] `CustomAuthenticationStateProvider.cs` Nullable 경고 수정
- [x] `Dashboard.razor` 미사용 변수 제거 및 UI 연계 바인딩 처리
@@ -27,6 +27,7 @@ public static class DependencyInjection
services.AddScoped<RevenueTrackingService>();
services.AddScoped<TelegramReportService>();
services.AddScoped<PortalUserService>();
services.AddScoped<CommonCodeService>();
return services;
}
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Application.Services;
public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
{
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
{
return await commonCodeRepository.GetByGroupAsync(codeGroup, ct);
}
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
return await commonCodeRepository.GetAllActiveAsync(ct);
}
}
@@ -33,6 +33,9 @@ public class ConsultingActivityService(IConsultingActivityRepository repository)
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
await repository.GetPendingFollowupsAsync(ct);
@@ -36,6 +36,9 @@ public class ContractService(IContractRepository repository)
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -34,6 +34,9 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
await repository.GetPendingPaymentsAsync(ct);
@@ -33,6 +33,9 @@ public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
@@ -31,10 +31,16 @@ public class TaxProfileService(ITaxProfileRepository repository)
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct);
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
{
var profile = new TaxProfile { Id = profileId };
var profile = await repository.GetByIdAsync(profileId, ct);
if (profile == null)
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
if (!string.IsNullOrWhiteSpace(businessType))
profile.BusinessType = businessType.Trim();
if (!string.IsNullOrWhiteSpace(accountingMethod))
+10
View File
@@ -0,0 +1,10 @@
namespace TaxBaik.Domain.Entities;
public class CommonCode
{
public string CodeGroup { get; set; } = string.Empty;
public string CodeValue { get; set; } = string.Empty;
public string CodeName { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Domain.Interfaces;
public interface ICommonCodeRepository
{
Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default);
Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
}
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IConsultingActivityRepository
{
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IContractRepository
{
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IRevenueTrackingRepository
{
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
@@ -5,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface ITaxFilingScheduleRepository
{
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
@@ -5,6 +5,8 @@ using TaxBaik.Domain.Entities;
public interface ITaxProfileRepository
{
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
@@ -27,6 +27,7 @@ public static class DependencyInjection
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
services.AddScoped<IContractRepository, ContractRepository>();
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
return services;
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Infrastructure.Repositories;
public class CommonCodeRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICommonCodeRepository
{
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryAsync<CommonCode>(
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
FROM common_codes
WHERE code_group = @CodeGroup AND is_active = TRUE
ORDER BY sort_order",
new { CodeGroup = codeGroup });
}
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryAsync<CommonCode>(
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
FROM common_codes
WHERE is_active = TRUE
ORDER BY code_group, sort_order");
}
}
@@ -16,6 +16,14 @@ public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory
activity);
}
public async Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<ConsultingActivity>(
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
FROM consulting_activities ORDER BY activity_date DESC");
}
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRe
contract);
}
public async Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<Contract>(
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
FROM contracts ORDER BY contract_date DESC");
}
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
revenue);
}
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<RevenueTracking>(
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
FROM revenue_tracking ORDER BY invoice_date DESC");
}
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -16,6 +16,14 @@ public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory)
schedule);
}
public async Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxFilingSchedule>(
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
FROM tax_filing_schedules ORDER BY due_date DESC");
}
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
@@ -20,6 +20,27 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
profile);
}
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<TaxProfile>(
@"SELECT id, client_id, business_registration, business_type, establishment_date,
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
FROM tax_profiles WHERE id = @Id",
new { Id = id });
}
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxProfile>(
@"SELECT id, client_id, business_registration, business_type, establishment_date,
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
FROM tax_profiles ORDER BY id DESC");
}
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private const string PortFile = "/home/kjh2064/taxbaik_port";
private static int _fallbackPort = 5003;
static async Task Main(string[] args)
{
// Allow setting fallback port via args
if (args.Length > 0 && int.TryParse(args[0], out var port))
{
_fallbackPort = port;
}
var listener = new TcpListener(IPAddress.Loopback, 5001);
listener.Start();
Console.WriteLine($"[TaxBaik Proxy] Listening on 127.0.0.1:5001 (Forwarding to target in {PortFile})");
while (true)
{
try
{
var client = await listener.AcceptTcpClientAsync();
_ = HandleClientAsync(client);
}
catch (Exception ex)
{
Console.WriteLine($"[TaxBaik Proxy] Accept error: {ex.Message}");
await Task.Delay(100);
}
}
}
private static int GetTargetPort()
{
try
{
if (File.Exists(PortFile))
{
var content = File.ReadAllText(PortFile).Trim();
if (int.TryParse(content, out var port) && port > 1024 && port < 65535)
{
return port;
}
}
}
catch { }
return _fallbackPort;
}
private static async Task HandleClientAsync(TcpClient client)
{
client.NoDelay = true;
int targetPort = GetTargetPort();
using var backend = new TcpClient();
backend.NoDelay = true;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await backend.ConnectAsync(IPAddress.Loopback, targetPort, cts.Token);
}
catch (Exception ex)
{
Console.WriteLine($"[TaxBaik Proxy] Failed to connect to backend on port {targetPort}: {ex.Message}");
client.Close();
return;
}
try
{
using var clientStream = client.GetStream();
using var backendStream = backend.GetStream();
var toBackend = clientStream.CopyToAsync(backendStream);
var toClient = backendStream.CopyToAsync(clientStream);
await Task.WhenAny(toBackend, toClient);
}
catch { }
finally
{
client.Close();
backend.Close();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+52
View File
@@ -0,0 +1,52 @@
{
"runtimeOptions": {
"tfm": "net10.0",
"includedFrameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
}
],
"wasmHostProperties": {
"perHostConfig": [
{
"name": "browser",
"host": "browser"
}
]
},
"configProperties": {
"Microsoft.AspNetCore.Components.Routing.RegexConstraintSupport": false,
"Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true,
"System.ComponentModel.DefaultValueAttribute.IsSupported": false,
"System.ComponentModel.Design.IDesignerHost.IsSupported": false,
"System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false,
"System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false,
"System.Data.DataSet.XmlSerializationIsSupported": false,
"System.Diagnostics.Debugger.IsSupported": false,
"System.Diagnostics.Metrics.Meter.IsSupported": false,
"System.Diagnostics.Tracing.EventSource.IsSupported": false,
"System.GC.Server": true,
"System.Globalization.Invariant": false,
"System.TimeZoneInfo.Invariant": false,
"System.Linq.Enumerable.IsSizeOptimized": true,
"System.Net.Http.EnableActivityPropagation": false,
"System.Net.Http.WasmEnableStreamingResponse": true,
"System.Net.SocketsHttpHandler.Http3Support": false,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Resources.ResourceManager.AllowCustomResourceTypes": false,
"System.Resources.UseSystemResourceKeys": true,
"System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": true,
"System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false,
"System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false,
"System.Runtime.InteropServices.EnableCppCLIHostActivation": false,
"System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false,
"System.StartupHookProvider.IsSupported": false,
"System.Text.Encoding.EnableUnsafeUTF7Encoding": false,
"System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": true,
"System.Threading.Thread.EnableAutoreleasePool": false,
"Microsoft.AspNetCore.Components.Endpoints.NavigationManager.DisableThrowNavigationException": false
}
}
}
+2
View File
@@ -0,0 +1,2 @@
global using System.Net.Http;
global using System.Net.Http.Json;
+13
View File
@@ -0,0 +1,13 @@
@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@
@rendermode InteractiveWebAssembly
<MudPaper Class="pa-6 ma-4" Elevation="2">
<MudText Typo="Typo.h5" GutterBottom="true">WebAssembly 렌더 모드 점검</MudText>
<MudText Typo="Typo.body2" Class="mb-4">이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Increment">카운트: @count</MudButton>
</MudPaper>
@code {
private int count;
private void Increment() => count++;
}
+51
View File
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
using TaxBaik.Web.Services.AdminClients;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// MudBlazor (WASM 측 인터랙티브 컴포넌트용)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
// API Base Url 동적 구성 (호스트 기준 /taxbaik/api/)
var apiBaseUrl = builder.HostEnvironment.BaseAddress.TrimEnd('/') + "/taxbaik/api/";
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped<TokenRefreshHandler>();
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// 각 Browser API Client 등록
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
// Blazor 인증 (WASM 측 클라이언트)
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ILocalStorageService, LocalStorageService>();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
@@ -0,0 +1,56 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Collections.Generic;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
using Microsoft.Extensions.Logging;
public interface ICommonCodeBrowserClient
{
Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default);
}
public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<CommonCodeBrowserClient> logger) : ICommonCodeBrowserClient
{
private const string BaseUrl = "/api/commoncode";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get all active common codes");
return [];
}
}
public async Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}/group/{group}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get common codes for group {Group}", group);
return [];
}
}
}
@@ -14,15 +14,24 @@ public interface IConsultingActivityBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<ConsultingActivityBrowserClient> logger)
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
: IConsultingActivityBrowserClient
{
private const string BaseUrl = "/api/consultingactivity";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -36,6 +45,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -49,6 +59,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
@@ -66,6 +77,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -83,6 +95,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var request = new { outcome, nextFollowupDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -97,6 +110,7 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<Cons
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,15 +16,24 @@ public interface IContractBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowserClient> logger)
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
: IContractBrowserClient
{
private const string BaseUrl = "/api/contract";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -64,6 +75,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
@@ -96,6 +109,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
if (response.TryGetProperty("mrr", out var mrrValue))
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
@@ -113,6 +127,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -130,6 +145,7 @@ public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowse
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,15 +16,24 @@ public interface IRevenueTrackingBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<RevenueTrackingBrowserClient> logger)
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
: IRevenueTrackingBrowserClient
{
private const string BaseUrl = "/api/revenuetracking";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -67,6 +78,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
@@ -83,6 +95,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>(
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
if (response.TryGetProperty("total", out var totalValue))
@@ -101,6 +114,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -118,6 +132,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var request = new { paymentDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
response.EnsureSuccessStatusCode();
@@ -132,6 +147,7 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<Revenue
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -15,15 +15,24 @@ public interface ITaxFilingScheduleBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFilingScheduleBrowserClient> logger)
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
: ITaxFilingScheduleBrowserClient
{
private const string BaseUrl = "/api/taxfilingschedule";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -37,6 +46,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -50,6 +60,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -63,6 +74,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -97,6 +110,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
response.EnsureSuccessStatusCode();
}
@@ -110,6 +124,7 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFi
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -17,14 +17,23 @@ public interface ITaxProfileBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
{
private const string BaseUrl = "/api/taxprofile";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
@@ -38,6 +47,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -51,6 +61,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -64,6 +75,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -80,6 +92,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
if (response.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
@@ -97,6 +110,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -115,6 +129,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -129,6 +144,7 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBr
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -33,10 +33,10 @@ public class AdminDashboardClient : IAdminDashboardClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
@@ -29,10 +29,10 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
@@ -34,10 +34,10 @@ public class ClientBrowserClient : IClientBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Services;
@@ -8,18 +9,18 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly ITokenStore _tokenStore;
private readonly AuthService _authService;
private readonly IApiClient _apiClient;
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
public CustomAuthenticationStateProvider(
ILocalStorageService localStorage,
ITokenStore tokenStore,
AuthService authService,
IApiClient apiClient,
ILogger<CustomAuthenticationStateProvider> logger)
{
_localStorage = localStorage;
_tokenStore = tokenStore;
_authService = authService;
_apiClient = apiClient;
_logger = logger;
}
@@ -32,21 +33,22 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken))
{
accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(accessToken))
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken))
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (long.TryParse(ticksStr, out var ticks))
{
_tokenStore.AccessToken = accessToken;
_tokenStore.AccessToken = storedToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
}
}
}
if (string.IsNullOrEmpty(accessToken))
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
@@ -63,8 +65,9 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
{
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
var newTokenPair = await _authService.RefreshAccessTokenAsync(_tokenStore.RefreshToken);
if (newTokenPair != null)
var request = new { RefreshToken = _tokenStore.RefreshToken };
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
{
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
_logger.LogInformation("토큰 자동 갱신 성공");
@@ -78,7 +81,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
var principal = _authService.ValidateToken(accessToken);
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
if (principal == null)
{
await LogoutAsync();
@@ -94,6 +97,22 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
return new ClaimsPrincipal(identity);
}
catch
{
return null;
}
}
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
{
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
@@ -114,13 +133,13 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
if (_tokenStore.TokenExpiryTicks <= 0)
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime((long)_tokenStore.TokenExpiryTicks, DateTimeKind.Utc);
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
}
@@ -157,3 +176,17 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
}
public class WasmAuthTokenPair
{
public WasmAuthTokenPair() { }
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresIn = expiresIn;
}
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}
@@ -28,10 +28,10 @@ public class FaqBrowserClient : IFaqBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
@@ -33,10 +33,10 @@ public class InquiryBrowserClient : IInquiryBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
@@ -32,10 +32,10 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
}
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default)
@@ -44,7 +44,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"tax-filing/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
@@ -60,7 +60,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<TaxFilingListResponse>(
$"tax-filing/client/{clientId}", cancellationToken: ct);
$"taxfiling/client/{clientId}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
@@ -76,7 +76,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<TaxFiling>(
$"tax-filing/{id}", cancellationToken: ct);
$"taxfiling/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
@@ -90,7 +90,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("tax-filing", filing, cancellationToken: ct);
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -111,7 +111,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"tax-filing/{id}", filing, cancellationToken: ct);
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -132,7 +132,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"tax-filing/{id}", cancellationToken: ct);
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
@@ -10,12 +10,12 @@ using System.Text.Json;
/// </summary>
public class TokenRefreshHandler : DelegatingHandler
{
private readonly ITokenStore _tokenStore;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(ITokenStore tokenStore, ILogger<TokenRefreshHandler> logger)
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger)
{
_tokenStore = tokenStore;
_serviceProvider = serviceProvider;
_logger = logger;
}
@@ -23,10 +23,13 @@ public class TokenRefreshHandler : DelegatingHandler
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// 최신 Scoped ITokenStore 실시간 해석 (Scope Capture 차단 및 기존 Blazor 회로 수명 공유)
var tokenStore = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<ITokenStore>(_serviceProvider);
// 요청에 access token 추가
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
{
request.Headers.Authorization = new("Bearer", _tokenStore.AccessToken);
request.Headers.Authorization = new("Bearer", tokenStore.AccessToken);
}
var response = await base.SendAsync(request, cancellationToken);
@@ -34,15 +37,15 @@ public class TokenRefreshHandler : DelegatingHandler
// 401 응답이면 토큰 갱신 시도
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken))
if (!string.IsNullOrEmpty(tokenStore.RefreshToken))
{
var newTokenPair = await RefreshTokenAsync(_tokenStore.RefreshToken, request, cancellationToken);
var newTokenPair = await RefreshTokenAsync(tokenStore.RefreshToken, request, cancellationToken);
if (newTokenPair != null)
{
// TokenStore에 토큰 저장
_tokenStore.AccessToken = newTokenPair.AccessToken;
_tokenStore.RefreshToken = newTokenPair.RefreshToken;
_tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
tokenStore.AccessToken = newTokenPair.AccessToken;
tokenStore.RefreshToken = newTokenPair.RefreshToken;
tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
// 새 토큰으로 재요청
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
@@ -51,7 +54,7 @@ public class TokenRefreshHandler : DelegatingHandler
else
{
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
_tokenStore.Clear();
tokenStore.Clear();
}
}
}
@@ -59,7 +62,7 @@ public class TokenRefreshHandler : DelegatingHandler
return response;
}
private async Task<AuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
private async Task<WasmAuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
{
try
{
@@ -84,7 +87,7 @@ public class TokenRefreshHandler : DelegatingHandler
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result != null
? new AuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
? new WasmAuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
: null;
}
catch (Exception ex)
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>TaxBaik.WasmClient</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using MudBlazor
@using TaxBaik.WasmClient
@using static Microsoft.AspNetCore.Components.Web.RenderMode
+573
View File
@@ -0,0 +1,573 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v10.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v10.0": {
"TaxBaik.Web/1.0.0": {
"dependencies": {
"BCrypt.Net-Next": "4.0.3",
"Microsoft.AspNetCore.Authentication.Google": "10.0.9",
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.9",
"Microsoft.AspNetCore.Components.WebAssembly.Server": "10.0.9",
"Microsoft.IdentityModel.Tokens": "8.19.1",
"MudBlazor": "6.10.0",
"Serilog.AspNetCore": "8.0.1",
"Serilog.Sinks.Console": "6.0.0",
"Serilog.Sinks.File": "5.0.0",
"System.IdentityModel.Tokens.Jwt": "8.19.1",
"TaxBaik.Application": "1.0.0",
"TaxBaik.Infrastructure": "1.0.0",
"TaxBaik.Web.Client": "1.0.0"
},
"runtime": {
"TaxBaik.Web.dll": {}
}
},
"BCrypt.Net-Next/4.0.3": {
"runtime": {
"lib/net6.0/BCrypt.Net-Next.dll": {
"assemblyVersion": "4.0.3.0",
"fileVersion": "4.0.3.0"
}
}
},
"Dapper/2.1.15": {
"runtime": {
"lib/net5.0/Dapper.dll": {
"assemblyVersion": "2.0.0.0",
"fileVersion": "2.1.15.52653"
}
}
},
"Microsoft.AspNetCore.Authentication.Google/10.0.9": {
"runtime": {
"lib/net10.0/Microsoft.AspNetCore.Authentication.Google.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.9": {
"dependencies": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
},
"runtime": {
"lib/net10.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"Microsoft.AspNetCore.Components.WebAssembly/10.0.9": {
"dependencies": {
"Microsoft.JSInterop.WebAssembly": "10.0.9"
},
"runtime": {
"lib/net10.0/Microsoft.AspNetCore.Components.WebAssembly.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"Microsoft.AspNetCore.Components.WebAssembly.Server/10.0.9": {
"dependencies": {
"Microsoft.AspNetCore.Components.WebAssembly": "10.0.9"
},
"runtime": {
"lib/net10.0/Microsoft.AspNetCore.Components.WebAssembly.Server.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"Microsoft.Bcl.Cryptography/10.0.2": {
"runtime": {
"lib/net10.0/Microsoft.Bcl.Cryptography.dll": {
"assemblyVersion": "10.0.0.2",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.DependencyModel/8.0.0": {
"runtime": {
"lib/net8.0/Microsoft.Extensions.DependencyModel.dll": {
"assemblyVersion": "8.0.0.0",
"fileVersion": "8.0.23.53103"
}
}
},
"Microsoft.IdentityModel.Abstractions/8.19.1": {
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.Abstractions.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.19.1"
},
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"Microsoft.IdentityModel.Logging/8.19.1": {
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.19.1"
},
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.Logging.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"Microsoft.IdentityModel.Protocols/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.19.1"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.Protocols": "8.0.1",
"System.IdentityModel.Tokens.Jwt": "8.19.1"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.IdentityModel.Tokens/8.19.1": {
"dependencies": {
"Microsoft.Bcl.Cryptography": "10.0.2",
"Microsoft.IdentityModel.Logging": "8.19.1"
},
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.Tokens.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"Microsoft.JSInterop.WebAssembly/10.0.9": {
"runtime": {
"lib/net10.0/Microsoft.JSInterop.WebAssembly.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"MudBlazor/6.10.0": {
"runtime": {
"lib/net7.0/MudBlazor.dll": {
"assemblyVersion": "6.10.0.0",
"fileVersion": "6.10.0.0"
}
}
},
"Npgsql/10.0.3": {
"runtime": {
"lib/net10.0/Npgsql.dll": {
"assemblyVersion": "10.0.3.0",
"fileVersion": "10.0.3.0"
}
}
},
"Serilog/4.0.0": {
"runtime": {
"lib/net8.0/Serilog.dll": {
"assemblyVersion": "4.0.0.0",
"fileVersion": "4.0.0.0"
}
}
},
"Serilog.AspNetCore/8.0.1": {
"dependencies": {
"Serilog": "4.0.0",
"Serilog.Extensions.Hosting": "8.0.0",
"Serilog.Extensions.Logging": "8.0.0",
"Serilog.Formatting.Compact": "2.0.0",
"Serilog.Settings.Configuration": "8.0.0",
"Serilog.Sinks.Console": "6.0.0",
"Serilog.Sinks.Debug": "2.0.0",
"Serilog.Sinks.File": "5.0.0"
},
"runtime": {
"lib/net8.0/Serilog.AspNetCore.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.0"
}
}
},
"Serilog.Extensions.Hosting/8.0.0": {
"dependencies": {
"Serilog": "4.0.0",
"Serilog.Extensions.Logging": "8.0.0"
},
"runtime": {
"lib/net8.0/Serilog.Extensions.Hosting.dll": {
"assemblyVersion": "7.0.0.0",
"fileVersion": "8.0.0.0"
}
}
},
"Serilog.Extensions.Logging/8.0.0": {
"dependencies": {
"Serilog": "4.0.0"
},
"runtime": {
"lib/net8.0/Serilog.Extensions.Logging.dll": {
"assemblyVersion": "7.0.0.0",
"fileVersion": "8.0.0.0"
}
}
},
"Serilog.Formatting.Compact/2.0.0": {
"dependencies": {
"Serilog": "4.0.0"
},
"runtime": {
"lib/net7.0/Serilog.Formatting.Compact.dll": {
"assemblyVersion": "2.0.0.0",
"fileVersion": "2.0.0.0"
}
}
},
"Serilog.Settings.Configuration/8.0.0": {
"dependencies": {
"Microsoft.Extensions.DependencyModel": "8.0.0",
"Serilog": "4.0.0"
},
"runtime": {
"lib/net8.0/Serilog.Settings.Configuration.dll": {
"assemblyVersion": "8.0.0.0",
"fileVersion": "8.0.0.0"
}
}
},
"Serilog.Sinks.Console/6.0.0": {
"dependencies": {
"Serilog": "4.0.0"
},
"runtime": {
"lib/net8.0/Serilog.Sinks.Console.dll": {
"assemblyVersion": "6.0.0.0",
"fileVersion": "6.0.0.0"
}
}
},
"Serilog.Sinks.Debug/2.0.0": {
"dependencies": {
"Serilog": "4.0.0"
},
"runtime": {
"lib/netstandard2.1/Serilog.Sinks.Debug.dll": {
"assemblyVersion": "2.0.0.0",
"fileVersion": "2.0.0.0"
}
}
},
"Serilog.Sinks.File/5.0.0": {
"dependencies": {
"Serilog": "4.0.0"
},
"runtime": {
"lib/net5.0/Serilog.Sinks.File.dll": {
"assemblyVersion": "5.0.0.0",
"fileVersion": "5.0.0.0"
}
}
},
"System.IdentityModel.Tokens.Jwt/8.19.1": {
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "8.19.1",
"Microsoft.IdentityModel.Tokens": "8.19.1"
},
"runtime": {
"lib/net10.0/System.IdentityModel.Tokens.Jwt.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"TaxBaik.Application/1.0.0": {
"dependencies": {
"TaxBaik.Domain": "1.0.0"
},
"runtime": {
"TaxBaik.Application.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"TaxBaik.Domain/1.0.0": {
"runtime": {
"TaxBaik.Domain.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"TaxBaik.Infrastructure/1.0.0": {
"dependencies": {
"Dapper": "2.1.15",
"Npgsql": "10.0.3",
"TaxBaik.Domain": "1.0.0"
},
"runtime": {
"TaxBaik.Infrastructure.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"TaxBaik.Web.Client/1.0.0": {
"dependencies": {
"Microsoft.AspNetCore.Components.WebAssembly": "10.0.9",
"Microsoft.IdentityModel.Tokens": "8.19.1",
"MudBlazor": "6.10.0",
"System.IdentityModel.Tokens.Jwt": "8.19.1",
"TaxBaik.Application": "1.0.0"
},
"runtime": {
"TaxBaik.Web.Client.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
}
}
},
"libraries": {
"TaxBaik.Web/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"BCrypt.Net-Next/4.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-W+U9WvmZQgi5cX6FS5GDtDoPzUCV4LkBLkywq/kRZhuDwcbavOzcDAr3LXJFqHUi952Yj3LEYoWW0jbEUQChsA==",
"path": "bcrypt.net-next/4.0.3",
"hashPath": "bcrypt.net-next.4.0.3.nupkg.sha512"
},
"Dapper/2.1.15": {
"type": "package",
"serviceable": true,
"sha512": "sha512-1aWSAosZymEM+mRwfrXteRIN74/JTUjqj9B/KqEbanH6vfUKy9D9cemRN0q1ZOEfSB7d1PpFTpVOCbf2Uv70Og==",
"path": "dapper/2.1.15",
"hashPath": "dapper.2.1.15.nupkg.sha512"
},
"Microsoft.AspNetCore.Authentication.Google/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-xqjTc8/ap0dwKmdaqSlV8RxjXb02uQ8rynDtTuHRU2gmOYaNm6O+uUjobp4Ararzq0ndKNXiWnQErxjWEGFGiA==",
"path": "microsoft.aspnetcore.authentication.google/10.0.9",
"hashPath": "microsoft.aspnetcore.authentication.google.10.0.9.nupkg.sha512"
},
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Hs5NDsGm8YicDDNx5RoBIT+H2AB9R27MvZ2gHoupTiHr+nnH3VxzY7DcmlbJ3b5DvvOhK35lWt/9Odtrq9sjtA==",
"path": "microsoft.aspnetcore.authentication.jwtbearer/10.0.9",
"hashPath": "microsoft.aspnetcore.authentication.jwtbearer.10.0.9.nupkg.sha512"
},
"Microsoft.AspNetCore.Components.WebAssembly/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-tBv68AsZ3r6z2QdV2m3cSSKUCbvEscN8REpHxcUs22vlR6UjTz6IKdInKNREkJ/3G1AQrBKrRTdrfrHVffE8Iw==",
"path": "microsoft.aspnetcore.components.webassembly/10.0.9",
"hashPath": "microsoft.aspnetcore.components.webassembly.10.0.9.nupkg.sha512"
},
"Microsoft.AspNetCore.Components.WebAssembly.Server/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ZTtYvBILwGxhIiXi1L03ETBBOgMmizStu7dO/YblK6rPTa27wpEgYKp5Z9bUfr+wsFvHIDWd/ZMGb9on41f6yw==",
"path": "microsoft.aspnetcore.components.webassembly.server/10.0.9",
"hashPath": "microsoft.aspnetcore.components.webassembly.server.10.0.9.nupkg.sha512"
},
"Microsoft.Bcl.Cryptography/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==",
"path": "microsoft.bcl.cryptography/10.0.2",
"hashPath": "microsoft.bcl.cryptography.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.DependencyModel/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==",
"path": "microsoft.extensions.dependencymodel/8.0.0",
"hashPath": "microsoft.extensions.dependencymodel.8.0.0.nupkg.sha512"
},
"Microsoft.IdentityModel.Abstractions/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-gFA8THIk23uNF/vMdOHnjIdXD1LyA2g12cHzMJ+Xag6WpgWLw6E/6uCXxvA0gp9d2yAvkRt3xzFzMUiO/hofnQ==",
"path": "microsoft.identitymodel.abstractions/8.19.1",
"hashPath": "microsoft.identitymodel.abstractions.8.19.1.nupkg.sha512"
},
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-6eeY+y2QFyjj3XnCz/8gJdoP5smYHTS9ow1bw2nsZzDIPjPhBZlackYTIduSMipVpxnoT/B62LkrXX2jPggOXg==",
"path": "microsoft.identitymodel.jsonwebtokens/8.19.1",
"hashPath": "microsoft.identitymodel.jsonwebtokens.8.19.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Logging/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-H+sMrMpdbWnwkQnpb/ESkQovtOgdefmj0ecGCcP40mDKzE5i4dUYkH6599M9mWYFNGNJnTp92l/9wLubYXWimw==",
"path": "microsoft.identitymodel.logging/8.19.1",
"hashPath": "microsoft.identitymodel.logging.8.19.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Protocols/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
"path": "microsoft.identitymodel.protocols/8.0.1",
"hashPath": "microsoft.identitymodel.protocols.8.0.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
"hashPath": "microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Tokens/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-KDiuSLXud2AFVNAOottd8ztVysfPeHyr4r8gofU3/VKUXlI7oytzGTnPsNJ/B3nui17rgz8wAdWNJOtzPjkUxw==",
"path": "microsoft.identitymodel.tokens/8.19.1",
"hashPath": "microsoft.identitymodel.tokens.8.19.1.nupkg.sha512"
},
"Microsoft.JSInterop.WebAssembly/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-4G0A7GuQrtCAes8PuJPTDUcy+lCrxHWjr8ZlkDOa4h8a2Txj1XdhbXKLnld2vMY5EyZNC5jZXxa1xTD/AOCUlw==",
"path": "microsoft.jsinterop.webassembly/10.0.9",
"hashPath": "microsoft.jsinterop.webassembly.10.0.9.nupkg.sha512"
},
"MudBlazor/6.10.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Dpjouo3MVva4p8Nh2VCzHzvzReWhnzmCBNlrhymeXjn6oBEtT3Oi9z/R2sHOg/jYrW/hIPKMhfZHnptilHScsw==",
"path": "mudblazor/6.10.0",
"hashPath": "mudblazor.6.10.0.nupkg.sha512"
},
"Npgsql/10.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
"path": "npgsql/10.0.3",
"hashPath": "npgsql.10.0.3.nupkg.sha512"
},
"Serilog/4.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-2jDkUrSh5EofOp7Lx5Zgy0EB+7hXjjxE2ktTb1WVQmU00lDACR2TdROGKU0K1pDTBSJBN1PqgYpgOZF8mL7NJw==",
"path": "serilog/4.0.0",
"hashPath": "serilog.4.0.0.nupkg.sha512"
},
"Serilog.AspNetCore/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-B/X+wAfS7yWLVOTD83B+Ip9yl4MkhioaXj90JSoWi1Ayi8XHepEnsBdrkojg08eodCnmOKmShFUN2GgEc6c0CQ==",
"path": "serilog.aspnetcore/8.0.1",
"hashPath": "serilog.aspnetcore.8.0.1.nupkg.sha512"
},
"Serilog.Extensions.Hosting/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-db0OcbWeSCvYQkHWu6n0v40N4kKaTAXNjlM3BKvcbwvNzYphQFcBR+36eQ/7hMMwOkJvAyLC2a9/jNdUL5NjtQ==",
"path": "serilog.extensions.hosting/8.0.0",
"hashPath": "serilog.extensions.hosting.8.0.0.nupkg.sha512"
},
"Serilog.Extensions.Logging/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==",
"path": "serilog.extensions.logging/8.0.0",
"hashPath": "serilog.extensions.logging.8.0.0.nupkg.sha512"
},
"Serilog.Formatting.Compact/2.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ob6z3ikzFM3D1xalhFuBIK1IOWf+XrQq+H4KeH4VqBcPpNcmUgZlRQ2h3Q7wvthpdZBBoY86qZOI2LCXNaLlNA==",
"path": "serilog.formatting.compact/2.0.0",
"hashPath": "serilog.formatting.compact.2.0.0.nupkg.sha512"
},
"Serilog.Settings.Configuration/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-nR0iL5HwKj5v6ULo3/zpP8NMcq9E2pxYA6XKTSWCbugVs4YqPyvaqaKOY+OMpPivKp7zMEpax2UKHnDodbRB0Q==",
"path": "serilog.settings.configuration/8.0.0",
"hashPath": "serilog.settings.configuration.8.0.0.nupkg.sha512"
},
"Serilog.Sinks.Console/6.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==",
"path": "serilog.sinks.console/6.0.0",
"hashPath": "serilog.sinks.console.6.0.0.nupkg.sha512"
},
"Serilog.Sinks.Debug/2.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Y6g3OBJ4JzTyyw16fDqtFcQ41qQAydnEvEqmXjhwhgjsnG/FaJ8GUqF5ldsC/bVkK8KYmqrPhDO+tm4dF6xx4A==",
"path": "serilog.sinks.debug/2.0.0",
"hashPath": "serilog.sinks.debug.2.0.0.nupkg.sha512"
},
"Serilog.Sinks.File/5.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-uwV5hdhWPwUH1szhO8PJpFiahqXmzPzJT/sOijH/kFgUx+cyoDTMM8MHD0adw9+Iem6itoibbUXHYslzXsLEAg==",
"path": "serilog.sinks.file/5.0.0",
"hashPath": "serilog.sinks.file.5.0.0.nupkg.sha512"
},
"System.IdentityModel.Tokens.Jwt/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-2VHcRtT95GAcW1E3aVBLvL2rAAMxKHXKMXKXFyWzwgkdFXZPMMvP8tVOfnRydL4vTr1RirNuGC6T8VSEF2YsPQ==",
"path": "system.identitymodel.tokens.jwt/8.19.1",
"hashPath": "system.identitymodel.tokens.jwt.8.19.1.nupkg.sha512"
},
"TaxBaik.Application/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"TaxBaik.Domain/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"TaxBaik.Infrastructure/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"TaxBaik.Web.Client/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"runtimeOptions": {
"tfm": "net10.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "10.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
File diff suppressed because one or more lines are too long
+17 -11
View File
@@ -6,9 +6,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>백원숙 세무회계 - 관리자</title>
<base href="/taxbaik/" />
<link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" />
<link rel="alternate icon" href="/taxbaik/favicon.ico" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<!-- EasyMDE 마크다운 에디터 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css" />
<script src="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.js"></script>
<!-- Marked 라이브러리 (EasyMDE 미리보기용) -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script>
document.documentElement.classList.toggle(
'admin-login-route',
@@ -32,12 +39,11 @@
</div>
</div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<MudDialogProvider />
<MudSnackbarProvider />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: true)" />
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/admin-session.js"></script>
<script src="_framework/blazor.web.js"></script>
<script>window.taxbaikAdminSession?.bindLoginForm();</script>
<script>window.taxbaikAdminSession?.watchReconnect();</script>
</body>
</html>
@@ -79,49 +85,49 @@
},
LayoutProperties = new LayoutProperties()
{
DefaultBorderRadius = "8px"
DefaultBorderRadius = "6px"
},
Typography = new Typography()
{
Default = new Default()
{
FontSize = ".875rem",
FontSize = ".8125rem",
FontWeight = 400,
LineHeight = 1.5
},
H1 = new H1()
{
FontSize = "2.5rem",
FontSize = "1.75rem",
FontWeight = 600,
LineHeight = 1.2
},
H2 = new H2()
{
FontSize = "2rem",
FontSize = "1.5rem",
FontWeight = 600,
LineHeight = 1.3
},
H3 = new H3()
{
FontSize = "1.75rem",
FontSize = "1.25rem",
FontWeight = 600,
LineHeight = 1.3
},
H4 = new H4()
{
FontSize = "1.5rem",
FontSize = "1.1rem",
FontWeight = 600,
LineHeight = 1.4
},
H5 = new H5()
{
FontSize = "1.25rem",
FontSize = "0.95rem",
FontWeight = 500,
LineHeight = 1.4
},
H6 = new H6()
{
FontSize = "1rem",
FontSize = "0.85rem",
FontWeight = 500,
LineHeight = 1.5
}
@@ -1,7 +1,13 @@
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject IJSRuntime JS
@inject VersionInfo VersionInfo
@implements IDisposable
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout Class="admin-shell">
<MudAppBar Elevation="0" Class="admin-topbar">
@@ -10,9 +16,9 @@
Edge="Edge.Start"
Class="admin-menu-button"
OnClick="@ToggleDrawer" />
<div class="admin-topbar-title">
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
<div class="admin-topbar-title" style="display: flex; align-items: center; gap: 8px;">
<MudText Typo="Typo.body2" Class="font-weight-bold" Style="color: var(--primary-color);">[TaxBaik]</MudText>
<MudText Typo="Typo.body2" Style="font-weight: bold; color: #1E293B;">세무회계 관리 대시보드</MudText>
</div>
<MudSpacer />
@@ -83,6 +89,12 @@
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
</MudNavMenu>
<div class="admin-drawer-version">
<div class="admin-drawer-version-label">Version</div>
<div class="admin-drawer-version-value">v@(VersionInfo.Version)</div>
<div class="admin-drawer-version-built">@VersionInfo.Built</div>
</div>
</MudDrawer>
<MudMainContent Class="admin-main">
@@ -22,14 +22,22 @@
</MudButton>
</section>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface" Elevation="0">
@if (announcements is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!announcements.Any())
else if (!FilteredAnnouncements.Any())
{
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.Campaign" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">검색 조건에 맞는 공지사항이 없습니다.</MudText>
</div>
}
else
{
@@ -45,7 +53,7 @@
</tr>
</thead>
<tbody>
@foreach (var item in announcements)
@foreach (var item in FilteredAnnouncements)
{
<tr>
<td>@item.Title</td>
@@ -86,15 +94,38 @@
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
</MudText>
}
</MudPaper>
@code {
private List<Announcement>? announcements;
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
protected override async Task OnInitializedAsync()
private List<Announcement>? announcements;
private string searchQuery = "";
private IEnumerable<Announcement> FilteredAnnouncements => announcements?
.Where(a => string.IsNullOrEmpty(searchQuery) ||
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadAsync();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync()
@@ -1,5 +1,6 @@
@page "/admin/blog/create"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@@ -21,19 +22,22 @@
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="model.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
@@ -57,12 +61,24 @@
private MudForm? form;
private List<Domain.Entities.Category> categories = [];
private CreatePostModel model = new();
private EasyMDE.Editor? editor;
[Inject]
private IJSRuntime JS { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
categories = (await CategoryRepository.GetAllAsync()).ToList();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
}
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/blog");
@@ -73,6 +89,15 @@
if (form == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
return;
@@ -110,3 +135,33 @@
public bool IsPublished { get; set; }
}
}
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -1,5 +1,6 @@
@page "/admin/blog/{id:int}/edit"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@@ -32,19 +33,22 @@ else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="model.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
@@ -71,6 +75,9 @@ else
[Parameter]
public int Id { get; set; }
[Inject]
private IJSRuntime JS { get; set; } = null!;
private MudForm? form;
private Domain.Entities.BlogPost? post;
private List<Domain.Entities.Category> categories = [];
@@ -98,6 +105,14 @@ else
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && post != null)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
}
private void MapPostToModel(Domain.Entities.BlogPost post)
{
model.Title = post.Title;
@@ -119,6 +134,15 @@ else
if (form == null || post == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
return;
@@ -185,3 +209,33 @@ else
public bool IsPublished { get; set; }
}
}
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -15,14 +15,19 @@
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
</section>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface mb-4" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
</MudStack>
</MudPaper>
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행">
@@ -50,16 +55,36 @@
</MudStack>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
private string searchQuery = "";
private bool isLoading = true;
private int currentPage = 1;
private int totalPages = 1;
private int totalPosts = 0;
private const int PageSize = 20;
protected override async Task OnInitializedAsync()
private IEnumerable<TaxBaik.Domain.Entities.BlogPost> FilteredPosts => posts?
.Where(p => string.IsNullOrEmpty(searchQuery) ||
p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
(p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))) ?? Enumerable.Empty<TaxBaik.Domain.Entities.BlogPost>();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadPosts();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadPosts();
StateHasChanged();
}
}
}
}
private async Task LoadPosts()
@@ -129,6 +129,9 @@
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Client>? clients;
private string searchText = "";
private string statusFilter = "";
@@ -137,7 +140,21 @@
private int totalPages;
private const int PageSize = 20;
protected override async Task OnInitializedAsync() => await LoadAsync();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync()
{
@@ -100,10 +100,17 @@
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("방문 상담")">방문 상담</MudSelectItem>
<MudSelectItem Value="@("전화 상담")">전화 상담</MudSelectItem>
<MudSelectItem Value="@("세무조사 대응 미팅")">세무조사 대응 미팅</MudSelectItem>
<MudSelectItem Value="@("카카오톡 상담")">카카오톡 상담</MudSelectItem>
<MudSelectItem Value="@("이메일 자료 접수")">이메일 자료 접수</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
@@ -116,6 +123,9 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<ConsultingActivity>? activities;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -124,9 +134,20 @@
private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -134,9 +155,9 @@
try
{
activities = await ActivityClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -147,7 +168,11 @@
private void OpenCreateDialog()
{
editingActivity = null;
activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
activityForm = new ConsultingActivityForm
{
ActivityDate = DateTime.Now,
ClientId = clients.FirstOrDefault()?.Id ?? 0
};
isDialogOpen = true;
}
@@ -167,6 +192,16 @@
private async Task SaveActivity()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
if (editingActivity == null)
@@ -238,6 +273,12 @@
activityForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class ConsultingActivityForm
{
public int ClientId { get; set; }
+184 -108
View File
@@ -21,122 +21,162 @@
</MudText>
}
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
새 계약 추가
</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0">
@if (contracts is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (contracts.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
계약이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="Contract"
Items="@contracts"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
@if (contracts is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (contracts.Count == 0)
{
<MudAlert Severity="Severity.Info">
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
계약이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="Contract"
Items="@contracts"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedContract"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
<TemplateColumn Title="계약기간">
<CellTemplate>
@context.Item.StartDate.ToString("yyyy-MM-dd")
@if (context.Item.EndDate.HasValue)
{
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="상태">
<CellTemplate>
@{
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
}
@if (isActive)
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "계약 상세 정보" : "새 계약 추가")</MudText>
@if (isEditMode)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
새로 작성
</MudButton>
}
</div>
<MudForm @ref="form">
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
@foreach (var client in clients)
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
<TemplateColumn Title="계약기간">
<CellTemplate>
@context.Item.StartDate.ToString("yyyy-MM-dd")
@if (context.Item.EndDate.HasValue)
</MudSelect>
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudSelect T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem>
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem>
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem>
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem>
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem>
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" />
<div class="d-flex justify-end gap-2">
@if (isEditMode)
{
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="상태">
<CellTemplate>
@{
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
}
@if (isActive)
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteContract(selectedContract?.Id ?? 0))">삭제</MudButton>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveContract" id="btn-save-contract">저장</MudButton>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
<!-- Create Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">새 계약 추가</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveContract">저장</MudButton>
</DialogActions>
</MudDialog>
</div>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
}
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Contract>? contracts;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private decimal mrr = 0;
private MudForm? form;
private bool isDialogOpen;
private bool isEditMode;
private Contract? selectedContract;
private ContractForm contractForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -144,9 +184,9 @@
try
{
contracts = await ContractClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
}
catch (Exception ex)
@@ -155,18 +195,49 @@
}
}
private void OpenCreateDialog()
private void PrepareCreate()
{
contractForm = new();
isDialogOpen = true;
selectedContract = null;
isEditMode = false;
contractForm = new ContractForm
{
ClientId = clients.FirstOrDefault()?.Id,
StartDate = DateTime.Today
};
}
private void OnRowSelected(Contract contract)
{
if (contract == null) return;
selectedContract = contract;
isEditMode = true;
contractForm = new ContractForm
{
ClientId = contract.ClientId,
ContractNumber = contract.ContractNumber,
ServiceType = contract.ServiceType,
StartDate = contract.StartDate,
MonthlyFee = contract.MonthlyFee
};
}
private async Task SaveContract()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
if (contractForm.ClientId == null) return;
var newId = await ContractClient.CreateAsync(
contractForm.ClientId,
contractForm.ClientId.Value,
contractForm.ContractNumber,
contractForm.ServiceType,
contractForm.StartDate ?? DateTime.Now,
@@ -175,7 +246,7 @@
if (newId > 0)
{
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
CloseDialog();
PrepareCreate();
await LoadData();
}
}
@@ -203,6 +274,10 @@
{
await ContractClient.DeleteAsync(id);
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
if (selectedContract?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -211,15 +286,16 @@
}
}
private void CloseDialog()
{
isDialogOpen = false;
contractForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class ContractForm
{
public int ClientId { get; set; }
public int? ClientId { get; set; }
public string ContractNumber { get; set; } = "";
public string ServiceType { get; set; } = "";
public DateTime? StartDate { get; set; }
@@ -17,49 +17,58 @@
</MudButton>
</section>
<!-- Metrics Grid - Pure HTML div instead of MudGrid to ensure proper layout -->
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
}
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<!-- Metrics Grid -->
<div class="admin-metric-grid">
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">이번달 문의</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #1565c0;">@summary.ThisMonthInquiries</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #1976d2;">💬</span>
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">이번달 문의</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--primary-dark);">@summary.ThisMonthInquiries</span>
<span class="admin-metric-card-icon" style="color: var(--primary-color);">💬</span>
</div>
<span style="font-size: 0.9rem; color: #666;">월간 상담 유입 (클릭 시 이동)</span>
<span class="admin-metric-card-caption">월간 상담 유입 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">신규 문의</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #e65100;">@summary.NewInquiries</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #f57c00;">⚠️</span>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">신규 문의</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--tertiary-dark);">@summary.NewInquiries</span>
<span class="admin-metric-card-icon" style="color: var(--tertiary-color);">⚠️</span>
</div>
<span style="font-size: 0.9rem; color: #666;">처리 대기 (클릭 시 이동)</span>
<span class="admin-metric-card-caption">처리 대기 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">전체 포스트</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #455a64;">@summary.TotalPosts</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #607d8b;">📄</span>
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">전체 포스트</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: #455a64;">@summary.TotalPosts</span>
<span class="admin-metric-card-icon" style="color: #607d8b;">📄</span>
</div>
<span style="font-size: 0.9rem; color: #666;">콘텐츠 자산 (클릭 시 이동)</span>
<span class="admin-metric-card-caption">콘텐츠 자산 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">발행된 포스트</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #2e7d32;">@summary.PublishedPosts</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #388e3c;">🌐</span>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">발행된 포스트</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--secondary-dark);">@summary.PublishedPosts</span>
<span class="admin-metric-card-icon" style="color: var(--secondary-color);">🌐</span>
</div>
<span style="font-size: 0.9rem; color: #666;">검색 노출 대상 (클릭 시 이동)</span>
<span class="admin-metric-card-caption">검색 노출 대상 (클릭 시 이동)</span>
</div>
</div>
</div>
@@ -158,31 +167,45 @@
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
private string? errorMessage;
private bool isLoading = true;
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try
if (firstRender)
{
// API 클라이언트 사용 (서비스 직접 호출 X)
var summaryTask = DashboardClient.GetSummaryAsync();
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
try
{
// API 클라이언트 사용 (서비스 직접 호출 X)
var summaryTask = DashboardClient.GetSummaryAsync();
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
await Task.WhenAll(summaryTask, filingsTask);
summary = await summaryTask;
upcomingFilings = (await filingsTask).ToList();
}
catch (Exception ex)
{
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
}
finally
{
isLoading = false;
await Task.WhenAll(summaryTask, filingsTask);
summary = await summaryTask;
upcomingFilings = (await filingsTask).ToList();
}
catch (Exception ex)
{
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
}
finally
{
isLoading = false;
StateHasChanged();
}
}
}
}
}
@@ -22,16 +22,21 @@
</MudButton>
</section>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface" Elevation="0">
@if (faqs is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!faqs.Any())
else if (!FilteredFaqs.Any())
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
<MudText Class="mt-2 text-muted">검색 조건에 맞는 FAQ가 없습니다.</MudText>
</div>
}
else
@@ -39,7 +44,7 @@
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th style="width:60px;">순서</th>
<th style="width:110px;">순서</th>
<th>질문</th>
<th style="width:130px;">카테고리</th>
<th style="width:90px;">상태</th>
@@ -47,11 +52,15 @@
</tr>
</thead>
<tbody>
@foreach (var item in faqs)
@foreach (var item in FilteredFaqs)
{
<tr>
<td class="text-center">
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
<td>
<div class="d-flex align-center justify-start gap-1">
<MudText Typo="Typo.body2" Class="mr-2">@item.SortOrder</MudText>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropUp" Size="Size.Small" OnClick="@(() => MoveUpAsync(item))" Style="padding:2px;" Dense="true" />
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropDown" Size="Size.Small" OnClick="@(() => MoveDownAsync(item))" Style="padding:2px;" Dense="true" />
</div>
</td>
<td>
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
@@ -77,10 +86,10 @@
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
수정
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
삭제
삭제
</MudButton>
</MudButtonGroup>
</td>
@@ -89,21 +98,45 @@
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
</MudText>
}
</MudPaper>
@code {
private List<Faq>? faqs;
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
protected override async Task OnInitializedAsync() => await LoadAsync();
private List<Faq>? faqs;
private string searchQuery = "";
private IEnumerable<Faq> FilteredFaqs => faqs?
.Where(f => string.IsNullOrEmpty(searchQuery) ||
f.Question.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
(f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)))
.OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync()
{
try
{
faqs = (await FaqClient.GetAllAsync()).ToList();
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList();
}
catch (Exception ex)
{
@@ -112,6 +145,66 @@
}
}
private async Task MoveUpAsync(Faq item)
{
if (faqs == null) return;
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
var index = sorted.IndexOf(item);
if (index <= 0) return;
var prev = sorted[index - 1];
var temp = item.SortOrder;
item.SortOrder = prev.SortOrder;
prev.SortOrder = temp;
if (item.SortOrder == prev.SortOrder)
{
prev.SortOrder = item.SortOrder + 1;
}
try
{
await FaqClient.UpdateAsync(item.Id, item);
await FaqClient.UpdateAsync(prev.Id, prev);
Snackbar.Add("순서가 상향되었습니다.", Severity.Success);
await LoadAsync();
}
catch (Exception ex)
{
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
}
}
private async Task MoveDownAsync(Faq item)
{
if (faqs == null) return;
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
var index = sorted.IndexOf(item);
if (index < 0 || index >= sorted.Count - 1) return;
var next = sorted[index + 1];
var temp = item.SortOrder;
item.SortOrder = next.SortOrder;
next.SortOrder = temp;
if (item.SortOrder == next.SortOrder)
{
next.SortOrder = item.SortOrder + 1;
}
try
{
await FaqClient.UpdateAsync(item.Id, item);
await FaqClient.UpdateAsync(next.Id, next);
Snackbar.Add("순서가 하향되었습니다.", Severity.Success);
await LoadAsync();
}
catch (Exception ex)
{
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
}
}
private async Task DeleteAsync(Faq item)
{
var confirmed = await DialogService.ShowMessageBox(
@@ -46,11 +46,31 @@ else
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private bool isLoading = true;
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
{
isLoading = true;
try
{
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
+21 -102
View File
@@ -1,12 +1,10 @@
@page "/admin/login"
@using System.ComponentModel.DataAnnotations
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous]
@rendermode @(new InteractiveServerRenderMode(prerender: true))
@inject IApiClient ApiClient
@inject NavigationManager NavigationManager
@inject CustomAuthenticationStateProvider AuthStateProvider
@inject IJSRuntime Js
@inject ILocalStorageService LocalStorageService
@inject IJSRuntime Js
<PageTitle>로그인</PageTitle>
@@ -14,52 +12,39 @@
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
<form @onsubmit="HandleLogin" @onsubmit:preventDefault>
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="사용자명"
autocomplete="username"
@bind-Value="model.Username" />
<form id="admin-login-form">
<input class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="사용자명"
autocomplete="username"
name="username"
value="@model.Username" />
<InputText type="password"
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="비밀번호"
autocomplete="current-password"
@bind-Value="model.Password" />
<input type="password"
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="비밀번호"
autocomplete="current-password"
name="password" />
<div class="mb-4">
<InputCheckbox class="mud-checkbox" @bind-Value="model.RememberMe" />
<input class="mud-checkbox" type="checkbox" name="rememberMe" />
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
}
<div class="mud-alert mud-alert-filled-error mb-4 login-error-message" style="display:none;">로그인 중 오류가 발생했습니다.</div>
<button type="submit"
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
disabled="@isLoading">
@if (isLoading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>로그인 중...</span>
}
else
{
<span>로그인</span>
}
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;">
<span>로그인</span>
</button>
</form>
</MudPaper>
</MudContainer>
@code {
private bool isLoading = false;
private string errorMessage = "";
private LoginModel model = new();
private readonly LoginModel model = new();
private const string RememberedUsernameKey = "admin-remembered-username";
protected override async Task OnInitializedAsync()
@@ -70,12 +55,11 @@
if (!string.IsNullOrEmpty(remembered))
{
model.Username = remembered;
model.RememberMe = true;
}
}
catch
{
// LocalStorage not available in pre-render
// LocalStorage may be unavailable during prerender.
}
}
@@ -85,75 +69,10 @@
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
}
private async Task HandleLogin()
{
if (isLoading)
return;
isLoading = true;
errorMessage = "";
try
{
var request = new { model.Username, model.Password };
var response = await ApiClient.PostAsync<LoginResponse>("auth/login", request);
if (response?.AccessToken == null || response?.RefreshToken == null)
{
errorMessage = "사용자명 또는 비밀번호가 올바르지 않습니다.";
isLoading = false;
return;
}
if (model.RememberMe)
{
await LocalStorageService.SetItemAsStringAsync(RememberedUsernameKey, model.Username);
}
else
{
await LocalStorageService.RemoveItemAsync(RememberedUsernameKey);
}
await ApiClient.SetAuthToken(response.AccessToken);
await AuthStateProvider.LoginAsync(response.AccessToken, response.RefreshToken, response.ExpiresIn);
NavigationManager.NavigateTo(GetReturnUrl(), forceLoad: false);
}
catch
{
errorMessage = "로그인 중 오류가 발생했습니다.";
isLoading = false;
}
}
private class LoginResponse
{
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}
private class LoginModel
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool RememberMe { get; set; }
}
private string GetReturnUrl()
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
if (!Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("returnUrl", out var returnUrl)
|| string.IsNullOrWhiteSpace(returnUrl))
{
return "/taxbaik/admin/dashboard";
}
var value = returnUrl.ToString();
if (!value.StartsWith("admin", StringComparison.OrdinalIgnoreCase))
{
return "/taxbaik/admin/dashboard";
}
return $"/taxbaik/{value.TrimStart('/')}";
}
}
@@ -96,13 +96,19 @@
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem>
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem>
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem>
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem>
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
@@ -113,6 +119,9 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<RevenueTracking>? revenues;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -120,9 +129,20 @@
private bool isDialogOpen;
private RevenueForm revenueForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -130,9 +150,9 @@
try
{
revenues = await RevenueClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -142,12 +162,27 @@
private void OpenCreateDialog()
{
revenueForm = new();
revenueForm = new RevenueForm
{
ClientId = clients.FirstOrDefault()?.Id ?? 0,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(14)
};
isDialogOpen = true;
}
private async Task SaveRevenue()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
var newId = await RevenueClient.CreateAsync(
@@ -217,6 +252,12 @@
revenueForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class RevenueForm
{
public int ClientId { get; set; }
@@ -14,150 +14,201 @@
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText>
</div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenCreateDialog"
StartIcon="@Icons.Material.Filled.Add">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-schedule">
새 일정 추가
</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0">
@if (schedules is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (schedules.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
신고 일정이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="TaxFilingSchedule"
Items="@schedules"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
@if (schedules is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (schedules.Count == 0)
{
<MudAlert Severity="Severity.Info">
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
신고 일정이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="TaxFilingSchedule"
Items="@schedules"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedSchedule"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
<TemplateColumn Title="마감일">
<CellTemplate>
@{
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
}
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
@context.Item.DueDate.ToString("yyyy-MM-dd")
@if (daysLeft >= 0)
{
<span class="ms-1">(D-@daysLeft)</span>
}
else
{
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
}
</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
<TemplateColumn Title="상태">
<CellTemplate>
@if (context.Item.Status == "completed")
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.Status != "completed")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
Color="Color.Success"
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
Title="완료" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
Title="삭제" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "신고 일정 상세" : "새 신고 일정 추가")</MudText>
@if (isEditMode)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
새로 작성
</MudButton>
}
</div>
<MudForm @ref="form">
<MudSelect T="int?"
@bind-Value="scheduleForm.ClientId"
Label="고객"
Required="true"
Variant="Variant.Outlined"
FullWidth="@true"
Class="mb-3"
RequiredError="고객을 선택하세요."
Disabled="@isEditMode">
@foreach (var client in clients)
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
<TemplateColumn Title="마감일">
<CellTemplate>
@{
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
}
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
@context.Item.DueDate.ToString("yyyy-MM-dd")
@if (daysLeft >= 0)
{
<span class="ms-1">(D-@daysLeft)</span>
}
else
{
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
}
</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
<TemplateColumn Title="상태">
<CellTemplate>
@if (context.Item.Status == "completed")
</MudSelect>
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
<MudSelectItem Value="@("종합소득세")">종합소득세</MudSelectItem>
<MudSelectItem Value="@("부가가치세")">부가가치세</MudSelectItem>
<MudSelectItem Value="@("법인세")">법인세</MudSelectItem>
<MudSelectItem Value="@("원천세")">원천세</MudSelectItem>
<MudSelectItem Value="@("종합부동산세")">종합부동산세</MudSelectItem>
<MudSelectItem Value="@("양도소득세")">양도소득세</MudSelectItem>
<MudSelectItem Value="@("상속·증여세")">상속·증여세</MudSelectItem>
<MudSelectItem Value="@("세무조정")">세무조정</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" Required="true" />
<div class="d-flex justify-end gap-2">
@if (isEditMode && selectedSchedule?.Status != "completed")
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
<MudButton Variant="Variant.Outlined" Color="Color.Success" OnClick="@(async () => await CompleteSchedule(selectedSchedule?.Id ?? 0))">완료 처리</MudButton>
}
@if (isEditMode)
{
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteSchedule(selectedSchedule?.Id ?? 0))">삭제</MudButton>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSchedule" id="btn-save-schedule">저장</MudButton>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.Status != "completed")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
Color="Color.Success"
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
Title="완료" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
Title="삭제" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">새 신고 일정 추가</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int"
@bind-Value="scheduleForm.ClientId"
Label="고객"
Required="true"
Variant="Variant.Outlined"
FullWidth="true"
Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveSchedule">저장</MudButton>
</DialogActions>
</MudDialog>
</div>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
}
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxFilingSchedule>? schedules;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen;
private bool isEditMode;
private TaxFilingSchedule? selectedSchedule;
private TaxFilingScheduleForm scheduleForm = new();
protected override async Task OnInitializedAsync() => await LoadData();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
}
}
private async Task LoadData()
{
try
{
schedules = await TaxFilingClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -165,18 +216,49 @@
}
}
private void OpenCreateDialog()
private void PrepareCreate()
{
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
isDialogOpen = true;
selectedSchedule = null;
isEditMode = false;
scheduleForm = new TaxFilingScheduleForm
{
FilingYear = DateTime.Now.Year,
DueDate = DateTime.Today,
ClientId = clients.FirstOrDefault()?.Id
};
}
private void OnRowSelected(TaxFilingSchedule schedule)
{
if (schedule == null) return;
selectedSchedule = schedule;
isEditMode = true;
scheduleForm = new TaxFilingScheduleForm
{
ClientId = schedule.ClientId,
FilingType = schedule.FilingType,
DueDate = schedule.DueDate,
FilingYear = schedule.FilingYear
};
}
private async Task SaveSchedule()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
if (scheduleForm.ClientId == null) return;
var newId = await TaxFilingClient.CreateAsync(
scheduleForm.ClientId,
scheduleForm.ClientId.Value,
scheduleForm.FilingType,
scheduleForm.DueDate ?? DateTime.Today,
scheduleForm.FilingYear);
@@ -184,7 +266,7 @@
if (newId > 0)
{
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
CloseDialog();
PrepareCreate();
await LoadData();
}
else
@@ -204,6 +286,10 @@
{
await TaxFilingClient.MarkCompletedAsync(id);
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -229,6 +315,10 @@
{
await TaxFilingClient.DeleteAsync(id);
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -237,15 +327,16 @@
}
}
private void CloseDialog()
{
isDialogOpen = false;
scheduleForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class TaxFilingScheduleForm
{
public int ClientId { get; set; }
public int? ClientId { get; set; }
public string FilingType { get; set; } = "";
public DateTime? DueDate { get; set; }
public int FilingYear { get; set; } = DateTime.Now.Year;
@@ -101,7 +101,7 @@
{
try
{
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
return items;
}
catch
@@ -110,6 +110,12 @@
}
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private async Task AddFiling()
{
try
@@ -2,6 +2,7 @@
@using TaxBaik.Web.Services.AdminClients
@inject ITaxProfileBrowserClient TaxProfileClient
@inject IClientBrowserClient ClientClient
@inject ICommonCodeBrowserClient CommonCodeClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@attribute [Authorize]
@@ -14,7 +15,7 @@
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-profile">
새 프로필 추가
</MudButton>
</section>
@@ -23,102 +24,139 @@
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (profiles.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">세무 프로필이 없습니다.</MudAlert>
}
else
{
<MudDataGrid T="TaxProfile"
Items="@profiles"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid mt-4">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (profiles.Count == 0)
{
<MudAlert Severity="Severity.Info">세무 프로필이 없습니다.</MudAlert>
}
else
{
<MudDataGrid T="TaxProfile"
Items="@profiles"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedProfile"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
<TemplateColumn Title="위험도">
<CellTemplate>
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
@context.Item.TaxRiskLevel
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="다음 신고">
<CellTemplate>
@if (context.Item.NextFilingDueDate.HasValue)
{
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
@if (isEditMode)
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
새로 작성
</MudButton>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
<TemplateColumn Title="위험도">
<CellTemplate>
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
@context.Item.TaxRiskLevel
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="다음 신고">
<CellTemplate>
@if (context.Item.NextFilingDueDate.HasValue)
{
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
</div>
<MudForm @ref="form">
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
@foreach (var client in clients)
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
@foreach (var type in businessTypes)
{
<MudSelectItem Value="@type.CodeValue">@type.CodeName</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3">
@foreach (var level in riskLevels)
{
<MudSelectItem Value="@level.CodeValue">@level.CodeName</MudSelectItem>
}
</MudSelect>
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" />
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="@true" Lines="3" Class="mb-4" />
<div class="d-flex justify-end gap-2">
@if (isEditMode)
{
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteProfile(selectedProfile?.Id ?? 0))">삭제</MudButton>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveProfile" id="btn-save-profile">저장</MudButton>
</div>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
}
<!-- Create/Edit Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("low")">낮음</MudSelectItem>
<MudSelectItem Value="@("normal")">보통</MudSelectItem>
<MudSelectItem Value="@("high")">높음</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="true" Lines="2" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveProfile">저장</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxProfile>? profiles;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private List<CommonCode> businessTypes = [];
private List<CommonCode> riskLevels = [];
private MudForm? form;
private bool isDialogOpen;
private bool isEditMode;
private TaxProfile? editingProfile;
private TaxProfile? selectedProfile;
private TaxProfileForm profileForm = new();
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await LoadData();
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -126,9 +164,35 @@ else
try
{
profiles = await TaxProfileClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
businessTypes = await CommonCodeClient.GetByGroupAsync("BUSINESS_TYPE");
if (businessTypes.Count == 0)
{
businessTypes = [
new() { CodeValue = "일반제조업", CodeName = "일반제조업" },
new() { CodeValue = "도소매업", CodeName = "도소매업" },
new() { CodeValue = "서비스업", CodeName = "서비스업" },
new() { CodeValue = "정보통신업", CodeName = "정보통신업" },
new() { CodeValue = "부동산업", CodeName = "부동산업" },
new() { CodeValue = "건설업", CodeName = "건설업" },
new() { CodeValue = "음식점업", CodeName = "음식점업" },
new() { CodeValue = "프리랜서", CodeName = "프리랜서" },
new() { CodeValue = "기타", CodeName = "기타" }
];
}
riskLevels = await CommonCodeClient.GetByGroupAsync("TAX_RISK_LEVEL");
if (riskLevels.Count == 0)
{
riskLevels = [
new() { CodeValue = "low", CodeName = "낮음" },
new() { CodeValue = "normal", CodeName = "보통" },
new() { CodeValue = "high", CodeName = "높음" }
];
}
}
catch (Exception ex)
{
@@ -136,18 +200,23 @@ else
}
}
private void OpenCreateDialog()
private void PrepareCreate()
{
selectedProfile = null;
isEditMode = false;
editingProfile = null;
profileForm = new();
isDialogOpen = true;
profileForm = new TaxProfileForm
{
ClientId = clients.FirstOrDefault()?.Id,
TaxRiskLevel = "normal",
NextFilingDueDate = DateTime.Today.AddMonths(1)
};
}
private async Task OpenEditDialog(TaxProfile profile)
private void OnRowSelected(TaxProfile profile)
{
if (profile == null) return;
selectedProfile = profile;
isEditMode = true;
editingProfile = profile;
profileForm = new TaxProfileForm
{
ClientId = profile.ClientId,
@@ -156,34 +225,50 @@ else
NextFilingDueDate = profile.NextFilingDueDate,
SpecialNotes = profile.SpecialNotes
};
isDialogOpen = true;
}
private async Task SaveProfile()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
}
try
{
if (isEditMode)
if (isEditMode && selectedProfile != null)
{
await TaxProfileClient.UpdateAsync(
editingProfile!.Id,
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
await TaxProfileClient.UpdateAsync(selectedProfile.Id, profileForm.BusinessType,
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success);
}
else
{
if (!profileForm.ClientId.HasValue)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
var newId = await TaxProfileClient.CreateAsync(
profileForm.ClientId,
profileForm.ClientId.Value,
profileForm.BusinessType);
if (newId > 0)
{
await TaxProfileClient.UpdateAsync(
newId,
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
}
}
CloseDialog();
PrepareCreate();
await LoadData();
}
catch (Exception ex)
@@ -208,6 +293,10 @@ else
{
await TaxProfileClient.DeleteAsync(id);
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
if (selectedProfile?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -216,14 +305,6 @@ else
}
}
private void CloseDialog()
{
isDialogOpen = false;
isEditMode = false;
editingProfile = null;
profileForm = new();
}
private Color GetRiskColor(string riskLevel) => riskLevel switch
{
"high" => Color.Error,
@@ -232,9 +313,16 @@ else
_ => Color.Default
};
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class TaxProfileForm
{
public int ClientId { get; set; }
public int? ClientId { get; set; }
public string BusinessType { get; set; } = "";
public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; }
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Route("api/admin-dashboard")]
[Authorize]
public class AdminDashboardController : ControllerBase
{
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CommonCodeController(CommonCodeService commonCodeService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAllActive()
{
try
{
var codes = await commonCodeService.GetAllActiveAsync();
return Ok(codes);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "공통코드 조회 실패", message = ex.Message });
}
}
[HttpGet("group/{group}")]
public async Task<IActionResult> GetByGroup(string group)
{
try
{
var codes = await commonCodeService.GetByGroupAsync(group);
return Ok(codes);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "그룹별 공통코드 조회 실패", message = ex.Message });
}
}
}
@@ -24,6 +24,20 @@ public class ConsultingActivityController(ConsultingActivityService service) : C
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var activities = await service.GetAllAsync();
return Ok(activities);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class ContractController(ContractService service) : ControllerBase
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var contracts = await service.GetAllAsync();
return Ok(contracts);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class RevenueTrackingController(RevenueTrackingService service) : Control
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var revenues = await service.GetAllAsync();
return Ok(revenues);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class TaxFilingScheduleController(TaxFilingScheduleService service) : Con
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var schedules = await service.GetAllAsync();
return Ok(schedules);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
@@ -24,6 +24,20 @@ public class TaxProfileController(TaxProfileService taxProfileService) : Control
}
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
try
{
var profiles = await taxProfileService.GetAllAsync();
return Ok(profiles);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
}
}
[HttpGet("client/{clientId:int}")]
public async Task<IActionResult> GetByClientId(int clientId)
{
-87
View File
@@ -1,87 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace TaxBaik.Web.Hubs;
/// <summary>
/// Real-time notification hub for admin dashboard
/// SOLID: Single Responsibility - Only broadcasts change notifications
/// No state management - stateless broadcast pattern
/// </summary>
[Authorize]
public class NotificationHub : Hub
{
private const string AdminGroup = "admins";
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
await base.OnConnectedAsync();
}
/// <summary>
/// Broadcast inquiry status changed to all connected admins
/// Clients should re-fetch from API to verify
/// </summary>
public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
{
await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
{
InquiryId = inquiryId,
Status = newStatus,
ChangedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast inquiry submitted (new inquiry created)
/// </summary>
public async Task NotifyInquiryCreated(int inquiryId, string name)
{
await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
{
InquiryId = inquiryId,
Name = name,
CreatedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast client created
/// </summary>
public async Task NotifyClientCreated(int clientId, string name)
{
await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
{
ClientId = clientId,
Name = name,
CreatedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast announcement published
/// </summary>
public async Task NotifyAnnouncementPublished(int announcementId, string title)
{
await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
{
AnnouncementId = announcementId,
Title = title,
PublishedAt = DateTime.UtcNow
});
}
/// <summary>
/// Broadcast tax filing completed
/// </summary>
public async Task NotifyFilingCompleted(int filingId, string filingType)
{
await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
{
FilingId = filingId,
FilingType = filingType,
CompletedAt = DateTime.UtcNow
});
}
}
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using Serilog.Core;
using Serilog.Events;
namespace TaxBaik.Web.Logging;
public class TelegramSink : ILogEventSink
{
private readonly string _botToken;
private readonly string _chatId;
private readonly HttpClient _httpClient;
public TelegramSink(string botToken, string chatId)
{
_botToken = botToken;
_chatId = chatId;
_httpClient = new HttpClient();
}
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < LogEventLevel.Error)
{
return;
}
// Filter out harmless client disconnect and task cancellation exceptions
if (logEvent.Exception != null)
{
var exTypeName = logEvent.Exception.GetType().FullName ?? "";
var exMessage = logEvent.Exception.Message ?? "";
if (exTypeName.Contains("JSDisconnectedException") ||
exTypeName.Contains("TaskCanceledException") ||
exMessage.Contains("JavaScript interop calls cannot be issued") ||
exMessage.Contains("circuit has disconnected"))
{
return;
}
}
// Emit is a synchronous method, so we dispatch the network call asynchronously
Task.Run(async () =>
{
try
{
var timestamp = logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz");
var level = logEvent.Level.ToString().ToUpper();
var message = logEvent.RenderMessage();
var exceptionDetails = logEvent.Exception?.ToString();
var sb = new StringBuilder();
sb.AppendLine($"<b>🚨 [{level}] 에러 발생</b>");
sb.AppendLine($"<b>시간:</b> {timestamp}");
sb.AppendLine($"<b>메시지:</b> {EscapeHtml(message)}");
if (!string.IsNullOrEmpty(exceptionDetails))
{
var escapedException = EscapeHtml(exceptionDetails);
if (escapedException.Length > 3000)
{
escapedException = escapedException.Substring(0, 3000) + "\n[이하 생략]";
}
sb.AppendLine($"<b>Exception 상세:</b>\n<pre>{escapedException}</pre>");
}
var url = $"https://api.telegram.org/bot{_botToken}/sendMessage";
var payload = new
{
chat_id = _chatId,
text = sb.ToString(),
parse_mode = "HTML"
};
var response = await _httpClient.PostAsJsonAsync(url, payload);
if (!response.IsSuccessStatusCode)
{
var errorResponse = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[TelegramSink] Failed to send log to Telegram: {response.StatusCode} - {errorResponse}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[TelegramSink] Error in TelegramSink: {ex.Message}");
}
});
}
private static string EscapeHtml(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
}
+159 -43
View File
@@ -3,55 +3,171 @@
ViewData["Title"] = "소개 | 백원숙 세무회계";
}
<div class="container py-5">
<h1 class="fw-bold mb-5">백원숙 세무사</h1>
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb" class="py-3" style="background: #F9F7F3; border-bottom: 1px solid #D9D3C4;">
<div class="container">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/taxbaik/" class="text-decoration-none">홈</a></li>
<li class="breadcrumb-item active">소개</li>
</ol>
</div>
</nav>
<div class="row g-5">
<div class="col-md-6">
<p class="lead">사업자 세무, 부동산 거래, 가족 자산 관리 등 종합적인 세무 컨설팅을 제공합니다.</p>
<p>10년 이상의 풍부한 경험과 3개의 국가자격증을 바탕으로, 각 클라이언트의 상황에 맞는 맞춤형 솔루션을 제시합니다.</p>
</div>
<div class="col-md-6">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">보유 자격증</h5>
<div class="mb-3">
<p class="mb-1">🎓 <strong>세무사</strong></p>
<small class="text-muted">2015년 자격취득</small>
</div>
<div class="mb-3">
<p class="mb-1">🏠 <strong>부동산중개사</strong></p>
<small class="text-muted">부동산 거래 전문성</small>
</div>
<div>
<p class="mb-1">📊 <strong>보험설계사</strong></p>
<small class="text-muted">자산관리 전문성</small>
<div class="container py-5">
<!-- 돌아가기 버튼 -->
<div class="mb-4">
<a href="/taxbaik/" class="btn btn-sm btn-outline-secondary">← 홈으로 돌아가기</a>
</div>
<!-- Hero Section -->
<section class="mb-5 pb-5 border-bottom">
<h1 class="fw-bold mb-4" style="font-size: 2.5rem;">안녕하세요, 백원숙 세무사입니다.</h1>
<div class="row g-5">
<div class="col-lg-6">
<p class="lead">사업자 세무, 부동산 거래, 가족 자산 관리 등 종합적인 세무 컨설팅을 제공합니다.</p>
<p>10년 이상의 풍부한 경험과 3개의 국가자격증을 바탕으로, 각 클라이언트의 상황에 맞는 맞춤형 솔루션을 제시합니다.</p>
<p class="text-muted">저도 작게 시작하는 사업가였습니다. 처음 사업을 시작할 때의 막막함을 잘 알고 있습니다. 그 경험이 오늘날 고객분들과 소통하는 원동력입니다.</p>
</div>
<div class="col-lg-6">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">보유 자격증</h5>
<div class="mb-3">
<p class="mb-1">🎓 <strong>세무사</strong></p>
<small class="text-muted">2015년 자격취득 · 10년 경력</small>
</div>
<div class="mb-3">
<p class="mb-1">🏠 <strong>부동산중개사</strong></p>
<small class="text-muted">부동산 거래 구조 이해 · 실무 전문성</small>
</div>
<div>
<p class="mb-1">📊 <strong>보험설계사</strong></p>
<small class="text-muted">자산관리·상속 대비 전문성</small>
</div>
</div>
</div>
</div>
</div>
</section>
<hr class="my-5" />
<!-- Expertise Section -->
<section class="mb-5 pb-5 border-bottom">
<h2 class="fw-bold mb-4">세 가지 자격의 시너지</h2>
<p class="text-muted mb-4">단순히 세금을 계산하는 것이 아니라, 사업 구조와 자산 흐름을 종합적으로 이해합니다.</p>
<div class="row g-4">
<div class="col-md-6">
<div class="p-4 border rounded-3" style="border-color: #D9D3C4;">
<div style="font-size: 2rem; margin-bottom: 1rem;">⚖️</div>
<h5 class="fw-bold mb-2">공인 세무사</h5>
<p class="text-muted small mb-0">
세무신고·장부관리·조세 자문 등 세무 업무 전반을 공식 대리합니다. 신고 기한 내 불이익 없는 신고를 기본으로 합니다.
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-4 border rounded-3" style="border-color: #D9D3C4;">
<div style="font-size: 2rem; margin-bottom: 1rem;">🏠</div>
<h5 class="fw-bold mb-2">공인 부동산중개사</h5>
<p class="text-muted small mb-0">
부동산 거래 구조를 이해해 양도·증여·임대 세무상담에 현실감을 더합니다. 계약 전 사전검토로 선택지를 최대화합니다.
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-4 border rounded-3" style="border-color: #D9D3C4;">
<div style="font-size: 2rem; margin-bottom: 1rem;">🛡️</div>
<h5 class="fw-bold mb-2">보험설계사 자격</h5>
<p class="text-muted small mb-0">
상속·증여·대표자 리스크 관점에서 가족 현금흐름과 보험 구조를 함께 설명합니다. 절세와 리스크 관리를 동시에 다룹니다.
</p>
</div>
</div>
</div>
</section>
<h2 class="fw-bold mb-4">서비스 철학</h2>
<div class="row g-4">
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2rem;">🎯</div>
<h5>명확한 설명</h5>
<p class="small">어려운 세법을 쉽게 설명하여 이해를 높입니다</p>
<!-- Philosophy Section -->
<section class="mb-5 pb-5 border-bottom">
<h2 class="fw-bold mb-4">상담 철학</h2>
<div class="row g-4">
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">🎯</div>
<h5>명확한 설명</h5>
<p class="small text-muted">어려운 세법을 쉽게 설명하여 이해를 높입니다. 전문용어로 일방적 설명하지 않습니다.</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">💰</div>
<h5>최대 절세</h5>
<p class="small text-muted">법적 범위 내에서 세금을 최소화합니다. 초기 세무 전략이 연간 수백만 원의 차이를 만듭니다.</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">🤝</div>
<h5>신뢰 파트너</h5>
<p class="small text-muted">장기적 파트너로서 성장을 함께 합니다. 일회성 상담이 아닌 지속적 관계를 지향합니다.</p>
</div>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2rem;">💰</div>
<h5>최대 절세</h5>
<p class="small">법적 범위 내에서 세금을 최소화합니다</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2rem;">🤝</div>
<h5>신뢰 관계</h5>
<p class="small">장기적 파트너로서 성장을 함께 합니다</p>
</div>
</div>
</section>
<div class="text-center mt-5">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
</div>
<!-- Online Consultation Section -->
<section class="mb-5 pb-5 border-bottom">
<h2 class="fw-bold mb-4">전국 비대면 온라인 상담</h2>
<div class="row g-4">
<div class="col-md-6">
<h5 class="fw-bold mb-3">왜 온라인인가?</h5>
<ul class="list-unstyled">
<li class="mb-2"><strong>✓ 시간 절약</strong><br/><small class="text-muted">서울로 올 필요 없이 카카오·이메일로 진행</small></li>
<li class="mb-2"><strong>✓ 자료 공유 편의</strong><br/><small class="text-muted">온라인으로 자료 검토 후 맞춤 상담</small></li>
<li class="mb-2"><strong>✓ 기록 남음</strong><br/><small class="text-muted">채팅·메일로 모든 내용을 기록 관리</small></li>
<li class="mb-2"><strong>✓ 비용 절감</strong><br/><small class="text-muted">방문 비용 없이 효율적 상담 제공</small></li>
</ul>
</div>
<div class="col-md-6">
<h5 class="fw-bold mb-3">상담 방식</h5>
<div class="p-3 bg-light rounded-3 mb-3">
<p class="fw-bold mb-2">📞 전화 상담</p>
<small class="text-muted">즉시 상황 파악 필요 시 · 010-4122-8268</small>
</div>
<div class="p-3 bg-light rounded-3 mb-3">
<p class="fw-bold mb-2">💬 카카오채널</p>
<small class="text-muted">당일 응답 · 편한 시간에 문의</small>
</div>
<div class="p-3 bg-light rounded-3 mb-3">
<p class="fw-bold mb-2">✉️ 이메일</p>
<small class="text-muted">자료 첨부 상담 · taxbaik5668@gmail.com</small>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="text-center mb-5 pb-5 border-bottom">
<h3 class="fw-bold mb-3">세금 고민, 이제 끝내세요</h3>
<p class="text-muted mb-5">무료 상담으로 현재 상황을 진단하고 맞춤형 절세 전략을 받아보세요.</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-warning btn-lg">카카오로 문의</a>
</div>
</section>
<!-- 관련 페이지 네비게이션 -->
<section class="text-center py-5">
<h4 class="fw-bold mb-4">다른 페이지 보기</h4>
<div class="row g-3 justify-content-center">
<div class="col-md-4">
<a href="/taxbaik/" class="btn btn-outline-primary btn-sm w-100 py-3">
🏠 홈으로<br/>
<small class="text-muted">서비스 및 최신 정보</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/services" class="btn btn-outline-primary btn-sm w-100 py-3">
📊 전문 서비스<br/>
<small class="text-muted">사업자·부동산·자산 관리</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-sm w-100 py-3">
📝 세무 정보 블로그<br/>
<small class="text-muted">절세팁 및 신고 가이드</small>
</a>
</div>
</div>
</section>
</div>
+4
View File
@@ -0,0 +1,4 @@
@page "/announcement"
@{
Response.Redirect("/taxbaik/#top");
}
+2 -2
View File
@@ -39,8 +39,8 @@
<hr class="my-4" />
<div class="article-body lh-lg">
@Html.Raw(Model.Post.Content)
<div class="article-body lh-lg markdown-body">
@Html.Raw(Model.HtmlContent)
</div>
<hr class="my-4" />
+3
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using Markdig;
namespace TaxBaik.Web.Pages.Blog;
@@ -9,6 +10,7 @@ public class BlogPostModel : PageModel
private readonly BlogService _blogService;
public BlogPost? Post { get; set; }
public string? HtmlContent { get; set; }
public BlogPostModel(BlogService blogService)
{
@@ -20,6 +22,7 @@ public class BlogPostModel : PageModel
Post = await _blogService.GetBySlugAsync(slug);
if (Post != null)
{
HtmlContent = Markdown.ToHtml(Post.Content ?? "");
_ = _blogService.IncrementViewCountAsync(Post.Id);
}
}
+4
View File
@@ -0,0 +1,4 @@
@page "/faq"
@{
Response.Redirect("/taxbaik/#faq");
}
+90 -131
View File
@@ -103,31 +103,14 @@ else
</section>
}
<!-- 신뢰도 스트립 — 자격과 경험 -->
<section class="trust-strip">
<!-- About 링크 배너 -->
<section class="py-3" style="background: rgba(46, 92, 78, 0.05); border-bottom: 1px solid rgba(46, 92, 78, 0.1);">
<div class="container">
<div class="row">
<div class="col-md-4">
<div class="trust-item">
<div class="trust-icon">🎓</div>
<h3>세무사</h3>
<p>국가공인 세무사 자격<br/>2015년 취득 · 10년 경력</p>
</div>
</div>
<div class="col-md-4">
<div class="trust-item">
<div class="trust-icon">🏢</div>
<h3>부동산중개사</h3>
<p>부동산 거래 전문 자격<br/>양도세·취득세 컨설팅</p>
</div>
</div>
<div class="col-md-4">
<div class="trust-item">
<div class="trust-icon">📊</div>
<h3>보험설계사</h3>
<p>자산관리 전문 자격<br/>가족 자산 플래닝</p>
</div>
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<p class="mb-0 small text-muted">세무사의 경력, 자격, 상담 철학을 알아보세요</p>
</div>
<a href="/taxbaik/about" class="btn btn-sm btn-outline-primary">백원숙 세무사 소개 →</a>
</div>
</div>
</section>
@@ -144,7 +127,7 @@ else
@{
var focusService = season?.FocusService ?? "";
// 시즌에 따라 서비스 카드 순서 결정: 시즌 관련 카드가 맨 앞
// 시즌에 따라 서비스 카드 순서 결정
var cardOrder = focusService switch
{
"real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" },
@@ -162,18 +145,10 @@ else
<div class="col-lg-4 col-md-6">
<div class="card service-card h-100 @(isFeatured ? "service-card--featured" : "")">
@if (isFeatured) { <div class="service-card-badge">현재 시즌</div> }
<div class="service-icon">🏪</div>
<div class="service-icon">📊</div>
<div class="card-body pt-0">
<h3 class="card-title">사업자 세무</h3>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 정확한 기장 및 결산</li>
<li class="mb-2">✓ 세금계산서 관리</li>
<li class="mb-2">✓ 경비처리 최적화</li>
<li class="mb-2">✓ 절세 전략 수립</li>
</ul>
<p class="text-muted small">
초기부터 세무 전략을 수립하면 연간 최대 수백만 원의 절세가 가능합니다.
</p>
<p class="text-muted small">월 기장부터 종합소득세, 신규 사업자 세무까지 — 사업 초기부터 체계적인 세무 관리.</p>
<a href="/taxbaik/services#business-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
@@ -187,15 +162,7 @@ else
<div class="service-icon">🏠</div>
<div class="card-body pt-0">
<h3 class="card-title">부동산 세금</h3>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 양도세 최소화</li>
<li class="mb-2">✓ 취득세 절감</li>
<li class="mb-2">✓ 임대소득 관리</li>
<li class="mb-2">✓ 다주택자 세무</li>
</ul>
<p class="text-muted small">
부동산 거래 시 미리 상담하면 세금 부담을 크게 줄일 수 있습니다.
</p>
<p class="text-muted small">양도세·취득세·임대소득세 — 부동산 거래 시 세금 부담을 줄이는 전략.</p>
<a href="/taxbaik/services#real-estate-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
@@ -209,15 +176,7 @@ else
<div class="service-icon">👨‍👩‍👧‍👦</div>
<div class="card-body pt-0">
<h3 class="card-title">가족자산 관리</h3>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 증여세 전략</li>
<li class="mb-2">✓ 상속세 대비</li>
<li class="mb-2">✓ 자산 이전 계획</li>
<li class="mb-2">✓ 가족법인 설립</li>
</ul>
<p class="text-muted small">
세대 이전 전에 사전 계획하면 세금 부담을 현저히 줄일 수 있습니다.
</p>
<p class="text-muted small">증여·상속 사전 계획부터 대표자 리스크 관리까지 — 가족 자산을 지키는 전략.</p>
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
@@ -228,6 +187,85 @@ else
</div>
</section>
<!-- 블로그 & 시즌 포스트 (상단으로 올림) -->
<section class="py-5">
<div class="container">
<div class="text-center mb-5">
@if (season != null)
{
<div class="seasonal-blog-header mb-2">
<span class="seasonal-blog-tag">📅 @season.Name 시즌</span>
</div>
<h2 class="section-title">이번 시즌 세무 정보</h2>
<p class="text-muted">@season.Name 관련 절세 팁과 신고 가이드를 확인하세요</p>
}
else
{
<h2 class="section-title">세무 정보 & 절세 팁</h2>
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
}
</div>
@{
var hasSeasonalPosts = Model.SeasonalPosts?.Count > 0;
var hasRecentPosts = Model.RecentPosts?.Count > 0;
}
@if (hasSeasonalPosts || hasRecentPosts)
{
<div class="row g-4">
@* 시즌 관련 글 (배지 강조) *@
@if (hasSeasonalPosts)
{
@foreach (var post in Model.SeasonalPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100 blog-card--seasonal">
<div class="blog-seasonal-ribbon">이번 시즌 추천</div>
<div class="blog-placeholder">🗓️</div>
<div class="card-body">
<small class="badge bg-season-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-seasonal">읽기</a>
</div>
</div>
</div>
}
}
@* 최신 글 (나머지 채우기) *@
@if (hasRecentPosts)
{
@foreach (var post in Model.RecentPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100">
<div class="blog-placeholder">📝</div>
<div class="card-body">
<small class="badge bg-primary-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">읽기</a>
</div>
</div>
</div>
}
}
</div>
<div class="text-center mt-5 d-flex justify-content-center gap-3 flex-wrap">
@if (season != null && !string.IsNullOrEmpty(season.RelatedCategorySlug))
{
<a href="/taxbaik/blog?category=@season.RelatedCategorySlug" class="btn btn-outline-secondary btn-lg">
📅 @season.Name 전체 글 보기
</a>
}
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
</div>
}
</div>
</section>
<!-- 상담 프로세스 -->
<section class="py-5" style="background: #F9F7F3;">
<div class="container">
@@ -273,85 +311,6 @@ else
</div>
</section>
<!-- 세무 정보 블로그 -->
<section class="py-5">
<div class="container">
<div class="text-center mb-5">
@if (season != null)
{
<div class="seasonal-blog-header mb-2">
<span class="seasonal-blog-tag">📅 @season.Name 시즌</span>
</div>
<h2 class="section-title">이번 시즌 세무 정보</h2>
<p class="text-muted">@season.Name 관련 절세 팁과 신고 가이드를 확인하세요</p>
}
else
{
<h2 class="section-title">세무 정보</h2>
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
}
</div>
@{
var hasSeasonalPosts = Model.SeasonalPosts?.Count > 0;
var hasRecentPosts = Model.RecentPosts?.Count > 0;
}
@if (hasSeasonalPosts || hasRecentPosts)
{
<div class="row g-4">
@* 시즌 관련 글 (배지 강조) *@
@if (hasSeasonalPosts)
{
@foreach (var post in Model.SeasonalPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100 blog-card--seasonal">
<div class="blog-seasonal-ribbon">이번 시즌 추천</div>
<div class="blog-placeholder">🗓️</div>
<div class="card-body">
<small class="badge bg-season-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-seasonal">자세히 보기</a>
</div>
</div>
</div>
}
}
@* 최신 글 (나머지 채우기) *@
@if (hasRecentPosts)
{
@foreach (var post in Model.RecentPosts!)
{
<div class="col-lg-4 col-md-6">
<div class="card blog-card h-100">
<div class="blog-placeholder">📝</div>
<div class="card-body">
<small class="badge bg-primary-badge">@post.CategoryName</small>
<h4 class="card-title mt-3">@post.Title</h4>
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">글 내용 보기</a>
</div>
</div>
</div>
}
}
</div>
<div class="text-center mt-5 d-flex justify-content-center gap-3 flex-wrap">
@if (season != null && !string.IsNullOrEmpty(season.RelatedCategorySlug))
{
<a href="/taxbaik/blog?category=@season.RelatedCategorySlug" class="btn btn-outline-seasonal btn-lg">
📅 @season.Name 전체 글 보기
</a>
}
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
</div>
}
</div>
</section>
<!-- 자주 묻는 질문 (DB 연동) -->
@if (Model.ActiveFaqs.Count > 0)
{
+4
View File
@@ -0,0 +1,4 @@
@page "/inquiry"
@{
Response.Redirect("/taxbaik/contact");
}
+4 -8
View File
@@ -54,7 +54,7 @@
<div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm rounded-3 mb-4">
<div class="card glass-card mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="h5 fw-bold text-dark mb-0">
@@ -124,7 +124,7 @@
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm rounded-3">
<div class="card glass-card">
<div class="card-body p-4">
<h3 class="h5 fw-bold text-dark mb-4">
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
@@ -139,14 +139,10 @@
}
else
{
<div class="timeline">
<div class="timeline ps-2">
@foreach (var activity in Model.Consultations)
{
<div class="border-start border-2 border-primary-subtle ps-3 pb-4 position-relative">
<!-- 타임라인 아이콘 -->
<div class="position-absolute start-0 translate-middle-x bg-primary rounded-circle"
style="width: 10px; height: 10px; margin-left: -1px; top: 6px;"></div>
<div class="timeline-item-modern">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
+39 -1
View File
@@ -4,7 +4,20 @@
ViewData["Description"] = "사업자 세무, 부동산 세금, 종합소득세 등 전문 상담 서비스";
}
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb" class="py-3" style="background: #F9F7F3; border-bottom: 1px solid #D9D3C4;">
<div class="container">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="/taxbaik/" class="text-decoration-none">홈</a></li>
<li class="breadcrumb-item active">서비스</li>
</ol>
</div>
</nav>
<div class="container py-5">
<div class="mb-4">
<a href="/taxbaik/" class="btn btn-sm btn-outline-secondary">← 홈으로 돌아가기</a>
</div>
<h1 class="fw-bold mb-5 text-center">주요 서비스</h1>
<!-- 사업자 세무 -->
@@ -124,11 +137,36 @@
</section>
<!-- CTA -->
<section class="bg-primary text-white py-5 rounded mt-5">
<section class="bg-primary text-white py-5 rounded mt-5 mb-5">
<div class="text-center">
<h2 class="fw-bold mb-3">전문 상담받으세요</h2>
<p class="lead mb-4">정확한 진단 후 맞춤형 솔루션을 제시합니다</p>
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">무료 상담 신청</a>
</div>
</section>
<!-- 관련 페이지 네비게이션 -->
<section class="text-center py-5">
<h4 class="fw-bold mb-4">다른 페이지 보기</h4>
<div class="row g-3 justify-content-center">
<div class="col-md-4">
<a href="/taxbaik/" class="btn btn-outline-primary btn-sm w-100 py-3">
🏠 홈<br/>
<small class="text-muted">최신 정보 및 블로그</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/about" class="btn btn-outline-primary btn-sm w-100 py-3">
👤 세무사 소개<br/>
<small class="text-muted">자격 및 상담 철학</small>
</a>
</div>
<div class="col-md-4">
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-sm w-100 py-3">
📝 세무 정보<br/>
<small class="text-muted">절세팁 및 신고 가이드</small>
</a>
</div>
</div>
</section>
</div>
+74 -22
View File
@@ -3,58 +3,110 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계")</title>
<meta name="description" content="@(ViewData["Description"] ?? "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담.")" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["Description"]" />
<meta property="og:image" content="@ViewData["OgImage"]" />
<meta property="og:url" content="@ViewData["OgUrl"]" />
<title>@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")</title>
<meta name="description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta name="keywords" content="백원숙 세무회계, 세무사, 사업자 기장, 양도소득세, 증여세, 상속세, 종합소득세, 절세 상담, 세무 대리" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="og:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "http://178.104.200.7/taxbaik/images/og-image.jpg")" />
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#C89D6E" />
<link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" />
<link rel="alternate icon" href="/taxbaik/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="canonical" href="@ViewData["CanonicalUrl"]" />
<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"] ?? "http://178.104.200.7/taxbaik/")" />
<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" />
<!-- 구조화된 데이터 (JSON-LD Schema Markup) -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "ProfessionalService",
"name": "백원숙 세무회계",
"description": "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담 세무사",
"url": "http://178.104.200.7/taxbaik/",
"telephone": "010-4122-8268",
"email": "taxbaik5668@gmail.com",
"address": {
"@@type": "PostalAddress",
"addressCountry": "KR"
},
"sameAs": [
"https://www.instagram.com/taxtory5668/",
"http://pf.kakao.com/_xoxchTX"
]
}
</script>
</head>
<body class="with-mobile-cta">
<partial name="_Header" />
<main role="main" class="pb-5">
@RenderBody()
</main>
<footer class="bg-light border-top mt-5 py-4">
<footer class="bg-light border-top mt-5 py-5">
<div class="container">
<div class="row g-4">
<div class="col-md-4">
<h6 class="fw-bold">백원숙 세무회계</h6>
<div class="row g-5">
<div class="col-md-3">
<h6 class="fw-bold mb-3">백원숙 세무회계</h6>
<p class="small text-muted">
사업자 기장, 부동산 양도세·증여세,<br />
종합소득세 전문 상담
</p>
</div>
<div class="col-md-4">
<h6 class="fw-bold">연락처</h6>
<div class="col-md-3">
<h6 class="fw-bold mb-3">메뉴</h6>
<ul class="list-unstyled small">
<li class="mb-2"><a href="/taxbaik/" class="text-decoration-none text-muted">홈</a></li>
<li class="mb-2"><a href="/taxbaik/about" class="text-decoration-none text-muted">세무사 소개</a></li>
<li class="mb-2"><a href="/taxbaik/services" class="text-decoration-none text-muted">전문 서비스</a></li>
<li class="mb-2"><a href="/taxbaik/blog" class="text-decoration-none text-muted">세무 정보</a></li>
<li><a href="/taxbaik/contact" class="text-decoration-none text-muted">상담 신청</a></li>
</ul>
</div>
<div class="col-md-3">
<h6 class="fw-bold mb-3">연락처</h6>
<p class="small">
📞 <a href="tel:010-4122-8268" class="text-decoration-none">010-4122-8268</a><br />
📧 <a href="mailto:taxbaik5668@gmail.com" class="text-decoration-none">taxbaik5668@gmail.com</a>
📞 <a href="tel:010-4122-8268" class="text-decoration-none text-muted">010-4122-8268</a><br />
📧 <a href="mailto:taxbaik5668@gmail.com" class="text-decoration-none text-muted">taxbaik5668@gmail.com</a>
</p>
</div>
<div class="col-md-4">
<h6 class="fw-bold">채널</h6>
<div class="col-md-3">
<h6 class="fw-bold mb-3">채널</h6>
<p class="small">
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-sm btn-warning me-2">카카오톡</a>
<a href="https://www.instagram.com/taxtory5668/" target="_blank" class="btn btn-sm btn-outline-secondary">Instagram</a>
</p>
</div>
</div>
<hr class="my-3" />
<hr class="my-4" />
<div class="text-center small text-muted">
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
<a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a>
<a href="/taxbaik/terms" class="text-decoration-none text-muted">이용약관</a>
<a href="/taxbaik/portal" class="text-decoration-none text-muted ms-2">고객 포털</a>
<div class="mb-2">
<a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a>
<span class="text-muted">|</span>
<a href="/taxbaik/terms" class="text-decoration-none text-muted ms-2 me-2">이용약관</a>
<span class="text-muted">|</span>
<a href="/taxbaik/portal" class="text-decoration-none text-muted ms-2">고객 포털</a>
</div>
@if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version)
{
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
+43 -31
View File
@@ -38,6 +38,13 @@ builder.Host.UseSerilog((context, config) =>
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.Enrich.FromLogContext()
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
var botToken = context.Configuration["Telegram:BotToken"];
var systemChatId = context.Configuration["Telegram:SystemChatId"] ?? context.Configuration["Telegram:ChatId"];
if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(systemChatId))
{
config.WriteTo.Sink(new TaxBaik.Web.Logging.TelegramSink(botToken, systemChatId), Serilog.Events.LogEventLevel.Error);
}
});
// Controllers (API)
@@ -45,12 +52,11 @@ builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
// SignalR (Notifications only, no state management)
builder.Services.AddSignalR();
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DetailedErrors = true;
@@ -190,9 +196,6 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
// Notifications (SignalR)
builder.Services.AddScoped<INotificationService, NotificationService>();
// Telegram Notification
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
@@ -207,70 +210,65 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
// Phase 5: Tax Accounting & CRM Browser Clients
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
})
.AddHttpMessageHandler<TokenRefreshHandler>();
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
builder.Services.AddMemoryCache();
builder.Services.AddResponseCompression(opts => {
@@ -317,6 +315,20 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/taxbaik/favicon.ico", StringComparison.OrdinalIgnoreCase))
{
context.Response.ContentType = "image/svg+xml";
await context.Response.SendFileAsync(Path.Combine(app.Environment.WebRootPath ?? "wwwroot", "favicon.svg"));
return;
}
await next();
});
// Run migrations on startup (non-blocking for development)
try
{
@@ -354,12 +366,12 @@ app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages();
// SignalR Hub
app.MapHub<TaxBaik.Web.Hubs.NotificationHub>("/taxbaik/notifications");
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(TaxBaik.WasmClient._Imports).Assembly)
.AllowAnonymous();
// 애플리케이션 시작/종료 로깅
@@ -1,72 +0,0 @@
namespace TaxBaik.Web.Services;
/// <summary>
/// Notification service for real-time admin updates
/// SOLID: Single Responsibility - Event notification only
/// Uses Blazor Server's built-in SignalR for real-time communication
/// </summary>
public interface INotificationService
{
event Func<int, string, Task>? OnInquiryStatusChanged;
event Func<int, string, Task>? OnInquiryCreated;
event Func<int, string, Task>? OnClientCreated;
event Func<int, string, Task>? OnAnnouncementPublished;
event Func<int, string, Task>? OnFilingCompleted;
Task TriggerInquiryStatusChanged(int inquiryId, string status);
Task TriggerInquiryCreated(int inquiryId, string name);
Task TriggerClientCreated(int clientId, string name);
Task TriggerAnnouncementPublished(int announcementId, string title);
Task TriggerFilingCompleted(int filingId, string filingType);
}
public class NotificationService : INotificationService
{
private readonly ILogger<NotificationService> _logger;
public NotificationService(ILogger<NotificationService> logger)
{
_logger = logger;
}
public event Func<int, string, Task>? OnInquiryStatusChanged;
public event Func<int, string, Task>? OnInquiryCreated;
public event Func<int, string, Task>? OnClientCreated;
public event Func<int, string, Task>? OnAnnouncementPublished;
public event Func<int, string, Task>? OnFilingCompleted;
public async Task TriggerInquiryStatusChanged(int inquiryId, string status)
{
_logger.LogInformation($"Inquiry {inquiryId} status changed to {status}");
if (OnInquiryStatusChanged != null)
await OnInquiryStatusChanged(inquiryId, status);
}
public async Task TriggerInquiryCreated(int inquiryId, string name)
{
_logger.LogInformation($"New inquiry {inquiryId} from {name}");
if (OnInquiryCreated != null)
await OnInquiryCreated(inquiryId, name);
}
public async Task TriggerClientCreated(int clientId, string name)
{
_logger.LogInformation($"New client {clientId}: {name}");
if (OnClientCreated != null)
await OnClientCreated(clientId, name);
}
public async Task TriggerAnnouncementPublished(int announcementId, string title)
{
_logger.LogInformation($"Announcement {announcementId} published: {title}");
if (OnAnnouncementPublished != null)
await OnAnnouncementPublished(announcementId, title);
}
public async Task TriggerFilingCompleted(int filingId, string filingType)
{
_logger.LogInformation($"Filing {filingId} ({filingType}) completed");
if (OnFilingCompleted != null)
await OnFilingCompleted(filingId, filingType);
}
}
@@ -13,6 +13,7 @@ public interface ITelegramNotificationService
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default);
}
public class TelegramNotificationService : ITelegramNotificationService
@@ -96,4 +97,10 @@ public class TelegramNotificationService : ITelegramNotificationService
var text = $"<b>️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
await SendMessageAsync(text, ct);
}
public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default)
{
var text = $"<b>📊 {reportTitle}</b>\n\n{reportContent}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
await SendToChat(_systemChatId, text, ct);
}
}
@@ -15,18 +15,29 @@ public class TelegramReportBackgroundService(
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
try
{
try
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, KoreaTimeZone);
await TrySendReportsAsync(now, stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Telegram report background loop failed");
try
{
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, KoreaTimeZone);
await TrySendReportsAsync(now, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Telegram report background loop failed");
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown path.
}
}
private async Task TrySendReportsAsync(DateTimeOffset nowKst, CancellationToken ct)
@@ -48,7 +59,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildDailyReportAsync(date, ct);
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
_lastDailyReportDate = date;
logger.LogInformation("Daily telegram report sent for {Date}", date);
}
@@ -63,7 +74,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
_lastWeeklyReportWeekStart = weekStart;
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
}
+3
View File
@@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
<ProjectReference Include="..\TaxBaik.Infrastructure\TaxBaik.Infrastructure.csproj" />
<ProjectReference Include="..\TaxBaik.Web.Client\TaxBaik.Web.Client.csproj" />
</ItemGroup>
<PropertyGroup>
@@ -12,6 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
@@ -21,6 +23,7 @@
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Markdig" Version="0.38.0" />
</ItemGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show More