Compare commits

..

1 Commits

366 changed files with 6286 additions and 16692 deletions
+2 -20
View File
@@ -49,13 +49,12 @@ 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)"
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
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_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:-?}, login=${LOGIN_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
sleep 3
fi
done
@@ -73,23 +72,6 @@ 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: |
+10 -26
View File
@@ -1,6 +1,7 @@
name: TaxBaik CI/CD
on:
workflow_dispatch:
push:
branches:
- master
@@ -32,9 +33,6 @@ 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
@@ -69,13 +67,8 @@ 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: mkdir -p ./publish/db && cp -r db/migrations ./publish/db/ || true
run: cp -r db/migrations ./publish/migrations || true
- name: Generate build info
run: |
@@ -107,14 +100,12 @@ 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 }}"
@@ -157,7 +148,7 @@ jobs:
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
-o ServerAliveInterval=10 \
"$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
set -e
DEPLOY_HOME="/home/kjh2064"
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
@@ -171,12 +162,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/4] Green-Blue 배포 실행 ---"
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
echo "--- [3/5] 심볼릭 링크 전환 ---"
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
echo "--- [4/5] 서비스 재시작 ---"
sudo /usr/bin/systemctl restart taxbaik
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
ATTEMPTS=20
@@ -200,20 +191,13 @@ jobs:
fi
echo "✓ [3/4] 버전 정보 확인 완료"
# 검증 4: 5001 프록시 확인
if ! ss -tlnp | grep -q ':5001 '; then
echo "❌ 5001 프록시가 실행 중이 아님" >&2
exit 1
fi
echo "✓ [4/5] 5001 프록시 확인 완료"
# 검증 5: 관리자 로그인 페이지
# 검증 3: 관리자 로그인 페이지
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 "✓ [5/5] 관리자 페이지 로드 완료"
echo "✓ [4/4] 관리자 페이지 로드 완료"
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
# 구 배포 디렉토리 정리 (최근 5개 보존)
-777
View File
@@ -1,777 +0,0 @@
# 블로그 포스트 작성 템플릿
## 정확성 원칙 (법적 책임 수반)
블로그는 **사실 기반, 세법 기반, 데이터 기반**이어야 합니다. 추측이나 예상은 법적 문제를 일으킬 수 있습니다.
### 절대 금지 표현
- "아마도", "할 것 같다", "추측된다" (추측)
- "대략", "정도일 거다", "보통" (예상)
- "좋을 것 같다", "나쁠 것 같다" (의견)
- 증거 없는 "모두", "항상", "누구나" (일반화)
- 출처 없는 통계 ("80% 고객이", "평균 X만 원")
### 필수 요소
**1. 세법 기반**:
- 모든 주장에 세법/시행령/고시 인용
- 조항 명시: "소득세법 제XX조에 따르면"
- 최신 기준 명시: "2025년 기준"
- 변경사항 반영: "전년도와 다르게..."
**2. 사실 기반**:
- 실제 일어난 고객 사례만 사용
- 가정일 경우 명시: "예를 들어, 만약 이렇다면"
- 가상 사례는 "예시 사례"라고 명확히
- 개인정보는 익명화 (이름, 나이는 일반적인 표현)
**3. 데이터 기반**:
- 객관적 수치만 사용 (국세청 통계, 협회 자료)
- 출처 명시: "2025년 세무청 통계에 따르면"
- 구체적 금액: "약 50만 원" (범위 표현)
- 비교 데이터: "작년 대비 X% 증가"
**4. 사례 제시 시 확인 사항**:
```
✅ 실제 고객인가? (공개 가능한 정보만)
✅ 세법을 정확하게 적용했는가?
✅ 금액 계산이 정확한가?
✅ 이 사례가 대표적인가? (극단적 사례면 명시)
✅ 다른 고객에게도 적용 가능한가?
```
---
## 카테고리 필수 규칙
**모든 블로그 포스트는 반드시 하나의 카테고리에 할당되어야 합니다. (NOT NULL)**
### 카테고리별 포스트 배치
| 카테고리 | 최소 포스트 | 주제 범위 |
|---------|-----------|---------|
| 사업자 세무 | 3개 | 기장, 세무신고, 부가세, 종합소득세 |
| 부동산 세금 | 3개 | 월세, 양도세, 상속세(부동산) |
| 종합소득세 | 3개 | 프리랜서, 부업, 경비 처리 |
| 부가가치세 | 3개 | 신고, 기한, 간이과세 vs 일반과세 |
| 가족자산·증여 | 3개 | 자녀 증여, 상속, 자산 이전 |
### 카테고리 할당 규칙
1. **명확한 주제 분류**: 포스트 내용이 카테고리 범위에 명확하게 해당
2. **중복 금지**: 한 포스트는 정확히 하나의 카테고리에만 할당
3. **균형 배치**: 각 카테고리당 최소 3개씩 (고객 검색 UX)
4. **검색 최적화**: 고객이 카테고리로 찾을 때 관련 포스트 3개 이상 노출
### 카테고리 미할당 시 (오류)
- ❌ category_id = NULL (데이터베이스 제약 위반)
- ❌ SQL 실행 실패 (NOT NULL 제약)
- ❌ 블로그 페이지 노출 불가
**이 규칙은 모든 포스트 생성/수정 시 필수 준수사항입니다.**
---
## 핵심 철학: 고객이 느끼는 여정
### 1️⃣ 기초: "이 정도는 할 수 있어요"
- 고객이 배울 수 있는 기본 개념
- 실제 사례로 구체화
- 단계별 설명
### 2️⃣ 현실: "하지만 복잡하네요"
- 겹겹이 쌓인 세부사항들
- 매년 바뀌는 세법
- "이거 일일이 다 챙기기 어렵다"는 느낌
### 3️⃣ 해결: "세무사와 함께면 괜찮아요"
- 디테일 자동 관리
- 세법 변화 자동 반영
- 고객은 사업에만 집중
---
**고객이 글을 읽은 후 느끼는 것**:
1️⃣ 읽고 나서: "아, 이 정도는 내가 할 수 있겠네"
2️⃣ 생각해보니: "근데 이 모든 걸 매년 챙기기는... 힘들겠는데?"
3️⃣ 결론: "그럼 전문가 도움을 받는 게 낫겠다"
→ 자연스럽게 세무사의 필요성을 깨달음 (강요 아님)
---
## 템플릿 (복사해서 사용)
### 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 세무사와 함께
**세법 변화 추적**
- 혼자: "어? 규칙이 바뀌었네?"
- 세무사: 자동으로 적용됨
**새로운 공제**
- 혼자: 놓치기 쉬움
- 세무사: 모두 적용됨
**매년 재계산**
- 혼자: 직접 해야 함
- 세무사: 자동 갱신
**마음 편함**
- 혼자: 불안감 ("맞나?")
- 세무사: 확신 ("전문가가 관리")
**투자 시간**
- 혼자: 당신의 시간
- 세무사: 포함 (전문가 비용)
---
## 요약: 왜 세무사가 필요한가
**기초는 배울 수 있지만**:
- 세법은 매년 바뀌고
- 당신은 본업이 있어서 추적이 어렵고
- 실수 하나가 가산세 50만 원...
**그래서 세무사가 있으면**:
- 변화를 자동으로 적용해주고
- 새 제도도 놓치지 않아주고
- 당신은 사업에만 집중
**결국 시간, 돈, 스트레스 모두 절약**
---
### 💡 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. 과도한 절세 약속 & 절대 표현**:
- ❌ "50만 원 절약 가능"
- ❌ "최대한 경비를 깎아줍니다"
- ❌ "세금을 반으로 줄여드립니다"
- ❌ "세금을 덜 냅니다" (보장으로 해석)
- ❌ "가장 많이 절세해드립니다"
- ✅ "이 사례에서는 약 50만 원 절약되었습니다" (과거 사례만)
- ✅ "정확한 경비 처리로 세법에 따른 정당한 공제를 받을 수 있습니다" (법적 근거)
- ✅ "경비를 빠짐없이 처리합니다" (객관적 프로세스)
**2. 보장 표현 (불가능한 결과 약속)**:
- ❌ "반드시 세금을 줄입니다"
- ❌ "세무조사 안 받게 해드립니다"
- ❌ "100% 절세를 보장합니다"
- ❌ "세금을 보장합니다"
- ✅ "정확한 신고로 세무조사 리스크를 최소화합니다"
- ✅ "세법에 따른 정당한 공제를 받을 수 있습니다"
**3. 무료 & 가격 표현**:
- ❌ "무료로 세금 절약해드립니다"
- ❌ "최저가 신고료"
- ❌ "가장 저렴한 가격"
- ✅ "합리적인 비용으로 전문 서비스를 제공합니다"
**4. 절대/최상급 표현**:
- ❌ "반드시", "무조건", "반듯이", "항상", "절대"
- ❌ "최고", "최우수", "1등", "유일"
- ❌ "모든", "완벽하게"
- ✅ "일반적으로", "대부분의 경우", "보통"
**5. 과도한 단순화 표현**:
- ❌ "매우 편합니다", "너무 쉽습니다"
- ❌ "아무도 실수할 수 없습니다"
- ❌ "5분이면 끝납니다"
- ✅ "기초 개념을 배울 수 있습니다"
- ✅ "복잡한 부분은 전문가가 관리합니다"
**6. 객관적 증거 없는 수치**:
- ❌ "평균 170만 원 절약" (근거 없으면)
- ❌ "고객의 80%가 만족" (통계 없으면)
- ❌ "보통 2배의 환급" (데이터 없으면)
- ✅ "이 사례에서는 약 170만 원 절약되었습니다"
- ✅ "많은 고객들이 정확한 기장의 필요성을 느낍니다"
---
### ✅ **안전한 표현 (권장)**
| 대신 이렇게 | 이유 |
|----------|------|
| "정확한 기장으로 세법에 따른 공제를 받을 수 있습니다" | 법적 근거 (보장 아님) |
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
| "이 사례에서는 약 50만 원 절약되었습니다" | 과거 사례 (보장 아님) |
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
| "세무조사 대비 근거를 정리합니다" | 예방적 표현 |
| "당신의 상황에 맞는 최선의 방법을 제시합니다" | 개별 맞춤형 |
| "세법이 자주 바뀌므로 전문가 도움이 효율적입니다" | 필요성 설명 |
| "이 정도는 자신이 충분히 가능합니다" | 존중과 임파워먼트 |
| "복잡한 경우는 전문가와 상담하세요" | 선택지 제시 |
| "정확하게 하면 나중에 편합니다" | 미래 가치 (현재 보장 아님) |
---
### 📋 블로그 작성 시 광고 규칙 체크리스트
- [ ] **절세 약속 제거**: "최대한", "반드시", "보장", "무조건" 단어 없음
- [ ] **보장 표현 제거**: "세무조사 안 받게", "100% 절세", "확실" 제거
- [ ] **무료/가격 표현 제거**: "무료", "최저가", "가장 저렴" 제거
- [ ] **절대 표현 제거**: "항상", "절대", "모두", "완벽" 제거
- [ ] **최상급 제거**: "최고", "최우수", "1등" (객관적 증거 있으면 가능)
- [ ] **과도한 단순화 제거**: "매우 쉽습니다", "아무도 실수할 수 없음" 제거
- [ ] **수치는 사례로**: "절약 가능" → "이 사례에서는 약 X만 원 절약"
- [ ] **객관성 유지**: 구체적 사례 + 과거형 표현 사용
- [ ] **필요성 설명**: "왜 필요한가" → 이해와 선택 유도
- [ ] **세무사협회 규정 준수**: 법적 문제 없음
---
## 시즌별 주제 예시
| 월 | 추천 주제 | 톤 |
|----|---------|-----|
| 1월 | 부가세 2기 신고 기한 | "이 정도면 자신이 가능" |
| 5월 | 종소세 신고 방법 | "핵심 개념 + 전문가 도움 타이밍" |
| 7월 | 부가세 1기 신고 | "기초 정리 방법" |
| 11월 | 다음해 준비 | "계획하면 편해요" |
---
## ⚠️ 실수 방지 체크리스트 (과거 오류 기록)
**이전에 반복된 실수들을 기록하여, 같은 실수를 하지 않도록 합니다.**
### 1️⃣ 카테고리 할당 실수 ❌
**과거 오류**: 포스트를 만들 때 category_id를 NULL로 두었음
**문제점**:
- DB NOT NULL 제약 위반
- 블로그 페이지에 노출 안 됨
- 고객이 카테고리로 검색 불가
**예방책**:
-**SQL INSERT 시 반드시 category_id 명시**
-**포스트 작성 전에 카테고리 결정**
-**DB 적용 후 category_id NOT NULL 확인**
-**각 카테고리별 최소 3개 이상 포스트 유지**
**SQL 예시** (권장):
```sql
INSERT INTO blog_posts (title, slug, content, category_id, is_published, ...)
VALUES ('제목', 'slug', $$$$, 1, true, ...);
-- category_id 절대 생략 금지!
```
---
### 2️⃣ 내용 길이 부족 ❌
**과거 오류**: 에이전트가 지침(1,500~2,500자)을 무시하고 간단한 버전(500자)으로 생성
**문제점**:
- 고객 설득력 부족
- 계산 예시 없음
- 3단계 구조 불완전
- 세법 인용 부족
**예방책**:
-**각 포스트 최소 1,500자 이상 (추천 2,000~2,500자)**
-**포스트 작성 후 글자 수 확인: `LENGTH(content) >= 1500`**
-**항상 실제 사례 포함** (이름, 나이, 직업, 구체적 상황)
-**항상 계산 과정 포함** (절세액 수치화)
-**3단계 구조 필수** (1️⃣ 기초 → 2️⃣ 현실 → 3️⃣ 해결책)
**확인 쿼리**:
```sql
SELECT id, title, LENGTH(content) as length FROM blog_posts
WHERE LENGTH(content) < 1500; -- 부족한 포스트 검출
```
---
### 3️⃣ 테이블 사용 금지 ❌
**과거 오류**: 마크다운 테이블(`| |---|---|`) 사용
**문제점**:
- 지침 위반 (리스트만 사용)
- 모바일에서 가독성 저하
- 유지보수 어려움
**예방책**:
-**테이블 금지, 리스트만 사용** (- 또는 숫자 목록)
-**작성 후 `| |` 패턴 검색으로 테이블 확인**
-**수치/계산은 리스트 형식**:
**❌ 금지 (테이블)**:
```markdown
| 항목 | 월 | 연간 |
|------|-----|------|
| 월세 | 150만 | 1,800만 |
```
**✅ 권장 (리스트)**:
```markdown
월 경비 구성:
- 월세: 150만 원 (연 1,800만 원)
- 재료비: 180만 원 (연 2,160만 원)
- 직원급여: 100만 원 (연 1,200만 원)
```
---
### 4️⃣ 계산 예시 누락 ❌
**과거 오류**: 포스트에 개념만 있고 실제 계산 예시 부족
**문제점**:
- 고객이 "내 상황에 얼마나 해당하나" 판단 어려움
- 추상적 설명으로 설득력 감소
- 세무사 필요성 전달 미흡
**예방책**:
-**모든 포스트에 구체적 계산 예시 필수**
-**절세액을 수치로 제시** ("약 50만 원 절약")
-**단계별 계산 과정 포함** (Step 1️⃣, 2️⃣, 3️⃣, 4️⃣)
-**실제 사례로 숫자 구체화**:
**예시**:
```markdown
### Step 1️⃣: 매출 정리
월 600만 원 × 12개월 = 연 7,200만 원
### Step 2️⃣: 경비 계산
- 월세: 150만 원 → 연 1,800만 원
- 재료비: 180만 원 → 연 2,160만 원
합계: 5,400만 원
### Step 3️⃣: 순이익
7,200만 - 5,400만 = 1,800만 원
### Step 4️⃣: 세금
1,800만 원 × 약 6% = **약 108만 원/년**
```
---
### 5️⃣ 카테고리 주제 불일치 ❌
**과거 오류**: 포스트 주제와 카테고리가 맞지 않음
**문제점**:
- 고객이 원하는 정보 검색 불가
- 카테고리 신뢰도 저하
- UX 혼란
**예방책**:
-**포스트 작성 전 카테고리 명확히 결정**
-**포스트 주제와 카테고리 일관성 검증**:
| 포스트 | 카테고리 | 확인 |
|--------|---------|------|
| 프리랜서 경비 | 종합소득세 (3) | ✅ 맞음 |
| 월세 신고 | 부동산 세금 (2) | ✅ 맞음 |
| 자녀 증여세 | 가족자산·증여 (5) | ✅ 맞음 |
| 사업자 기장 | 사업자 세무 (1) | ✅ 맞음 |
| 부가세 신고 | 부가가치세 (4) | ✅ 맞음 |
---
### 6️⃣ 정확한 세법 인용 누락 ❌
**과거 오류**: 일부 포스트에서 법조 명시 부족
**문제점**:
- 정확성 원칙 위반
- 법적 책임 불명확
- 고객 신뢰도 저하
**예방책**:
-**모든 주요 내용에 세법 조항 인용 필수**
-**형식**: "소득세법 제XX조에 따르면"
-**연도 기준 명시**: "2025년 기준"
-**포스트 끝에 "법적 근거" 섹션 필수**:
```markdown
**법적 근거**:
- 소득세법 제29조 (수입금액의 계산)
- 국세기본법 제47조 (가산세)
- 소득세법 제160조 (증빙 보관)
```
---
## ✅ 포스트 최종 체크리스트
모든 포스트를 DB에 등록하기 전에 다음을 확인하세요:
- [ ] **카테고리 할당**: `category_id NOT NULL` (필수)
- [ ] **내용 길이**: `LENGTH(content) >= 1500` (최소 1,500자)
- [ ] **테이블 확인**: `| |` 패턴 없음 (리스트만)
- [ ] **계산 예시**: Step 1️⃣~4️⃣ 포함 (절세액 수치)
- [ ] **세법 인용**: 모든 주요 내용에 법조 명시
- [ ] **카테고리 일치**: 포스트 주제 ↔ 카테고리 일관성
- [ ] **3단계 구조**: 1️⃣ 기초 → 2️⃣ 현실 → 3️⃣ 해결책
- [ ] **광고 규칙**: 금지 표현(보장, 최저가, 무료) 없음
- [ ] **사례 포함**: 실제 상황 + 이름/나이/직업 구체화
- [ ] **정확성**: 추측/예상/의견 표현 없음
**체크 쿼리**:
```sql
-- DB 적용 후 확인
SELECT id, title, LENGTH(content), category_id
FROM blog_posts
WHERE LENGTH(content) < 1500 OR category_id IS NULL
ORDER BY id;
-- 결과 없음이 정상!
```
+46 -53
View File
@@ -8,8 +8,8 @@
Blazor → Service (서버) → DB
✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
Blazor 데이터 변경 자동 push/broadcast 금지
Blazor (UI만) ← API (모든 로직) ← DB
SignalR (변경 알림만)
```
### SOLID 기반 순차 마이그레이션 전략
@@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
#### Phase 6: SignalR 통합
- [ ] NotificationHub (변경 알림만)
- [ ] Blazor에서 구독
- [ ] 알림 후 API로 데이터 검증
#### Phase 7: 순차적 마이그레이션 ✅
- [x] Blog 페이지 → API 클라이언트
@@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080;
- Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동)
### **Phase 6: Lite Blazor 운영 원칙** ✅
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
### **Phase 6: SignalR 통합** ✅
- NotificationHub (브로드캐스트만, 상태 관리 없음)
- INotificationService (이벤트 기반)
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
- Program.cs SignalR 등록
---
@@ -160,11 +160,11 @@ Repositories (데이터 계층)
PostgreSQL Database
```
**Lite Blazor 데이터 갱신**:
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
**Blazor Server SignalR**:
- 자동 연결 (내장 Hub connection)
- NotificationHub 클라이언트 그룹 (admins)
- 이벤트 기반 메시지 (상태 관리 없음)
- 클라이언트는 알림 후 API로 데이터 검증
---
@@ -182,10 +182,10 @@ PostgreSQL Database
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
**Lite Blazor / 데이터 갱신 (Phase 6)**:
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
**실시간 알림 (Phase 6)**:
- [x] NotificationHub 구현
- [x] Event-driven 알림 시스템
- [x] Scoped DI 등록
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지
@@ -564,24 +564,33 @@ ssh kjh2064@178.104.200.7
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
**무중단 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`)합니다.
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.
**표준 배포 (현재)**:
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 포트로 라우팅하면 자동 전환됨
**운영 규칙**:
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다.
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
- `rsync`로 직접 아티팩트를 올리지 않는다
- 배포 실패 시 CI 로그를 먼저 본다
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
**롤백**:
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다.
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
- 서버 파일을 수동으로 복구하지 않는다
- 롤백은 커밋 단위로 추적 가능해야 한다
### 3.4 서비스 파일 위치
```
@@ -745,22 +754,6 @@ ssh kjh2064@178.104.200.7 crontab -l | grep backup
---
## 5-1. 블로그 & FAQ 콘텐츠 작성 규칙
**핵심**: 고객 임파워먼트 (당신도 할 수 있습니다!)
- ✅ 주변에서 흔히 보는 실제 사례 (이름, 나이, 직업 구체화)
- ✅ 절세 효과 수치화 ("세금을 X만 원 절약했습니다")
- ✅ 중학교 2학년도 이해 가능한 수준
- ✅ 단계별 설명 + 표로 시각화
- ✅ 결론: "정확하게 하면 이런 이점이 있습니다" (임파워먼트)
**피해야 할 톤**: "복잡하니까 맡기세요" (세무사 의존성 강화)
**세무사 언급**: "더 복잡하면 전문가와 상담하세요" (선택지)
**자세한 템플릿 및 체크리스트**: `BLOG_TEMPLATE.md` 참고
---
## 6. 코드 규칙
### 6.1 C# 네이밍
@@ -1644,7 +1637,7 @@ curl http://127.0.0.1/taxbaik/admin/login
### E2E 테스트 & 반응형 검증
```bash
# 문의 폼 제출
curl -X POST http://taxbaik.com/taxbaik/contact \
curl -X POST http://178.104.200.7/taxbaik/contact \
-d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
# 관리자 DB에서 확인
@@ -1683,7 +1676,7 @@ npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
**프로덕션 E2E 테스트**:
```bash
export E2E_BASE_URL="http://taxbaik.com/taxbaik"
export E2E_BASE_URL="http://178.104.200.7/taxbaik"
export E2E_ADMIN_USERNAME="test_admin"
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
@@ -1951,7 +1944,7 @@ else
2. **Actions run 생성 확인**
```powershell
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
$runs = Invoke-RestMethod -Headers $headers -Uri "http://gitea.taxbaik.com/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
$runs = Invoke-RestMethod -Headers $headers -Uri "http://178.104.200.7/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`가 있어야 배포가 실제로 시작된 것이다.
+22 -129
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) |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
| 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,84 +126,16 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
### 4.2. Nginx 리버스 프록시
```nginx
# /etc/nginx/sites-available/taxbaik-domains.conf
# /etc/nginx/sites-enabled/gitea-ip.conf
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
server {
server_name taxbaik.com www.taxbaik.com;
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 512M;
# /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";
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;
}
# /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;
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 300;
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 / {
# QuantEngine Blazor Web App
location /quant/ {
proxy_pass http://127.0.0.1:5000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -215,64 +147,25 @@ server {
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
# Gitea (기본)
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
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://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
- `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
## 5. Gitea
@@ -491,7 +384,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) |
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
+11 -44
View File
@@ -19,46 +19,32 @@ 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:5004
Environment=ASPNETCORE_URLS=http://127.0.0.1:5001
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 cp deploy/nginx-taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf
# 현재 Nginx 설정 확인
sudo cat /etc/nginx/sites-available/default | head -30
# 기존 설정(IP 기반 및 default) 활성화 해제
sudo rm -f /etc/nginx/sites-enabled/default
sudo rm -f /etc/nginx/sites-enabled/gitea-ip.conf
# location 블록 추가 (또는 기존 설정에 병합)
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.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
```
@@ -79,7 +65,7 @@ sudo systemctl reload nginx
master 브랜치 push → build → test → publish → restart → health check → Playwright
```
수동 배포는 사용하지 않습니다. `deploy_gb.sh`는 `TAXBAIK_DEPLOY_FROM_CI=1`이 없으면 즉시 종료하므로, 배포는 반드시 Gitea Actions에서만 실행됩니다.
수동 배포는 비상 롤백 외에는 사용하지 않습니다. 배포 이슈는 Gitea Actions 로그로 해결합니다.
## 마이그레이션 자동 실행
@@ -142,7 +128,6 @@ 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
```
@@ -154,10 +139,10 @@ sudo systemctl restart taxbaik
ssh kjh2064@178.104.200.7
# 서비스 상태
systemctl status taxbaik taxbaik-proxy
systemctl status taxbaik
# 포트 확인
netstat -tlnp | grep -E '5001|5004'
netstat -tlnp | grep -E '5001'
# 프로세스 확인
ps aux | grep TaxBaik
@@ -180,27 +165,9 @@ 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-proxy taxbaik` |
| 503 Service Unavailable | 미시작 | `sudo systemctl restart 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'
```
## 초기 데이터
### 관리자 계정
+40 -8
View File
@@ -48,7 +48,29 @@ ssh kjh2064@178.104.200.7 'bash ~/SERVER_SETUP.sh'
# ~/taxbaik_active
```
### 2단계: Gitea Actions 설정
### 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 설정 (선택)
**Gitea 저장소 Settings → Secrets 추가**:
- `DEPLOY_USER`: `kjh2064`
@@ -195,8 +217,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-proxy taxbaik` |
| 503 Service Unavailable | 백엔드 충돌 또는 비밀값 누락 | 로그 확인: `journalctl -u taxbaik -n 50` |
| 502 Bad Gateway | 미실행 | `sudo systemctl restart taxbaik` |
| 503 Service Unavailable | 앱 충돌 | 로그 확인: `journalctl -u taxbaik -n 50` |
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
@@ -208,11 +230,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-proxy -f'
# 터미널 2: 통합 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 3: Nginx 로그
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik'
@@ -224,7 +246,13 @@ ssh kjh2064@178.104.200.7 'watch -n 1 "ps aux | grep TaxBaik"'
### 정기적 검사
```bash
# 일일 체크는 CI 배포 후 자동 검증으로 대체
# 일일 체크 (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
```
---
@@ -240,6 +268,11 @@ 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"
```
### 롤백 절차
@@ -251,7 +284,6 @@ 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용 서버 비밀값
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
---
+16 -59
View File
@@ -425,9 +425,9 @@ Todo:
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
Todo:
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [x] 일간/주간 리포트 메시지 템플릿
- [x] TelegramNotificationService에 리포트 메서드 추가
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
- [ ] 일간/주간 리포트 메시지 템플릿
- [ ] TelegramNotificationService에 리포트 메서드 추가
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
@@ -439,9 +439,9 @@ Todo:
- 개인정보 열람 범위는 세무사가 허용한 항목만
Todo:
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [x] 고객 전용 Razor Pages 추가
- [x] 세무사 허용 권한 설정 UI
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
- [ ] 고객 전용 Razor Pages 추가
- [ ] 세무사 허용 권한 설정 UI
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
@@ -485,16 +485,16 @@ DB 스키마:
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
Todo:
- [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`) — 소셜 버튼 + 이메일 폼
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
- [ ] V011__CreatePortalUsers.sql 마이그레이션
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
- [ ] 네이버 OAuth Handler 구현
- [ ] 카카오·구글 패키지 추가 및 설정
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
- [ ] Gitea Secrets에 OAuth 키 추가
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
@@ -522,46 +522,3 @@ 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,7 +27,6 @@ public static class DependencyInjection
services.AddScoped<RevenueTrackingService>();
services.AddScoped<TelegramReportService>();
services.AddScoped<PortalUserService>();
services.AddScoped<CommonCodeService>();
return services;
}
}
@@ -1,20 +0,0 @@
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,9 +33,6 @@ 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,9 +36,6 @@ 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,9 +34,6 @@ 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,9 +33,6 @@ 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,16 +31,10 @@ 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 = await repository.GetByIdAsync(profileId, ct);
if (profile == null)
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
var profile = new TaxProfile { Id = profileId };
if (!string.IsNullOrWhiteSpace(businessType))
profile.BusinessType = businessType.Trim();
if (!string.IsNullOrWhiteSpace(accountingMethod))
-10
View File
@@ -1,10 +0,0 @@
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;
}
@@ -1,12 +0,0 @@
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,7 +5,6 @@ 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,7 +5,6 @@ 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,7 +5,6 @@ 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,7 +5,6 @@ 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,8 +5,6 @@ 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,7 +27,6 @@ public static class DependencyInjection
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
services.AddScoped<IContractRepository, ContractRepository>();
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
return services;
}
@@ -1,33 +0,0 @@
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,14 +16,6 @@ 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,14 +16,6 @@ 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,14 +16,6 @@ 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,14 +16,6 @@ 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,27 +20,6 @@ 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
@@ -1,93 +0,0 @@
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
@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
-52
View File
@@ -1,52 +0,0 @@
{
"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
@@ -1,2 +0,0 @@
global using System.Net.Http;
global using System.Net.Http.Json;
-13
View File
@@ -1,13 +0,0 @@
@* 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
@@ -1,51 +0,0 @@
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();
@@ -1,56 +0,0 @@
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 [];
}
}
}
@@ -1,24 +0,0 @@
<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
@@ -1,13 +0,0 @@
@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
@@ -1,573 +0,0 @@
{
"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
@@ -1,20 +0,0 @@
{
"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
+11 -17
View File
@@ -6,16 +6,9 @@
<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',
@@ -39,11 +32,12 @@
</div>
</div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: true)" />
<MudDialogProvider />
<MudSnackbarProvider />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<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>
@@ -85,49 +79,49 @@
},
LayoutProperties = new LayoutProperties()
{
DefaultBorderRadius = "6px"
DefaultBorderRadius = "8px"
},
Typography = new Typography()
{
Default = new Default()
{
FontSize = ".8125rem",
FontSize = ".875rem",
FontWeight = 400,
LineHeight = 1.5
},
H1 = new H1()
{
FontSize = "1.75rem",
FontSize = "2.5rem",
FontWeight = 600,
LineHeight = 1.2
},
H2 = new H2()
{
FontSize = "1.5rem",
FontSize = "2rem",
FontWeight = 600,
LineHeight = 1.3
},
H3 = new H3()
{
FontSize = "1.25rem",
FontSize = "1.75rem",
FontWeight = 600,
LineHeight = 1.3
},
H4 = new H4()
{
FontSize = "1.1rem",
FontSize = "1.5rem",
FontWeight = 600,
LineHeight = 1.4
},
H5 = new H5()
{
FontSize = "0.95rem",
FontSize = "1.25rem",
FontWeight = 500,
LineHeight = 1.4
},
H6 = new H6()
{
FontSize = "0.85rem",
FontSize = "1rem",
FontWeight = 500,
LineHeight = 1.5
}
@@ -1,13 +1,7 @@
@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">
@@ -16,9 +10,9 @@
Edge="Edge.Start"
Class="admin-menu-button"
OnClick="@ToggleDrawer" />
<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 class="admin-topbar-title">
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
</div>
<MudSpacer />
@@ -89,12 +83,6 @@
<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,22 +22,14 @@
</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 (!FilteredAnnouncements.Any())
else if (!announcements.Any())
{
<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>
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
}
else
{
@@ -53,7 +45,7 @@
</tr>
</thead>
<tbody>
@foreach (var item in FilteredAnnouncements)
@foreach (var item in announcements)
{
<tr>
<td>@item.Title</td>
@@ -94,38 +86,15 @@
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
</MudText>
}
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
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)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
protected override async Task OnInitializedAsync()
{
await LoadAsync();
StateHasChanged();
}
}
}
}
private async Task LoadAsync()
@@ -1,6 +1,5 @@
@page "/admin/blog/create"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@@ -22,22 +21,19 @@
<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" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<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.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
@@ -61,24 +57,12 @@
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");
@@ -89,15 +73,6 @@
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;
@@ -135,33 +110,3 @@
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,6 +1,5 @@
@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
@@ -33,22 +32,19 @@ 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" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<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.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
@@ -75,9 +71,6 @@ 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 = [];
@@ -105,14 +98,6 @@ 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;
@@ -134,15 +119,6 @@ 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;
@@ -209,33 +185,3 @@ 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,19 +15,14 @@
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">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
</MudStack>
</MudPaper>
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행">
@@ -55,36 +50,16 @@
</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;
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)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
protected override async Task OnInitializedAsync()
{
await LoadPosts();
StateHasChanged();
}
}
}
}
private async Task LoadPosts()
@@ -129,9 +129,6 @@
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Client>? clients;
private string searchText = "";
private string statusFilter = "";
@@ -140,21 +137,7 @@
private int totalPages;
private const int PageSize = 20;
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();
}
}
}
}
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
@@ -100,17 +100,10 @@
<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">@GetClientDisplayName(client)</MudSelectItem>
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
}
</MudSelect>
<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>
<MudTextField T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<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" />
@@ -123,9 +116,6 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<ConsultingActivity>? activities;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -134,20 +124,9 @@
private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
protected override async Task OnInitializedAsync()
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -155,9 +134,9 @@
try
{
activities = await ActivityClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
}
catch (Exception ex)
{
@@ -168,11 +147,7 @@
private void OpenCreateDialog()
{
editingActivity = null;
activityForm = new ConsultingActivityForm
{
ActivityDate = DateTime.Now,
ClientId = clients.FirstOrDefault()?.Id ?? 0
};
activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
isDialogOpen = true;
}
@@ -192,16 +167,6 @@
private async Task SaveActivity()
{
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try
{
if (editingActivity == null)
@@ -273,12 +238,6 @@
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; }
@@ -21,23 +21,19 @@
</MudText>
}
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 계약 추가
</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0">
@if (contracts is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
else if (contracts.Count == 0)
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (contracts.Count == 0)
{
<MudAlert Severity="Severity.Info">
<MudAlert Severity="Severity.Info" Class="mt-4">
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
계약이 없습니다.
</MudAlert>
@@ -51,8 +47,6 @@ else
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedContract"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
@@ -60,7 +54,9 @@ else
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</CellTemplate>
</TemplateColumn>
@@ -93,90 +89,54 @@ else
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
<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>
}
</MudItem>
</MudPaper>
<!-- 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>
<!-- 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-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
<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="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
}
</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)
{
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteContract(selectedContract?.Id ?? 0))">삭제</MudButton>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveContract" id="btn-save-contract">저장</MudButton>
}
</div>
<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>
</MudPaper>
</MudItem>
</MudGrid>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveContract">저장</MudButton>
</DialogActions>
</MudDialog>
@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 isEditMode;
private Contract? selectedContract;
private bool isDialogOpen;
private ContractForm contractForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
protected override async Task OnInitializedAsync()
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -184,9 +144,9 @@ else
try
{
contracts = await ContractClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
}
catch (Exception ex)
@@ -195,49 +155,18 @@ else
}
}
private void PrepareCreate()
private void OpenCreateDialog()
{
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
};
contractForm = new();
isDialogOpen = true;
}
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.Value,
contractForm.ClientId,
contractForm.ContractNumber,
contractForm.ServiceType,
contractForm.StartDate ?? DateTime.Now,
@@ -246,7 +175,7 @@ else
if (newId > 0)
{
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
PrepareCreate();
CloseDialog();
await LoadData();
}
}
@@ -274,10 +203,6 @@ else
{
await ContractClient.DeleteAsync(id);
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
if (selectedContract?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -286,16 +211,15 @@ else
}
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private void CloseDialog()
{
isDialogOpen = false;
contractForm = new();
}
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,58 +17,49 @@
</MudButton>
</section>
@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 -->
<!-- Metrics Grid - Pure HTML div instead of MudGrid to ensure proper layout -->
<div class="admin-metric-grid">
<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 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>
<span class="admin-metric-card-caption">월간 상담 유입 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">월간 상담 유입 (클릭 시 이동)</span>
</div>
</div>
<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 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>
<span class="admin-metric-card-caption">처리 대기 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">처리 대기 (클릭 시 이동)</span>
</div>
</div>
<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 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>
<span class="admin-metric-card-caption">콘텐츠 자산 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">콘텐츠 자산 (클릭 시 이동)</span>
</div>
</div>
<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 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>
<span class="admin-metric-card-caption">검색 노출 대상 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">검색 노출 대상 (클릭 시 이동)</span>
</div>
</div>
</div>
@@ -167,22 +158,12 @@
</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 OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
protected override async Task OnInitializedAsync()
{
try
{
@@ -202,10 +183,6 @@
finally
{
isLoading = false;
StateHasChanged();
}
}
}
}
}
@@ -22,21 +22,16 @@
</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 (!FilteredFaqs.Any())
else if (!faqs.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
@@ -44,7 +39,7 @@
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th style="width:110px;">순서</th>
<th style="width:60px;">순서</th>
<th>질문</th>
<th style="width:130px;">카테고리</th>
<th style="width:90px;">상태</th>
@@ -52,15 +47,11 @@
</tr>
</thead>
<tbody>
@foreach (var item in FilteredFaqs)
@foreach (var item in faqs)
{
<tr>
<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 class="text-center">
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
</td>
<td>
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
@@ -98,45 +89,21 @@
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
</MudText>
}
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
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();
}
}
}
}
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
try
{
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList();
faqs = (await FaqClient.GetAllAsync()).ToList();
}
catch (Exception ex)
{
@@ -145,66 +112,6 @@
}
}
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,31 +46,11 @@ else
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private bool isLoading = true;
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
protected override async Task OnAfterRenderAsync(bool firstRender)
protected override async Task OnInitializedAsync()
{
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);
+94 -13
View File
@@ -1,10 +1,12 @@
@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 ILocalStorageService LocalStorageService
@inject NavigationManager NavigationManager
@inject CustomAuthenticationStateProvider AuthStateProvider
@inject IJSRuntime Js
@inject ILocalStorageService LocalStorageService
<PageTitle>로그인</PageTitle>
@@ -12,39 +14,52 @@
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
<form id="admin-login-form">
<input class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
<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"
name="username"
value="@model.Username" />
@bind-Value="model.Username" />
<input type="password"
<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"
name="password" />
@bind-Value="model.Password" />
<div class="mb-4">
<input class="mud-checkbox" type="checkbox" name="rememberMe" />
<InputCheckbox class="mud-checkbox" @bind-Value="model.RememberMe" />
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
</div>
<div class="mud-alert mud-alert-filled-error mb-4 login-error-message" style="display:none;">로그인 중 오류가 발생했습니다.</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
}
<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;">
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>
}
</button>
</form>
</MudPaper>
</MudContainer>
@code {
private readonly LoginModel model = new();
private bool isLoading = false;
private string errorMessage = "";
private LoginModel model = new();
private const string RememberedUsernameKey = "admin-remembered-username";
protected override async Task OnInitializedAsync()
@@ -55,11 +70,12 @@
if (!string.IsNullOrEmpty(remembered))
{
model.Username = remembered;
model.RememberMe = true;
}
}
catch
{
// LocalStorage may be unavailable during prerender.
// LocalStorage not available in pre-render
}
}
@@ -69,10 +85,75 @@
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,19 +96,13 @@
<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">@GetClientDisplayName(client)</MudSelectItem>
<MudSelectItem Value="@client.Id">@client.CompanyName</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" />
<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>
<MudTextField T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
@@ -119,9 +113,6 @@
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<RevenueTracking>? revenues;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
@@ -129,20 +120,9 @@
private bool isDialogOpen;
private RevenueForm revenueForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
protected override async Task OnInitializedAsync()
{
await LoadData();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -150,9 +130,9 @@
try
{
revenues = await RevenueClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
}
catch (Exception ex)
{
@@ -162,27 +142,12 @@
private void OpenCreateDialog()
{
revenueForm = new RevenueForm
{
ClientId = clients.FirstOrDefault()?.Id ?? 0,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(14)
};
revenueForm = new();
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(
@@ -252,12 +217,6 @@
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,23 +14,22 @@
<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="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-schedule">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenCreateDialog"
StartIcon="@Icons.Material.Filled.Add">
새 일정 추가
</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0">
@if (schedules is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
else if (schedules.Count == 0)
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (schedules.Count == 0)
{
<MudAlert Severity="Severity.Info">
<MudAlert Severity="Severity.Info" Class="mt-4">
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
신고 일정이 없습니다.
</MudAlert>
@@ -44,8 +43,6 @@ else
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedSchedule"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
@@ -53,7 +50,9 @@ else
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</CellTemplate>
</TemplateColumn>
@@ -110,105 +109,55 @@ else
</Columns>
</MudDataGrid>
}
</MudItem>
</MudPaper>
<!-- 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>
<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?"
<MudSelect T="int"
@bind-Value="scheduleForm.ClientId"
Label="고객"
Required="true"
Variant="Variant.Outlined"
FullWidth="@true"
Class="mb-3"
RequiredError="고객을 선택하세요."
Disabled="@isEditMode">
FullWidth="true"
Class="mb-4">
@foreach (var client in clients)
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
}
</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")
{
<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
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSchedule" id="btn-save-schedule">저장</MudButton>
}
</div>
<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>
</MudPaper>
</MudItem>
</MudGrid>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveSchedule">저장</MudButton>
</DialogActions>
</MudDialog>
@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 isEditMode;
private TaxFilingSchedule? selectedSchedule;
private bool isDialogOpen;
private TaxFilingScheduleForm scheduleForm = new();
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();
}
}
}
}
protected override async Task OnInitializedAsync() => await LoadData();
private async Task LoadData()
{
try
{
schedules = await TaxFilingClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
}
catch (Exception ex)
{
@@ -216,49 +165,18 @@ else
}
}
private void PrepareCreate()
private void OpenCreateDialog()
{
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
};
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
isDialogOpen = true;
}
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.Value,
scheduleForm.ClientId,
scheduleForm.FilingType,
scheduleForm.DueDate ?? DateTime.Today,
scheduleForm.FilingYear);
@@ -266,7 +184,7 @@ else
if (newId > 0)
{
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
PrepareCreate();
CloseDialog();
await LoadData();
}
else
@@ -286,10 +204,6 @@ else
{
await TaxFilingClient.MarkCompletedAsync(id);
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -315,10 +229,6 @@ else
{
await TaxFilingClient.DeleteAsync(id);
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -327,16 +237,15 @@ else
}
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private void CloseDialog()
{
isDialogOpen = false;
scheduleForm = new();
}
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, 100, search: value);
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
return items;
}
catch
@@ -110,12 +110,6 @@
}
}
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,7 +2,6 @@
@using TaxBaik.Web.Services.AdminClients
@inject ITaxProfileBrowserClient TaxProfileClient
@inject IClientBrowserClient ClientClient
@inject ICommonCodeBrowserClient CommonCodeClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@attribute [Authorize]
@@ -15,7 +14,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="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-profile">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 프로필 추가
</MudButton>
</section>
@@ -24,14 +23,9 @@
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else
else if (profiles.Count == 0)
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (profiles.Count == 0)
{
<MudAlert Severity="Severity.Info">세무 프로필이 없습니다.</MudAlert>
<MudAlert Severity="Severity.Info" Class="mt-4">세무 프로필이 없습니다.</MudAlert>
}
else
{
@@ -42,16 +36,16 @@ else
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedProfile"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
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))
{
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</CellTemplate>
</TemplateColumn>
@@ -73,90 +67,58 @@ else
</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))" />
<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>
}
</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>
<!-- 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-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
<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="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
<MudSelectItem Value="@client.Id">@client.CompanyName</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>
}
<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>
<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>
<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>
</MudPaper>
</MudItem>
</MudGrid>
}
</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? selectedProfile;
private TaxProfile? editingProfile;
private TaxProfileForm profileForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
protected override async Task OnInitializedAsync()
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
}
}
private async Task LoadData()
@@ -164,35 +126,9 @@ else
try
{
profiles = await TaxProfileClient.GetAllAsync();
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
var (clientItems, _) = await ClientClient.GetPagedAsync();
clients = clientItems.ToList();
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 = "높음" }
];
}
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
}
catch (Exception ex)
{
@@ -200,23 +136,18 @@ else
}
}
private void PrepareCreate()
private void OpenCreateDialog()
{
selectedProfile = null;
isEditMode = false;
profileForm = new TaxProfileForm
{
ClientId = clients.FirstOrDefault()?.Id,
TaxRiskLevel = "normal",
NextFilingDueDate = DateTime.Today.AddMonths(1)
};
editingProfile = null;
profileForm = new();
isDialogOpen = true;
}
private void OnRowSelected(TaxProfile profile)
private async Task OpenEditDialog(TaxProfile profile)
{
if (profile == null) return;
selectedProfile = profile;
isEditMode = true;
editingProfile = profile;
profileForm = new TaxProfileForm
{
ClientId = profile.ClientId,
@@ -225,50 +156,34 @@ 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 && selectedProfile != null)
{
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.Value,
profileForm.BusinessType);
if (newId > 0)
if (isEditMode)
{
await TaxProfileClient.UpdateAsync(
newId,
editingProfile!.Id,
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
}
else
{
var newId = await TaxProfileClient.CreateAsync(
profileForm.ClientId,
profileForm.BusinessType);
if (newId > 0)
{
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
}
}
PrepareCreate();
CloseDialog();
await LoadData();
}
catch (Exception ex)
@@ -293,10 +208,6 @@ else
{
await TaxProfileClient.DeleteAsync(id);
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
if (selectedProfile?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -305,6 +216,14 @@ else
}
}
private void CloseDialog()
{
isDialogOpen = false;
isEditMode = false;
editingProfile = null;
profileForm = new();
}
private Color GetRiskColor(string riskLevel) => riskLevel switch
{
"high" => Color.Error,
@@ -313,16 +232,9 @@ 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/admin-dashboard")]
[Route("api/[controller]")]
[Authorize]
public class AdminDashboardController : ControllerBase
{
@@ -1,39 +0,0 @@
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,20 +24,6 @@ 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,20 +24,6 @@ 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,20 +24,6 @@ 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,20 +24,6 @@ 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,20 +24,6 @@ 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
@@ -0,0 +1,87 @@
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
@@ -1,99 +0,0 @@
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;");
}
}
+16 -132
View File
@@ -3,171 +3,55 @@
ViewData["Title"] = "소개 | 백원숙 세무회계";
}
<!-- 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">백원숙 세무사</h1>
<!-- 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">
<div class="col-md-6">
<p class="lead">사업자 세무, 부동산 거래, 가족 자산 관리 등 종합적인 세무 컨설팅을 제공합니다.</p>
<p>10년 이상의 풍부한 경험과 3개의 국가자격증을 바탕으로, 각 클라이언트의 상황에 맞는 맞춤형 솔루션을 제시합니다.</p>
<p class="text-muted">저도 작게 시작하는 사업가였습니다. 처음 사업을 시작할 때의 막막함을 잘 알고 있습니다. 그 경험이 오늘날 고객분들과 소통하는 원동력입니다.</p>
</div>
<div class="col-lg-6">
<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년 자격취득 · 10년 경력</small>
<small class="text-muted">2015년 자격취득</small>
</div>
<div class="mb-3">
<p class="mb-1">🏠 <strong>부동산중개사</strong></p>
<small class="text-muted">부동산 거래 구조 이해 · 실무 전문성</small>
<small class="text-muted">부동산 거래 전문성</small>
</div>
<div>
<p class="mb-1">📊 <strong>보험설계사</strong></p>
<small class="text-muted">자산관리·상속 대비 전문성</small>
<small class="text-muted">자산관리 전문성</small>
</div>
</div>
</div>
</div>
</section>
<!-- 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>
<hr class="my-5" />
<!-- Philosophy Section -->
<section class="mb-5 pb-5 border-bottom">
<h2 class="fw-bold mb-4">상담 철학</h2>
<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>
<div class="mb-3" style="font-size: 2rem;">🎯</div>
<h5>명확한 설명</h5>
<p class="small text-muted">어려운 세법을 쉽게 설명하여 이해를 높입니다. 전문용어로 일방적 설명하지 않습니다.</p>
<p class="small">어려운 세법을 쉽게 설명하여 이해를 높입니다</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">💰</div>
<div class="mb-3" style="font-size: 2rem;">💰</div>
<h5>최대 절세</h5>
<p class="small text-muted">법적 범위 내에서 세금을 최소화합니다. 초기 세무 전략이 연간 수백만 원의 차이를 만듭니다.</p>
<p class="small">법적 범위 내에서 세금을 최소화합니다</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 class="mb-3" style="font-size: 2rem;">🤝</div>
<h5>신뢰 관계</h5>
<p class="small">장기적 파트너로서 성장을 함께 합니다</p>
</div>
</div>
</section>
<!-- 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">
<div class="text-center mt-5">
<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
@@ -1,4 +0,0 @@
@page "/announcement"
@{
Response.Redirect("/taxbaik/#top");
}
+2 -2
View File
@@ -39,8 +39,8 @@
<hr class="my-4" />
<div class="article-body lh-lg markdown-body">
@Html.Raw(Model.HtmlContent)
<div class="article-body lh-lg">
@Html.Raw(Model.Post.Content)
</div>
<hr class="my-4" />
-3
View File
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using Markdig;
namespace TaxBaik.Web.Pages.Blog;
@@ -10,7 +9,6 @@ public class BlogPostModel : PageModel
private readonly BlogService _blogService;
public BlogPost? Post { get; set; }
public string? HtmlContent { get; set; }
public BlogPostModel(BlogService blogService)
{
@@ -22,7 +20,6 @@ public class BlogPostModel : PageModel
Post = await _blogService.GetBySlugAsync(slug);
if (Post != null)
{
HtmlContent = Markdown.ToHtml(Post.Content ?? "");
_ = _blogService.IncrementViewCountAsync(Post.Id);
}
}
-4
View File
@@ -1,4 +0,0 @@
@page "/faq"
@{
Response.Redirect("/taxbaik/#faq");
}
+131 -90
View File
@@ -103,14 +103,31 @@ else
</section>
}
<!-- About 링크 배너 -->
<section class="py-3" style="background: rgba(46, 92, 78, 0.05); border-bottom: 1px solid rgba(46, 92, 78, 0.1);">
<!-- 신뢰도 스트립 — 자격과 경험 -->
<section class="trust-strip">
<div class="container">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<p class="mb-0 small text-muted">세무사의 경력, 자격, 상담 철학을 알아보세요</p>
<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>
<a href="/taxbaik/about" class="btn btn-sm btn-outline-primary">백원숙 세무사 소개 →</a>
</div>
</div>
</section>
@@ -127,7 +144,7 @@ else
@{
var focusService = season?.FocusService ?? "";
// 시즌에 따라 서비스 카드 순서 결정
// 시즌에 따라 서비스 카드 순서 결정: 시즌 관련 카드가 맨 앞
var cardOrder = focusService switch
{
"real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" },
@@ -145,10 +162,18 @@ 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>
<p class="text-muted small">월 기장부터 종합소득세, 신규 사업자 세무까지 — 사업 초기부터 체계적인 세무 관리.</p>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 정확한 기장 및 결산</li>
<li class="mb-2">✓ 세금계산서 관리</li>
<li class="mb-2">✓ 경비처리 최적화</li>
<li class="mb-2">✓ 절세 전략 수립</li>
</ul>
<p class="text-muted small">
초기부터 세무 전략을 수립하면 연간 최대 수백만 원의 절세가 가능합니다.
</p>
<a href="/taxbaik/services#business-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
@@ -162,7 +187,15 @@ else
<div class="service-icon">🏠</div>
<div class="card-body pt-0">
<h3 class="card-title">부동산 세금</h3>
<p class="text-muted small">양도세·취득세·임대소득세 — 부동산 거래 시 세금 부담을 줄이는 전략.</p>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 양도세 최소화</li>
<li class="mb-2">✓ 취득세 절감</li>
<li class="mb-2">✓ 임대소득 관리</li>
<li class="mb-2">✓ 다주택자 세무</li>
</ul>
<p class="text-muted small">
부동산 거래 시 미리 상담하면 세금 부담을 크게 줄일 수 있습니다.
</p>
<a href="/taxbaik/services#real-estate-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
@@ -176,7 +209,15 @@ else
<div class="service-icon">👨‍👩‍👧‍👦</div>
<div class="card-body pt-0">
<h3 class="card-title">가족자산 관리</h3>
<p class="text-muted small">증여·상속 사전 계획부터 대표자 리스크 관리까지 — 가족 자산을 지키는 전략.</p>
<ul class="list-unstyled small mb-3">
<li class="mb-2">✓ 증여세 전략</li>
<li class="mb-2">✓ 상속세 대비</li>
<li class="mb-2">✓ 자산 이전 계획</li>
<li class="mb-2">✓ 가족법인 설립</li>
</ul>
<p class="text-muted small">
세대 이전 전에 사전 계획하면 세금 부담을 현저히 줄일 수 있습니다.
</p>
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
@@ -187,85 +228,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-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">
@@ -311,6 +273,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-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
@@ -1,4 +0,0 @@
@page "/inquiry"
@{
Response.Redirect("/taxbaik/contact");
}
+8 -4
View File
@@ -54,7 +54,7 @@
<div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8">
<div class="card glass-card mb-4">
<div class="card border-0 shadow-sm rounded-3 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 glass-card">
<div class="card border-0 shadow-sm rounded-3">
<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,10 +139,14 @@
}
else
{
<div class="timeline ps-2">
<div class="timeline">
@foreach (var activity in Model.Consultations)
{
<div class="timeline-item-modern">
<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="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>
+1 -1
View File
@@ -52,5 +52,5 @@ public class LoginModel : PageModel
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
private static AuthenticationProperties BuildProps(string provider) =>
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
new() { RedirectUri = $"/portal/external-callback?provider={provider}" };
}
+1 -39
View File
@@ -4,20 +4,7 @@
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>
<!-- 사업자 세무 -->
@@ -137,36 +124,11 @@
</section>
<!-- CTA -->
<section class="bg-primary text-white py-5 rounded mt-5 mb-5">
<section class="bg-primary text-white py-5 rounded mt-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>
+20 -72
View File
@@ -3,110 +3,58 @@
<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 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="구글_서치콘솔_토큰_입력" /> -->
<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"]" />
<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=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://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://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-5">
<footer class="bg-light border-top mt-5 py-4">
<div class="container">
<div class="row g-5">
<div class="col-md-3">
<h6 class="fw-bold mb-3">백원숙 세무회계</h6>
<div class="row g-4">
<div class="col-md-4">
<h6 class="fw-bold">백원숙 세무회계</h6>
<p class="small text-muted">
사업자 기장, 부동산 양도세·증여세,<br />
종합소득세 전문 상담
</p>
</div>
<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>
<div class="col-md-4">
<h6 class="fw-bold">연락처</h6>
<p class="small">
📞 <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>
📞 <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>
</p>
</div>
<div class="col-md-3">
<h6 class="fw-bold mb-3">채널</h6>
<div class="col-md-4">
<h6 class="fw-bold">채널</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-4" />
<hr class="my-3" />
<div class="text-center small text-muted">
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
<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/terms" class="text-decoration-none text-muted">이용약관</a>
<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;">
+36 -48
View File
@@ -38,13 +38,6 @@ 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)
@@ -52,11 +45,12 @@ 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()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DetailedErrors = true;
@@ -95,8 +89,8 @@ var authenticationBuilder = builder.Services.AddAuthentication(opts =>
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
opts.LoginPath = "/taxbaik/portal/login";
opts.AccessDeniedPath = "/taxbaik/portal/login";
opts.LoginPath = "/portal/login";
opts.AccessDeniedPath = "/portal/login";
opts.SlidingExpiration = true;
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
})
@@ -117,7 +111,7 @@ if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(goo
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = googleClientId;
opts.ClientSecret = googleClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-google";
opts.CallbackPath = "/portal/signin-google";
});
}
@@ -130,7 +124,7 @@ if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(nave
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = naverClientId;
opts.ClientSecret = naverClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-naver";
opts.CallbackPath = "/portal/signin-naver";
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
@@ -162,7 +156,7 @@ if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kaka
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = kakaoClientId;
opts.ClientSecret = kakaoClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
opts.CallbackPath = "/portal/signin-kakao";
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
@@ -196,6 +190,9 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
// Notifications (SignalR)
builder.Services.AddScoped<INotificationService, NotificationService>();
// Telegram Notification
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
@@ -210,65 +207,70 @@ 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>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
})
.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 => {
@@ -315,20 +317,6 @@ 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
{
@@ -366,12 +354,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();
// 애플리케이션 시작/종료 로깅
@@ -14,24 +14,15 @@ public interface IConsultingActivityBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ConsultingActivityBrowserClient> logger)
public class ConsultingActivityBrowserClient(HttpClient httpClient, 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)
@@ -45,7 +36,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -59,7 +49,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{
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()) ?? [];
@@ -77,7 +66,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{
try
{
EnsureAuthHeader();
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -95,7 +83,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{
try
{
EnsureAuthHeader();
var request = new { outcome, nextFollowupDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -110,7 +97,6 @@ public class ConsultingActivityBrowserClient(HttpClient httpClient, ITokenStore
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,24 +16,15 @@ public interface IContractBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<ContractBrowserClient> logger)
public class ContractBrowserClient(HttpClient httpClient, 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)
@@ -47,7 +38,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -61,7 +51,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -75,7 +64,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{
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()) ?? [];
@@ -92,7 +80,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{
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()) ?? [];
@@ -109,7 +96,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{
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());
@@ -127,7 +113,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{
try
{
EnsureAuthHeader();
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -145,7 +130,6 @@ public class ContractBrowserClient(HttpClient httpClient, ITokenStore tokenStore
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -16,24 +16,15 @@ public interface IRevenueTrackingBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<RevenueTrackingBrowserClient> logger)
public class RevenueTrackingBrowserClient(HttpClient httpClient, 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)
@@ -47,7 +38,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -61,7 +51,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{
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()) ?? [];
@@ -78,7 +67,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{
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()) ?? [];
@@ -95,7 +83,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{
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))
@@ -114,7 +101,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{
try
{
EnsureAuthHeader();
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -132,7 +118,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{
try
{
EnsureAuthHeader();
var request = new { paymentDate };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
response.EnsureSuccessStatusCode();
@@ -147,7 +132,6 @@ public class RevenueTrackingBrowserClient(HttpClient httpClient, ITokenStore tok
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -15,24 +15,15 @@ public interface ITaxFilingScheduleBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxFilingScheduleBrowserClient> logger)
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, 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)
@@ -46,7 +37,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -60,7 +50,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -74,7 +63,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{
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()) ?? [];
@@ -92,7 +80,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{
try
{
EnsureAuthHeader();
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -110,7 +97,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{
try
{
EnsureAuthHeader();
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
response.EnsureSuccessStatusCode();
}
@@ -124,7 +110,6 @@ public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ITokenStore t
{
try
{
EnsureAuthHeader();
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
response.EnsureSuccessStatusCode();
}
@@ -17,23 +17,14 @@ public interface ITaxProfileBrowserClient
Task DeleteAsync(int id, CancellationToken ct = default);
}
public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
public class TaxProfileBrowserClient(HttpClient httpClient, 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)
@@ -47,7 +38,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
}
catch (Exception ex)
@@ -61,7 +51,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
}
catch (Exception ex)
@@ -75,7 +64,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{
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()) ?? [];
@@ -92,7 +80,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{
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()) ?? [];
@@ -110,7 +97,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{
try
{
EnsureAuthHeader();
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
response.EnsureSuccessStatusCode();
@@ -129,7 +115,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{
try
{
EnsureAuthHeader();
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
response.EnsureSuccessStatusCode();
@@ -144,7 +129,6 @@ public class TaxProfileBrowserClient(HttpClient httpClient, ITokenStore tokenSto
{
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))
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_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))
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_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))
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
}
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
@@ -1,7 +1,6 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Services;
@@ -9,18 +8,18 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly ITokenStore _tokenStore;
private readonly IApiClient _apiClient;
private readonly AuthService _authService;
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
public CustomAuthenticationStateProvider(
ILocalStorageService localStorage,
ITokenStore tokenStore,
IApiClient apiClient,
AuthService authService,
ILogger<CustomAuthenticationStateProvider> logger)
{
_localStorage = localStorage;
_tokenStore = tokenStore;
_apiClient = apiClient;
_authService = authService;
_logger = logger;
}
@@ -33,22 +32,21 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken))
{
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken))
accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(accessToken))
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (long.TryParse(ticksStr, out var ticks))
{
_tokenStore.AccessToken = storedToken;
_tokenStore.AccessToken = accessToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
}
}
}
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
if (string.IsNullOrEmpty(accessToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
@@ -65,9 +63,8 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
{
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
var request = new { RefreshToken = _tokenStore.RefreshToken };
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
var newTokenPair = await _authService.RefreshAccessTokenAsync(_tokenStore.RefreshToken);
if (newTokenPair != null)
{
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
_logger.LogInformation("토큰 자동 갱신 성공");
@@ -81,7 +78,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
var principal = _authService.ValidateToken(accessToken);
if (principal == null)
{
await LogoutAsync();
@@ -97,22 +94,6 @@ 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;
@@ -133,13 +114,13 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
if (_tokenStore.TokenExpiryTicks <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
var expiryTime = new DateTime((long)_tokenStore.TokenExpiryTicks, DateTimeKind.Utc);
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
}
@@ -176,17 +157,3 @@ 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))
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_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))
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
}
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
@@ -0,0 +1,72 @@
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);
}
}
@@ -32,10 +32,10 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
{
_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>(
$"taxfiling/upcoming?daysAhead={daysAhead}", cancellationToken: ct);
$"tax-filing/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>(
$"taxfiling/client/{clientId}", cancellationToken: ct);
$"tax-filing/client/{clientId}", cancellationToken: ct);
return result?.Data ?? [];
}
catch (HttpRequestException ex)
@@ -76,7 +76,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<TaxFiling>(
$"taxfiling/{id}", cancellationToken: ct);
$"tax-filing/{id}", cancellationToken: ct);
}
catch (HttpRequestException ex)
{
@@ -90,7 +90,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("taxfiling", filing, cancellationToken: ct);
var response = await _http.PostAsJsonAsync("tax-filing", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -111,7 +111,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"taxfiling/{id}", filing, cancellationToken: ct);
var response = await _http.PutAsJsonAsync($"tax-filing/{id}", filing, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
@@ -132,7 +132,7 @@ public class TaxFilingBrowserClient : ITaxFilingBrowserClient
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"taxfiling/{id}", cancellationToken: ct);
var response = await _http.DeleteAsync($"tax-filing/{id}", cancellationToken: ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
@@ -13,7 +13,6 @@ 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
@@ -97,10 +96,4 @@ 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,8 +15,6 @@ public class TelegramReportBackgroundService(
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
@@ -24,21 +22,12 @@ public class TelegramReportBackgroundService(
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)
{
@@ -59,7 +48,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildDailyReportAsync(date, ct);
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
_lastDailyReportDate = date;
logger.LogInformation("Daily telegram report sent for {Date}", date);
}
@@ -74,7 +63,7 @@ public class TelegramReportBackgroundService(
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
_lastWeeklyReportWeekStart = weekStart;
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
}
@@ -10,12 +10,12 @@ using System.Text.Json;
/// </summary>
public class TokenRefreshHandler : DelegatingHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly ITokenStore _tokenStore;
private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(IServiceProvider serviceProvider, ILogger<TokenRefreshHandler> logger)
public TokenRefreshHandler(ITokenStore tokenStore, ILogger<TokenRefreshHandler> logger)
{
_serviceProvider = serviceProvider;
_tokenStore = tokenStore;
_logger = logger;
}
@@ -23,13 +23,10 @@ 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);
@@ -37,15 +34,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);
@@ -54,7 +51,7 @@ public class TokenRefreshHandler : DelegatingHandler
else
{
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
tokenStore.Clear();
_tokenStore.Clear();
}
}
}
@@ -62,7 +59,7 @@ public class TokenRefreshHandler : DelegatingHandler
return response;
}
private async Task<WasmAuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
private async Task<AuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
{
try
{
@@ -87,7 +84,7 @@ public class TokenRefreshHandler : DelegatingHandler
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result != null
? new WasmAuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
? new AuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
: null;
}
catch (Exception ex)

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