Compare commits

..

6 Commits

371 changed files with 8205 additions and 14079 deletions
+2 -20
View File
@@ -49,13 +49,12 @@ jobs:
# Suppress stderr and allow failures to handle transition/down periods cleanly # Suppress stderr and allow failures to handle transition/down periods cleanly
VERSION_BODY="$(curl -fsS "http://${DEPLOY_HOST}/taxbaik/version.json" 2>/dev/null || true)" 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)" 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" ]; then
if echo "$VERSION_BODY" | grep -q "\"version\": \"${SHORT_VERSION}\"" && [ "$BLOG_STATUS" = "200" ] && [ "$LOGIN_STATUS" = "200" ]; then
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)" echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
exit 0 exit 0
fi fi
if [ $i -lt 20 ]; then 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 sleep 3
fi fi
done done
@@ -73,23 +72,6 @@ jobs:
echo "Running E2E tests on Desktop Chrome (production verification)" echo "Running E2E tests on Desktop Chrome (production verification)"
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list 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 - name: Browser E2E summary
if: always() if: always()
run: | run: |
+9 -25
View File
@@ -1,6 +1,7 @@
name: TaxBaik CI/CD name: TaxBaik CI/CD
on: on:
workflow_dispatch:
push: push:
branches: branches:
- master - master
@@ -32,9 +33,6 @@ jobs:
- name: Publish Web - name: Publish Web
run: dotnet publish TaxBaik.Web/ -c Release -o ./publish --no-restore 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 - name: Write production secrets
run: | run: |
set -e set -e
@@ -69,11 +67,6 @@ jobs:
)' )'
test -s ./publish/appsettings.Production.json || { echo "appsettings.Production.json is empty" >&2; exit 1; } 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 - name: Copy migrations
run: cp -r db/migrations ./publish/migrations || true run: cp -r db/migrations ./publish/migrations || true
@@ -107,14 +100,12 @@ jobs:
- name: Package artifact - name: Package artifact
run: | run: |
cp deploy_gb.sh ./publish/deploy_gb.sh
tar -czf taxbaik_deploy.tgz -C ./publish . tar -czf taxbaik_deploy.tgz -C ./publish .
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)" echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
- name: Deploy & verify on server - name: Deploy & verify on server
run: | run: |
set -e set -e
export TAXBAIK_DEPLOY_FROM_CI=1
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
COMMIT=$(git rev-parse --short HEAD) COMMIT=$(git rev-parse --short HEAD)
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}" DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
@@ -157,7 +148,7 @@ jobs:
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원) # 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \ ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
-o ServerAliveInterval=10 \ -o ServerAliveInterval=10 \
"$DEPLOY_USER@$DEPLOY_HOST" TAXBAIK_DEPLOY_FROM_CI=1 bash << REMOTE "$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
set -e set -e
DEPLOY_HOME="/home/kjh2064" DEPLOY_HOME="/home/kjh2064"
DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}" DEPLOY_DIR="\$DEPLOY_HOME/deployments/taxbaik_${TIMESTAMP}"
@@ -171,12 +162,12 @@ jobs:
echo "--- [2/5] 운영 설정 검증 ---" echo "--- [2/5] 운영 설정 검증 ---"
test -s "\$DEPLOY_DIR/appsettings.Production.json" \ test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; } || { 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 배포 실행 ---" echo "--- [3/5] 심볼릭 링크 전환 ---"
chmod +x "\$DEPLOY_DIR/deploy_gb.sh" ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
echo "--- [4/5] 서비스 재시작 ---"
sudo /usr/bin/systemctl restart taxbaik
echo "--- [5/5] 헬스 체크 (최대 60초) ---" echo "--- [5/5] 헬스 체크 (최대 60초) ---"
ATTEMPTS=20 ATTEMPTS=20
@@ -200,20 +191,13 @@ jobs:
fi fi
echo "✓ [3/4] 버전 정보 확인 완료" echo "✓ [3/4] 버전 정보 확인 완료"
# 검증 4: 5001 프록시 확인 # 검증 3: 관리자 로그인 페이지
if ! ss -tlnp | grep -q ':5001 '; then
echo "❌ 5001 프록시가 실행 중이 아님" >&2
exit 1
fi
echo "✓ [4/5] 5001 프록시 확인 완료"
# 검증 5: 관리자 로그인 페이지
LOGIN_STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/admin/login 2>/dev/null || echo "000") 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 if [ "\$LOGIN_STATUS" != "200" ]; then
echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2 echo "❌ 관리자 로그인 페이지 로드 실패 (상태: \$LOGIN_STATUS)" >&2
exit 1 exit 1
fi fi
echo "✓ [5/5] 관리자 페이지 로드 완료" echo "✓ [4/4] 관리자 페이지 로드 완료"
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)" echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
# 구 배포 디렉토리 정리 (최근 5개 보존) # 구 배포 디렉토리 정리 (최근 5개 보존)
-486
View File
@@ -1,486 +0,0 @@
# 블로그 포스트 작성 템플릿
## 🎯 핵심 철학
**블로그의 진정한 목적 - 3층 구조**:
### 1층: 기초 교육 (누구나 배울 수 있음)
"이 정도는 자신이 할 수 있어요"
- 기본 개념 설명
- 단계별 방법론
### 2층: 디테일 & 세법 변화 (추적 불가능)
**"하지만 악마는 디테일... 그리고 세법은 계속 바뀌어요"**
- 겹겹이 쌓인 디테일들
- 매년 변경되는 세법
- 고객이 추적 불가능한 영역
### 3층: 세무사의 가치 (전문가만 가능)
**"그래서 전문가가 필요합니다"**
- 디테일 관리
- 세법 변화 자동 추적
- 리스크 관리
- 시간/돈/스트레스 절약
---
**최종 메시지**:
```
기초는 배울 수 있어요.
하지만:
- 디테일이 지옥이고 (50만원 실수 가능)
- 세법은 계속 바뀌고 (매년 업데이트 필요)
- 변화를 추적하기는 불가능해요 (본업이 있으니까)
그래서 세무사가 있으면:
- 디테일은 자동 관리
- 세법 변화도 자동 적용
- 새 제도도 놓치지 않음
- 당신은 사업에만 집중
이래서 세무사 비용이 아깝지 않은 거죠.
```
---
## 📝 템플릿 (복사해서 사용)
### 📌 Step 1: 도입부 (공감)
```markdown
# [제목]
"[구체적 상황]?"
"많은 [직업]들이 이 상황을 겪습니다."
→ 독자가 자신의 상황을 발견하도록
```
**예시**:
```markdown
# 동네 카페 월세 낼 때 세금이 안 나와요 - 정말 그럴까?
"사업을 시작했는데 세금을 낸 적이 없어요"
"많은 소규모 사업자들이 이렇게 생각합니다."
```
---
### 👤 Step 2: 실제 사례 (구체적 페르소나)
**필수 정보**:
- 이름, 나이, 직업, 사업 경력
- 월/연간 매출 (현실적 수치)
- 실제 겪은 문제/성공 사례
```markdown
### 상황: [지역] [카테고리]를 운영하는 [이름]님 ([나이]세, 사업 [년]차)
**기본 정보**:
- 위치: [구체적 위치]
- 월 매출: [금액]
- 월 경비: [주요 항목들]
### 원래는 이렇게 했어요 (실패 사례)
→ [실제 실수 1]
→ [실제 실수 2]
**결과**: 세금을 [X만 원] 더 내게 됨 (또는 세무조사 대상)
### 바뀐 후 (성공 사례)
→ [해결책 1]
→ [해결책 2]
**결과**: 세금을 [X만 원] 절약함 (또는 안정적인 운영)
```
**예시**:
```markdown
### 상황: 강남 역삼동에서 카페를 운영하는 김민수님 (34세, 사업 3년차)
**기본 정보**:
- 위치: 강남역 3번 출구 근처
- 월 매출: 약 600만 원 (평일 200만, 주말 400만)
- 월 경비: 월세 150만, 재료비 180만, 직원급여 100만 원
### 원래는 이렇게 했어요
→ "세금은 큰 회사나 내는 거라고 생각했어요"
→ 영수증도 대충 정리하고
**결과**: 세무청에서 3년치를 추징받고 가산세까지...손해 70만 원
### 바뀐 후
→ 매달 영수증을 정리해서
→ 세무사와 년 1회 기장 상담
**결과**: 세금도 명확하고, 추징도 없음. 심플하고 안전
```
---
### 📊 Step 3: 계산 & 설명
**구조**:
1. **기본 정보 확인** (위에서 제시한 사례 요약)
2. **단계별 계산** (Step 1, Step 2, ... 명확히)
3. **표로 시각화**
```markdown
## 계산 방법
### Step 1️⃣: 매출 정리
월 600만 원 × 12개월 = 연 7,200만 원
### Step 2️⃣: 경비 계산
| 항목 | 월 | 연간 |
|------|-----|------|
| 월세 | 150만 | 1,800만 |
| 재료비 | 180만 | 2,160만 |
| 직원급여 | 100만 | 1,200만 |
| 기타 | 20만 | 240만 |
| **합계** | **450만** | **5,400만** |
### Step 3️⃣: 순이익
7,200만 - 5,400만 = **1,800만 원**
### Step 4️⃣: 세금
1,800만 원 × 약 6% = **약 108만 원/년**
```
---
### 🎭 Step 3.5: 악마는 디테일이다 (선택사항이지만 강력함)
**구조**: "간단해 보이지만, 실제로는..."
```markdown
## 겉으로는 간단해 보여요... 하지만
### 📄 "영수증을 정리하세요"라고 했는데
**겉으로는**:
→ 영수증을 모으기만 하면 돼
**현실의 디테일**:
→ 이 영수증은 인정되고, 이건 안 됨 (세법)
→ 이건 개인비? 사업비? (판단)
→ 카드값이랑 현금값이랑 다르면? (대사)
→ 3년 지났는데 영수증을 못 찾으면? (소송)
→ 세무청이 불인정하면? (항의 절차)
**세무사가 처리하는 것**:
✅ 어떤 영수증이 인정될지 사전에 판단
✅ 개인비와 사업비의 경계 명확히
✅ 세법 변경사항 적용
✅ 세무청 부인시 대응 준비
---
### 📊 "매출과 경비를 기록하세요"라고 했는데
**겉으로는**:
→ 엑셀에 숫자만 입력하면 돼
**현실의 디테일**:
→ 카드 명세서와 입금액이 안 맞음 (환불? 수수료?)
→ 한 달간 매출을 빼먹음 (추가 계산)
→ 같은 카테고리인데 세법상 다르게 분류돼야 함 (부가세/소득세 다름)
→ 작년에 잘못 입력한 게 발견됨 (수정신고)
→ 월별로 편차가 커서 세무청이 의심함 (설명 준비)
**세무사가 처리하는 것**:
✅ 카드명세서 vs 입금액 정산
✅ 누락된 부분 찾아서 추가
✅ 세법상 올바른 분류
✅ 이전년도 오류 수정신고
✅ 세무청 질의에 대한 근거 제시
---
### ✅ "정확하게 기장하면 세금이 확정돼요"라고 했는데
**겉으로는**:
→ 기장만 잘하면 세금 끝
**현실의 디테일**:
→ 같은 사업도 절세 방법이 다양함 (어떤 게 맞나?)
→ 올해는 이렇게, 내년은 저렇게? (일관성)
→ 부가세와 소득세 둘 다 고려해야 함 (연계 계산)
→ 세무조사가 오면 3년치를 모두 봄 (소급 처리)
→ 이의신청/항소하려면? (법적 절차)
**세무사가 처리하는 것**:
✅ 최적의 절세 전략 제시
✅ 연도별 일관된 기장 방식 유지
✅ 부가세/소득세 동시 최적화
✅ 세무조사 대비 사전 정리
✅ 이의신청/항소 등 법적 대응
```
**💡 핵심**:
- 기초는 누구나 배울 수 있어요
- **하지만 디테일을 모두 처리하려면?**
- **그 디테일들이 바로 세무사가 하는 일**
- **디테일 하나 놓쳤다가 가산세 50만 원... 이래서 세무사 비용이 아깝지 않음**
---
### 🔄 Step 3.6: 세법은 계속 바뀐다 (매년 업데이트)
**구조**: "올해의 세법 변화"를 포스트 작성 시점에 맞춰 갱신
```markdown
## 그런데 세법은 해마다 바뀝니다
### 📋 [연도] 변경사항들 (꼭 알아야 할 것들)
**✅ 2025년 부가세 변화**:
- 신고 기한이 [날짜]로 변경됨
- 영세사업자 기준이 [금액]로 상향조정됨
- 새로운 공제 항목이 추가됨: [항목들]
**✅ 2025년 소득세 변화**:
- 기본공제가 [금액]에서 [금액]로 증가
- 자녀 공제 조건이 변경됨
- 월급 원천징수 기준이 조정됨
**✅ 2025년 새로운 제도**:
- 소상공인 세금 감면 확대
- 청년사업자 지원 강화
- 부가가치세 간편신청 범위 확대
---
**혼자서 할 때의 문제**:
❌ "작년 기준으로 기장했는데 올해 기준이 바뀐 거야?"
❌ "이 공제가 되는 건지 안 되는 건지 모르겠어"
❌ "새로운 제도가 나왔다는 것도 몰랐어"
❌ "처음 다시 계산해야 하나?"
**세무사가 처리하는 것**:
✅ 매년 변경사항 자동 추적
✅ 당신의 상황에 맞는 새로운 공제 적용
✅ 이전년도 재계산 필요시 수정신고
✅ 연중 세법 개정 소식 안내
✅ 새로운 지원 정책 놓치지 않게 관리
---
## 왜 세무사를 고용해야 하나요?
### 📈 "혼자하기" vs "세무사와 함께"
| 항목 | 혼자할 때 | 세무사와 함께 |
|------|----------|-----------|
| **세법 추적** | 부분적 (인터넷 검색) | 자동 (전문가 업데이트) |
| **새 제도 활용** | 놓칠 확률 높음 | 100% 적용 |
| **변경사항 대응** | 재계산 필요 | 자동 반영 |
| **신뢰도** | 불안감 | 확신 |
| **업데이트 비용** | 당신의 시간 | 포함됨 |
**세법이 계속 바뀐다는 것 자체가 세무사가 필요한 이유**
```
**💡 강조점**:
- 세법은 **정적이지 않음** (계속 변함)
- 고객은 **변화를 추적할 수 없음** (본업이 있으니까)
- 세무사는 **자동으로 최신 기준 적용** (전문가니까)
- 결과: **"세무사 한 명이면 내가 평생 세법 공부 안 해도 돼"**
---
### 💡 Step 4: 실무 팁 (3~5개)
**구조**: ✅ 이렇게 하세요 / ❌ 이렇게 하면 안 돼요
```markdown
## 이렇게 하면 세금이 명확해요
### ✅ 해야 할 것
1. **영수증 정리** - 매달 봉투에 모아두기
2. **기본 기록** - 엑셀에 간단히 기입
3. **연 1회 점검** - 세무사와 기본 상담
4. **투명성** - 세무청 신고는 정확하게
### ❌ 하면 안 되는 것
1. **영수증 버리기** - 나중에 증거 없음
2. **개인비와 섞기** - 기장 혼란
3. **신고 늦추기** - 가산세 발생
4. **과하게 깎기** - 세무조사 리스크
```
---
### 🎓 Step 5: 결론 (임파워먼트 + 세무사 자연스러운 유도)
**구조**:
1. "기초 수준이면 이 정도면 충분"
2. "하지만 이렇게 복잡해지면 전문가 도움이 효율적"
3. "세무사와 함께 하는 게 장기적으로 낫다"
```markdown
## 기초는 누구나 할 수 있어요
**이 정도면 자신이 충분히 가능합니다**:
- 소규모 사업 (월 500만~1,000만 원)
- 단순 경비 (재료, 임차료 등)
- 월 1회 정도 기본 정리
→ 영수증 정리 + 기본 엑셀 기입면 충분
---
## 하지만 이렇게 복잡하면 전문가 도움이 효율적입니다
**세무사 상담을 권하는 경우**:
- 📊 월 매출이 2,000만 원을 넘어갈 때
- 💼 여러 사업을 동시에 운영할 때
- 🏠 부동산 등 추가 수입이 있을 때
- 📈 직원을 여러 명 두고 있을 때
- 🌍 해외 거래나 수입이 있을 때
### 💰 세무사와 함께 하는 이유 (숫자가 말해줍니다)
| 항목 | 혼자할 때 | 세무사와 함께 | 차이 |
|------|----------|-----------|------|
| **절세액** | X만 원 | X + 200만 원 | +200만 원 절약 |
| **세무조사 스트레스** | 매년 불안 | 안정적 대응 | 심리적 안정 |
| **시간 투자** | 월 10시간 | 월 1시간 | 월 9시간 자유 |
| **세무사 비용** | 0원 | 약 100만 원/년 | -100만 원 |
| **실제 이익** | 순이익 | 순이익 + 100만 원 | **+100만 원 순이익** |
**돈을 쓰는 이유가 있습니다**:
- 💰 **세금 절약**: 절세 기법으로 200만 원 절약 - 100만 원 비용 = 순 100만 원 이득
-**시간 절약**: 월 9시간(연 108시간) 절약 = 사업에 집중 가능
- 😌 **스트레스 감소**: 세무조사 불안 제거, 복잡한 계산 안 함
- 🛡️ **리스크 관리**: 실수로 인한 가산세/과태료 방지
**결론**: 세무사 비용 > 절세액 + 시간 절약 + 스트레스 감소
---
## 요약
**기본 개념을 아는 것만으로도**:
- 실수를 줄이고
- 세금을 절약하고
- 세무사와의 상담이 훨씬 효율적
당신의 상황이 어느 정도인지 판단하고,
필요할 때 전문가와 함께 하세요.
```
---
## ✅ 작성 체크리스트
### 내용
- [ ] **실제 사례**: 동네 카페/편의점/학원 같은 주변 상황
- [ ] **구체적 페르소나**: 이름, 나이, 직업, 사업 경력
- [ ] **실제 금액**: 매출, 경비, 세금 (현실적 수치)
- [ ] **Before/After**: 실패 사례 → 성공 사례
- [ ] **절세 효과**: "X만 원 절약" 또는 "손해를 막음"
- [ ] **계산**: Step별로 명확, 표 포함
- [ ] **악마는 디테일**: "겉으로는 간단해 보이지만 실제로는..." (세무사가 처리하는 디테일들)
- [ ] **세법 변화**: 해당 연도의 세법 변경사항과 그 영향 설명
### 톤
- [ ] **교육적**: 개념을 이해하도록
- [ ] **격려적**: 경고/협박 없음
- [ ] **현실적**: 복잡할 수 있다는 인정
- [ ] **세무사 자연스러운 유도**: "필요할 때 도움되는 선택"
- [ ] **임파워먼트**: "기초는 누구나 할 수 있어요"
### 표현
- [ ] **중학교 수준**: 어려운 용어는 () 설명
- [ ] **이모지**: 🏠💰✅❌📊 등으로 시각화
- [ ] **짧은 문장**: 한 문장에 한 개념
- [ ] **표와 리스트**: 수치는 표로, 항목은 리스트로
---
## 🚫 피해야 할 표현 (한국세무사협회 광고 규칙 준수)
### ❌ **절대 금지 표현** (법적 위반 위험)
**1. 과도한 절세 약속 & 절대 표현**:
- ❌ "50만 원 절약 가능"
- ❌ "최대한 경비를 깎아줍니다"
- ❌ "세금을 반으로 줄여드립니다"
- ❌ "세금을 덜 냅니다" (보장으로 해석)
- ❌ "가장 많이 절세해드립니다"
- ✅ "이 사례에서는 약 50만 원 절약되었습니다" (과거 사례만)
- ✅ "정확한 경비 처리로 세법에 따른 정당한 공제를 받을 수 있습니다" (법적 근거)
- ✅ "경비를 빠짐없이 처리합니다" (객관적 프로세스)
**2. 보장 표현 (불가능한 결과 약속)**:
- ❌ "반드시 세금을 줄입니다"
- ❌ "세무조사 안 받게 해드립니다"
- ❌ "100% 절세를 보장합니다"
- ❌ "세금을 보장합니다"
- ✅ "정확한 신고로 세무조사 리스크를 최소화합니다"
- ✅ "세법에 따른 정당한 공제를 받을 수 있습니다"
**3. 무료 & 가격 표현**:
- ❌ "무료로 세금 절약해드립니다"
- ❌ "최저가 신고료"
- ❌ "가장 저렴한 가격"
- ✅ "합리적인 비용으로 전문 서비스를 제공합니다"
**4. 절대/최상급 표현**:
- ❌ "반드시", "무조건", "반듯이", "항상", "절대"
- ❌ "최고", "최우수", "1등", "유일"
- ❌ "모든", "완벽하게"
- ✅ "일반적으로", "대부분의 경우", "보통"
**5. 과도한 단순화 표현**:
- ❌ "매우 편합니다", "너무 쉽습니다"
- ❌ "아무도 실수할 수 없습니다"
- ❌ "5분이면 끝납니다"
- ✅ "기초 개념을 배울 수 있습니다"
- ✅ "복잡한 부분은 전문가가 관리합니다"
**6. 객관적 증거 없는 수치**:
- ❌ "평균 170만 원 절약" (근거 없으면)
- ❌ "고객의 80%가 만족" (통계 없으면)
- ❌ "보통 2배의 환급" (데이터 없으면)
- ✅ "이 사례에서는 약 170만 원 절약되었습니다"
- ✅ "많은 고객들이 정확한 기장의 필요성을 느낍니다"
---
### ✅ **안전한 표현 (권장)**
| 대신 이렇게 | 이유 |
|----------|------|
| "정확한 기장으로 세법에 따른 공제를 받을 수 있습니다" | 법적 근거 (보장 아님) |
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
| "이 사례에서는 약 50만 원 절약되었습니다" | 과거 사례 (보장 아님) |
| "경비를 빠짐없이 처리합니다" | 객관적 프로세스 |
| "세무조사 대비 근거를 정리합니다" | 예방적 표현 |
| "당신의 상황에 맞는 최선의 방법을 제시합니다" | 개별 맞춤형 |
| "세법이 자주 바뀌므로 전문가 도움이 효율적입니다" | 필요성 설명 |
| "이 정도는 자신이 충분히 가능합니다" | 존중과 임파워먼트 |
| "복잡한 경우는 전문가와 상담하세요" | 선택지 제시 |
| "정확하게 하면 나중에 편합니다" | 미래 가치 (현재 보장 아님) |
---
### 📋 블로그 작성 시 광고 규칙 체크리스트
- [ ] **절세 약속 제거**: "최대한", "반드시", "보장", "무조건" 단어 없음
- [ ] **보장 표현 제거**: "세무조사 안 받게", "100% 절세", "확실" 제거
- [ ] **무료/가격 표현 제거**: "무료", "최저가", "가장 저렴" 제거
- [ ] **절대 표현 제거**: "항상", "절대", "모두", "완벽" 제거
- [ ] **최상급 제거**: "최고", "최우수", "1등" (객관적 증거 있으면 가능)
- [ ] **과도한 단순화 제거**: "매우 쉽습니다", "아무도 실수할 수 없음" 제거
- [ ] **수치는 사례로**: "절약 가능" → "이 사례에서는 약 X만 원 절약"
- [ ] **객관성 유지**: 구체적 사례 + 과거형 표현 사용
- [ ] **필요성 설명**: "왜 필요한가" → 이해와 선택 유도
- [ ] **세무사협회 규정 준수**: 법적 문제 없음
---
## 시즌별 주제 예시
| 월 | 추천 주제 | 톤 |
|----|---------|-----|
| 1월 | 부가세 2기 신고 기한 | "이 정도면 자신이 가능" |
| 5월 | 종소세 신고 방법 | "핵심 개념 + 전문가 도움 타이밍" |
| 7월 | 부가세 1기 신고 | "기초 정리 방법" |
| 11월 | 다음해 준비 | "계획하면 편해요" |
+87 -54
View File
@@ -12,6 +12,23 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D
Blazor 데이터 변경 자동 push/broadcast 금지 Blazor 데이터 변경 자동 push/broadcast 금지
``` ```
### UI 기준 원칙 (2026-06-29 추가)
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/` 기준으로 한다.
- 신규 또는 리팩토링 UI는 Fluent UI Blazor v5 패턴을 우선 적용한다.
- MudBlazor는 레거시 폐기 대상이다. 새 UI나 리팩토링 UI에서는 사용하지 않는다.
- 기존 MudBlazor 잔여 코드는 Fluent v5 또는 순수 HTML/CSS로 점진 전환한다.
- 기본 로딩 상태는 `Skeleton`이다. `MudProgressCircular` / `MudProgressLinear`는 예외적으로만 사용한다.
- `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이다. 새 작업에서는 사용하지 말고 Fluent v5 또는 순수 HTML/CSS 패턴으로 대체한다.
- 목록, 카드, 대시보드, 상세 페이지의 초기 데이터 상태는 스켈톤으로 먼저 렌더링하고, 데이터 수신 후 실제 UI로 교체한다.
- 로딩 중 블로킹 스피너보다 스켈톤을 우선한다.
- 관리자와 공개 사이트는 가능한 한 같은 `design-tokens.css` / `ui-primitives.css` 기반으로 구성한다.
- Blazor 진입점은 중복 매핑하지 말고, 동일 호스트 내에서 라우트 충돌이 없도록 단일 엔트리 기준으로 구성한다.
- `@page` 중복이나 동일 경로의 Razor Pages + Blazor 중복 선언은 배포 전에 반드시 제거한다.
### 레거시 정책
- MudBlazor, MudDataGrid, MudDialog, MudTabs는 신규 도입 금지다.
- 남아 있는 레거시 UI는 우선순위에 따라 Fluent v5 또는 순수 HTML/CSS로 교체한다.
### SOLID 기반 순차 마이그레이션 전략 ### SOLID 기반 순차 마이그레이션 전략
#### Phase 1-3: API Foundations ✅ #### Phase 1-3: API Foundations ✅
@@ -29,6 +46,7 @@ Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← D
- AdminDashboardClient 구현 - AdminDashboardClient 구현
- 서비스 inject → API 호출로 변경 - 서비스 inject → API 호출로 변경
- 에러 처리 & 로딩 상태 - 에러 처리 & 로딩 상태
- 기본 로딩은 Skeleton 적용
- [x] 구조: IAdminDashboardClient → HttpClient 추상화 - [x] 구조: IAdminDashboardClient → HttpClient 추상화
**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출 **완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출
@@ -76,10 +94,18 @@ _refreshTokenExpirationMinutes = 10080;
- 모든 API 엔드포인트 구현됨 - 모든 API 엔드포인트 구현됨
- 모든 Browser Client 구현됨 - 모든 Browser Client 구현됨
- 16개 Blazor 페이지 API-First 마이그레이션 완료 - 16개 Blazor 페이지 API-First 마이그레이션 완료
- MudDataGrid Douzone ERP 수준 UX 적용 - 과거 기록: 관리 화면에서 그리드/모달 UX를 빠르게 안정화한 단계
- MudDialog 모달 패턴 (흰 화면 플래시 제거) - 모달 패턴 (흰 화면 플래시 제거)
- ConfirmDialog 삭제 확인 컴포넌트 - ConfirmDialog 삭제 확인 컴포넌트
### 2026-06-29 운영 기준 업데이트
- 관리자 백오피스는 Fluent UI v5 우선 구조로 재정리한다.
- 기본 로딩은 스피너가 아니라 Skeleton이다.
- `design-tokens.css``ui-primitives.css`는 사이트/관리자 공통의 기본 계층이다.
- 라우팅 충돌은 가장 먼저 확인할 항목이며, 동일 경로가 두 번 등록되는 구조를 만들지 않는다.
- 커밋은 기능/호스팅/UI/CSS처럼 주제별로 분리한다.
- 레거시 제거 우선순위는 `MudBlazor` 계열 UI가 1순위다.
--- ---
## 📊 **전체 프로젝트 완료 현황** ## 📊 **전체 프로젝트 완료 현황**
@@ -118,7 +144,7 @@ _refreshTokenExpirationMinutes = 10080;
**Phase 7-4: CRM & 세무관리 (신규 - 2026-06-28)** **Phase 7-4: CRM & 세무관리 (신규 - 2026-06-28)**
- 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking) - 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking)
- 5개 Browser Client (API-First 패턴) - 5개 Browser Client (API-First 패턴)
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog) - 5개 Blazor 페이지 (그리드 Dense, Virtualize, 모달 패턴)
- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화) - Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
| 페이지 | API | Client | Blazor | 핵심 기능 | | 페이지 | API | Client | Blazor | 핵심 기능 |
@@ -130,8 +156,8 @@ _refreshTokenExpirationMinutes = 10080;
| RevenueTrackings | ✅ RevenueTrackingController | ✅ IRevenueTrackingBrowserClient | ✅ List + Modal | 청구/납부 추적, 상태 관리 | | RevenueTrackings | ✅ RevenueTrackingController | ✅ IRevenueTrackingBrowserClient | ✅ List + Modal | 청구/납부 추적, 상태 관리 |
**UI 특성**: **UI 특성**:
- MudDataGrid Dense (행높이 32px) + Virtualize (1000+ 행 성능) - Dense 그리드 + Virtualize (1000+ 행 성능)
- MudDialog Create/Edit (흰 화면 플래시 방지) - Create/Edit 모달 (흰 화면 플래시 방지)
- ConfirmDialog Delete (사용자 확인) - ConfirmDialog Delete (사용자 확인)
- Status Color Chips (Error/Warning/Success) - Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동) - Client 링크 (상세 페이지 연동)
@@ -189,8 +215,8 @@ PostgreSQL Database
**Blazor 페이지 & UI 고도화 (Phase 7-4)**: **Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지 - [x] 5개 CRM/세무관리 Blazor 페이지
- [x] MudDataGrid Dense + Virtualize (32px 행 높이) - [x] Dense 그리드 + Virtualize (32px 행 높이)
- [x] MudDialog 모달 Create/Edit (흰 화면 플래시 제거) - [x] 모달 Create/Edit (흰 화면 플래시 제거)
- [x] ConfirmDialog 삭제 확인 - [x] ConfirmDialog 삭제 확인
- [x] 상태별 컬러 칩 (Status/Risk Level) - [x] 상태별 컬러 칩 (Status/Risk Level)
- [x] 클라이언트 링크 (상세 페이지 연동) - [x] 클라이언트 링크 (상세 페이지 연동)
@@ -564,24 +590,33 @@ ssh kjh2064@178.104.200.7
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다. 배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
**무중단 Green-Blue 배포 아키텍처 (2026-06-30 적용 완료)**: **표준 배포 (현재)**:
1. **프록시 레이어**: 포트 `5001`에서 영구 가동되는 초경량 .NET TCP 프록시([TaxBaik.Proxy])가 수신 대기합니다. Nginx는 `/taxbaik` 트래픽을 기존과 같이 `5001`로 중계합니다. 1. `master` 브랜치에 push
2. **동적 포트 스위칭**: 프록시는 요청이 들어올 때마다 `/home/kjh2064/taxbaik_port` 파일을 읽어 active 포트(5003 또는 5004)를 판단하고 트래픽을 포워딩합니다. 2. Gitea Actions가 `TaxBaik.Web`을 build/publish
3. **배포 흐름 (`deploy_gb.sh`)**: 3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
- Gitea Actions가 코드를 build/publish 후 압축하여 서버에 업로드합니다. 4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
- 서버의 배포 스크립트([deploy_gb.sh])가 실행되어 현재 미사용 중인 예비 포트(Target Port: 5003 또는 5004)를 파악합니다.
- 예비 포트에서 새 .NET 웹 앱을 실행하고 `http://127.0.0.1:$target_port/taxbaik/healthz` 헬스 체크를 통과할 때까지 폴링(최대 60초)합니다. **API 클라이언트 설정 (Green-Blue 대비)**:
- 헬스 체크 성공 시 `/home/kjh2064/taxbaik_port` 파일에 새 포트 번호를 기입하여 **트래픽을 즉시 무중단 전환**합니다. - API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
- 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다. - 기본값: `http://localhost:5001/taxbaik/api/`
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다. - 배포 시 환경변수로 오버라이드 가능:
```bash
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
systemctl start taxbaik # 새 포트에 배포
```
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
**운영 규칙**: **운영 규칙**:
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다. - 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다. - `rsync`로 직접 아티팩트를 올리지 않는다
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다. - 배포 실패 시 CI 로그를 먼저 본다
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
**롤백**: **롤백**:
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다. - 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
- 서버 파일을 수동으로 복구하지 않는다
- 롤백은 커밋 단위로 추적 가능해야 한다
### 3.4 서비스 파일 위치 ### 3.4 서비스 파일 위치
``` ```
@@ -745,22 +780,6 @@ ssh kjh2064@178.104.200.7 crontab -l | grep backup
--- ---
## 5-1. 블로그 & FAQ 콘텐츠 작성 규칙
**핵심**: 고객 임파워먼트 (당신도 할 수 있습니다!)
- ✅ 주변에서 흔히 보는 실제 사례 (이름, 나이, 직업 구체화)
- ✅ 절세 효과 수치화 ("세금을 X만 원 절약했습니다")
- ✅ 중학교 2학년도 이해 가능한 수준
- ✅ 단계별 설명 + 표로 시각화
- ✅ 결론: "정확하게 하면 이런 이점이 있습니다" (임파워먼트)
**피해야 할 톤**: "복잡하니까 맡기세요" (세무사 의존성 강화)
**세무사 언급**: "더 복잡하면 전문가와 상담하세요" (선택지)
**자세한 템플릿 및 체크리스트**: `BLOG_TEMPLATE.md` 참고
---
## 6. 코드 규칙 ## 6. 코드 규칙
### 6.1 C# 네이밍 ### 6.1 C# 네이밍
@@ -971,6 +990,8 @@ Admin 로그인 페이지만 [AllowAnonymous]:
- 전역 상태 불필요 (세션 → DB에서 읽음) - 전역 상태 불필요 (세션 → DB에서 읽음)
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기 - 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
- 업데이트는 `StateHasChanged()` 호출 - 업데이트는 `StateHasChanged()` 호출
- 초기 렌더는 Skeleton 우선
- 로딩이 필요한 목록/카드/대시보드는 `items == null` 또는 `summary == null` 패턴으로 스켈톤 렌더링
### 8.6 어드민 그리드 UX (Dorsum ERP 수준) ### 8.6 어드민 그리드 UX (Dorsum ERP 수준)
@@ -990,9 +1011,11 @@ Admin 로그인 페이지만 [AllowAnonymous]:
- **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지) - **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지)
- **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리) - **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)
#### MudBlazor 적용 패턴 #### UI 적용 패턴
```razor ```razor
<MudDataGrid T="YourItem" ```razor
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
<YourGridComponent T="YourItem"
Dense="true" Dense="true"
Hover="true" Hover="true"
Striped="true" Striped="true"
@@ -1018,7 +1041,8 @@ Admin 로그인 페이지만 [AllowAnonymous]:
</CellTemplate> </CellTemplate>
</TemplateColumn> </TemplateColumn>
</Columns> </Columns>
</MudDataGrid> </YourGridComponent>
```
``` ```
#### 색상 & 상태 표시 #### 색상 & 상태 표시
@@ -1133,7 +1157,7 @@ Admin 로그인 페이지만 [AllowAnonymous]:
<!-- 로딩 상태 --> <!-- 로딩 상태 -->
@if (items == null) @if (items == null)
{ {
<MudProgressCircular Indeterminate="true" Class="mt-4" /> <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
} }
<!-- 빈 상태 --> <!-- 빈 상태 -->
else if (items.Count == 0) else if (items.Count == 0)
@@ -1143,7 +1167,9 @@ else if (items.Count == 0)
<!-- 데이터 그리드 --> <!-- 데이터 그리드 -->
else else
{ {
<MudDataGrid T="YourEntity" ```razor
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
<YourGridComponent T="YourEntity"
Items="@items" Items="@items"
Dense="true" Dense="true"
Hover="true" Hover="true"
@@ -1154,13 +1180,16 @@ else
<Columns> <Columns>
<!-- 필수: 컬럼 정의 --> <!-- 필수: 컬럼 정의 -->
</Columns> </Columns>
</MudDataGrid> </YourGridComponent>
```
} }
``` ```
**Step 3: 모달 다이얼로그 (Create/Edit)** **Step 3: 모달 다이얼로그 (Create/Edit)**
```razor ```razor
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }"> ```razor
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 별도 라우트로 대체 -->
<YourDialogComponent @bind-IsVisible="isDialogOpen">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">@(isEditMode ? "항목 수정" : "새 항목 추가")</MudText> <MudText Typo="Typo.h6">@(isEditMode ? "항목 수정" : "새 항목 추가")</MudText>
</TitleContent> </TitleContent>
@@ -1173,7 +1202,8 @@ else
<MudButton OnClick="CloseDialog">취소</MudButton> <MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveItem">저장</MudButton> <MudButton Color="Color.Primary" OnClick="SaveItem">저장</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </YourDialogComponent>
```
``` ```
**Step 4: @code 섹션 구조** **Step 4: @code 섹션 구조**
@@ -1295,10 +1325,10 @@ else
- [ ] @inject로 필요한 Client 주입 - [ ] @inject로 필요한 Client 주입
- [ ] <PageTitle> 추가 - [ ] <PageTitle> 추가
- [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼) - [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼)
- [ ] 로딩 상태 (MudProgressCircular) - [ ] 로딩 상태 기본값은 `Skeleton`
- [ ] 빈 상태 (MudAlert) - [ ] 빈 상태 (MudAlert)
- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스) - [ ] Dense 그리드 (Virtualize=true, RowsPerPage=30, admin-grid 클래스)
- [ ] MudDialog (Create/Edit 모달) - [ ] 모달 (Create/Edit)
- [ ] ConfirmDialog (Delete 확인) - [ ] ConfirmDialog (Delete 확인)
- [ ] @code 섹션: OnInitializedAsync → LoadData() 패턴 - [ ] @code 섹션: OnInitializedAsync → LoadData() 패턴
- [ ] 모든 에러 처리 (try-catch, Snackbar 메시지) - [ ] 모든 에러 처리 (try-catch, Snackbar 메시지)
@@ -1309,7 +1339,7 @@ else
❌ **이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:** ❌ **이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:**
- 페이지 헤더 (admin-page-hero) 누락 - 페이지 헤더 (admin-page-hero) 누락
- 인라인 스타일로 레이아웃 구성 - 인라인 스타일로 레이아웃 구성
- MudDialog 없이 별도 라우트로 Create/Edit 처리 (흰 화면 플래시) - 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
- @code 섹션 구조 다름 - @code 섹션 구조 다름
- 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공 - 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공
@@ -1644,7 +1674,7 @@ curl http://127.0.0.1/taxbaik/admin/login
### E2E 테스트 & 반응형 검증 ### E2E 테스트 & 반응형 검증
```bash ```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=테스트" -d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
# 관리자 DB에서 확인 # 관리자 DB에서 확인
@@ -1683,7 +1713,7 @@ npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
**프로덕션 E2E 테스트**: **프로덕션 E2E 테스트**:
```bash ```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_USERNAME="test_admin"
export E2E_ADMIN_PASSWORD="TestAdmin@123456" export E2E_ADMIN_PASSWORD="TestAdmin@123456"
@@ -1730,7 +1760,9 @@ public async Task NotifyDeploymentStart()
@* Components/Admin/Shared/DeploymentNotification.razor *@ @* Components/Admin/Shared/DeploymentNotification.razor *@
@if (showNotification) @if (showNotification)
{ {
<MudDialog @bind-Visible="showNotification"> ```razor
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 HTML/CSS 패턴으로 대체 -->
<YourDialogComponent @bind-Visible="showNotification">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">새 버전 배포</MudText> <MudText Typo="Typo.h6">새 버전 배포</MudText>
</TitleContent> </TitleContent>
@@ -1745,7 +1777,8 @@ public async Task NotifyDeploymentStart()
<MudButton Color="Color.Primary" OnClick="RefreshNow">지금 새로고침</MudButton> <MudButton Color="Color.Primary" OnClick="RefreshNow">지금 새로고침</MudButton>
<MudButton Color="Color.Default" OnClick="DismissNotification">나중에</MudButton> <MudButton Color="Color.Default" OnClick="DismissNotification">나중에</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </YourDialogComponent>
```
} }
@code { @code {
@@ -1951,7 +1984,7 @@ else
2. **Actions run 생성 확인** 2. **Actions run 생성 확인**
```powershell ```powershell
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" } $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 $runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
``` ```
`deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다. `deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.
+23 -130
View File
@@ -17,7 +17,7 @@
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 | | 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 | | 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 | | 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 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 | | 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` | | 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
@@ -126,84 +126,16 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
### 4.2. Nginx 리버스 프록시 ### 4.2. Nginx 리버스 프록시
```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 {
server_name taxbaik.com www.taxbaik.com; listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 512M; client_max_body_size 512M;
# QuantEngine Blazor Web App
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응 location /quant/ {
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 / {
proxy_pass http://127.0.0.1:5000/; proxy_pass http://127.0.0.1:5000/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -215,64 +147,25 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
listen 443 ssl; # managed by Certbot # Gitea (기본)
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot location / {
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot proxy_pass http://127.0.0.1:3000;
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot proxy_http_version 1.1;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot 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;
server { proxy_read_timeout 300;
if ($host = www.taxbaik.com) { proxy_connect_timeout 300;
return 301 https://$host$request_uri; proxy_send_timeout 300;
} # 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://178.104.200.7/` → Gitea Web UI
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`) - `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`) - `ssh://178.104.200.7:2222` → Gitea Git SSH
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
## 5. Gitea ## 5. Gitea
@@ -491,7 +384,7 @@ ClientAliveCountMax 2
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) | | **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) | | **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) | | **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) | | **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 | | **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` | | **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS | | **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. 환경 변수 설정 ### 2. 환경 변수 설정
**Web 서비스** (`/etc/systemd/system/taxbaik.service`, 백엔드 전용): **Web 서비스** (`/etc/systemd/system/taxbaik.service`):
```ini ```ini
[Service] [Service]
Environment=ASPNETCORE_ENVIRONMENT=Production 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 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 서비스 파일 설치 ### 3. systemd 서비스 파일 설치
```bash ```bash
sudo cp deploy/taxbaik.service /etc/systemd/system/ sudo cp deploy/taxbaik.service /etc/systemd/system/
sudo cp deploy/taxbaik-proxy.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable taxbaik sudo systemctl enable taxbaik
sudo systemctl enable taxbaik-proxy
``` ```
### 4. Nginx 설정 ### 4. Nginx 설정
```bash ```bash
# Nginx 도메인 기반 가상 호스트 설정 복사 # 현재 Nginx 설정 확인
sudo cp deploy/nginx-taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf sudo cat /etc/nginx/sites-available/default | head -30
# 기존 설정(IP 기반 및 default) 활성화 해제 # location 블록 추가 (또는 기존 설정에 병합)
sudo rm -f /etc/nginx/sites-enabled/default sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
sudo rm -f /etc/nginx/sites-enabled/gitea-ip.conf
# 새 설정 활성화 (심링크 생성) # 테스트 및 재로드
sudo ln -sfn /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-enabled/taxbaik-domains.conf
# 설정 문법 테스트 및 Nginx 서비스 리로드
sudo nginx -t sudo nginx -t
sudo systemctl reload nginx sudo systemctl reload nginx
``` ```
@@ -79,7 +65,7 @@ sudo systemctl reload nginx
master 브랜치 push → build → test → publish → restart → health check → Playwright 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) # 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000)
ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active
sudo systemctl restart taxbaik-proxy
sudo systemctl restart taxbaik sudo systemctl restart taxbaik
``` ```
@@ -154,10 +139,10 @@ sudo systemctl restart taxbaik
ssh kjh2064@178.104.200.7 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 ps aux | grep TaxBaik
@@ -180,27 +165,9 @@ journalctl -u taxbaik -f
| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` | | 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection \"Upgrade\"` 헤더가 모두 있는지 확인 | | Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection \"Upgrade\"` 헤더가 모두 있는지 확인 |
| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 | | 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;` | | 마이그레이션 실패 | 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 # ~/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 추가**: **Gitea 저장소 Settings → Secrets 추가**:
- `DEPLOY_USER`: `kjh2064` - `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` | | 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` |
| 502 Bad Gateway | 프록시 또는 백엔드 미실행 | `sudo systemctl restart taxbaik-proxy taxbaik` | | 502 Bad Gateway | 미실행 | `sudo systemctl restart taxbaik` |
| 503 Service Unavailable | 백엔드 충돌 또는 비밀값 누락 | 로그 확인: `journalctl -u taxbaik -n 50` | | 503 Service Unavailable | 앱 충돌 | 로그 확인: `journalctl -u taxbaik -n 50` |
| DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 | | DB 연결 오류 | 환경 변수 미설정 | systemd 파일의 ConnectionStrings__Default 확인 |
| HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) | | HTTPS 오류 | SSL 미구성 | 개발 환경에서는 HTTP 사용 (IP 기반) |
| 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 | | 마이그레이션 실패 | 테이블 존재 | `DROP DATABASE taxbaikdb;` 후 재시작 |
@@ -208,11 +230,11 @@ curl -I -H "Accept-Encoding: gzip" http://178.104.200.7/taxbaik/ | grep -i encod
### 실시간 모니터링 ### 실시간 모니터링
```bash ```bash
# 터미널 1: 백엔드 로그 # 터미널 1: 웹 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f' ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 2: 프록시 로그 # 터미널 2: 통합 서비스 로그
ssh kjh2064@178.104.200.7 'journalctl -u taxbaik-proxy -f' ssh kjh2064@178.104.200.7 'journalctl -u taxbaik -f'
# 터미널 3: Nginx 로그 # 터미널 3: Nginx 로그
ssh kjh2064@178.104.200.7 'sudo tail -f /var/log/nginx/access.log | grep taxbaik' 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 ```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 git push origin master
# 2. Gitea Actions가 자동으로 배포 # 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) # 롤백 (예: 이전 버전이 taxbaik_20260625_100000)
ssh kjh2064@178.104.200.7 << EOF ssh kjh2064@178.104.200.7 << EOF
ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active ln -sfn ~/deployments/taxbaik_20260625_100000 ~/taxbaik_active
sudo systemctl restart taxbaik-proxy
sudo systemctl restart taxbaik sudo systemctl restart taxbaik
EOF EOF
``` ```
+10 -2
View File
@@ -26,7 +26,7 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위
|-----|------| |-----|------|
| **백엔드** | ASP.NET Core 10, C# | | **백엔드** | ASP.NET Core 10, C# |
| **공개 사이트** | Razor Pages (SSR) | | **공개 사이트** | Razor Pages (SSR) |
| **관리자** | Blazor Server + MudBlazor | | **관리자** | Blazor Server + Fluent UI Blazor v5 |
| **데이터베이스** | PostgreSQL 18.4 | | **데이터베이스** | PostgreSQL 18.4 |
| **ORM** | Dapper | | **ORM** | Dapper |
| **리버스 프록시** | Nginx | | **리버스 프록시** | Nginx |
@@ -98,6 +98,14 @@ TaxBaik/
- 연락처 정보 - 연락처 정보
- 소셜 미디어 링크 - 소셜 미디어 링크
- **UI 기준**
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/`
- 기본 로딩 상태는 `Skeleton`
- MudBlazor는 레거시 폐기 대상이며 신규 UI에 사용하지 않음
- `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이며 신규 UI에 사용하지 않음
- 사이트와 관리자는 `design-tokens.css` / `ui-primitives.css`를 공유
- Blazor 라우트는 중복 선언하지 않고 단일 엔트리 기준으로 관리
--- ---
## 빠른 시작 ## 빠른 시작
@@ -168,7 +176,7 @@ master 브랜치에 푸시하면 파이프라인이 다음 단계를 수행합
- `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호 - `TAXBAIK_ADMIN_TEST_PASSWORD`: 배포 검증용 관리자 비밀번호
- `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값 - `Admin__PasswordResetToken`: 관리자 비밀번호 재설정 API용 서버 비밀값
배포는 Gitea Actions CI/CD로만 수행합니다. 수동 배포 경로는 CI 하네스로 차단되어 있으며, 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다. 수동 배포는 비상 롤백 절차 외에는 사용하지 않습니다. 실패 시 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)의 CI 점검 절차를 따릅니다.
--- ---
-43
View File
@@ -522,46 +522,3 @@ Todo:
- WBS-UX-03/04 구현 완료 - WBS-UX-03/04 구현 완료
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요) - WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수 - 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<RevenueTrackingService>();
services.AddScoped<TelegramReportService>(); services.AddScoped<TelegramReportService>();
services.AddScoped<PortalUserService>(); services.AddScoped<PortalUserService>();
services.AddScoped<CommonCodeService>();
return services; 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);
}
}
@@ -34,6 +34,9 @@ public class RevenueTrackingService(IRevenueTrackingRepository repository)
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) => public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct); await repository.GetByClientIdAsync(clientId, ct);
public async Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) => public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
await repository.GetAllAsync(ct); await repository.GetAllAsync(ct);
@@ -37,10 +37,7 @@ public class TaxProfileService(ITaxProfileRepository repository)
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod, public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default) DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
{ {
var profile = await repository.GetByIdAsync(profileId, ct); var profile = new TaxProfile { Id = profileId };
if (profile == null)
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
if (!string.IsNullOrWhiteSpace(businessType)) if (!string.IsNullOrWhiteSpace(businessType))
profile.BusinessType = businessType.Trim(); profile.BusinessType = businessType.Trim();
if (!string.IsNullOrWhiteSpace(accountingMethod)) 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,6 +5,7 @@ using TaxBaik.Domain.Entities;
public interface IRevenueTrackingRepository public interface IRevenueTrackingRepository
{ {
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default); Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default); Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default); Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default); Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface ITaxProfileRepository public interface ITaxProfileRepository
{ {
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default); Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default); Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default); Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default); Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
@@ -27,7 +27,6 @@ public static class DependencyInjection
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>(); services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
services.AddScoped<IContractRepository, ContractRepository>(); services.AddScoped<IContractRepository, ContractRepository>();
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>(); services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
return services; 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");
}
}
@@ -24,6 +24,15 @@ public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) :
FROM revenue_tracking ORDER BY invoice_date DESC"); FROM revenue_tracking ORDER BY invoice_date DESC");
} }
public async Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<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 WHERE id = @Id",
new { Id = id });
}
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default) public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
@@ -20,17 +20,6 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
profile); 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) public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); 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
+12 -100
View File
@@ -1,4 +1,5 @@
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.FluentUI.AspNetCore.Components
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
@@ -6,16 +7,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>백원숙 세무회계 - 관리자</title> <title>백원숙 세무회계 - 관리자</title>
<base href="/taxbaik/" /> <base href="/taxbaik/" />
<link rel="icon" type="image/svg+xml" href="/taxbaik/favicon.svg" /> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap" rel="stylesheet" />
<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="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" /> <link rel="stylesheet" href="css/design-tokens.css" />
<!-- EasyMDE 마크다운 에디터 --> <link rel="stylesheet" href="css/ui-primitives.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css" /> <link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
<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> <script>
document.documentElement.classList.toggle( document.documentElement.classList.toggle(
'admin-login-route', 'admin-login-route',
@@ -38,99 +34,15 @@
<p>로드 중...</p> <p>로드 중...</p>
</div> </div>
</div> </div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: true)" /> <FluentProviders />
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <FluentDialogProvider />
<FluentTooltipProvider />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="js/admin-session.js"></script> <script src="js/admin-session.js"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script>window.taxbaikAdminSession?.bindLoginForm();</script>
<script>window.taxbaikAdminSession?.watchReconnect();</script> <script>window.taxbaikAdminSession?.watchReconnect();</script>
</body> </body>
</html> </html>
@code {
private bool isDarkMode = false;
private MudTheme mudTheme = new()
{
Palette = new PaletteLight()
{
Primary = "#1976D2",
PrimaryContrastText = "#FFFFFF",
Secondary = "#2D9F7E",
SecondaryContrastText = "#FFFFFF",
Tertiary = "#FF8A50",
TertiaryContrastText = "#FFFFFF",
Surface = "#F5F7FA",
Background = "#FFFFFF",
BackgroundGrey = "#F8F9FB",
DrawerBackground = "#FFFFFF",
DrawerText = "#424242",
AppbarBackground = "#FFFFFF",
AppbarText = "#424242",
TextPrimary = "#1A1A1A",
TextSecondary = "#64748B",
TextDisabled = "#94A3B8",
ActionDefault = "#1976D2",
ActionDisabled = "#BDBDBD",
Divider = "#E2E8F0",
DividerLight = "#F1F5F9",
Error = "#DC2626",
ErrorContrastText = "#FFFFFF",
Warning = "#F59E0B",
WarningContrastText = "#FFFFFF",
Info = "#06B6D4",
InfoContrastText = "#FFFFFF",
Success = "#16A34A",
SuccessContrastText = "#FFFFFF",
},
LayoutProperties = new LayoutProperties()
{
DefaultBorderRadius = "6px"
},
Typography = new Typography()
{
Default = new Default()
{
FontSize = ".8125rem",
FontWeight = 400,
LineHeight = 1.5
},
H1 = new H1()
{
FontSize = "1.75rem",
FontWeight = 600,
LineHeight = 1.2
},
H2 = new H2()
{
FontSize = "1.5rem",
FontWeight = 600,
LineHeight = 1.3
},
H3 = new H3()
{
FontSize = "1.25rem",
FontWeight = 600,
LineHeight = 1.3
},
H4 = new H4()
{
FontSize = "1.1rem",
FontWeight = 600,
LineHeight = 1.4
},
H5 = new H5()
{
FontSize = "0.95rem",
FontWeight = 500,
LineHeight = 1.4
},
H6 = new H6()
{
FontSize = "0.85rem",
FontWeight = 500,
LineHeight = 1.5
}
}
};
}
@@ -1,18 +1,17 @@
@using MudBlazor @using Microsoft.FluentUI.AspNetCore.Components
<div class="admin-dialog">
<MudDialog> <div class="admin-dialog-title">삭제 확인</div>
<DialogContent> <p class="admin-dialog-message">정말로 삭제하시겠습니까?</p>
<MudText>정말로 삭제하시겠습니까?</MudText> <div class="admin-dialog-actions">
</DialogContent> <FluentButton Appearance="ButtonAppearance.Transparent" @onclick="Cancel">취소</FluentButton>
<DialogActions> <FluentButton Appearance="ButtonAppearance.Primary" @onclick="Confirm">삭제</FluentButton>
<MudButton OnClick="@Cancel">취소</MudButton> </div>
<MudButton Color="Color.Error" OnClick="@Confirm">삭제</MudButton> </div>
</DialogActions>
</MudDialog>
@code { @code {
[CascadingParameter] MudDialogInstance? MudDialog { get; set; } [Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public EventCallback OnConfirm { get; set; }
void Cancel() => MudDialog?.Cancel(); Task Cancel() => OnCancel.InvokeAsync();
void Confirm() => MudDialog?.Close(DialogResult.Ok(true)); Task Confirm() => OnConfirm.InvokeAsync();
} }
@@ -1,49 +1,28 @@
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using Microsoft.FluentUI.AspNetCore.Components
<MudForm @ref="form"> <form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드" <FluentTextInput Label="회사 코드" @bind-CurrentValue="model.CompanyCode" />
Variant="Variant.Outlined" Class="mb-4" Required="true" <FluentTextInput Label="회사명" @bind-CurrentValue="model.CompanyName" />
HelperText="영문/숫자, 최대 50자" /> <FluentTextInput Label="담당자명" @bind-CurrentValue="model.ContactPerson" />
<FluentTextInput Label="전화번호" @bind-CurrentValue="model.Phone" />
<MudTextField @bind-Value="model.CompanyName" Label="회사명" <FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
Variant="Variant.Outlined" Class="mb-4" Required="true" /> <FluentTextArea Label="메모" @bind-CurrentValue="model.Memo" />
<label class="admin-checkbox-row">
<MudTextField @bind-Value="model.ContactPerson" Label="담당자명" <input type="checkbox" @bind="model.IsActive" />
Variant="Variant.Outlined" Class="mb-4" /> <span>활성</span>
</label>
<MudTextField @bind-Value="model.Phone" Label="전화번호" <div class="admin-form-actions">
Variant="Variant.Outlined" Class="mb-4" /> <button type="submit" class="admin-login-submit">@ButtonText</button>
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudTextField @bind-Value="model.Memo" Label="메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
@ButtonText
</MudButton>
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
</div> </div>
</MudForm> </form>
@code { @code {
[Parameter, EditorRequired] [Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
public string ButtonText { get; set; } = "저장"; [Parameter] public EventCallback<CompanyFormModel> OnSubmit { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] [Parameter] public CompanyFormModel? InitialData { get; set; }
public EventCallback<CompanyFormModel> OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
[Parameter]
public CompanyFormModel? InitialData { get; set; }
private MudForm? form;
private CompanyFormModel model = new(); private CompanyFormModel model = new();
protected override void OnInitialized() protected override void OnInitialized()
@@ -63,17 +42,7 @@
} }
} }
private async Task HandleSubmit() private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync(model);
}
public class CompanyFormModel public class CompanyFormModel
{ {
@@ -1,61 +1,38 @@
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using Microsoft.FluentUI.AspNetCore.Components
<MudForm @ref="form"> <form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
<MudTextField @bind-Value="model.Name" Label="이름" <FluentTextInput Label="이름" @bind-CurrentValue="model.Name" />
Variant="Variant.Outlined" Class="mb-4" Required="true" /> <FluentTextInput Label="전화번호 (예: 010-1234-5678)" @bind-CurrentValue="model.Phone" />
<FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
<FluentSelect TValue="string" TOption="string" Label="문의 유형" @bind-CurrentValue="model.ServiceType">
<FluentOption Value="@("사업자세무")">사업자세무</FluentOption>
<FluentOption Value="@("부동산세금")">부동산세금</FluentOption>
<FluentOption Value="@("가족자산")">가족자산</FluentOption>
<FluentOption Value="@("기타")">기타</FluentOption>
</FluentSelect>
<FluentTextArea Label="문의 내용" @bind-CurrentValue="model.Message" />
<FluentSelect TValue="string" TOption="string" Label="상태" @bind-CurrentValue="model.Status">
<FluentOption Value="@("new")">신규</FluentOption>
<FluentOption Value="@("consulting")">상담중</FluentOption>
<FluentOption Value="@("contracted")">계약완료</FluentOption>
<FluentOption Value="@("rejected")">거절</FluentOption>
<FluentOption Value="@("closed")">종결</FluentOption>
</FluentSelect>
<FluentTextArea Label="관리 메모" @bind-CurrentValue="model.AdminMemo" />
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)" <div class="admin-form-actions">
Variant="Variant.Outlined" Class="mb-4" Required="true" /> <button type="submit" class="admin-login-submit">@ButtonText</button>
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudSelect @bind-Value="model.ServiceType" Label="문의 유형"
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("사업자세무")">사업자세무</MudSelectItem>
<MudSelectItem Value="@("부동산세금")">부동산세금</MudSelectItem>
<MudSelectItem Value="@("가족자산")">가족자산</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="model.Message" Label="문의 내용"
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
<MudSelect @bind-Value="model.Status" Label="상태"
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("new")">신규</MudSelectItem>
<MudSelectItem Value="@("consulting")">상담중</MudSelectItem>
<MudSelectItem Value="@("contracted")">계약완료</MudSelectItem>
<MudSelectItem Value="@("rejected")">거절</MudSelectItem>
<MudSelectItem Value="@("closed")">종결</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
@ButtonText
</MudButton>
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
</div> </div>
</MudForm> </form>
@code { @code {
[Parameter, EditorRequired] [Parameter, EditorRequired] public string ButtonText { get; set; } = "저장";
public string ButtonText { get; set; } = "저장"; [Parameter] public EventCallback<InquiryFormModel> OnSubmit { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] [Parameter] public InquiryFormModel? InitialData { get; set; }
public EventCallback<InquiryFormModel> OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
[Parameter]
public InquiryFormModel? InitialData { get; set; }
private MudForm? form;
private InquiryFormModel model = new(); private InquiryFormModel model = new();
protected override void OnInitialized() protected override void OnInitialized()
@@ -75,17 +52,7 @@
} }
} }
private async Task HandleSubmit() private Task HandleSubmit() => OnSubmit.InvokeAsync(model);
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync(model);
}
public class InquiryFormModel public class InquiryFormModel
{ {
+14 -16
View File
@@ -1,4 +1,5 @@
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4"> <div class="admin-table-wrap">
<table class="admin-table mt-4">
<thead> <thead>
<tr> <tr>
<th>이름</th> <th>이름</th>
@@ -18,22 +19,19 @@
<td>@inquiry.Phone</td> <td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td> <td>@inquiry.ServiceType</td>
<td> <td>
<MudChip Size="Size.Small" Color="@GetStatusColor(inquiry.Status)"> <span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span>
@GetStatusLabel(inquiry.Status)
</MudChip>
</td> </td>
<td>@GetPreview(inquiry.Message)</td> <td>@GetPreview(inquiry.Message)</td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td> <td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
<td> <td>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" <a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</a>
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</MudButton> <a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</a>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</MudButton>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</MudSimpleTable> </table>
</div>
@code { @code {
[Parameter, EditorRequired] [Parameter, EditorRequired]
@@ -66,14 +64,14 @@
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}..."; return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
} }
private static Color GetStatusColor(string status) => status switch private static string GetStatusClass(string status) => status switch
{ {
"new" => Color.Warning, "new" => "warning",
"consulting" => Color.Info, "consulting" => "info",
"contracted" => Color.Success, "contracted" => "success",
"rejected" => Color.Error, "rejected" => "danger",
"closed" => Color.Dark, "closed" => "muted",
_ => Color.Default _ => "muted"
}; };
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status); private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
@@ -1,114 +1,88 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IJSRuntime JS @inject IJSRuntime JS
@inject VersionInfo VersionInfo
@implements IDisposable @implements IDisposable
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
<MudPopoverProvider /> <div class="admin-shell">
<MudDialogProvider /> <header class="admin-topbar">
<MudSnackbarProvider /> <button type="button" class="admin-icon-button admin-menu-button" @onclick="ToggleDrawer" aria-label="메뉴 열기">
<span class="material-icons">menu</span>
</button>
<MudLayout Class="admin-shell"> <div class="admin-topbar-title">
<MudAppBar Elevation="0" Class="admin-topbar"> <span class="admin-topbar-kicker">TaxBaik Admin</span>
<MudIconButton Icon="@Icons.Material.Filled.Menu" <h1>세무회계 관리 대시보드</h1>
Color="Color.Inherit"
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> </div>
<MudSpacer />
<!-- 상단 액션 바 -->
<div class="admin-topbar-actions"> <div class="admin-topbar-actions">
<MudTooltip Text="공개 웹사이트 방문"> <a class="admin-topbar-action" href="/taxbaik" target="_blank" rel="noreferrer">
<MudButton Class="admin-topbar-action" <span class="material-icons">open_in_new</span>
Variant="Variant.Text" 공개 사이트
Color="Color.Inherit" </a>
Size="Size.Small" <a class="admin-topbar-action danger" href="/taxbaik/admin/logout">
StartIcon="@Icons.Material.Filled.OpenInNew" <span class="material-icons">logout</span>
Href="/taxbaik" 로그아웃
Target="_blank"> </a>
공개 사이트
</MudButton>
</MudTooltip>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<MudTooltip Text="로그아웃 (Ctrl+Q)">
<MudButton Class="admin-topbar-action"
Variant="Variant.Text"
Color="Color.Error"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Logout"
Href="/taxbaik/admin/logout">
로그아웃
</MudButton>
</MudTooltip>
</div> </div>
</MudAppBar> </header>
<MudDrawer @bind-open="@drawerOpen" <aside class="@DrawerClass">
Elevation="0"
Variant="DrawerVariant.Responsive"
Breakpoint="Breakpoint.Md"
Class="admin-drawer">
<div class="admin-drawer-brand"> <div class="admin-drawer-brand">
<div class="admin-brand-mark">T</div> <div class="admin-brand-mark">T</div>
<div> <div>
<MudText Typo="Typo.subtitle1">TaxBaik</MudText> <div class="admin-brand-title">TaxBaik</div>
<MudText Typo="Typo.caption">세무 운영 콘솔</MudText> <div class="admin-brand-subtitle">세무 운영 콘솔</div>
</div> </div>
</div> </div>
<MudNavMenu Class="admin-nav">
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
<MudNavGroup Title="CRM & 세무관리" Icon="@Icons.Material.Filled.BusinessCenter" @bind-Expanded="@expandedCRMGroup"> <nav class="admin-nav">
<MudNavLink Href="/taxbaik/admin/tax-profiles" Icon="@Icons.Material.Filled.Assignment">세무 프로필</MudNavLink> <a href="/taxbaik/admin/dashboard" class="admin-nav-link">대시보드</a>
<MudNavLink Href="/taxbaik/admin/tax-filing-schedules" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
<MudNavLink Href="/taxbaik/admin/contracts" Icon="@Icons.Material.Filled.Description">계약 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/consulting-activities" Icon="@Icons.Material.Filled.ChatBubble">상담 활동</MudNavLink>
<MudNavLink Href="/taxbaik/admin/revenue-trackings" Icon="@Icons.Material.Filled.Receipt">수익 추적</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup"> <details open>
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink> <summary>CRM & 세무관리</summary>
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.Assessment">세무신고</MudNavLink> <a href="/taxbaik/admin/tax-profiles" class="admin-nav-link">세무 프로필</a>
</MudNavGroup> <a href="/taxbaik/admin/tax-filing-schedules" class="admin-nav-link">신고 일정</a>
<a href="/taxbaik/admin/contracts" class="admin-nav-link">계약 관리</a>
<a href="/taxbaik/admin/consulting-activities" class="admin-nav-link">상담 활동</a>
<a href="/taxbaik/admin/revenue-trackings" class="admin-nav-link">수익 추적</a>
</details>
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup"> <details>
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink> <summary>고객 관리</summary>
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink> <a href="/taxbaik/admin/clients" class="admin-nav-link">고객 카드</a>
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink> <a href="/taxbaik/admin/tax-filings" class="admin-nav-link">세무신고</a>
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink> </details>
</MudNavGroup>
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink> <details>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink> <summary>홈페이지</summary>
</MudNavMenu> <a href="/taxbaik/admin/announcements" class="admin-nav-link">공지사항</a>
<a href="/taxbaik/admin/faqs" class="admin-nav-link">FAQ 관리</a>
<a href="/taxbaik/admin/blog" class="admin-nav-link">블로그 관리</a>
<a href="/taxbaik/admin/season-simulator" class="admin-nav-link">시즌 시뮬레이터</a>
</details>
<div class="admin-drawer-version"> <a href="/taxbaik/admin/inquiries" class="admin-nav-link">문의 관리</a>
<div class="admin-drawer-version-label">Version</div> <a href="/taxbaik/admin/settings" class="admin-nav-link">설정</a>
<div class="admin-drawer-version-value">v@(VersionInfo.Version)</div> </nav>
<div class="admin-drawer-version-built">@VersionInfo.Built</div>
<div class="admin-drawer-footer">
<div class="admin-footer-item">
<span class="material-icons">shield</span>
<span>보안 모드</span>
</div>
<div class="admin-footer-meta">Fluent UI Blazor 기반 관리자 콘솔</div>
</div> </div>
</MudDrawer> </aside>
<MudMainContent Class="admin-main"> <main class="admin-content">
<MudContainer MaxWidth="MaxWidth.False" Class="admin-content"> <div class="admin-content-inner">
@Body @Body
</MudContainer> </div>
</MudMainContent> </main>
</MudLayout> </div>
@code { @code {
private bool drawerOpen = true; private bool drawerOpen = true;
private bool expandedCRMGroup = true;
private bool expandedCustomerGroup = false;
private bool expandedWebsiteGroup = false;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -125,15 +99,14 @@
StateHasChanged(); StateHasChanged();
} }
private string DrawerClass => drawerOpen ? "admin-drawer open" : "admin-drawer";
private void OnLocationChanged(object? sender, LocationChangedEventArgs args) private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{ {
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading")); _ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
} }
private void ToggleDrawer() private void ToggleDrawer() => drawerOpen = !drawerOpen;
{
drawerOpen = !drawerOpen;
}
public void Dispose() public void Dispose()
{ {
@@ -5,101 +5,47 @@
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@inject IAnnouncementBrowserClient AnnouncementClient @inject IAnnouncementBrowserClient AnnouncementClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle> <PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText> <div class="admin-eyebrow">Homepage</div>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</MudText> <h1 class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</h1>
</div> </div>
</section> </section>
<MudPaper Class="admin-surface" Elevation="0"> <div class="admin-surface" style="max-width:720px;">
<MudForm @ref="form"> <form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
<MudGrid> <label>제목 * <input class="admin-input" @bind="model.Title" /></label>
<MudItem xs="12"> <label>상세 내용 (선택) <textarea class="admin-input" rows="3" @bind="model.Content"></textarea></label>
<MudTextField @bind-Value="model.Title" <label>유형
Label="제목" <select class="admin-input" @bind="model.DisplayType">
Variant="Variant.Outlined" <option value="info">일반 (파란색)</option>
Required="true" <option value="banner">배너 (주황색)</option>
RequiredError="제목을 입력하세요." <option value="urgent">긴급 (빨간색)</option>
HelperText="홈페이지 상단 공지 바에 표시되는 텍스트입니다." /> </select>
</MudItem> </label>
<label>노출 순서 <input class="admin-input" type="number" @bind="model.SortOrder" /></label>
<MudItem xs="12"> <label>게시 시작일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="StartsAtText" /></label>
<MudTextField @bind-Value="model.Content" <label>게시 종료일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="EndsAtText" /></label>
Label="상세 내용 (선택)" <label><input type="checkbox" @bind="model.IsActive" /> @(model.IsActive ? "활성화" : "비활성화")</label>
Variant="Variant.Outlined" <div class="admin-dialog-actions">
Lines="3" <button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
HelperText="부가 설명이 있을 경우 입력합니다. 없으면 제목만 표시됩니다." /> <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/announcements")'>취소</button>
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect @bind-Value="model.DisplayType"
Label="유형"
Variant="Variant.Outlined">
<MudSelectItem Value="@("info")">일반 (파란색)</MudSelectItem>
<MudSelectItem Value="@("banner")">배너 (주황색) — 중요 이벤트</MudSelectItem>
<MudSelectItem Value="@("urgent")">긴급 (빨간색) — 마감 임박</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField @bind-Value="model.SortOrder"
Label="노출 순서"
Variant="Variant.Outlined"
HelperText="숫자가 클수록 먼저 표시됩니다." />
</MudItem>
<MudItem xs="12" sm="6">
<MudDatePicker @bind-Date="startsAtDate"
Label="게시 시작일 (비우면 즉시)"
Variant="Variant.Outlined"
DateFormat="yyyy-MM-dd"
Clearable="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudDatePicker @bind-Date="endsAtDate"
Label="게시 종료일 (비우면 무기한)"
Variant="Variant.Outlined"
DateFormat="yyyy-MM-dd"
Clearable="true" />
</MudItem>
<MudItem xs="12">
<MudSwitch @bind-Checked="model.IsActive"
Label="@(model.IsActive ? "활성화 (홈페이지에 노출)" : "비활성화 (홈페이지 미노출)")"
Color="Color.Primary" />
</MudItem>
</MudGrid>
<div class="d-flex gap-2 mt-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
Disabled="isSaving"
@onclick="SaveAsync">
@(isSaving ? "저장 중..." : "저장")
</MudButton>
<MudButton Variant="Variant.Outlined"
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/announcements"))">
취소
</MudButton>
</div> </div>
</MudForm> </form>
</MudPaper> </div>
@code { @code {
[Parameter] public int? Id { get; set; } [Parameter] public int? Id { get; set; }
private MudForm? form;
private bool isSaving; private bool isSaving;
private DateTime? startsAtDate; private DateTime? startsAtDate;
private DateTime? endsAtDate; private DateTime? endsAtDate;
private AnnouncementDto model = new(); private AnnouncementDto model = new();
private string StartsAtText { get => startsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => startsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string EndsAtText { get => endsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => endsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -115,15 +61,15 @@
} }
model = new AnnouncementDto model = new AnnouncementDto
{ {
Id = entity.Id, Id = entity.Id,
Title = entity.Title, Title = entity.Title,
Content = entity.Content, Content = entity.Content,
DisplayType = entity.DisplayType, DisplayType = entity.DisplayType,
IsActive = entity.IsActive, IsActive = entity.IsActive,
SortOrder = entity.SortOrder SortOrder = entity.SortOrder
}; };
startsAtDate = entity.StartsAt?.ToLocalTime(); startsAtDate = entity.StartsAt?.ToLocalTime();
endsAtDate = entity.EndsAt?.ToLocalTime(); endsAtDate = entity.EndsAt?.ToLocalTime();
} }
catch catch
{ {
@@ -134,41 +80,18 @@
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (form is null) return;
await form.Validate();
if (!form.IsValid) return;
isSaving = true; isSaving = true;
try try
{ {
model.StartsAt = startsAtDate.HasValue model.StartsAt = startsAtDate.HasValue ? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() : null;
? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() model.EndsAt = endsAtDate.HasValue ? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime() : null;
: null; var result = Id.HasValue ? await AnnouncementClient.UpdateAsync(Id.Value, model) : await AnnouncementClient.CreateAsync(model);
model.EndsAt = endsAtDate.HasValue await JS.InvokeVoidAsync("alert", result != null ? "공지사항이 저장되었습니다." : "저장 실패");
? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime()
: null;
if (Id.HasValue)
{
var result = await AnnouncementClient.UpdateAsync(Id.Value, model);
if (result != null)
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
else
Snackbar.Add("저장 실패", Severity.Error);
}
else
{
var result = await AnnouncementClient.CreateAsync(model);
if (result != null)
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
else
Snackbar.Add("저장 실패", Severity.Error);
}
Navigation.NavigateTo("/taxbaik/admin/announcements"); Navigation.NavigateTo("/taxbaik/admin/announcements");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
finally finally
{ {
@@ -4,126 +4,93 @@
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@inject IAnnouncementBrowserClient AnnouncementClient @inject IAnnouncementBrowserClient AnnouncementClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IDialogService DialogService @inject IJSRuntime JS
@inject ISnackbar Snackbar
<PageTitle>공지사항 관리</PageTitle> <PageTitle>공지사항 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText> <div class="admin-eyebrow">Homepage</div>
<MudText Typo="Typo.h4" Class="admin-page-title">공지사항 관리</MudText> <h1 class="admin-page-title">공지사항 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</MudText> <p class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" <a class="site-button primary" href="/taxbaik/admin/announcements/create">공지 등록</a>
StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/announcements/create">
공지 등록
</MudButton>
</section> </section>
<div class="d-flex pa-4 gap-4 align-center"> <div class="admin-surface">
<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) @if (announcements is null)
{ {
<MudProgressLinear Indeterminate="true" /> <Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
} }
else if (!FilteredAnnouncements.Any()) else if (!announcements.Any())
{ {
<div class="pa-6 text-center"> <div class="muted">등록된 공지사항이 없습니다.</div>
<MudIcon Icon="@Icons.Material.Filled.Campaign" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">검색 조건에 맞는 공지사항이 없습니다.</MudText>
</div>
} }
else else
{ {
<MudSimpleTable Striped="true" Dense="true" Class="admin-table"> <div class="admin-table-wrap">
<thead> <table class="admin-table">
<tr> <thead>
<th>제목</th>
<th>유형</th>
<th>상태</th>
<th>게시 기간</th>
<th>순서</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in FilteredAnnouncements)
{
<tr> <tr>
<td>@item.Title</td> <th>제목</th>
<td> <th>유형</th>
<MudChip Size="Size.Small" Color="@GetTypeColor(item.DisplayType)"> <th>상태</th>
@GetTypeLabel(item.DisplayType) <th>게시 기간</th>
</MudChip> <th>순서</th>
</td> <th></th>
<td>
@if (IsCurrentlyActive(item))
{
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
}
else if (!item.IsActive)
{
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Warning">기간 외</MudChip>
}
</td>
<td class="small">
@FormatPeriod(item)
</td>
<td>@item.SortOrder</td>
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
삭제
</MudButton>
</MudButtonGroup>
</td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</MudSimpleTable> @foreach (var item in announcements)
<MudText Typo="Typo.caption" Class="pa-2 text-muted"> {
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개 <tr>
</MudText> <td>@item.Title</td>
<td><span class="status-pill info">@GetTypeLabel(item.DisplayType)</span></td>
<td>
@if (IsCurrentlyActive(item))
{
<span class="status-pill success">노출 중</span>
}
else if (!item.IsActive)
{
<span class="status-pill default">비활성</span>
}
else
{
<span class="status-pill warning">기간 외</span>
}
</td>
<td class="small">@FormatPeriod(item)</td>
<td>@item.SortOrder</td>
<td>
<div class="admin-actions">
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} }
</MudPaper> </div>
@code { @code {
[CascadingParameter] [CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; } private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Announcement>? announcements; 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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadAsync();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadAsync();
StateHasChanged();
}
} }
} }
} }
@@ -136,36 +103,32 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
announcements = []; announcements = [];
} }
} }
private async Task DeleteAsync(Announcement item) private async Task DeleteAsync(Announcement item)
{ {
var confirmed = await DialogService.ShowMessageBox( var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Title}' 공지를 삭제하시겠습니까?");
"공지 삭제", if (!confirmed) return;
$"'{item.Title}' 공지를 삭제하시겠습니까?",
yesText: "삭제", cancelText: "취소");
if (confirmed != true) return;
try try
{ {
var success = await AnnouncementClient.DeleteAsync(item.Id); var success = await AnnouncementClient.DeleteAsync(item.Id);
if (success) if (success)
{ {
Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "공지사항이 삭제되었습니다.");
await LoadAsync(); await LoadAsync();
} }
else else
{ {
Snackbar.Add("삭제 실패", Severity.Error); await JS.InvokeVoidAsync("alert", "삭제 실패");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
} }
@@ -174,28 +137,21 @@
if (!a.IsActive) return false; if (!a.IsActive) return false;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (a.StartsAt.HasValue && a.StartsAt > now) return false; if (a.StartsAt.HasValue && a.StartsAt > now) return false;
if (a.EndsAt.HasValue && a.EndsAt < now) return false; if (a.EndsAt.HasValue && a.EndsAt < now) return false;
return true; return true;
} }
private static string FormatPeriod(Announcement a) private static string FormatPeriod(Announcement a)
{ {
var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시"; var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시";
var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한"; var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한";
return $"{start} ~ {end}"; return $"{start} ~ {end}";
} }
private static Color GetTypeColor(string type) => type switch
{
"urgent" => Color.Error,
"banner" => Color.Warning,
_ => Color.Info
};
private static string GetTypeLabel(string type) => type switch private static string GetTypeLabel(string type) => type switch
{ {
"urgent" => "긴급", "urgent" => "긴급",
"banner" => "배너", "banner" => "배너",
_ => "일반" _ => "일반"
}; };
} }
@@ -1,107 +1,58 @@
@page "/admin/blog/create" @page "/admin/blog/create"
@attribute [Authorize] @attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces @using TaxBaik.Domain.Interfaces
@inject BlogService BlogService @inject BlogService BlogService
@inject ICategoryRepository CategoryRepository @inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>새 포스트 작성</PageTitle> <PageTitle>새 포스트 작성</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText> <div class="admin-eyebrow">Content</div>
<MudText Typo="Typo.h4" Class="admin-page-title">새 포스트 작성</MudText> <h1 class="admin-page-title">새 포스트 작성</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</MudText> <p class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</p>
</div> </div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton> <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
</section> </section>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <div class="admin-surface mt-4">
<MudForm @ref="form"> <form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
<MudTextField @bind-Value="model.Title" Label="제목 *" <label>제목 * <input class="admin-input" @bind="model.Title" /></label>
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" /> <label>카테고리
<select class="admin-input" @bind="CategoryIdText">
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리" <option value="">선택하세요</option>
Variant="Variant.Outlined" Class="mb-4"> @foreach (var category in categories)
@foreach (var category in categories) {
{ <option value="@category.Id.ToString()">@category.Name</option>
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem> }
} </select>
</MudSelect> </label>
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
<div class="mb-4"> <label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label> <label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea> <label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div> <label><input type="checkbox" @bind="model.IsPublished" /> 즉시 발행</label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">저장</button>
</div> </div>
</form>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)" </div>
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
</div>
</MudForm>
</MudPaper>
@code { @code {
private MudForm? form;
private List<Domain.Entities.Category> categories = []; private List<Domain.Entities.Category> categories = [];
private CreatePostModel model = new(); private CreatePostModel model = new();
private EasyMDE.Editor? editor; private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
[Inject]
private IJSRuntime JS { get; set; } = null!;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
categories = (await CategoryRepository.GetAllAsync()).ToList(); 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");
}
private async Task SavePost() private async Task SavePost()
{ {
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;
try try
{ {
await BlogService.CreateAsync(new CreateBlogPostDto await BlogService.CreateAsync(new CreateBlogPostDto
@@ -115,12 +66,12 @@
IsPublished = model.IsPublished IsPublished = model.IsPublished
}); });
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/blog"); Navigation.NavigateTo("/taxbaik/admin/blog");
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
Snackbar.Add(ex.Message, Severity.Error); await JS.InvokeVoidAsync("alert", ex.Message);
} }
} }
@@ -135,33 +86,3 @@
public bool IsPublished { get; set; } 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,88 +1,65 @@
@page "/admin/blog/{id:int}/edit" @page "/admin/blog/{id:int}/edit"
@attribute [Authorize] @attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces @using TaxBaik.Domain.Interfaces
@inject BlogService BlogService @inject BlogService BlogService
@inject ICategoryRepository CategoryRepository @inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
@inject IDialogService DialogService
<PageTitle>포스트 수정</PageTitle> <PageTitle>포스트 수정</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText> <div class="admin-eyebrow">Content</div>
<MudText Typo="Typo.h4" Class="admin-page-title">포스트 수정</MudText> <h1 class="admin-page-title">포스트 수정</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">블로그 포스트를 수정합니다.</MudText> <p class="admin-page-subtitle">블로그 포스트를 수정합니다.</p>
</div> </div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton> <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
</section> </section>
@if (isLoading) @if (isLoading)
{ {
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" /> <div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
} }
else if (post == null) else if (post == null)
{ {
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert> <div class="admin-surface mt-4">포스트를 찾을 수 없습니다.</div>
} }
else else
{ {
<MudPaper Class="pa-4 mt-4" Elevation="1"> <div class="admin-surface mt-4">
<MudForm @ref="form"> <form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
<MudTextField @bind-Value="model.Title" Label="제목 *" <label>제목 * <input class="admin-input" @bind="model.Title" /></label>
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" /> <label>카테고리
<select class="admin-input" @bind="CategoryIdText">
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리" <option value="">선택하세요</option>
Variant="Variant.Outlined" Class="mb-4"> @foreach (var category in categories)
@foreach (var category in categories) {
{ <option value="@category.Id.ToString()">@category.Name</option>
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem> }
} </select>
</MudSelect> </label>
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
<div class="mb-4"> <label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label> <label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea> <label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div> <label><input type="checkbox" @bind="model.IsPublished" /> 발행</label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">저장</button>
<button type="button" class="site-button secondary" @onclick="DeletePost">삭제</button>
</div> </div>
</form>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)" </div>
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error"
@onclick="DeletePost">삭제</MudButton>
</div>
</MudForm>
</MudPaper>
} }
@code { @code {
[Parameter] [Parameter] public int Id { get; set; }
public int Id { get; set; }
[Inject]
private IJSRuntime JS { get; set; } = null!;
private MudForm? form;
private Domain.Entities.BlogPost? post; private Domain.Entities.BlogPost? post;
private List<Domain.Entities.Category> categories = []; private List<Domain.Entities.Category> categories = [];
private EditPostModel model = new(); private EditPostModel model = new();
private bool isLoading = true; private bool isLoading = true;
private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -97,7 +74,7 @@ else
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"포스트 로드 실패: {ex.Message}");
} }
finally finally
{ {
@@ -105,14 +82,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) private void MapPostToModel(Domain.Entities.BlogPost post)
{ {
model.Title = post.Title; model.Title = post.Title;
@@ -124,29 +93,9 @@ else
model.IsPublished = post.IsPublished; model.IsPublished = post.IsPublished;
} }
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/blog");
}
private async Task SavePost() private async Task SavePost()
{ {
if (form == null || post == null) if (post == null) return;
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;
try try
{ {
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
@@ -159,43 +108,22 @@ else
SeoDescription = model.SeoDescription, SeoDescription = model.SeoDescription,
IsPublished = model.IsPublished IsPublished = model.IsPublished
}); });
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog"); Navigation.NavigateTo("/taxbaik/admin/blog");
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
Snackbar.Add(ex.Message, Severity.Error); await JS.InvokeVoidAsync("alert", ex.Message);
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
} }
} }
private async Task DeletePost() private async Task DeletePost()
{ {
if (post == null) if (post == null) return;
return; if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
await BlogService.DeleteAsync(post.Id);
var result = await DialogService.ShowMessageBox( await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
"포스트 삭제", Navigation.NavigateTo("/taxbaik/admin/blog");
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
return;
try
{
await BlogService.DeleteAsync(post.Id);
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
}
} }
private class EditPostModel private class EditPostModel
@@ -209,33 +137,3 @@ else
public bool IsPublished { get; set; } 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,92 +1,94 @@
@page "/admin/blog" @page "/admin/blog"
@attribute [Authorize] @attribute [Authorize]
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>블로그 관리</PageTitle> <PageTitle>블로그 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText> <div class="admin-eyebrow">Content</div>
<MudText Typo="Typo.h4" Class="admin-page-title">블로그 관리</MudText> <h1 class="admin-page-title">블로그 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</MudText> <p class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote" <button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
</section> </section>
<div class="d-flex pa-4 gap-4 align-center"> <div class="admin-surface mb-4">
<MudTextField @bind-Value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start" <div class="admin-summary-bar">
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" /> <span>전체 포스트: @($"{totalPosts}개")</span>
<span>페이지 @currentPage / @totalPages</span>
</div>
</div> </div>
<MudPaper Class="admin-surface mb-4" Elevation="0"> <div class="admin-surface">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween"> @if (isLoading)
<MudText Typo="Typo.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText> {
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText> <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
</MudStack> }
</MudPaper> else
{
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>제목</th>
<th>발행</th>
<th>조회수</th>
<th>작성일</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var post in posts)
{
<tr>
<td>@post.Title</td>
<td><label><input type="checkbox" checked="@post.IsPublished" @onchange="@(async e => await TogglePublish(post, (bool)e.Value!))" /> 발행</label></td>
<td>@post.ViewCount</td>
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
<td>
<div class="admin-row-actions">
<a class="site-button secondary" href="@($"/taxbaik/admin/blog/{post.Id}/edit")">수정</a>
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeletePost(post.Id))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid"> <div class="admin-pagination">
<Columns> <button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
<PropertyColumn Property="x => x.Title" Title="제목" /> <button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
<PropertyColumn Property="x => x.IsPublished" Title="발행"> </div>
<CellTemplate Context="cell">
<MudCheckBox T="bool" Value="@cell.Item.IsPublished"
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.ViewCount" Title="조회수" />
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
<TemplateColumn>
<CellTemplate Context="cell">
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
</MudStack>
@code { @code {
[CascadingParameter] [CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxBaik.Domain.Entities.BlogPost> posts = []; private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
private string searchQuery = "";
private bool isLoading = true; private bool isLoading = true;
private int currentPage = 1; private int currentPage = 1;
private int totalPages = 1; private int totalPages = 1;
private int totalPosts = 0; private int totalPosts = 0;
private const int PageSize = 20; 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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadPosts();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadPosts();
StateHasChanged();
}
} }
} }
} }
private string NavTo(string url) => url;
private async Task LoadPosts() private async Task LoadPosts()
{ {
isLoading = true; isLoading = true;
@@ -103,58 +105,33 @@
totalPosts = 0; totalPosts = 0;
totalPages = 1; totalPages = 1;
} }
isLoading = false; finally
{
isLoading = false;
}
} }
private async Task PreviousPage() private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadPosts(); } }
{ private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadPosts(); } }
if (currentPage <= 1)
return;
currentPage--;
await LoadPosts();
}
private async Task NextPage()
{
if (currentPage >= totalPages)
return;
currentPage++;
await LoadPosts();
}
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished) private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
{ {
var previous = post.IsPublished; var previous = post.IsPublished;
post.IsPublished = isPublished; post.IsPublished = isPublished;
var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new { post.Title, post.Content, post.CategoryId, post.Tags, post.SeoTitle, post.SeoDescription, post.ThumbnailUrl, IsPublished = isPublished, post.AuthorId });
{
post.Title,
post.Content,
post.CategoryId,
post.Tags,
post.SeoTitle,
post.SeoDescription,
post.ThumbnailUrl,
IsPublished = isPublished,
post.AuthorId
});
if (result == null) if (result == null)
{ {
post.IsPublished = previous; post.IsPublished = previous;
Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "발행 상태 변경에 실패했습니다.");
return; return;
} }
await JS.InvokeVoidAsync("alert", "발행 상태가 변경되었습니다.");
Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success);
} }
private async Task DeletePost(int postId) private async Task DeletePost(int postId)
{ {
await ApiClient.DeleteAsync($"blog/{postId}"); await ApiClient.DeleteAsync($"blog/{postId}");
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
await LoadPosts(); await LoadPosts();
} }
@@ -4,185 +4,123 @@
@inject ClientService ClientService @inject ClientService ClientService
@inject ConsultationService ConsultationService @inject ConsultationService ConsultationService
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>고객 상세</PageTitle> <PageTitle>고객 상세</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Client Details</MudText> <div class="admin-eyebrow">Client Details</div>
<MudText Typo="Typo.h4" Class="admin-page-title">고객 상세</MudText> <h1 class="admin-page-title">고객 상세</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</MudText> <p class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</p>
</div> </div>
</section> </section>
@if (client == null) @if (client == null)
{ {
<MudText>고객을 찾을 수 없습니다.</MudText> <div class="admin-surface mt-4">고객을 찾을 수 없습니다.</div>
return;
} }
else
{
<div class="admin-page-actions">
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{ClientId}/edit")">수정</a>
</div>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4" Spacing="2"> <div class="admin-detail-grid">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" <section class="admin-surface">
StartIcon="@Icons.Material.Filled.ArrowBack" <h3 class="admin-section-title">고객 정보</h3>
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/clients"))"> <div class="admin-kv-grid">
목록으로 <div><span>이름</span><strong>@client.Name</strong></div>
</MudButton> <div><span>상호</span><strong>@(client.CompanyName ?? "-")</strong></div>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" <div><span>연락처</span><strong>@(client.Phone ?? "-")</strong></div>
StartIcon="@Icons.Material.Filled.Edit" <div><span>이메일</span><strong>@(client.Email ?? "-")</strong></div>
Href="@($"/taxbaik/admin/clients/{ClientId}/edit")"> <div><span>서비스</span><strong>@(client.ServiceType ?? "-")</strong></div>
수정 <div><span>사업자 유형</span><strong>@(client.TaxType ?? "-")</strong></div>
</MudButton> <div><span>유입 경로</span><strong>@(client.Source ?? "-")</strong></div>
</MudStack> <div><span>등록일</span><strong>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</strong></div>
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">고객 정보</MudText>
<MudGrid Spacing="2">
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
<MudText>@client.Name</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">상호</MudText>
<MudText>@(client.CompanyName ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
<MudText>@(client.Phone ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
<MudText>@(client.Email ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">서비스</MudText>
<MudText>@(client.ServiceType ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">사업자 유형</MudText>
<MudText>@(client.TaxType ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">유입 경로</MudText>
<MudText>@(client.Source ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">등록일</MudText>
<MudText>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</MudText>
</MudItem>
@if (!string.IsNullOrWhiteSpace(client.Memo)) @if (!string.IsNullOrWhiteSpace(client.Memo))
{ {
<MudItem xs="12"> <div class="span-2"><span>메모</span><strong style="white-space: pre-wrap;">@client.Memo</strong></div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">메모</MudText>
<MudText Style="white-space: pre-wrap;">@client.Memo</MudText>
</MudItem>
} }
</MudGrid> </div>
</MudPaper> </section>
</MudItem>
<MudItem xs="12" md="7"> <section class="admin-surface">
<MudPaper Class="pa-4" Elevation="1"> <div class="admin-section-header compact">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3"> <div>
<MudText Typo="Typo.h6">상담 이력</MudText> <h3 class="admin-section-title">상담 이력</h3>
<MudButton Variant="Variant.Filled" Color="Color.Primary" </div>
Size="Size.Small" <button type="button" class="site-button primary" @onclick="OpenAddConsultation">+ 상담 추가</button>
OnClick="OpenAddConsultation"> </div>
+ 상담 추가
</MudButton>
</MudStack>
@if (showAddForm) @if (showAddForm)
{ {
<MudPaper Class="pa-3 mb-3" Outlined="true"> <form class="admin-dialog-card mb-4" @onsubmit="AddConsultation" @onsubmit:preventDefault="true">
<MudGrid Spacing="2"> <label>상담일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="ConsultationDateText" /></label>
<MudItem xs="12" sm="6"> <label>서비스 분야
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" /> <select class="admin-input" @bind="newServiceType">
</MudItem> <option value="">선택하세요</option>
<MudItem xs="12" sm="6"> @foreach (var t in ClientService.ServiceTypes)
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야"> {
@foreach (var t in ClientService.ServiceTypes) <option value="@t">@t</option>
{ }
<MudSelectItem Value="@t">@t</MudSelectItem> </select>
} </label>
</MudSelect> <label>상담 내용 * <textarea class="admin-input" rows="3" @bind="newSummary"></textarea></label>
</MudItem> <label>결과
<MudItem xs="12"> <select class="admin-input" @bind="newResult">
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *" <option value="">-</option>
Lines="3" Variant="Variant.Outlined" Required="true" /> @foreach (var r in ConsultationService.Results)
</MudItem> {
<MudItem xs="12" sm="6"> <option value="@r">@r</option>
<MudSelect T="string" @bind-Value="newResult" Label="결과"> }
<MudSelectItem Value="@("")">-</MudSelectItem> </select>
@foreach (var r in ConsultationService.Results) </label>
{ <label>수임료 (원) <input class="admin-input" type="text" placeholder="100000" @bind="FeeText" /></label>
<MudSelectItem Value="@r">@r</MudSelectItem> <div class="admin-dialog-actions">
} <button type="submit" class="site-button primary">저장</button>
</MudSelect> <button type="button" class="site-button secondary" @onclick='() => showAddForm = false'>취소</button>
</MudItem> </div>
<MudItem xs="12" sm="6"> </form>
<MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
Format="N0" />
</MudItem>
</MudGrid>
<MudStack Row="true" Class="mt-2" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddConsultation">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
</MudStack>
</MudPaper>
} }
@if (consultations.Count == 0) @if (consultations.Count == 0)
{ {
<MudText Color="Color.Secondary">상담 이력이 없습니다.</MudText> <p class="muted">상담 이력이 없습니다.</p>
} }
else else
{ {
<MudList T="string" Dense="true"> <div class="admin-activity-list">
@foreach (var c in consultations) @foreach (var c in consultations)
{ {
<MudListItem> <article class="admin-activity-card">
<MudPaper Class="pa-3" Outlined="true" Style="width:100%"> <div class="admin-activity-head">
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween"> <div>
<div> <span class="muted">@c.ConsultationDate.ToString("yyyy-MM-dd") @(string.IsNullOrEmpty(c.ServiceType) ? "" : $"· {c.ServiceType}")</span>
<MudText Typo="Typo.caption" Color="Color.Secondary"> </div>
@c.ConsultationDate.ToString("yyyy-MM-dd") <button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteConsultation(c.Id))">✕</button>
@if (!string.IsNullOrEmpty(c.ServiceType)) { <text> · @c.ServiceType</text> } </div>
</MudText> <p style="white-space: pre-wrap;">@c.Summary</p>
<MudText Style="white-space: pre-wrap;" Class="mt-1">@c.Summary</MudText> @if (!string.IsNullOrEmpty(c.Result))
@if (!string.IsNullOrEmpty(c.Result)) {
{ <span class="status-pill info">@c.Result</span>
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="mt-1">@c.Result</MudChip> }
} @if (c.Fee.HasValue)
@if (c.Fee.HasValue) {
{ <div class="muted">수임료: @c.Fee.Value.ToString("N0")원</div>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1"> }
수임료: @c.Fee.Value.ToString("N0")원 </article>
</MudText>
}
</div>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteConsultation(c.Id))" />
</MudStack>
</MudPaper>
</MudListItem>
} }
</MudList> </div>
} }
</MudPaper> </section>
</MudItem> </div>
</MudGrid> }
@code { @code {
[Parameter] [Parameter] public int ClientId { get; set; }
public int ClientId { get; set; }
private Domain.Entities.Client? client; private Domain.Entities.Client? client;
private List<Domain.Entities.Consultation> consultations = []; private List<Domain.Entities.Consultation> consultations = [];
private bool showAddForm; private bool showAddForm;
private DateTime? newDate = DateTime.Today; private DateTime? newDate = DateTime.Today;
private string newServiceType = ""; private string newServiceType = "";
@@ -190,10 +128,10 @@
private string newResult = ""; private string newResult = "";
private decimal? newFee; private decimal? newFee;
protected override async Task OnInitializedAsync() private string ConsultationDateText { get => newDate?.ToString("yyyy-MM-dd") ?? ""; set => newDate = DateTime.TryParse(value, out var dt) ? dt : null; }
{ private string FeeText { get => newFee?.ToString() ?? ""; set => newFee = decimal.TryParse(value, out var d) ? d : null; }
await LoadAll();
} protected override async Task OnInitializedAsync() => await LoadAll();
private async Task LoadAll() private async Task LoadAll()
{ {
@@ -215,6 +153,12 @@
{ {
try try
{ {
if (string.IsNullOrWhiteSpace(newSummary))
{
await JS.InvokeVoidAsync("alert", "상담 내용을 입력하세요.");
return;
}
var c = new Domain.Entities.Consultation var c = new Domain.Entities.Consultation
{ {
ClientId = ClientId, ClientId = ClientId,
@@ -224,21 +168,23 @@
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult, Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
Fee = newFee Fee = newFee
}; };
await ConsultationService.CreateAsync(c); await ConsultationService.CreateAsync(c);
showAddForm = false; showAddForm = false;
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
Snackbar.Add("상담이 추가되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "상담이 추가되었습니다.");
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
Snackbar.Add(ex.Message, Severity.Error); await JS.InvokeVoidAsync("alert", ex.Message);
} }
} }
private async Task DeleteConsultation(int id) private async Task DeleteConsultation(int id)
{ {
if (!await JS.InvokeAsync<bool>("confirm", "이 상담을 삭제하시겠습니까?")) return;
await ConsultationService.DeleteAsync(id); await ConsultationService.DeleteAsync(id);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
Snackbar.Add("삭제되었습니다.", Severity.Info); await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
} }
} }
@@ -6,117 +6,74 @@
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle> <PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText> <div class="admin-eyebrow">CRM</div>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText> <h1 class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</h1>
</div> </div>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients" <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
</section> </section>
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;"> <div class="admin-surface" style="max-width:720px;">
@if (isLoading) @if (isLoading)
{ {
<MudProgressLinear Indeterminate="true" /> <Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
} }
else else
{ {
<MudForm @ref="form" @bind-IsValid="isValid"> <form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
<MudGrid Spacing="3"> <label>고객명 * <input class="admin-input" @bind="dto.Name" /></label>
@* 기본 정보 *@ <label>회사명 <input class="admin-input" @bind="dto.CompanyName" /></label>
<MudItem xs="12"> <label>연락처 <input class="admin-input" @bind="dto.Phone" /></label>
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">기본 정보</MudText> <label>이메일 <input class="admin-input" type="email" @bind="dto.Email" /></label>
<MudDivider /> <label>서비스 유형
</MudItem> <select class="admin-input" @bind="dto.ServiceType">
<MudItem xs="12" md="6"> <option value="">선택하세요</option>
<MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true" @foreach (var t in ClientService.ServiceTypes)
RequiredError="고객명을 입력하세요." /> {
</MudItem> <option value="@t">@t</option>
<MudItem xs="12" md="6"> }
<MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" /> </select>
</MudItem> </label>
<MudItem xs="12" md="6"> <label>세금 유형
<MudTextField @bind-Value="dto.Phone" Label="연락처" <select class="admin-input" @bind="dto.TaxType">
Placeholder="010-0000-0000" /> <option value="">선택하세요</option>
</MudItem> @foreach (var t in ClientService.TaxTypes)
<MudItem xs="12" md="6"> {
<MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" /> <option value="@t">@t</option>
</MudItem> }
</select>
@* 세무 정보 *@ </label>
<MudItem xs="12" Class="mt-2"> <label>상태
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">세무 정보</MudText> <select class="admin-input" @bind="dto.Status">
<MudDivider /> <option value="active">활성</option>
</MudItem> <option value="inactive">비활성</option>
<MudItem xs="12" md="6"> </select>
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true"> </label>
@foreach (var t in ClientService.ServiceTypes) <label>유입 경로
{ <select class="admin-input" @bind="dto.Source">
<MudSelectItem Value="@t">@t</MudSelectItem> <option value="">선택하세요</option>
} @foreach (var s in ClientService.Sources)
</MudSelect> {
</MudItem> <option value="@s">@s</option>
<MudItem xs="12" md="6"> }
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true"> </select>
@foreach (var t in ClientService.TaxTypes) </label>
{ <label>메모 <textarea class="admin-input" rows="4" @bind="dto.Memo"></textarea></label>
<MudSelectItem Value="@t">@t</MudSelectItem> <div class="admin-dialog-actions">
} <button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
</MudSelect> <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>취소</button>
</MudItem> </div>
</form>
@* 관리 정보 *@
<MudItem xs="12" Class="mt-2">
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">관리 정보</MudText>
<MudDivider />
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true">
<MudSelectItem Value="@("active")">활성</MudSelectItem>
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true">
@foreach (var s in ClientService.Sources)
{
<MudSelectItem Value="@s">@s</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="dto.Memo" Label="메모"
Lines="4" AutoGrow="true"
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
</MudItem>
@* 저장 버튼 *@
<MudItem xs="12" Class="d-flex gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="@SaveAsync" Disabled="@isSaving">
@(isSaving ? "저장 중..." : "저장")
</MudButton>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients">
취소
</MudButton>
</MudItem>
</MudGrid>
</MudForm>
} }
</MudPaper> </div>
@code { @code {
[Parameter] public int? Id { get; set; } [Parameter] public int? Id { get; set; }
private MudForm form = null!;
private CreateClientDto dto = new() { Status = "active" }; private CreateClientDto dto = new() { Status = "active" };
private bool isValid;
private bool isLoading = true; private bool isLoading = true;
private bool isSaving; private bool isSaving;
@@ -129,7 +86,7 @@
var client = await ClientClient.GetByIdAsync(Id.Value); var client = await ClientClient.GetByIdAsync(Id.Value);
if (client is null) if (client is null)
{ {
Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "고객을 찾을 수 없습니다.");
Navigation.NavigateTo("/taxbaik/admin/clients"); Navigation.NavigateTo("/taxbaik/admin/clients");
return; return;
} }
@@ -145,46 +102,42 @@
Source = client.Source, Source = client.Source,
Memo = client.Memo Memo = client.Memo
}; };
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
Navigation.NavigateTo("/taxbaik/admin/clients"); Navigation.NavigateTo("/taxbaik/admin/clients");
return; return;
} }
} }
isLoading = false; isLoading = false;
} }
private async Task SaveAsync() private async Task SaveAsync()
{ {
await form.Validate();
if (!isValid) return;
isSaving = true; isSaving = true;
try try
{ {
if (string.IsNullOrWhiteSpace(dto.Name))
{
await JS.InvokeVoidAsync("alert", "고객명을 입력하세요.");
return;
}
if (Id.HasValue) if (Id.HasValue)
{ {
var result = await ClientClient.UpdateAsync(Id.Value, dto); var result = await ClientClient.UpdateAsync(Id.Value, dto);
if (result != null) await JS.InvokeVoidAsync("alert", result != null ? "고객 정보가 수정되었습니다." : "수정에 실패했습니다.");
Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success);
else
Snackbar.Add("수정에 실패했습니다.", Severity.Error);
} }
else else
{ {
var result = await ClientClient.CreateAsync(dto); var result = await ClientClient.CreateAsync(dto);
if (result != null) await JS.InvokeVoidAsync("alert", result != null ? "고객이 등록되었습니다." : "등록에 실패했습니다.");
Snackbar.Add("고객이 등록되었습니다.", Severity.Success);
else
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
} }
Navigation.NavigateTo("/taxbaik/admin/clients"); Navigation.NavigateTo("/taxbaik/admin/clients");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
finally finally
{ {
@@ -4,134 +4,94 @@
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IDialogService DialogService @inject IJSRuntime JS
@inject ISnackbar Snackbar
<PageTitle>고객 관리</PageTitle> <PageTitle>고객 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText> <div class="admin-eyebrow">CRM</div>
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText> <h1 class="admin-page-title">고객 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText> <p class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" <button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients/create")'>고객 등록</button>
StartIcon="@Icons.Material.Filled.PersonAdd"
Href="/taxbaik/admin/clients/create">
고객 등록
</MudButton>
</section> </section>
@* 검색/필터 바 *@ <div class="admin-surface mb-3 pa-3">
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0"> <div class="admin-filter-grid">
<MudGrid> <input class="admin-input" placeholder="검색 (이름·연락처·회사명)" @bind="searchText" @onkeyup="OnSearchKeyUp" />
<MudItem xs="12" md="5"> <select class="admin-input" @bind="statusFilter">
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)" <option value="">전체</option>
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" <option value="active">활성</option>
Immediate="false" OnKeyUp="@OnSearchKeyUp" /> <option value="inactive">비활성</option>
</MudItem> </select>
<MudItem xs="12" md="3"> <button type="button" class="site-button secondary" @onclick="SearchAsync">검색</button>
<MudSelect @bind-Value="statusFilter" Label="상태" T="string"> <button type="button" class="site-button secondary" @onclick="ResetAsync">초기화</button>
<MudSelectItem Value="@("")">전체</MudSelectItem> </div>
<MudSelectItem Value="@("active")">활성</MudSelectItem> </div>
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
</MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Text" OnClick="@ResetAsync" FullWidth="true">초기화</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
<MudPaper Class="admin-surface" Elevation="0"> <div class="admin-surface">
@if (clients is null) @if (clients is null)
{ {
<MudProgressLinear Indeterminate="true" /> <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
} }
else if (!clients.Any()) else if (!clients.Any())
{ {
<div class="pa-6 text-center"> <div class="muted mt-4">등록된 고객이 없습니다.</div>
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
</div>
} }
else else
{ {
<MudSimpleTable Striped="true" Dense="true" Class="admin-table"> <div class="admin-table-wrap">
<thead> <table class="admin-table">
<tr> <thead>
<th>이름</th>
<th>회사명</th>
<th>연락처</th>
<th>서비스</th>
<th>세금 유형</th>
<th>상태</th>
<th>유입 경로</th>
<th>등록일</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var c in clients)
{
<tr> <tr>
<td><strong>@c.Name</strong></td> <th>이름</th>
<td>@(c.CompanyName ?? "—")</td> <th>회사명</th>
<td>@(c.Phone ?? "—")</td> <th>연락처</th>
<td> <th>서비스</th>
@if (!string.IsNullOrEmpty(c.ServiceType)) <th>세금 유형</th>
{ <th>상태</th>
<MudChip Size="Size.Small" Color="Color.Primary">@c.ServiceType</MudChip> <th>유입 경로</th>
} <th>등록일</th>
</td> <th></th>
<td>@(c.TaxType ?? "—")</td>
<td>
@if (c.Status == "active")
{
<MudChip Size="Size.Small" Color="Color.Success">활성</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
}
</td>
<td>@(c.Source ?? "—")</td>
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(c))">
삭제
</MudButton>
</MudButtonGroup>
</td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</MudSimpleTable> @foreach (var c in clients)
{
@* 페이징 *@ <tr>
<td><strong>@c.Name</strong></td>
<td>@(c.CompanyName ?? "—")</td>
<td>@(c.Phone ?? "—")</td>
<td>@(c.ServiceType ?? "—")</td>
<td>@(c.TaxType ?? "—")</td>
<td>@(c.Status == "active" ? "활성" : "비활성")</td>
<td>@(c.Source ?? "—")</td>
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
<td>
<div class="admin-row-actions">
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(c))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (totalPages > 1) @if (totalPages > 1)
{ {
<div class="d-flex justify-center pa-3"> <div class="admin-pagination">
<MudPagination BoundaryCount="1" MiddleCount="3" <button type="button" class="site-button secondary" disabled="@(currentPage <= 1)" @onclick="PreviousPage">이전</button>
Count="@totalPages" Selected="@currentPage" <span>@currentPage / @totalPages</span>
SelectedChanged="@OnPageChanged" /> <button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages)" @onclick="NextPage">다음</button>
</div> </div>
} }
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText> <div class="admin-table-footer">총 @(totalCount)명</div>
} }
</MudPaper> </div>
@code { @code {
[CascadingParameter] [CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Client>? clients; private List<Client>? clients;
private string searchText = ""; private string searchText = "";
private string statusFilter = ""; private string statusFilter = "";
@@ -142,16 +102,13 @@
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadAsync();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadAsync();
StateHasChanged();
}
} }
} }
} }
@@ -160,75 +117,39 @@
{ {
try try
{ {
var (items, total) = await ClientClient.GetPagedAsync( var (items, total) = await ClientClient.GetPagedAsync(currentPage, PageSize, string.IsNullOrEmpty(statusFilter) ? null : statusFilter, string.IsNullOrEmpty(searchText) ? null : searchText);
currentPage, PageSize,
string.IsNullOrEmpty(statusFilter) ? null : statusFilter,
string.IsNullOrEmpty(searchText) ? null : searchText);
clients = items.ToList(); clients = items.ToList();
totalCount = total; totalCount = total;
totalPages = (int)Math.Ceiling((double)total / PageSize); totalPages = (int)Math.Ceiling((double)total / PageSize);
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
clients = []; clients = [];
totalCount = 0;
totalPages = 0;
} }
} }
private async Task SearchAsync() private async Task SearchAsync() { currentPage = 1; await LoadAsync(); }
{ private async Task ResetAsync() { searchText = ""; statusFilter = ""; currentPage = 1; await LoadAsync(); }
currentPage = 1; private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadAsync(); } }
await LoadAsync(); private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadAsync(); } }
} private async Task OnSearchKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") await SearchAsync(); }
private async Task ResetAsync()
{
searchText = "";
statusFilter = "";
currentPage = 1;
await LoadAsync();
}
private async Task OnPageChanged(int page)
{
currentPage = page;
await LoadAsync();
}
private async Task OnSearchKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter") await SearchAsync();
}
private async Task DeleteAsync(Client client) private async Task DeleteAsync(Client client)
{ {
var confirmed = await DialogService.ShowMessageBox( var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.");
"고객 삭제", if (!confirmed) return;
$"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.",
yesText: "삭제", cancelText: "취소");
if (confirmed != true) return;
try try
{ {
var success = await ClientClient.DeleteAsync(client.Id); var success = await ClientClient.DeleteAsync(client.Id);
if (success) if (success)
{ {
Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", $"{client.Name} 고객이 삭제되었습니다.");
await LoadAsync(); await LoadAsync();
} }
else
{
Snackbar.Add("삭제에 실패했습니다.", Severity.Error);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
await LoadAsync();
} }
} }
@@ -3,22 +3,22 @@
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.Web.Components.Admin.Forms
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>고객사 등록</PageTitle> <PageTitle>고객사 등록</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText> <div class="admin-eyebrow">Settings</div>
<MudText Typo="Typo.h4" Class="admin-page-title">새 고객사 등록</MudText> <h1 class="admin-page-title">새 고객사 등록</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 고객사를 추가합니다.</MudText> <p class="admin-page-subtitle">새로운 고객사를 추가합니다.</p>
</div> </div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton> <button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section> </section>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <div class="admin-surface mt-4">
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" /> <CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
</MudPaper> </div>
@code { @code {
private void GoBack() private void GoBack()
@@ -40,12 +40,12 @@
memo = model.Memo memo = model.Memo
}); });
Snackbar.Add("고객사가 등록되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "고객사가 등록되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/companies"); Navigation.NavigateTo("/taxbaik/admin/companies");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
} }
} }
} }
@@ -3,39 +3,37 @@
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.Web.Components.Admin.Forms
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
@inject IDialogService DialogService
<PageTitle>고객사 수정</PageTitle> <PageTitle>고객사 수정</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText> <div class="admin-eyebrow">Settings</div>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 수정</MudText> <h1 class="admin-page-title">고객사 수정</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객사 정보를 수정합니다.</MudText> <p class="admin-page-subtitle">고객사 정보를 수정합니다.</p>
</div> </div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton> <button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section> </section>
@if (isLoading) @if (isLoading)
{ {
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" /> <div class="admin-surface mt-4">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
} }
else if (formModel == null) else if (formModel == null)
{ {
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert> <div class="admin-surface mt-4">고객사를 찾을 수 없습니다.</div>
} }
else else
{ {
<MudPaper Class="pa-4 mt-4" Elevation="1"> <div class="admin-surface mt-4">
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" /> <CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
<div class="mt-4">
<MudDivider Class="my-4" /> <button type="button" class="site-button secondary danger" @onclick="DeleteCompany">고객사 삭제</button>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2"> </div>
고객사 삭제
</MudButton>
</MudPaper>
} }
@code { @code {
@@ -67,7 +65,7 @@ else
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
} }
finally finally
{ {
@@ -95,34 +93,29 @@ else
isActive = model.IsActive isActive = model.IsActive
}); });
Snackbar.Add("고객사가 수정되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "고객사가 수정되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/companies"); Navigation.NavigateTo("/taxbaik/admin/companies");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
} }
} }
private async Task DeleteCompany() private async Task DeleteCompany()
{ {
var result = await DialogService.ShowMessageBox( if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."))
"고객사 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
return; return;
try try
{ {
await ApiClient.DeleteAsync($"company/{Id}"); await ApiClient.DeleteAsync($"company/{Id}");
Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "고객사가 삭제되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/companies"); Navigation.NavigateTo("/taxbaik/admin/companies");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
} }
} }
} }
@@ -1,53 +1,71 @@
@page "/admin/companies" @page "/admin/companies"
@attribute [Authorize] @attribute [Authorize]
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>고객사 관리</PageTitle> <PageTitle>고객사 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText> <div class="admin-eyebrow">Settings</div>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 관리</MudText> <h1 class="admin-page-title">고객사 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</MudText> <p class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" <button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/companies/create")'>새 고객사 등록</button>
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
</section> </section>
<MudPaper Class="admin-surface mb-4 mt-4" Elevation="0"> <div class="admin-surface mb-4 mt-4">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween"> <div class="admin-summary-bar">
<MudText Typo="Typo.subtitle1">@($"전체 고객사 {totalCompanies}개")</MudText> <span>@($"전체 고객사 {totalCompanies}개")</span>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText> <span>페이지 @currentPage / @totalPages</span>
</MudStack> </div>
</MudPaper> </div>
<MudDataGrid Items="@companies" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid"> <div class="admin-surface">
<Columns> @if (isLoading)
<PropertyColumn Property="x => x.CompanyCode" Title="회사코드" /> {
<PropertyColumn Property="x => x.CompanyName" Title="회사명" /> <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
<PropertyColumn Property="x => x.ContactPerson" Title="담당자" /> }
<PropertyColumn Property="x => x.Phone" Title="전화" /> else
<PropertyColumn Property="x => x.Email" Title="이메일" /> {
<PropertyColumn Property="x => x.IsActive" Title="활성"> <div class="admin-table-wrap">
<CellTemplate Context="cell"> <table class="admin-table">
<MudCheckBox T="bool" Value="@cell.Item.IsActive" Disabled="true" /> <thead>
</CellTemplate> <tr>
</PropertyColumn> <th>회사코드</th>
<PropertyColumn Property="x => x.CreatedAt" Title="등록일" Format="yyyy-MM-dd" /> <th>회사명</th>
<TemplateColumn> <th>담당자</th>
<CellTemplate Context="cell"> <th>전화</th>
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary" <th>이메일</th>
Href="@($"/taxbaik/admin/companies/{cell.Item.Id}/edit")">수정</MudButton> <th>활성</th>
</CellTemplate> <th>등록일</th>
</TemplateColumn> <th></th>
</Columns> </tr>
</MudDataGrid> </thead>
<tbody>
@foreach (var item in companies)
{
<tr>
<td>@item.CompanyCode</td>
<td>@item.CompanyName</td>
<td>@(item.ContactPerson ?? "—")</td>
<td>@(item.Phone ?? "—")</td>
<td>@(item.Email ?? "—")</td>
<td>@(item.IsActive ? "활성" : "비활성")</td>
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
<td><a class="site-button secondary" href="@($"/taxbaik/admin/companies/{item.Id}/edit")">수정</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2"> <div class="admin-pagination">
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton> <button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton> <button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
</MudStack> </div>
@code { @code {
private List<CompanyDto> companies = []; private List<CompanyDto> companies = [];
@@ -100,7 +118,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
} }
finally finally
{ {
@@ -131,4 +149,6 @@
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
private string NavTo(string url) => url;
} }
@@ -2,150 +2,122 @@
@using TaxBaik.Web.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@inject IConsultingActivityBrowserClient ActivityClient @inject IConsultingActivityBrowserClient ActivityClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
@inject IDialogService DialogService
@attribute [Authorize] @attribute [Authorize]
<PageTitle>상담 활동 관리</PageTitle> <PageTitle>상담 활동 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText> <div class="admin-eyebrow">CRM & 세무관리</div>
<MudText Typo="Typo.h4" Class="admin-page-title">상담 활동 관리</MudText> <h1 class="admin-page-title">상담 활동 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</MudText> <p class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add"> <button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 활동 기록</button>
새 활동 기록
</MudButton>
</section> </section>
<MudPaper Class="admin-surface" Elevation="0"> <div class="admin-surface">
@if (activities is null) @if (activities is null)
{ {
<MudProgressLinear Indeterminate="true" /> <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
} }
else if (activities.Count == 0) else if (activities.Count == 0)
{ {
<MudAlert Severity="Severity.Info" Class="mt-4"> <div class="muted">상담 활동이 없습니다.</div>
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="me-2" />
상담 활동이 없습니다.
</MudAlert>
} }
else else
{ {
<MudDataGrid T="ConsultingActivity" <div class="admin-table-wrap">
Items="@activities" <table class="admin-table">
Dense="true" <thead>
Hover="true" <tr>
Striped="true" <th>ID</th>
Virtualize="true" <th>고객</th>
RowsPerPage="30" <th>활동 유형</th>
Class="admin-grid"> <th>활동일시</th>
<Columns> <th>설명</th>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" /> <th>다음 팔로업</th>
<TemplateColumn Title="고객"> <th>작업</th>
<CellTemplate> </tr>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName)) </thead>
{ <tbody>
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary"> @foreach (var item in activities)
@clientName {
</MudLink> <tr>
} <td>@item.Id</td>
</CellTemplate> <td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
</TemplateColumn> <td>@item.ActivityType</td>
<PropertyColumn Property="x => x.ActivityType" Title="활동 유형" /> <td>@item.ActivityDate.ToString("g")</td>
<PropertyColumn Property="x => x.ActivityDate" Title="활동일시" Format="g" /> <td>@Truncate(item.Description)</td>
<TemplateColumn Title="설명"> <td>@(item.NextFollowupDate?.ToString("yyyy-MM-dd") ?? "—")</td>
<CellTemplate> <td>
@{ <div class="admin-row-actions">
var desc = context.Item.Description ?? ""; <button type="button" class="admin-icon-button" @onclick="@(async () => await OpenEditDialog(item))">✎</button>
if (desc.Length > 30) desc = desc.Substring(0, 30) + "..."; <button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteActivity(item.Id))">✕</button>
} </div>
<span>@desc</span> </td>
</CellTemplate> </tr>
</TemplateColumn> }
<TemplateColumn Title="다음 팔로업"> </tbody>
<CellTemplate> </table>
@if (context.Item.NextFollowupDate.HasValue) </div>
{
var daysLeft = (context.Item.NextFollowupDate.Value.Date - DateTime.Today).Days;
<MudChip Size="Size.Small"
Color="@(daysLeft < 0 ? Color.Error : daysLeft <= 3 ? Color.Warning : Color.Success)"
Variant="Variant.Filled">
@context.Item.NextFollowupDate.Value.ToString("yyyy-MM-dd")
</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
OnClick="@(async () => await DeleteActivity(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
} }
</MudPaper> </div>
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }"> <dialog class="admin-dialog" open="@isDialogOpen">
<TitleContent> <form class="admin-dialog-card" @onsubmit="SaveActivity" @onsubmit:preventDefault="true">
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText> <h3>@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</h3>
</TitleContent> <label>고객
<DialogContent> <select class="admin-input" @bind="ClientIdText">
<MudForm @ref="form"> <option value="">선택하세요</option>
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem> <option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
} }
</MudSelect> </select>
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true"> </label>
<MudSelectItem Value="@("방문 상담")">방문 상담</MudSelectItem> <label>활동 유형
<MudSelectItem Value="@("전화 상담")">전화 상담</MudSelectItem> <select class="admin-input" @bind="activityForm.ActivityType">
<MudSelectItem Value="@("세무조사 대응 미팅")">세무조사 대응 미팅</MudSelectItem> <option value="">선택하세요</option>
<MudSelectItem Value="@("카카오톡 상담")">카카오톡 상담</MudSelectItem> <option value="방문 상담">방문 상담</option>
<MudSelectItem Value="@("이메일 자료 접수")">이메일 자료 접수</MudSelectItem> <option value="전화 상담">전화 상담</option>
<MudSelectItem Value="@("기타")">기타</MudSelectItem> <option value="세무조사 대응 미팅">세무조사 대응 미팅</option>
</MudSelect> <option value="카카오톡 상담">카카오톡 상담</option>
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <option value="이메일 자료 접수">이메일 자료 접수</option>
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" /> <option value="기타">기타</option>
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" /> </select>
</MudForm> </label>
</DialogContent> <label>활동일 <input class="admin-input" type="text" placeholder="2026-06-29 14:00" @bind="ActivityDateText" /></label>
<DialogActions> <label>설명 <textarea class="admin-input" rows="4" @bind="activityForm.Description"></textarea></label>
<MudButton OnClick="CloseDialog">취소</MudButton> <label>다음 팔로업일 <input class="admin-input" type="text" placeholder="2026-07-10" @bind="NextFollowupText" /></label>
<MudButton Color="Color.Primary" OnClick="SaveActivity">저장</MudButton> <div class="admin-dialog-actions">
</DialogActions> <button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
</MudDialog> <button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code { @code {
[CascadingParameter] [CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<ConsultingActivity>? activities; private List<ConsultingActivity>? activities;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen; private bool isDialogOpen;
private ConsultingActivity? editingActivity; private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new(); private ConsultingActivityForm activityForm = new();
private string ClientIdText { get => activityForm.ClientId > 0 ? activityForm.ClientId.ToString() : ""; set => activityForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string ActivityDateText { get => activityForm.ActivityDate?.ToString("yyyy-MM-dd HH:mm") ?? ""; set => activityForm.ActivityDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string NextFollowupText { get => activityForm.NextFollowupDate?.ToString("yyyy-MM-dd") ?? ""; set => activityForm.NextFollowupDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadData();
StateHasChanged();
}
} }
} }
} }
@@ -161,18 +133,14 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
} }
} }
private void OpenCreateDialog() private void OpenCreateDialog()
{ {
editingActivity = null; editingActivity = null;
activityForm = new ConsultingActivityForm activityForm = new ConsultingActivityForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, ActivityDate = DateTime.Now };
{
ActivityDate = DateTime.Now,
ClientId = clients.FirstOrDefault()?.Id ?? 0
};
isDialogOpen = true; isDialogOpen = true;
} }
@@ -188,103 +156,60 @@
NextFollowupDate = activity.NextFollowupDate NextFollowupDate = activity.NextFollowupDate
}; };
isDialogOpen = true; isDialogOpen = true;
await Task.CompletedTask;
} }
private async Task SaveActivity() private async Task SaveActivity()
{ {
if (form != null) if (activityForm.ClientId <= 0 || string.IsNullOrWhiteSpace(activityForm.ActivityType) || string.IsNullOrWhiteSpace(activityForm.Description))
{ {
await form.Validate(); await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
if (!form.IsValid) return;
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
} }
try try
{ {
if (editingActivity == null) if (editingActivity == null)
{ {
var actDate = activityForm.ActivityDate ?? DateTime.Now; var newId = await ActivityClient.CreateAsync(activityForm.ClientId, activityForm.ActivityType, activityForm.ActivityDate ?? DateTime.Now, activityForm.Description, null, activityForm.NextFollowupDate);
var newId = await ActivityClient.CreateAsync(
activityForm.ClientId,
activityForm.ActivityType,
actDate,
activityForm.Description,
null,
activityForm.NextFollowupDate);
if (newId > 0) if (newId > 0)
{ {
Snackbar.Add("활동이 기록되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "활동이 기록되었습니다.");
CloseDialog(); CloseDialog();
await LoadData(); await LoadData();
} }
} }
else else
{ {
await ActivityClient.UpdateAsync( await ActivityClient.UpdateAsync(editingActivity.Id, null, activityForm.NextFollowupDate);
editingActivity.Id, await JS.InvokeVoidAsync("alert", "활동이 업데이트되었습니다.");
null,
activityForm.NextFollowupDate);
Snackbar.Add("활동이 업데이트되었습니다.", Severity.Success);
CloseDialog(); CloseDialog();
await LoadData(); await LoadData();
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
} }
private async Task DeleteActivity(int id) private async Task DeleteActivity(int id)
{ {
var parameters = new DialogParameters if (!await JS.InvokeAsync<bool>("confirm", "이 활동을 삭제하시겠습니까?")) return;
{
{ "Title", "삭제 확인" },
{ "Message", "이 활동을 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
try try
{ {
await ActivityClient.DeleteAsync(id); await ActivityClient.DeleteAsync(id);
Snackbar.Add("활동이 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "활동이 삭제되었습니다.");
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
} }
} }
private void CloseDialog() private void CloseDialog() { isDialogOpen = false; editingActivity = null; activityForm = new(); }
{ private static string Truncate(string? text) => string.IsNullOrWhiteSpace(text) ? "—" : text.Length > 30 ? text[..30] + "..." : text;
isDialogOpen = false; private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
editingActivity = null; private sealed class ConsultingActivityForm { public int ClientId { get; set; } public string ActivityType { get; set; } = ""; public DateTime? ActivityDate { get; set; } = DateTime.Now; public string Description { get; set; } = ""; public DateTime? NextFollowupDate { get; set; } }
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; }
public string ActivityType { get; set; } = "";
public DateTime? ActivityDate { get; set; } = DateTime.Now;
public string Description { get; set; } = "";
public DateTime? NextFollowupDate { get; set; }
}
} }
+110 -224
View File
@@ -2,179 +2,123 @@
@using TaxBaik.Web.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@inject IContractBrowserClient ContractClient @inject IContractBrowserClient ContractClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
@inject IDialogService DialogService
@attribute [Authorize] @attribute [Authorize]
<PageTitle>계약 관리</PageTitle> <PageTitle>계약 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText> <div class="admin-eyebrow">CRM & 세무관리</div>
<MudText Typo="Typo.h4" Class="admin-page-title">계약 관리</MudText> <h1 class="admin-page-title">계약 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</MudText> <p class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</p>
@if (mrr > 0) @if (mrr > 0)
{ {
<MudText Typo="Typo.body2" Class="mt-2"> <p class="admin-page-subtitle mt-2">월 정기수익: <strong>₩@mrr.ToString("N0")</strong></p>
월 정기수익:
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
</MudText>
} }
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract"> <button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 계약 추가</button>
새 계약 추가
</MudButton>
</section> </section>
@if (contracts is null) <div class="admin-surface">
{ @if (contracts is null)
<MudProgressLinear Indeterminate="true" /> {
} <Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
else }
{ else if (contracts.Count == 0)
<MudGrid Spacing="2" Class="mt-2"> {
<!-- Left: Dense Grid List --> <div class="muted">계약이 없습니다.</div>
<MudItem XS="12" MD="8"> }
@if (contracts.Count == 0) else
{ {
<MudAlert Severity="Severity.Info"> <div class="admin-table-wrap">
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" /> <table class="admin-table">
계약이 없습니다. <thead>
</MudAlert> <tr>
} <th>ID</th>
else <th>고객</th>
{ <th>계약번호</th>
<MudDataGrid T="Contract" <th>서비스 유형</th>
Items="@contracts" <th>월 수수료</th>
Dense="true" <th>계약기간</th>
Hover="true" <th>상태</th>
Striped="true" <th>작업</th>
Virtualize="true" </tr>
RowsPerPage="30" </thead>
SelectedItem="@selectedContract" <tbody>
SelectedItemChanged="OnRowSelected" @foreach (var item in contracts)
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
<TemplateColumn Title="계약기간">
<CellTemplate>
@context.Item.StartDate.ToString("yyyy-MM-dd")
@if (context.Item.EndDate.HasValue)
{
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="상태">
<CellTemplate>
@{
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
}
@if (isActive)
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "계약 상세 정보" : "새 계약 추가")</MudText>
@if (isEditMode)
{ {
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate"> var isActive = !item.EndDate.HasValue || item.EndDate.Value >= DateTime.Today;
새로 작성 <tr>
</MudButton> <td>@item.Id</td>
<td>@(clientMap.TryGetValue(item.ClientId, out var clientName) ? clientName : "")</td>
<td>@item.ContractNumber</td>
<td>@item.ServiceType</td>
<td>@(item.MonthlyFee?.ToString("C") ?? "—")</td>
<td>@item.StartDate@if (item.EndDate.HasValue){<span>~ @item.EndDate.Value</span>}</td>
<td><span class="status-pill @(isActive ? "success" : "muted")">@(isActive ? "활성" : "만료")</span></td>
<td><button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteContract(item.Id))">✕</button></td>
</tr>
} }
</div> </tbody>
<MudForm @ref="form"> </table>
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode"> </div>
@foreach (var client in clients) }
{ </div>
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
} <dialog class="admin-dialog" open="@isDialogOpen">
</MudSelect> <form class="admin-dialog-card" @onsubmit="SaveContract" @onsubmit:preventDefault="true">
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" /> <h3>새 계약 추가</h3>
<MudSelect T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true"> <label>고객
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem> <select class="admin-input" @bind="ClientIdText">
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem> <option value="">선택하세요</option>
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem> @foreach (var client in clients)
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem> {
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem> <option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem> }
</MudSelect> </select>
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" /> </label>
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" /> <label>계약번호 <input class="admin-input" @bind="contractForm.ContractNumber" /></label>
<label>서비스 유형
<div class="d-flex justify-end gap-2"> <select class="admin-input" @bind="contractForm.ServiceType">
@if (isEditMode) <option value="개인 기장대리">개인 기장대리</option>
{ <option value="법인 기장대리">법인 기장대리</option>
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteContract(selectedContract?.Id ?? 0))">삭제</MudButton> <option value="세무조정 대행">세무조정 대행</option>
} <option value="양도세 신고대리">양도세 신고대리</option>
else <option value="상속·증여 자문">상속·증여 자문</option>
{ <option value="세무조사 대응">세무조사 대응</option>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveContract" id="btn-save-contract">저장</MudButton> </select>
} </label>
</div> <label>계약 시작일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="StartDateText" /></label>
</MudForm> <label>월 수수료 <input class="admin-input" type="text" placeholder="100000" @bind="MonthlyFeeText" /></label>
</MudPaper> <div class="admin-dialog-actions">
</MudItem> <button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
</MudGrid> <button type="submit" class="site-button primary">저장</button>
} </div>
</form>
</dialog>
@code { @code {
[CascadingParameter] [CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Contract>? contracts; private List<Contract>? contracts;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
private decimal mrr = 0; private decimal mrr = 0;
private MudForm? form; private bool isDialogOpen;
private bool isEditMode;
private Contract? selectedContract;
private ContractForm contractForm = new(); private ContractForm contractForm = new();
private string ClientIdText { get => contractForm.ClientId > 0 ? contractForm.ClientId.ToString() : ""; set => contractForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string StartDateText { get => contractForm.StartDate?.ToString("yyyy-MM-dd") ?? ""; set => contractForm.StartDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string MonthlyFeeText { get => contractForm.MonthlyFee?.ToString() ?? ""; set => contractForm.MonthlyFee = decimal.TryParse(value, out var amount) ? amount : null; }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
} }
} }
} }
@@ -191,114 +135,56 @@ else
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
} }
} }
private void PrepareCreate() private void OpenCreateDialog()
{ {
selectedContract = null; contractForm = new ContractForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, StartDate = DateTime.Today };
isEditMode = false; isDialogOpen = true;
contractForm = new ContractForm
{
ClientId = clients.FirstOrDefault()?.Id,
StartDate = DateTime.Today
};
}
private void OnRowSelected(Contract contract)
{
if (contract == null) return;
selectedContract = contract;
isEditMode = true;
contractForm = new ContractForm
{
ClientId = contract.ClientId,
ContractNumber = contract.ContractNumber,
ServiceType = contract.ServiceType,
StartDate = contract.StartDate,
MonthlyFee = contract.MonthlyFee
};
} }
private async Task SaveContract() private async Task SaveContract()
{ {
if (form != null)
{
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
}
try try
{ {
if (contractForm.ClientId == null) return; if (contractForm.ClientId <= 0)
var newId = await ContractClient.CreateAsync( {
contractForm.ClientId.Value, await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
contractForm.ContractNumber, return;
contractForm.ServiceType, }
contractForm.StartDate ?? DateTime.Now,
contractForm.MonthlyFee);
var newId = await ContractClient.CreateAsync(contractForm.ClientId, contractForm.ContractNumber, contractForm.ServiceType, contractForm.StartDate ?? DateTime.Today, contractForm.MonthlyFee);
if (newId > 0) if (newId > 0)
{ {
Snackbar.Add("계약이 추가되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "계약이 추가되었습니다.");
PrepareCreate(); CloseDialog();
await LoadData(); await LoadData();
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
} }
private async Task DeleteContract(int id) private async Task DeleteContract(int id)
{ {
var parameters = new DialogParameters if (!await JS.InvokeAsync<bool>("confirm", "이 계약을 삭제하시겠습니까?")) return;
{
{ "Title", "삭제 확인" },
{ "Message", "이 계약을 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
try try
{ {
await ContractClient.DeleteAsync(id); await ContractClient.DeleteAsync(id);
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "계약이 삭제되었습니다.");
if (selectedContract?.Id == id)
{
PrepareCreate();
}
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
} }
} }
private static string GetClientDisplayName(Client client) private void CloseDialog() { isDialogOpen = false; contractForm = new(); }
=> !string.IsNullOrWhiteSpace(client.CompanyName) private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
? client.CompanyName private sealed class ContractForm { public int ClientId { get; set; } public string ContractNumber { get; set; } = ""; public string ServiceType { get; set; } = ""; public DateTime? StartDate { get; set; } public decimal? MonthlyFee { get; set; } }
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class ContractForm
{
public int? ClientId { get; set; }
public string ContractNumber { get; set; } = "";
public string ServiceType { get; set; } = "";
public DateTime? StartDate { get; set; }
public decimal? MonthlyFee { get; set; }
}
} }
+162 -173
View File
@@ -8,216 +8,205 @@
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Overview</MudText> <div class="admin-eyebrow">Overview</div>
<MudText Typo="Typo.h4" Class="admin-page-title">대시보드</MudText> <h1 class="admin-page-title">대시보드</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</MudText> <p class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="/taxbaik/admin/blog/create"> <button type="button" class="site-button primary" @onclick='() => Nav.NavigateTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
새 포스트 작성
</MudButton>
</section> </section>
@if (!string.IsNullOrEmpty(errorMessage)) @if (summary is null)
{ {
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert> <div class="admin-metric-grid">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
<div class="admin-surface mt-4">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
<div class="admin-surface mt-4">
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
</div>
} }
@if (isLoading) else
{ {
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" /> <div class="admin-metric-grid">
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
<div class="metric-card-inner">
<span class="metric-label">이번달 문의</span>
<div class="metric-value-row">
<span class="metric-value blue">@summary.ThisMonthInquiries</span>
<span class="metric-icon">💬</span>
</div>
<span class="metric-hint">월간 상담 유입</span>
</div>
</div>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
<div class="metric-card-inner">
<span class="metric-label">신규 문의</span>
<div class="metric-value-row">
<span class="metric-value amber">@summary.NewInquiries</span>
<span class="metric-icon">⚠️</span>
</div>
<span class="metric-hint">처리 대기</span>
</div>
</div>
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="metric-card-inner">
<span class="metric-label">전체 포스트</span>
<div class="metric-value-row">
<span class="metric-value slate">@summary.TotalPosts</span>
<span class="metric-icon">📄</span>
</div>
<span class="metric-hint">콘텐츠 자산</span>
</div>
</div>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="metric-card-inner">
<span class="metric-label">발행된 포스트</span>
<div class="metric-value-row">
<span class="metric-value green">@summary.PublishedPosts</span>
<span class="metric-icon">🌐</span>
</div>
<span class="metric-hint">검색 노출 대상</span>
</div>
</div>
</div>
} }
<!-- Metrics Grid --> @if (upcomingFilings.Count == 0)
<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>
<span class="admin-metric-card-caption">월간 상담 유입 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">신규 문의</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--tertiary-dark);">@summary.NewInquiries</span>
<span class="admin-metric-card-icon" style="color: var(--tertiary-color);">⚠️</span>
</div>
<span class="admin-metric-card-caption">처리 대기 (클릭 시 이동)</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>
<span class="admin-metric-card-caption">콘텐츠 자산 (클릭 시 이동)</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>
<span class="admin-metric-card-caption">검색 노출 대상 (클릭 시 이동)</span>
</div>
</div>
</div>
@if (upcomingFilings.Count > 0)
{ {
<MudPaper Class="admin-surface mt-4" Elevation="0"> <div class="admin-surface mt-4">이번 달 마감 임박 신고가 없습니다.</div>
}
else
{
<div class="admin-surface mt-4">
<div class="admin-section-header"> <div class="admin-section-header">
<div> <div>
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText> <h3 class="admin-section-title">이번 달 마감 임박 신고</h3>
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)</MudText> <p class="muted">30일 이내 신고 예정 건</p>
</div> </div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton> <a class="site-button secondary" href="/taxbaik/admin/tax-filings">전체 일정 보기</a>
</div> </div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table"> <div class="admin-table-wrap">
<thead> <table class="admin-table">
<tr> <thead>
<th>고객</th>
<th>신고 유형</th>
<th>기한</th>
<th>D-day</th>
</tr>
</thead>
<tbody>
@foreach (var f in upcomingFilings)
{
var dday = (f.DueDate.Date - DateTime.Today).Days;
<tr> <tr>
<td> <th>고객</th>
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold"> <th>신고 유형</th>
@f.ClientName <th>기한</th>
</MudLink> <th>D-day</th>
</td>
<td>@f.FilingType</td>
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Dark">기한 초과 (@(-dday)일)</MudChip>
}
else if (dday <= 7)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
}
else
{
<span>D-@dday</span>
}
</td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</MudSimpleTable> @foreach (var f in upcomingFilings)
</MudPaper> {
var dday = (f.DueDate.Date - DateTime.Today).Days;
<tr>
<td><a href="@($"/taxbaik/admin/clients/{f.ClientId}")">@f.ClientName</a></td>
<td>@f.FilingType</td>
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<span class="status-pill dark">기한 초과 (@(-dday)일)</span>
}
else if (dday <= 7)
{
<span class="status-pill danger">D-@dday</span>
}
else
{
<span>D-@dday</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} }
<MudPaper Class="admin-surface mt-4" Elevation="0"> @if (summary is not null)
<div class="admin-section-header"> {
<div> <div class="admin-surface mt-4">
<MudText Typo="Typo.h6">최근 문의</MudText> <div class="admin-section-header">
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)</MudText> <div>
<h3 class="admin-section-title">최근 문의</h3>
<p class="muted">최근 유입된 상담 요청을 빠르게 확인합니다.</p>
</div>
<a class="site-button secondary" href="/taxbaik/admin/inquiries">문의 전체 보기</a>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>이름</th>
<th>전화</th>
<th>분야</th>
<th>상태</th>
<th>날짜</th>
</tr>
</thead>
<tbody>
@foreach (var inquiry in summary.RecentInquiries)
{
<tr>
<td><a href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">@inquiry.Name</a></td>
<td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td>
<td><span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span></td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
</tr>
}
</tbody>
</table>
</div> </div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
</div> </div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table"> }
<thead>
<tr>
<th>이름</th>
<th>전화</th>
<th>분야</th>
<th>상태</th>
<th>날짜</th>
</tr>
</thead>
<tbody>
@foreach (var inquiry in summary.RecentInquiries)
{
<tr>
<td>
<MudLink Href="@($"/taxbaik/admin/inquiries?id={inquiry.Id}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
@inquiry.Name
</MudLink>
</td>
<td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td>
<td>
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
@GetStatusLabel(inquiry.Status)
</MudChip>
</td>
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
@code { @code {
[CascadingParameter] [CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; } private Task<AuthenticationState>? AuthStateTask { get; set; }
private AdminDashboardSummary summary = new(0, 0, 0, 0, []); private AdminDashboardSummary? summary;
private List<Domain.Entities.TaxFiling> upcomingFilings = []; private List<Domain.Entities.TaxFiling> upcomingFilings = [];
private string? errorMessage;
private bool isLoading = true;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; try
if (authState.User.Identity?.IsAuthenticated == true)
{ {
try var summaryTask = DashboardClient.GetSummaryAsync();
{ var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
// API 클라이언트 사용 (서비스 직접 호출 X) await Task.WhenAll(summaryTask, filingsTask);
var summaryTask = DashboardClient.GetSummaryAsync(); summary = await summaryTask;
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30); upcomingFilings = (await filingsTask).ToList();
await Task.WhenAll(summaryTask, filingsTask);
summary = await summaryTask;
upcomingFilings = (await filingsTask).ToList();
}
catch (Exception ex)
{
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
}
finally
{
isLoading = false;
StateHasChanged();
}
} }
catch (Exception ex)
{
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
}
StateHasChanged();
} }
} }
} }
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status); private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
private static string GetStatusClass(string status) => status switch
private static Color StatusColor(string status) => status switch
{ {
"new" => Color.Warning, "new" => "warning",
"consulting" => Color.Info, "consulting" => "info",
"contracted" => Color.Success, "contracted" => "success",
"rejected" => Color.Error, "rejected" => "danger",
"closed" => Color.Dark, "closed" => "dark",
_ => Color.Default _ => "default"
}; };
} }
@@ -5,85 +5,52 @@
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@inject IFaqBrowserClient FaqClient @inject IFaqBrowserClient FaqClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle> <PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText> <div class="admin-eyebrow">홈페이지</div>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText> <h1 class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</h1>
</div> </div>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs" <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>목록으로</button>
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
</section> </section>
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;"> <div class="admin-surface" style="max-width:720px;">
@if (isLoading) @if (isLoading)
{ {
<MudProgressLinear Indeterminate="true" /> <Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
} }
else else
{ {
<MudForm @ref="form" @bind-IsValid="isValid"> <form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
<MudGrid Spacing="3"> <label>질문 * <textarea class="admin-input" rows="3" @bind="faq.Question"></textarea></label>
<MudItem xs="12"> <label>답변 * <textarea class="admin-input" rows="6" @bind="faq.Answer"></textarea></label>
<MudTextField @bind-Value="faq.Question" <label>카테고리
Label="질문 *" Required="true" <select class="admin-input" @bind="faq.Category">
RequiredError="질문을 입력하세요." <option value="">선택하세요</option>
Counter="300" MaxLength="300" @foreach (var cat in FaqService.Categories)
Lines="2" AutoGrow="true" {
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" /> <option value="@cat">@cat</option>
</MudItem> }
<MudItem xs="12"> </select>
<MudTextField @bind-Value="faq.Answer" </label>
Label="답변 *" Required="true" <label>정렬 순서 <input class="admin-input" type="number" min="0" max="9999" @bind="SortOrderText" /></label>
RequiredError="답변을 입력하세요." <label><input type="checkbox" @bind="faq.IsActive" /> 노출 중</label>
Lines="5" AutoGrow="true" <div class="admin-dialog-actions">
Placeholder="방문자에게 보여질 답변을 입력하세요." /> <button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
</MudItem> <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>취소</button>
<MudItem xs="12" md="6"> </div>
<MudSelect @bind-Value="faq.Category" Label="카테고리" T="string" Clearable="true"> </form>
@foreach (var cat in FaqService.Categories)
{
<MudSelectItem Value="@cat">@cat</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudNumericField @bind-Value="faq.SortOrder"
Label="정렬 순서"
HelperText="작을수록 위에 노출"
Min="0" Max="9999" />
</MudItem>
<MudItem xs="12" md="3" Class="d-flex align-center">
<MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
Label="@(faq.IsActive ? "노출 중" : "비활성")" />
</MudItem>
<MudItem xs="12" Class="d-flex gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="@SaveAsync" Disabled="@isSaving">
@(isSaving ? "저장 중..." : "저장")
</MudButton>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs">
취소
</MudButton>
</MudItem>
</MudGrid>
</MudForm>
} }
</MudPaper> </div>
@code { @code {
[Parameter] public int? Id { get; set; } [Parameter] public int? Id { get; set; }
private MudForm form = null!;
private Faq faq = new() { SortOrder = 10, IsActive = true }; private Faq faq = new() { SortOrder = 10, IsActive = true };
private bool isValid;
private bool isLoading = true; private bool isLoading = true;
private bool isSaving; private bool isSaving;
private string SortOrderText { get => faq.SortOrder.ToString(); set => faq.SortOrder = int.TryParse(value, out var n) ? n : 0; }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -94,7 +61,7 @@
var existing = await FaqClient.GetByIdAsync(Id.Value); var existing = await FaqClient.GetByIdAsync(Id.Value);
if (existing is null) if (existing is null)
{ {
Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "FAQ를 찾을 수 없습니다.");
Navigation.NavigateTo("/taxbaik/admin/faqs"); Navigation.NavigateTo("/taxbaik/admin/faqs");
return; return;
} }
@@ -102,7 +69,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
Navigation.NavigateTo("/taxbaik/admin/faqs"); Navigation.NavigateTo("/taxbaik/admin/faqs");
return; return;
} }
@@ -112,33 +79,30 @@
private async Task SaveAsync() private async Task SaveAsync()
{ {
await form.Validate();
if (!isValid) return;
isSaving = true; isSaving = true;
try try
{ {
if (string.IsNullOrWhiteSpace(faq.Question) || string.IsNullOrWhiteSpace(faq.Answer))
{
await JS.InvokeVoidAsync("alert", "질문과 답변을 입력하세요.");
return;
}
if (Id.HasValue) if (Id.HasValue)
{ {
var result = await FaqClient.UpdateAsync(Id.Value, faq); var result = await FaqClient.UpdateAsync(Id.Value, faq);
if (result != null) await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 수정되었습니다." : "수정 실패");
Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success);
else
Snackbar.Add("수정 실패", Severity.Error);
} }
else else
{ {
var result = await FaqClient.CreateAsync(faq); var result = await FaqClient.CreateAsync(faq);
if (result != null) await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 등록되었습니다." : "등록 실패");
Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success);
else
Snackbar.Add("등록 실패", Severity.Error);
} }
Navigation.NavigateTo("/taxbaik/admin/faqs"); Navigation.NavigateTo("/taxbaik/admin/faqs");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
finally finally
{ {
@@ -4,130 +4,79 @@
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@inject IFaqBrowserClient FaqClient @inject IFaqBrowserClient FaqClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IDialogService DialogService @inject IJSRuntime JS
@inject ISnackbar Snackbar
<PageTitle>FAQ 관리</PageTitle> <PageTitle>FAQ 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText> <div class="admin-eyebrow">홈페이지</div>
<MudText Typo="Typo.h4" Class="admin-page-title">FAQ 관리</MudText> <h1 class="admin-page-title">FAQ 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</MudText> <p class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" <a class="site-button primary" href="/taxbaik/admin/faqs/create">FAQ 등록</a>
StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/faqs/create">
FAQ 등록
</MudButton>
</section> </section>
<div class="d-flex pa-4 gap-4 align-center"> <div class="admin-surface">
<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) @if (faqs is null)
{ {
<MudProgressLinear Indeterminate="true" /> <Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
} }
else if (!FilteredFaqs.Any()) else if (!faqs.Any())
{ {
<div class="pa-6 text-center"> <div class="muted">등록된 FAQ가 없습니다.</div>
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">검색 조건에 맞는 FAQ가 없습니다.</MudText>
</div>
} }
else else
{ {
<MudSimpleTable Striped="true" Dense="true" Class="admin-table"> <div class="admin-table-wrap">
<thead> <table class="admin-table">
<tr> <thead>
<th style="width:110px;">순서</th>
<th>질문</th>
<th style="width:130px;">카테고리</th>
<th style="width:90px;">상태</th>
<th style="width:160px;"></th>
</tr>
</thead>
<tbody>
@foreach (var item in FilteredFaqs)
{
<tr> <tr>
<td> <th>순서</th>
<div class="d-flex align-center justify-start gap-1"> <th>질문</th>
<MudText Typo="Typo.body2" Class="mr-2">@item.SortOrder</MudText> <th>카테고리</th>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropUp" Size="Size.Small" OnClick="@(() => MoveUpAsync(item))" Style="padding:2px;" Dense="true" /> <th>상태</th>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropDown" Size="Size.Small" OnClick="@(() => MoveDownAsync(item))" Style="padding:2px;" Dense="true" /> <th></th>
</div>
</td>
<td>
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
@item.Question
</MudText>
</td>
<td>
@if (!string.IsNullOrEmpty(item.Category))
{
<MudChip Size="Size.Small" Color="Color.Default">@item.Category</MudChip>
}
</td>
<td>
@if (item.IsActive)
{
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
}
</td>
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
삭제
</MudButton>
</MudButtonGroup>
</td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</MudSimpleTable> @foreach (var item in faqs)
<MudText Typo="Typo.caption" Class="pa-2 text-muted"> {
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개 <tr>
</MudText> <td>@item.SortOrder</td>
<td>@item.Question</td>
<td>@(string.IsNullOrEmpty(item.Category) ? "" : item.Category)</td>
<td><span class="status-pill @(item.IsActive ? "success" : "default")">@(item.IsActive ? "노출 중" : "비활성")</span></td>
<td>
<div class="admin-actions">
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="muted mt-2">총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개</div>
} }
</MudPaper> </div>
@code { @code {
[CascadingParameter] [CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; } private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Faq>? faqs; 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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadAsync();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadAsync();
StateHasChanged();
}
} }
} }
} }
@@ -136,100 +85,36 @@
{ {
try try
{ {
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList(); faqs = (await FaqClient.GetAllAsync()).ToList();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
faqs = []; faqs = [];
} }
} }
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) private async Task DeleteAsync(Faq item)
{ {
var confirmed = await DialogService.ShowMessageBox( var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Question}' 항목을 삭제하시겠습니까?");
"FAQ 삭제", if (!confirmed) return;
$"'{item.Question}' 항목을 삭제하시겠습니까?",
yesText: "삭제", cancelText: "취소");
if (confirmed != true) return;
try try
{ {
var success = await FaqClient.DeleteAsync(item.Id); var success = await FaqClient.DeleteAsync(item.Id);
if (success) if (success)
{ {
Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "FAQ가 삭제되었습니다.");
await LoadAsync(); await LoadAsync();
} }
else else
{ {
Snackbar.Add("삭제 실패", Severity.Error); await JS.InvokeVoidAsync("alert", "삭제 실패");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
} }
} }
@@ -5,51 +5,41 @@
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService @inject InquiryService InquiryService
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>문의 등록</PageTitle> <PageTitle>문의 등록</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText> <div class="admin-eyebrow">Customer Relations</div>
<MudText Typo="Typo.h4" Class="admin-page-title">새 문의 등록</MudText> <h1 class="admin-page-title">새 문의 등록</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</MudText> <p class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</p>
</div> </div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton> <button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section> </section>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <div class="admin-surface mt-4">
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" /> <InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
</MudPaper> </div>
@code { @code {
private void GoBack() private void GoBack() => Navigation.NavigateTo("/taxbaik/admin/inquiries");
{
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
private async Task HandleCreate(InquiryForm.InquiryFormModel model) private async Task HandleCreate(InquiryForm.InquiryFormModel model)
{ {
try try
{ {
await InquiryService.SubmitAsync( await InquiryService.SubmitAsync(model.Name, model.Phone, model.ServiceType, model.Message, model.Email, ipAddress: "admin-registered");
model.Name, await JS.InvokeVoidAsync("alert", "문의가 등록되었습니다.");
model.Phone,
model.ServiceType,
model.Message,
model.Email,
ipAddress: "admin-registered");
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries"); Navigation.NavigateTo("/taxbaik/admin/inquiries");
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
Snackbar.Add(ex.Message, Severity.Error); await JS.InvokeVoidAsync("alert", ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
} }
} }
} }
@@ -3,113 +3,75 @@
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@inject IInquiryBrowserClient InquiryClient @inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>문의 상세</PageTitle> <PageTitle>문의 상세</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Inquiry Details</MudText> <div class="admin-eyebrow">Inquiry Details</div>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 상세</MudText> <h1 class="admin-page-title">문의 상세</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</MudText> <p class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</p>
</div> </div>
</section> </section>
@if (inquiry != null) @if (inquiry != null)
{ {
<MudButton Variant="Variant.Outlined" <div class="admin-page-actions">
Color="Color.Primary" <button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries")'>문의 목록으로</button>
StartIcon="@Icons.Material.Filled.ArrowBack" </div>
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
문의 목록으로
</MudButton>
<MudGrid Class="mt-4"> <div class="admin-detail-grid">
<MudItem xs="12" md="8"> <section class="admin-surface">
<MudPaper Class="pa-4" Elevation="1"> <h3 class="admin-section-title">문의 정보</h3>
<MudText Typo="Typo.h6" Class="mb-3">문의 정보</MudText> <div class="admin-kv-grid">
<MudGrid> <div><span>이름</span><strong>@inquiry.Name</strong></div>
<MudItem xs="12" sm="6"> <div><span>연락처</span><strong>@inquiry.Phone</strong></div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText> <div><span>이메일</span><strong>@(inquiry.Email ?? "-")</strong></div>
<MudText>@inquiry.Name</MudText> <div><span>분야</span><strong>@inquiry.ServiceType</strong></div>
</MudItem> <div class="span-2"><span>문의 내용</span><strong style="white-space: pre-wrap;">@inquiry.Message</strong></div>
<MudItem xs="12" sm="6"> <div><span>접수일시</span><strong>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</strong></div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText> </div>
<MudText>@inquiry.Phone</MudText> </section>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
<MudText>@(inquiry.Email ?? "-")</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">분야</MudText>
<MudText>@inquiry.ServiceType</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">문의 내용</MudText>
<MudPaper Class="pa-3 mt-1" Outlined="true">
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">접수일시</MudText>
<MudText>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</MudText>
</MudItem>
</MudGrid>
</MudPaper>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <section class="admin-surface">
<MudText Typo="Typo.h6" Class="mb-3">담당자 메모</MudText> <h3 class="admin-section-title">담당자 메모</h3>
<MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)" <textarea class="admin-input" rows="6" @bind="adminMemo"></textarea>
Lines="4" Variant="Variant.Outlined" /> <div class="admin-dialog-actions mt-3">
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary" <button type="button" class="site-button primary" @onclick="SaveMemo">메모 저장</button>
OnClick="SaveMemo">메모 저장</MudButton> </div>
</MudPaper> </section>
</MudItem>
<MudItem xs="12" md="4"> <section class="admin-surface">
<MudPaper Class="pa-4" Elevation="1"> <h3 class="admin-section-title">처리 상태</h3>
<MudText Typo="Typo.h6" Class="mb-3">처리 상태</MudText> <div class="admin-stack">
<MudStack Spacing="2"> @foreach (var (key, label) in InquiryStatusMapper.Labels)
@foreach (var (key, label) in InquiryStatusMapper.Labels) {
{ <button type="button" class="@GetStatusButtonClass(key)" @onclick="@(() => OnStatusChanged(key))">@label</button>
<MudButton Variant="@(inquiry.Status == key ? Variant.Filled : Variant.Outlined)" }
Color="@StatusColor(key)" </div>
FullWidth="true" </section>
OnClick="@(() => OnStatusChanged(key))">
@label
</MudButton>
}
</MudStack>
</MudPaper>
@if (inquiry.ClientId == null) @if (inquiry.ClientId == null)
{ {
<MudPaper Class="pa-4 mt-4" Elevation="1"> <section class="admin-surface">
<MudText Typo="Typo.h6" Class="mb-3">고객 카드 생성</MudText> <h3 class="admin-section-title">고객 카드 생성</h3>
<MudText Typo="Typo.body2" Class="mb-3">이 문의를 고객 카드로 등록합니다.</MudText> <p class="muted">이 문의를 고객 카드로 등록합니다.</p>
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true" <button type="button" class="site-button primary" @onclick="ConvertToClient">고객으로 등록</button>
OnClick="ConvertToClient"> </section>
고객으로 등록 }
</MudButton> else
</MudPaper> {
} <section class="admin-surface">
else <h3 class="admin-section-title">연결된 고객</h3>
{ <a class="site-button secondary" href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">고객 카드 보기</a>
<MudPaper Class="pa-4 mt-4" Elevation="1"> </section>
<MudText Typo="Typo.h6" Class="mb-3">연결된 고객</MudText> }
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true" </div>
Href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">
고객 카드 보기
</MudButton>
</MudPaper>
}
</MudItem>
</MudGrid>
} }
else else
{ {
<MudText>문의를 찾을 수 없습니다.</MudText> <div class="admin-surface">문의를 찾을 수 없습니다.</div>
} }
@code { @code {
@@ -134,16 +96,16 @@ else
if (success) if (success)
{ {
inquiry.Status = status; inquiry.Status = status;
Snackbar.Add("상태가 변경되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "상태가 변경되었습니다.");
} }
else else
{ {
Snackbar.Add("상태 변경에 실패했습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "상태 변경에 실패했습니다.");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
} }
@@ -156,16 +118,16 @@ else
if (success) if (success)
{ {
inquiry.AdminMemo = adminMemo; inquiry.AdminMemo = adminMemo;
Snackbar.Add("메모가 저장되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "메모가 저장되었습니다.");
} }
else else
{ {
Snackbar.Add("메모 저장에 실패했습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "메모 저장에 실패했습니다.");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
} }
@@ -184,26 +146,19 @@ else
{ {
inquiry.ClientId = clientId; inquiry.ClientId = clientId;
inquiry.Status = "consulting"; inquiry.Status = "consulting";
Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "고객 카드가 생성되었습니다.");
} }
else else
{ {
Snackbar.Add("고객 카드 생성에 실패했습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "고객 카드 생성에 실패했습니다.");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
} }
private Color StatusColor(string status) => status switch private string GetStatusButtonClass(string status)
{ => inquiry?.Status == status ? "site-button primary" : "site-button secondary";
"new" => Color.Default,
"consulting" => Color.Info,
"contracted" => Color.Success,
"rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
};
} }
@@ -5,45 +5,39 @@
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService @inject InquiryService InquiryService
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject IJSRuntime JS
@inject IDialogService DialogService
<PageTitle>문의 수정</PageTitle> <PageTitle>문의 수정</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText> <div class="admin-eyebrow">Customer Relations</div>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 수정</MudText> <h1 class="admin-page-title">문의 수정</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의 정보를 수정합니다.</MudText> <p class="admin-page-subtitle">고객 문의 정보를 수정합니다.</p>
</div> </div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton> <button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
</section> </section>
@if (isLoading) @if (isLoading)
{ {
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" /> <div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
} }
else if (inquiry == null) else if (inquiry == null)
{ {
<MudAlert Severity="Severity.Error" Class="mt-4">문의를 찾을 수 없습니다.</MudAlert> <div class="admin-surface mt-4">문의를 찾을 수 없습니다.</div>
} }
else else
{ {
<MudPaper Class="pa-4 mt-4" Elevation="1"> <div class="admin-surface mt-4">
<InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" /> <InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
<div class="mt-4">
<MudDivider Class="my-4" /> <button type="button" class="site-button secondary danger" @onclick="DeleteInquiry">문의 삭제</button>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteInquiry" Class="mt-2"> </div>
문의 삭제
</MudButton>
</MudPaper>
} }
@code { @code {
[Parameter] [Parameter] public int Id { get; set; }
public int Id { get; set; }
private Domain.Entities.Inquiry? inquiry; private Domain.Entities.Inquiry? inquiry;
private InquiryForm.InquiryFormModel? formModel; private InquiryForm.InquiryFormModel? formModel;
private bool isLoading = true; private bool isLoading = true;
@@ -69,7 +63,7 @@ else
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"문의 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"문의 로드 실패: {ex.Message}");
} }
finally finally
{ {
@@ -77,16 +71,11 @@ else
} }
} }
private void GoBack() private void GoBack() => Navigation.NavigateTo("/taxbaik/admin/inquiries");
{
Navigation.NavigateTo("/taxbaik/admin/inquiries");
}
private async Task HandleUpdate(InquiryForm.InquiryFormModel model) private async Task HandleUpdate(InquiryForm.InquiryFormModel model)
{ {
if (inquiry == null) if (inquiry == null) return;
return;
try try
{ {
inquiry.Name = model.Name; inquiry.Name = model.Name;
@@ -97,47 +86,35 @@ else
inquiry.AdminMemo = model.AdminMemo; inquiry.AdminMemo = model.AdminMemo;
if (inquiry.Status != model.Status) if (inquiry.Status != model.Status)
{
await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status); await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status);
}
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo); await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo);
await JS.InvokeVoidAsync("alert", "문의가 수정되었습니다.");
Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries"); Navigation.NavigateTo("/taxbaik/admin/inquiries");
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
Snackbar.Add(ex.Message, Severity.Error); await JS.InvokeVoidAsync("alert", ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
} }
} }
private async Task DeleteInquiry() private async Task DeleteInquiry()
{ {
if (inquiry == null) if (inquiry == null) return;
return; if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
var result = await DialogService.ShowMessageBox(
"문의 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
return;
try try
{ {
await InquiryService.DeleteAsync(inquiry.Id); await InquiryService.DeleteAsync(inquiry.Id);
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "문의가 삭제되었습니다.");
Navigation.NavigateTo("/taxbaik/admin/inquiries"); Navigation.NavigateTo("/taxbaik/admin/inquiries");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
} }
} }
} }
@@ -7,47 +7,36 @@
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Requests</MudText> <div class="admin-eyebrow">Customer Requests</div>
<MudText Typo="Typo.h4" Class="admin-page-title">문의 관리</MudText> <h1 class="admin-page-title">문의 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</MudText> <p class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" <button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries/create")'>새 문의 등록</button>
Href="/taxbaik/admin/inquiries/create">새 문의 등록</MudButton>
</section> </section>
<MudPaper Class="admin-surface" Elevation="0"> <div class="admin-surface">
@if (isLoading) @if (isLoading)
{ {
<MudProgressCircular Indeterminate="true" Class="ma-4" /> <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
} }
else else
{ {
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs"> <div class="admin-tabbar">
<MudTabPanel Text="전체"> <button type="button" class="admin-tab active">전체</button>
<InquiryTable Inquiries="allInquiries" Status="" /> <button type="button" class="admin-tab">신규</button>
</MudTabPanel> <button type="button" class="admin-tab">상담중</button>
<MudTabPanel Text="신규"> <button type="button" class="admin-tab">계약완료</button>
<InquiryTable Inquiries="allInquiries" Status="new" /> <button type="button" class="admin-tab">거절</button>
</MudTabPanel> <button type="button" class="admin-tab">종결</button>
<MudTabPanel Text="상담중"> </div>
<InquiryTable Inquiries="allInquiries" Status="consulting" /> <InquiryTable Inquiries="allInquiries" Status="" />
</MudTabPanel> }
<MudTabPanel Text="계약완료"> </div>
<InquiryTable Inquiries="allInquiries" Status="contracted" />
</MudTabPanel>
<MudTabPanel Text="거절">
<InquiryTable Inquiries="allInquiries" Status="rejected" />
</MudTabPanel>
<MudTabPanel Text="종결">
<InquiryTable Inquiries="allInquiries" Status="closed" />
</MudTabPanel>
</MudTabs>
}
</MudPaper>
@code { @code {
[CascadingParameter] [CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; } private Task<AuthenticationState>? AuthStateTask { get; set; }
[Inject] private NavigationManager Navigation { get; set; } = default!;
private bool isLoading = true; private bool isLoading = true;
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = []; private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
+114 -33
View File
@@ -1,50 +1,65 @@
@page "/admin/login" @page "/admin/login"
@using Microsoft.FluentUI.AspNetCore.Components
@using System.ComponentModel.DataAnnotations
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout @layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@rendermode @(new InteractiveServerRenderMode(prerender: true))
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject ILocalStorageService LocalStorageService @inject NavigationManager NavigationManager
@inject CustomAuthenticationStateProvider AuthStateProvider
@inject IJSRuntime Js @inject IJSRuntime Js
@inject ILocalStorageService LocalStorageService
<PageTitle>로그인</PageTitle> <PageTitle>로그인</PageTitle>
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;"> <div class="admin-login-page">
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;"> <div class="admin-login-card admin-surface">
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText> <div class="admin-login-brand">
<span class="admin-brand-mark">T</span>
<form id="admin-login-form"> <div>
<input class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4" <div class="admin-brand-title">TaxBaik</div>
style="width: 100%; min-height: 56px; padding: 16px 14px;" <div class="admin-brand-subtitle">관리자 로그인</div>
placeholder="사용자명"
autocomplete="username"
name="username"
value="@model.Username" />
<input type="password"
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
style="width: 100%; min-height: 56px; padding: 16px 14px;"
placeholder="비밀번호"
autocomplete="current-password"
name="password" />
<div class="mb-4">
<input class="mud-checkbox" type="checkbox" name="rememberMe" />
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
</div> </div>
</div>
<div class="mud-alert mud-alert-filled-error mb-4 login-error-message" style="display:none;">로그인 중 오류가 발생했습니다.</div> <form class="admin-login-form" @onsubmit="HandleLogin" @onsubmit:preventDefault>
<label class="admin-field">
<span class="admin-field-label">사용자명</span>
<input class="admin-input" type="text" placeholder="사용자명" @bind="model.Username" autocomplete="username" />
</label>
<button type="submit" <label class="admin-field">
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0" <span class="admin-field-label">비밀번호</span>
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"> <input class="admin-input" type="password" placeholder="비밀번호" @bind="model.Password" autocomplete="current-password" />
<span>로그인</span> </label>
<label class="admin-login-remember">
<input type="checkbox" @bind="model.RememberMe" />
<span>아이디 저장</span>
</label>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="admin-inline-alert error" role="alert">@errorMessage</div>
}
<button type="submit" class="site-button primary admin-login-submit" disabled="@isLoading">
@if (isLoading)
{
<span>로그인 중...</span>
}
else
{
<span>로그인</span>
}
</button> </button>
</form> </form>
</MudPaper> </div>
</MudContainer> </div>
@code { @code {
private readonly LoginModel model = new(); private bool isLoading = false;
private string errorMessage = "";
private LoginModel model = new();
private const string RememberedUsernameKey = "admin-remembered-username"; private const string RememberedUsernameKey = "admin-remembered-username";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -55,11 +70,12 @@
if (!string.IsNullOrEmpty(remembered)) if (!string.IsNullOrEmpty(remembered))
{ {
model.Username = remembered; model.Username = remembered;
model.RememberMe = true;
} }
} }
catch catch
{ {
// LocalStorage may be unavailable during prerender. // LocalStorage not available in pre-render
} }
} }
@@ -69,10 +85,75 @@
await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass"); 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 private class LoginModel
{ {
public string Username { get; set; } = ""; public string Username { get; set; } = "";
public string Password { get; set; } = ""; public string Password { get; set; } = "";
public bool RememberMe { 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('/')}";
}
} }
@@ -2,145 +2,125 @@
@using TaxBaik.Web.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@inject IRevenueTrackingBrowserClient RevenueClient @inject IRevenueTrackingBrowserClient RevenueClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
@inject IDialogService DialogService
@attribute [Authorize] @attribute [Authorize]
<PageTitle>수익 추적 관리</PageTitle> <PageTitle>수익 추적 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText> <div class="admin-eyebrow">CRM & 세무관리</div>
<MudText Typo="Typo.h4" Class="admin-page-title">수익 추적 관리</MudText> <h1 class="admin-page-title">수익 추적 관리</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</MudText> <p class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add"> <button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 청구 추가</button>
새 청구 추가
</MudButton>
</section> </section>
<MudPaper Class="admin-surface" Elevation="0"> <div class="admin-surface">
@if (revenues is null) @if (revenues is null)
{ {
<MudProgressLinear Indeterminate="true" /> <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
} }
else if (revenues.Count == 0) else if (revenues.Count == 0)
{ {
<MudAlert Severity="Severity.Info" Class="mt-4"> <div class="muted">청구 기록이 없습니다.</div>
<MudIcon Icon="@Icons.Material.Filled.Payments" Class="me-2" />
청구 기록이 없습니다.
</MudAlert>
} }
else else
{ {
<MudDataGrid T="RevenueTracking" <div class="admin-table-wrap">
Items="@revenues" <table class="admin-table">
Dense="true" <thead>
Hover="true" <tr>
Striped="true" <th>ID</th>
Virtualize="true" <th>고객</th>
RowsPerPage="30" <th>청구번호</th>
Class="admin-grid"> <th>청구일</th>
<Columns> <th>청구액</th>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" /> <th>납부여부</th>
<TemplateColumn Title="고객"> <th>작업</th>
<CellTemplate> </tr>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName)) </thead>
{ <tbody>
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary"> @foreach (var item in revenues)
@clientName {
</MudLink> <tr>
} <td>@item.Id</td>
</CellTemplate> <td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
</TemplateColumn> <td>@item.InvoiceNumber</td>
<PropertyColumn Property="x => x.InvoiceNumber" Title="청구번호" /> <td>@item.InvoiceDate.ToString("yyyy-MM-dd")</td>
<PropertyColumn Property="x => x.InvoiceDate" Title="청구일" Format="yyyy-MM-dd" /> <td>@item.Amount.ToString("C")</td>
<PropertyColumn Property="x => x.Amount" Title="청구액" Format="C" /> <td><span class="status-pill @(item.PaymentStatus == "paid" ? "success" : "warning")">@(item.PaymentStatus == "paid" ? "납부" : "미납")</span></td>
<TemplateColumn Title="납부여부"> <td>
<CellTemplate> <div class="admin-row-actions">
@if (context.Item.PaymentStatus == "paid") @if (item.PaymentStatus != "paid")
{ {
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">납부</MudChip> <button type="button" class="site-button secondary" @onclick="@(async () => await MarkPaid(item.Id))">완료</button>
} }
else <button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteRevenue(item.Id))">✕</button>
{ </div>
<MudChip Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">미납</MudChip> </td>
} </tr>
</CellTemplate> }
</TemplateColumn> </tbody>
<TemplateColumn Title="작업" Sortable="false"> </table>
<CellTemplate> </div>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.PaymentStatus != "paid")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success"
OnClick="@(async () => await MarkPaid(context.Item.Id))" Title="납부 처리" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
OnClick="@(async () => await DeleteRevenue(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
} }
</MudPaper> </div>
<!-- Create Dialog --> <dialog class="admin-dialog" open="@isDialogOpen">
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }"> <form class="admin-dialog-card" @onsubmit="SaveRevenue" @onsubmit:preventDefault="true">
<TitleContent> <h3>새 청구 추가</h3>
<MudText Typo="Typo.h6">새 청구 추가</MudText> <label>고객
</TitleContent> <select class="admin-input" @bind="ClientIdText">
<DialogContent> <option value="">선택하세요</option>
<MudForm @ref="form">
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
@foreach (var client in clients) @foreach (var client in clients)
{ {
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem> <option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
} }
</MudSelect> </select>
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> </label>
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <label>청구번호 <input class="admin-input" @bind="revenueForm.InvoiceNumber" /></label>
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <label>청구일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="InvoiceDateText" /></label>
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4"> <label>청구액 <input class="admin-input" type="text" placeholder="100000" @bind="AmountText" /></label>
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem> <label>서비스 유형
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem> <select class="admin-input" @bind="revenueForm.ServiceType">
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem> <option value="">선택하세요</option>
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem> <option value="기장 수수료">기장 수수료</option>
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem> <option value="세무조정료">세무조정료</option>
</MudSelect> <option value="세무상담료">세무상담료</option>
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" /> <option value="신고 대행료">신고 대행료</option>
</MudForm> <option value="자문 수수료">자문 수수료</option>
</DialogContent> </select>
<DialogActions> </label>
<MudButton OnClick="CloseDialog">취소</MudButton> <label>납부예정일 <input class="admin-input" type="text" placeholder="2026-07-13" @bind="DueDateText" /></label>
<MudButton Color="Color.Primary" OnClick="SaveRevenue">저장</MudButton> <div class="admin-dialog-actions">
</DialogActions> <button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
</MudDialog> <button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code { @code {
[CascadingParameter] [CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<RevenueTracking>? revenues; private List<RevenueTracking>? revenues;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isDialogOpen; private bool isDialogOpen;
private RevenueForm revenueForm = new(); private RevenueForm revenueForm = new();
private string ClientIdText { get => revenueForm.ClientId > 0 ? revenueForm.ClientId.ToString() : ""; set => revenueForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string InvoiceDateText { get => revenueForm.InvoiceDate?.ToString("yyyy-MM-dd") ?? ""; set => revenueForm.InvoiceDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string AmountText { get => revenueForm.Amount?.ToString() ?? ""; set => revenueForm.Amount = decimal.TryParse(value, out var amt) ? amt : null; }
private string DueDateText { get => revenueForm.DueDate?.ToString("yyyy-MM-dd") ?? ""; set => revenueForm.DueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadData();
StateHasChanged();
}
} }
} }
} }
@@ -156,53 +136,36 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
} }
} }
private void OpenCreateDialog() private void OpenCreateDialog()
{ {
revenueForm = new RevenueForm revenueForm = new RevenueForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, InvoiceDate = DateTime.Today, DueDate = DateTime.Today.AddDays(14) };
{
ClientId = clients.FirstOrDefault()?.Id ?? 0,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(14)
};
isDialogOpen = true; isDialogOpen = true;
} }
private async Task SaveRevenue() private async Task SaveRevenue()
{ {
if (form != null) if (revenueForm.ClientId <= 0 || string.IsNullOrWhiteSpace(revenueForm.InvoiceNumber) || revenueForm.Amount is null)
{ {
await form.Validate(); await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
if (!form.IsValid) return;
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
} }
try try
{ {
var newId = await RevenueClient.CreateAsync( var newId = await RevenueClient.CreateAsync(revenueForm.ClientId, revenueForm.InvoiceNumber, revenueForm.InvoiceDate ?? DateTime.Today, revenueForm.Amount.Value, revenueForm.ServiceType, revenueForm.DueDate);
revenueForm.ClientId,
revenueForm.InvoiceNumber,
revenueForm.InvoiceDate ?? DateTime.Now,
revenueForm.Amount,
revenueForm.ServiceType,
revenueForm.DueDate);
if (newId > 0) if (newId > 0)
{ {
Snackbar.Add("청구가 추가되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "청구가 추가되었습니다.");
CloseDialog(); CloseDialog();
await LoadData(); await LoadData();
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
} }
@@ -211,60 +174,31 @@
try try
{ {
await RevenueClient.MarkPaidAsync(id, DateTime.Now); await RevenueClient.MarkPaidAsync(id, DateTime.Now);
Snackbar.Add("납부가 처리되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "납부가 처리되었습니다.");
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
} }
} }
private async Task DeleteRevenue(int id) private async Task DeleteRevenue(int id)
{ {
var parameters = new DialogParameters if (!await JS.InvokeAsync<bool>("confirm", "이 청구를 삭제하시겠습니까?")) return;
{
{ "Title", "삭제 확인" },
{ "Message", "이 청구를 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
try try
{ {
await RevenueClient.DeleteAsync(id); await RevenueClient.DeleteAsync(id);
Snackbar.Add("청구가 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "청구가 삭제되었습니다.");
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
} }
} }
private void CloseDialog() private void CloseDialog() { isDialogOpen = false; revenueForm = new(); }
{ private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
isDialogOpen = false; private sealed class RevenueForm { public int ClientId { get; set; } public string InvoiceNumber { get; set; } = ""; public DateTime? InvoiceDate { get; set; } public decimal? Amount { get; set; } public string? ServiceType { get; set; } public DateTime? DueDate { get; set; } }
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; }
public string InvoiceNumber { get; set; } = "";
public DateTime? InvoiceDate { get; set; }
public decimal Amount { get; set; }
public string? ServiceType { get; set; }
public DateTime? DueDate { get; set; }
}
} }
@@ -7,162 +7,92 @@
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Season Preview</MudText> <div class="admin-eyebrow">Season Preview</div>
<MudText Typo="Typo.h4" Class="admin-page-title">시즌 시뮬레이터</MudText> <h1 class="admin-page-title">시즌 시뮬레이터</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</MudText> <p class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</p>
</div> </div>
</section> </section>
<MudGrid> <div class="admin-detail-grid">
<MudItem xs="12" md="4"> <section class="admin-surface">
<MudPaper Class="pa-4" Elevation="1"> <h3 class="admin-section-title">시뮬레이션 날짜</h3>
<MudText Typo="Typo.h6" Class="mb-3">시뮬레이션 날짜</MudText> <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="SimulationDateText" />
<MudDatePicker @bind-Date="simulationDate" Label="날짜 선택" DateFormat="yyyy-MM-dd" PickerVariant="PickerVariant.Static" /> <div class="admin-divider"></div>
<MudDivider Class="my-3" /> <div class="admin-stack">
<MudText Typo="Typo.subtitle2" Class="mb-2">연간 세무 캘린더</MudText>
@foreach (var season in TaxSeasonCalendar.Seasons) @foreach (var season in TaxSeasonCalendar.Seasons)
{ {
<MudButton Variant="Variant.Outlined" Size="Size.Small" FullWidth="true" <button type="button" class="site-button secondary" @onclick="@(() => JumpToSeason(season))">@season.StartMonth/@season.StartDay - @season.Name</button>
Class="mb-1" Color="Color.Primary"
OnClick="@(() => JumpToSeason(season))">
@season.StartMonth/@season.StartDay — @season.Name
</MudButton>
} }
</MudPaper> </div>
</MudItem> </section>
<MudItem xs="12" md="8"> <section class="admin-surface">
<MudPaper Class="pa-4" Elevation="1"> <h3 class="admin-section-title">홈페이지 미리보기</h3>
<MudText Typo="Typo.h6" Class="mb-1"> <p class="muted">@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요")</p>
@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요") 홈페이지 미리보기 @if (activeSeason != null)
</MudText> {
@if (activeSeason != null) <span class="status-pill warning">@activeSeason.Name 시즌 활성</span>
{ <div class="season-preview">
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Class="mb-3"> @if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0)
@activeSeason.Name 시즌 활성 {
</MudChip> <div class="season-badge">D-@activeSeason.DaysUntilDeadline 마감 임박</div>
<MudDivider Class="mb-4" /> }
<!-- Hero 섹션 미리보기 --> <div class="season-headline">@activeSeason.HeroHeadline</div>
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 1.5rem;"> <div class="season-subtext">@activeSeason.HeroSubtext</div>
@if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0) <div class="season-cta">@activeSeason.CtaText</div>
{ </div>
<div style="background: #f59e0b; color: #1a202c; display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; margin-bottom: 1rem;"> <div class="admin-kv-grid mt-4">
D-@activeSeason.DaysUntilDeadline 마감 임박 <div><span>활성 시즌 키</span><strong><code>@activeSeason.Key</code></strong></div>
</div> <div><span>마감까지</span><strong>@(activeSeason.DaysUntilDeadline >= 0 ? $"D-{activeSeason.DaysUntilDeadline}" : $"마감 후 @(-activeSeason.DaysUntilDeadline)일")</strong></div>
} <div><span>포커스 서비스</span><strong>@activeSeason.FocusService</strong></div>
<div style="font-size: 1.8rem; font-weight: 800; white-space: pre-line; margin-bottom: 0.5rem; line-height: 1.3;"> <div><span>블로그 카테고리</span><strong>@activeSeason.RelatedCategorySlug</strong></div>
@activeSeason.HeroHeadline <div class="span-2"><span>긴박감 배지 문구</span><strong><code>@activeSeason.UrgencyBadge</code></strong></div>
</div> </div>
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;"> }
@activeSeason.HeroSubtext else
</div> {
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;"> <div class="muted">선택한 날짜는 시즌 비활성 기간입니다. 홈페이지는 기본 Hero를 표시합니다.</div>
<div style="background: #e53e3e; color: white; padding: 10px 20px; border-radius: 8px; font-weight: 700; font-size: 0.95rem;"> <div class="season-preview mt-4">
@activeSeason.CtaText <div class="season-headline">사업자 세금, 부동산,<br />가족자산까지</div>
</div> <div class="season-subtext">세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담</div>
<div style="background: transparent; border: 2px solid rgba(255,255,255,0.5); color: white; padding: 10px 20px; border-radius: 8px; font-size: 0.95rem;"> <div class="season-cta">무료 상담 신청</div>
서비스 안내 </div>
</div> }
</div> </section>
</div> </div>
<MudGrid Spacing="2"> <div class="admin-surface mt-4">
<MudItem xs="6"> <h3 class="admin-section-title">연간 시즌 타임라인</h3>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">활성 시즌 키</MudText> <div class="admin-table-wrap">
<MudText><code>@activeSeason.Key</code></MudText> <table class="admin-table">
</MudItem> <thead>
<MudItem xs="6"> <tr>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">마감까지</MudText> <th>기간</th>
<MudText> <th>시즌</th>
@if (activeSeason.DaysUntilDeadline >= 0) <th>블로그 카테고리</th>
{ <th>상태</th>
<MudChip T="string" Size="Size.Small" </tr>
Color="@(activeSeason.DaysUntilDeadline <= 7 ? Color.Error : Color.Warning)"> </thead>
D-@activeSeason.DaysUntilDeadline <tbody>
</MudChip> @foreach (var s in TaxSeasonCalendar.Seasons)
} {
else var isActive = activeSeason?.Key == s.Key;
{
<span>마감 후 @(-activeSeason.DaysUntilDeadline)일</span>
}
</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">포커스 서비스</MudText>
<MudText>@activeSeason.FocusService</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">블로그 카테고리</MudText>
<MudText>@activeSeason.RelatedCategorySlug</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">긴박감 배지 문구</MudText>
<MudText><code>@activeSeason.UrgencyBadge</code></MudText>
</MudItem>
</MudGrid>
}
else
{
<MudAlert Severity="Severity.Info">
선택한 날짜(@(simulationDate?.ToString("MM월 dd일") ?? "-"))는 시즌 비활성 기간입니다.
홈페이지는 기본 Hero를 표시합니다.
</MudAlert>
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-top: 1.5rem;">
<div style="font-size: 1.8rem; font-weight: 800; margin-bottom: 0.5rem;">
사업자 세금, 부동산,<br/>가족자산까지
</div>
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담
</div>
<div style="background: #e53e3e; color: white; display: inline-block; padding: 10px 20px; border-radius: 8px; font-weight: 700;">
무료 상담 신청
</div>
</div>
}
</MudPaper>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">연간 시즌 타임라인</MudText>
<MudSimpleTable Dense="true">
<thead>
<tr> <tr>
<th>기간</th> <td>@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay</td>
<th>시즌</th> <td>@s.Name</td>
<th>블로그 카테고리</th> <td><code>@s.RelatedCategorySlug</code></td>
<th>상태</th> <td>@(isActive ? "활성" : "비활성")</td>
</tr> </tr>
</thead> }
<tbody> </tbody>
@foreach (var s in TaxSeasonCalendar.Seasons) </table>
{ </div>
var isActive = activeSeason?.Key == s.Key; </div>
<tr style="@(isActive ? "background: rgba(66,153,225,0.1);" : "")">
<td style="white-space: nowrap;">
@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay
</td>
<td>@s.Name</td>
<td><code style="font-size:0.8rem;">@s.RelatedCategorySlug</code></td>
<td>
@if (isActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">활성</MudChip>
}
else
{
<span style="color: #a0aec0; font-size: 0.85rem;">비활성</span>
}
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
@code { @code {
private DateTime? simulationDate = DateTime.Today; private DateTime? simulationDate = DateTime.Today;
private CurrentSeasonDto? activeSeason; private CurrentSeasonDto? activeSeason;
private string SimulationDateText { get => simulationDate?.ToString("yyyy-MM-dd") ?? ""; set { simulationDate = DateTime.TryParse(value, out var dt) ? dt : null; ComputeSeason(); } }
protected override void OnInitialized() => ComputeSeason(); protected override void OnInitialized() => ComputeSeason();
@@ -183,10 +113,7 @@
var endYearCalc = (season.EndMonth < season.StartMonth) ? date.Year + 1 : date.Year; var endYearCalc = (season.EndMonth < season.StartMonth) ? date.Year + 1 : date.Year;
var deadline = new DateTime(endYearCalc, season.EndMonth, season.EndDay); var deadline = new DateTime(endYearCalc, season.EndMonth, season.EndDay);
var ddays = (deadline.Date - date.Date).Days; var ddays = (deadline.Date - date.Date).Days;
var badge = ddays <= 7 && ddays >= 0 ? season.UrgencyBadge.Replace("{n}", ddays.ToString()) : season.UrgencyBadge;
var badge = ddays <= 7 && ddays >= 0
? season.UrgencyBadge.Replace("{n}", ddays.ToString())
: season.UrgencyBadge;
activeSeason = new CurrentSeasonDto activeSeason = new CurrentSeasonDto
{ {
@@ -5,78 +5,58 @@
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Interfaces @using TaxBaik.Domain.Interfaces
@inject IApiClient ApiClient @inject IApiClient ApiClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>설정</PageTitle> <PageTitle>설정</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-6"> <section class="admin-page-hero">
<section class="admin-page-hero"> <div>
<div> <div class="admin-eyebrow">System</div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">System</MudText> <h1 class="admin-page-title">설정</h1>
<MudText Typo="Typo.h4" Class="admin-page-title">설정</MudText> <p class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</p>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</MudText> </div>
</section>
<div class="admin-detail-grid">
<section class="admin-surface">
<div class="admin-section-header compact">
<div>
<h3 class="admin-section-title">사이트 정보</h3>
<p class="muted">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</p>
</div>
</div> </div>
<form class="admin-form" @onsubmit="SaveSettings" @onsubmit:preventDefault="true">
<label>전화번호<input class="admin-input" @bind="phone" /></label>
<label>이메일<input class="admin-input" @bind="email" /></label>
<label>카카오 채널 URL<input class="admin-input" @bind="kakaoUrl" /></label>
<label>인스타그램<input class="admin-input" @bind="instagramUrl" /></label>
<div class="admin-dialog-actions">
<button type="submit" class="site-button primary">사이트 정보 저장</button>
</div>
</form>
</section> </section>
</MudContainer>
<MudGrid> <section class="admin-surface">
<MudItem xs="12" md="7"> <div class="admin-section-header compact">
<MudPaper Class="admin-surface" Elevation="0"> <div>
<div class="admin-section-header compact"> <h3 class="admin-section-title">계정 관리</h3>
<div> <p class="muted">비밀번호는 12자 이상으로 관리합니다.</p>
<MudText Typo="Typo.h6">사이트 정보</MudText>
<MudText Typo="Typo.body2">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</MudText>
</div>
</div> </div>
<MudForm> </div>
<MudTextField @bind-Value="phone" Label="전화번호"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="email" Label="이메일" <form class="admin-form" @onsubmit="ChangePassword" @onsubmit:preventDefault="true">
Variant="Variant.Outlined" Class="mb-4" /> <label>현재 비밀번호<input class="admin-input" type="password" @bind="currentPassword" /></label>
<label>새 비밀번호<input class="admin-input" type="password" @bind="newPassword" /></label>
<MudTextField @bind-Value="kakaoUrl" Label="카카오 채널 URL" <label>새 비밀번호 확인<input class="admin-input" type="password" @bind="confirmNewPassword" /></label>
Variant="Variant.Outlined" Class="mb-4" /> <div class="admin-dialog-actions">
<button type="submit" class="site-button primary" disabled="@isChangingPassword">
<MudTextField @bind-Value="instagramUrl" Label="인스타그램"
Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
@onclick="SaveSettings">사이트 정보 저장</MudButton>
</MudForm>
</MudPaper>
</MudItem>
<MudItem xs="12" md="5">
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
<div class="admin-section-header compact">
<div>
<MudText Typo="Typo.h6">계정 관리</MudText>
<MudText Typo="Typo.body2">비밀번호는 12자 이상으로 관리합니다.</MudText>
</div>
</div>
<MudForm>
<MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="newPassword" Label="새 비밀번호" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
Variant="Variant.Outlined" Class="mb-4" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@isChangingPassword"
StartIcon="@Icons.Material.Filled.LockReset"
@onclick="ChangePassword">
@(isChangingPassword ? "변경 중..." : "비밀번호 변경") @(isChangingPassword ? "변경 중..." : "비밀번호 변경")
</MudButton> </button>
</MudForm> </div>
</MudPaper> </form>
</MudItem> </section>
</MudGrid> </div>
@code { @code {
private string phone = "010-4122-8268"; private string phone = "010-4122-8268";
@@ -118,7 +98,7 @@
} }
catch catch
{ {
Snackbar.Add("사이트 설정을 불러오지 못했습니다.", Severity.Warning); await JS.InvokeVoidAsync("alert", "사이트 설정을 불러오지 못했습니다.");
} }
finally finally
{ {
@@ -141,11 +121,11 @@
if (response?.Message is null) if (response?.Message is null)
{ {
Snackbar.Add("설정 저장에 실패했습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "설정 저장에 실패했습니다.");
return; return;
} }
Snackbar.Add(response.Message, Severity.Success); await JS.InvokeVoidAsync("alert", response.Message);
} }
private async Task ChangePassword() private async Task ChangePassword()
@@ -155,13 +135,13 @@
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword)) if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
{ {
Snackbar.Add("현재 비밀번호와 새 비밀번호를 입력하세요.", Severity.Warning); await JS.InvokeVoidAsync("alert", "현재 비밀번호와 새 비밀번호를 입력하세요.");
return; return;
} }
if (newPassword != confirmNewPassword) if (newPassword != confirmNewPassword)
{ {
Snackbar.Add("새 비밀번호 확인이 일치하지 않습니다.", Severity.Warning); await JS.InvokeVoidAsync("alert", "새 비밀번호 확인이 일치하지 않습니다.");
return; return;
} }
@@ -177,18 +157,18 @@
if (response?.Message == null) if (response?.Message == null)
{ {
Snackbar.Add("비밀번호 변경에 실패했습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "비밀번호 변경에 실패했습니다.");
return; return;
} }
Snackbar.Add(response.Message, Severity.Success); await JS.InvokeVoidAsync("alert", response.Message);
currentPassword = ""; currentPassword = "";
newPassword = ""; newPassword = "";
confirmNewPassword = ""; confirmNewPassword = "";
} }
catch catch
{ {
Snackbar.Add("비밀번호 변경 중 오류가 발생했습니다.", Severity.Error); await JS.InvokeVoidAsync("alert", "비밀번호 변경 중 오류가 발생했습니다.");
} }
finally finally
{ {
@@ -2,201 +2,125 @@
@using TaxBaik.Web.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@inject ITaxFilingScheduleBrowserClient TaxFilingClient @inject ITaxFilingScheduleBrowserClient TaxFilingClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
@inject IDialogService DialogService
@attribute [Authorize] @attribute [Authorize]
<PageTitle>신고 일정</PageTitle> <PageTitle>신고 일정</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText> <div class="admin-eyebrow">CRM & 세무관리</div>
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText> <h1 class="admin-page-title">신고 일정</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText> <p class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-schedule"> <button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 일정 추가</button>
새 일정 추가
</MudButton>
</section> </section>
@if (schedules is null) <div class="admin-surface">
{ @if (schedules is null)
<MudProgressLinear Indeterminate="true" /> {
} <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
else }
{ else if (schedules.Count == 0)
<MudGrid Spacing="2" Class="mt-2"> {
<!-- Left: Dense Grid List --> <div class="muted">신고 일정이 없습니다.</div>
<MudItem XS="12" MD="8"> }
@if (schedules.Count == 0) else
{ {
<MudAlert Severity="Severity.Info"> <div class="admin-table-wrap">
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" /> <table class="admin-table">
신고 일정이 없습니다. <thead>
</MudAlert> <tr>
} <th>ID</th>
else <th>고객</th>
{ <th>신고 유형</th>
<MudDataGrid T="TaxFilingSchedule" <th>마감일</th>
Items="@schedules" <th>신고연도</th>
Dense="true" <th>상태</th>
Hover="true" <th>작업</th>
Striped="true" </tr>
Virtualize="true" </thead>
RowsPerPage="30" <tbody>
SelectedItem="@selectedSchedule" @foreach (var item in schedules)
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
<TemplateColumn Title="마감일">
<CellTemplate>
@{
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
}
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
@context.Item.DueDate.ToString("yyyy-MM-dd")
@if (daysLeft >= 0)
{
<span class="ms-1">(D-@daysLeft)</span>
}
else
{
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
}
</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
<TemplateColumn Title="상태">
<CellTemplate>
@if (context.Item.Status == "completed")
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.Status != "completed")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
Color="Color.Success"
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
Title="완료" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
Title="삭제" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "신고 일정 상세" : "새 신고 일정 추가")</MudText>
@if (isEditMode)
{ {
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate"> var daysLeft = (item.DueDate.Date - DateTime.Today).Days;
새로 작성 <tr>
</MudButton> <td>@item.Id</td>
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
<td>@item.FilingType</td>
<td>@item.DueDate.ToString("yyyy-MM-dd") @(daysLeft >= 0 ? $"(D-{daysLeft})" : $"(마감 {Math.Abs(daysLeft)}일 경과)")</td>
<td>@item.FilingYear</td>
<td>@(item.Status == "completed" ? "완료" : "대기")</td>
<td>
<div class="admin-row-actions">
@if (item.Status != "completed")
{
<button type="button" class="site-button secondary" @onclick="@(async () => await CompleteSchedule(item.Id))">완료</button>
}
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteSchedule(item.Id))">✕</button>
</div>
</td>
</tr>
} }
</div> </tbody>
<MudForm @ref="form"> </table>
<MudSelect T="int?" </div>
@bind-Value="scheduleForm.ClientId" }
Label="고객" </div>
Required="true"
Variant="Variant.Outlined" <dialog class="admin-dialog" open="@isDialogOpen">
FullWidth="@true" <form class="admin-dialog-card" @onsubmit="SaveSchedule" @onsubmit:preventDefault="true">
Class="mb-3" <h3>새 신고 일정 추가</h3>
RequiredError="고객을 선택하세요." <label>고객
Disabled="@isEditMode"> <select class="admin-input" @bind="ClientIdText">
@foreach (var client in clients) <option value="">선택하세요</option>
{ @foreach (var client in clients)
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem> {
} <option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
</MudSelect> }
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true"> </select>
<MudSelectItem Value="@("종합소득세")">종합소득세</MudSelectItem> </label>
<MudSelectItem Value="@("부가가치세")">부가가치세</MudSelectItem> <label>신고 유형
<MudSelectItem Value="@("법인세")">법인세</MudSelectItem> <select class="admin-input" @bind="scheduleForm.FilingType">
<MudSelectItem Value="@("원천세")">원천세</MudSelectItem> <option value="">선택하세요</option>
<MudSelectItem Value="@("종합부동산세")">종합부동산세</MudSelectItem> <option value="종합소득세">종합소득세</option>
<MudSelectItem Value="@("양도소득세")">양도소득세</MudSelectItem> <option value="부가가치세">부가가치세</option>
<MudSelectItem Value="@("상속·증여세")">상속·증여세</MudSelectItem> <option value="법인세">법인세</option>
<MudSelectItem Value="@("세무조정")">세무조정</MudSelectItem> <option value="원천세">원천세</option>
</MudSelect> <option value="종합부동산세">종합부동산세</option>
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" /> <option value="양도소득세">양도소득세</option>
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" Required="true" /> <option value="상속·증여세">상속·증여세</option>
<option value="세무조정">세무조정</option>
<div class="d-flex justify-end gap-2"> </select>
@if (isEditMode && selectedSchedule?.Status != "completed") </label>
{ <label>마감일 <input class="admin-input" type="text" placeholder="2026-07-01" @bind="DueDateText" /></label>
<MudButton Variant="Variant.Outlined" Color="Color.Success" OnClick="@(async () => await CompleteSchedule(selectedSchedule?.Id ?? 0))">완료 처리</MudButton> <label>신고연도 <input class="admin-input" type="text" placeholder="2026" @bind="FilingYearText" /></label>
} <div class="admin-dialog-actions">
@if (isEditMode) <button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
{ <button type="submit" class="site-button primary">저장</button>
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteSchedule(selectedSchedule?.Id ?? 0))">삭제</MudButton> </div>
} </form>
else </dialog>
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSchedule" id="btn-save-schedule">저장</MudButton>
}
</div>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
}
@code { @code {
[CascadingParameter] [CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxFilingSchedule>? schedules; private List<TaxFilingSchedule>? schedules;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
private MudForm? form; private bool isDialogOpen;
private bool isEditMode;
private TaxFilingSchedule? selectedSchedule;
private TaxFilingScheduleForm scheduleForm = new(); private TaxFilingScheduleForm scheduleForm = new();
private string ClientIdText { get => scheduleForm.ClientId > 0 ? scheduleForm.ClientId.ToString() : ""; set => scheduleForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string DueDateText { get => scheduleForm.DueDate?.ToString("yyyy-MM-dd") ?? ""; set => scheduleForm.DueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private string FilingYearText { get => scheduleForm.FilingYear.ToString(); set => scheduleForm.FilingYear = int.TryParse(value, out var year) ? year : DateTime.Now.Year; }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
} }
} }
} }
@@ -212,71 +136,36 @@ else
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
} }
} }
private void PrepareCreate() private void OpenCreateDialog()
{ {
selectedSchedule = null; scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year, DueDate = DateTime.Today, ClientId = clients.FirstOrDefault()?.Id ?? 0 };
isEditMode = false; isDialogOpen = true;
scheduleForm = new TaxFilingScheduleForm
{
FilingYear = DateTime.Now.Year,
DueDate = DateTime.Today,
ClientId = clients.FirstOrDefault()?.Id
};
}
private void OnRowSelected(TaxFilingSchedule schedule)
{
if (schedule == null) return;
selectedSchedule = schedule;
isEditMode = true;
scheduleForm = new TaxFilingScheduleForm
{
ClientId = schedule.ClientId,
FilingType = schedule.FilingType,
DueDate = schedule.DueDate,
FilingYear = schedule.FilingYear
};
} }
private async Task SaveSchedule() private async Task SaveSchedule()
{ {
if (form != null) if (scheduleForm.ClientId <= 0 || string.IsNullOrWhiteSpace(scheduleForm.FilingType))
{ {
await form.Validate(); await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
if (!form.IsValid) return;
{
Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
return;
}
} }
try try
{ {
if (scheduleForm.ClientId == null) return; var newId = await TaxFilingClient.CreateAsync(scheduleForm.ClientId, scheduleForm.FilingType, scheduleForm.DueDate ?? DateTime.Today, scheduleForm.FilingYear);
var newId = await TaxFilingClient.CreateAsync(
scheduleForm.ClientId.Value,
scheduleForm.FilingType,
scheduleForm.DueDate ?? DateTime.Today,
scheduleForm.FilingYear);
if (newId > 0) if (newId > 0)
{ {
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
PrepareCreate(); CloseDialog();
await LoadData(); await LoadData();
} }
else
{
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
} }
@@ -285,60 +174,31 @@ else
try try
{ {
await TaxFilingClient.MarkCompletedAsync(id); await TaxFilingClient.MarkCompletedAsync(id);
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "신고 일정이 완료 처리되었습니다.");
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
} }
} }
private async Task DeleteSchedule(int id) private async Task DeleteSchedule(int id)
{ {
var parameters = new DialogParameters if (!await JS.InvokeAsync<bool>("confirm", "이 신고 일정을 삭제하시겠습니까?")) return;
{
{ "Title", "삭제 확인" },
{ "Message", "이 신고 일정을 삭제하시겠습니까?" }
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
try try
{ {
await TaxFilingClient.DeleteAsync(id); await TaxFilingClient.DeleteAsync(id);
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "신고 일정이 삭제되었습니다.");
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
} }
} }
private static string GetClientDisplayName(Client client) private void CloseDialog() { isDialogOpen = false; scheduleForm = new(); }
=> !string.IsNullOrWhiteSpace(client.CompanyName) private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
? client.CompanyName private sealed class TaxFilingScheduleForm { public int ClientId { get; set; } public string FilingType { get; set; } = ""; public DateTime? DueDate { get; set; } public int FilingYear { get; set; } = DateTime.Now.Year; }
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class TaxFilingScheduleForm
{
public int? ClientId { get; set; }
public string FilingType { get; set; } = "";
public DateTime? DueDate { get; set; }
public int FilingYear { get; set; } = DateTime.Now.Year;
}
} }
@@ -1,60 +1,66 @@
@using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@inject ITaxFilingBrowserClient FilingClient @inject ITaxFilingBrowserClient FilingClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
@if (Filings == null || Filings.Count == 0) @if (Filings == null || Filings.Count == 0)
{ {
<MudText Class="pa-4" Color="Color.Secondary">항목이 없습니다.</MudText> <div class="muted">항목이 없습니다.</div>
} }
else else
{ {
<MudTable Items="Filings" Hover="true" Dense="true" Class="mt-2"> <div class="admin-table-wrap">
<HeaderContent> <table class="admin-table">
<MudTh>고객</MudTh> <thead>
<MudTh>신고 유형</MudTh> <tr>
<MudTh>기한</MudTh> <th>고객</th>
<MudTh>D-day</MudTh> <th>신고 유형</th>
<MudTh>메모</MudTh> <th>기한</th>
<MudTh>처리</MudTh> <th>D-day</th>
</HeaderContent> <th>메모</th>
<RowTemplate> <th>처리</th>
<MudTd>@context.ClientName</MudTd> </tr>
<MudTd>@context.FilingType</MudTd> </thead>
<MudTd>@context.DueDate.ToString("yyyy-MM-dd")</MudTd> <tbody>
<MudTd> @foreach (var filing in Filings)
@{
var dday = (context.DueDate.Date - DateTime.Today).Days;
}
@if (dday < 0)
{ {
<MudChip T="string" Size="Size.Small" Color="Color.Error">D+@(-dday)</MudChip> var dday = (filing.DueDate.Date - DateTime.Today).Days;
<tr>
<td>@filing.ClientName</td>
<td>@filing.FilingType</td>
<td>@filing.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<span class="status-pill danger">D+@(-dday)</span>
}
else if (dday <= 7)
{
<span class="status-pill warning">D-@dday</span>
}
else
{
<span>D-@dday</span>
}
</td>
<td>@(filing.Memo ?? "")</td>
<td>
<div class="admin-row-actions">
@if (filing.Status == "pending")
{
<button type="button" class="site-button secondary" @onclick="@(() => MarkFiled(filing))">완료</button>
}
else
{
<span class="status-pill success">완료</span>
}
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteFiling(filing.Id))">✕</button>
</div>
</td>
</tr>
} }
else if (dday <= 7) </tbody>
{ </table>
<MudChip T="string" Size="Size.Small" Color="Color.Warning">D-@dday</MudChip> </div>
}
else
{
<MudText Typo="Typo.body2">D-@dday</MudText>
}
</MudTd>
<MudTd>@(context.Memo ?? "")</MudTd>
<MudTd>
@if (context.Status == "pending")
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Success"
OnClick="@(() => MarkFiled(context))">완료</MudButton>
}
else if (context.Status == "filed")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">완료</MudChip>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteFiling(context.Id))" />
</MudTd>
</RowTemplate>
</MudTable>
} }
@code { @code {
@@ -66,44 +72,33 @@ else
private async Task MarkFiled(TaxFiling filing) private async Task MarkFiled(TaxFiling filing)
{ {
try filing.Status = "filed";
var result = await FilingClient.UpdateAsync(filing.Id, filing);
if (result != null)
{ {
filing.Status = "filed"; await JS.InvokeVoidAsync("alert", "신고 완료 처리되었습니다.");
var result = await FilingClient.UpdateAsync(filing.Id, filing); await OnStatusChange.InvokeAsync();
if (result != null)
{
Snackbar.Add("신고 완료 처리되었습니다.", Severity.Success);
await OnStatusChange.InvokeAsync();
}
else
{
Snackbar.Add("처리 실패", Severity.Error);
}
} }
catch (Exception ex) else
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", "처리 실패");
} }
} }
private async Task DeleteFiling(int id) private async Task DeleteFiling(int id)
{ {
try var confirmed = await JS.InvokeAsync<bool>("confirm", "이 항목을 삭제하시겠습니까?");
if (!confirmed) return;
var success = await FilingClient.DeleteAsync(id);
if (success)
{ {
var success = await FilingClient.DeleteAsync(id); await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
if (success) await OnStatusChange.InvokeAsync();
{
Snackbar.Add("삭제되었습니다.", Severity.Info);
await OnStatusChange.InvokeAsync();
}
else
{
Snackbar.Add("삭제 실패", Severity.Error);
}
} }
catch (Exception ex) else
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", "삭제 실패");
} }
} }
} }
@@ -4,130 +4,165 @@
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@inject ITaxFilingBrowserClient FilingClient @inject ITaxFilingBrowserClient FilingClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject IJSRuntime JS
<PageTitle>신고 일정 관리</PageTitle> <PageTitle>신고 일정 관리</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Tax Schedule</MudText> <div class="admin-eyebrow">Tax Schedule</div>
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText> <h1 class="admin-page-title">신고 일정</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</MudText> <p class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" <button type="button" class="site-button primary" @onclick="@(() => showAddForm = !showAddForm)">일정 추가</button>
OnClick="@(() => showAddForm = !showAddForm)"
StartIcon="@Icons.Material.Filled.Add">
일정 추가
</MudButton>
</section> </section>
@if (showAddForm) @if (showAddForm)
{ {
<MudPaper Class="pa-4 mb-4" Elevation="1"> <div class="admin-surface mb-4">
<MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText> <h3 class="admin-section-title">새 신고 일정</h3>
<MudGrid Spacing="2"> <form class="admin-dialog-card" @onsubmit="AddFiling" @onsubmit:preventDefault="true">
<MudItem xs="12" sm="6" md="4"> <label>고객 검색
<MudAutocomplete T="Domain.Entities.Client" @bind-Value="selectedClient" <select class="admin-input" @bind="SelectedClientIdText">
Label="고객 검색 *" <option value="">선택하세요</option>
SearchFunc="SearchClients" @foreach (var client in clients)
ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")" {
Variant="Variant.Outlined" /> <option value="@client.Id">@GetClientDisplayName(client)</option>
</MudItem> }
<MudItem xs="12" sm="6" md="4"> </select>
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined"> </label>
<label>신고 유형
<select class="admin-input" @bind="newFilingType">
<option value="">선택하세요</option>
@foreach (var t in TaxFilingService.FilingTypes) @foreach (var t in TaxFilingService.FilingTypes)
{ {
<MudSelectItem Value="@t">@t</MudSelectItem> <option value="@t">@t</option>
} }
</MudSelect> </select>
</MudItem> </label>
<MudItem xs="12" sm="6" md="4"> <label>신고 기한 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="DueDateText" /></label>
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" /> <label>메모 <textarea class="admin-input" rows="3" @bind="newMemo"></textarea></label>
</MudItem> <div class="admin-dialog-actions">
<MudItem xs="12"> <button type="submit" class="site-button primary">저장</button>
<MudTextField T="string" @bind-Value="newMemo" Label="메모" Variant="Variant.Outlined" /> <button type="button" class="site-button secondary" @onclick='() => showAddForm = false'>취소</button>
</MudItem> </div>
</MudGrid> </form>
<MudStack Row="true" Class="mt-3" Spacing="2"> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddFiling">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
</MudStack>
</MudPaper>
} }
<MudPaper Class="admin-surface" Elevation="0"> <div class="admin-surface">
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs"> <div class="admin-tabbar">
<MudTabPanel Text="신고 예정"> <button type="button" class="admin-tab @(activeTab == "pending" ? "active" : "")" @onclick='() => activeTab = "pending"'>신고 예정</button>
<FilingTable Filings="@pending" OnStatusChange="Reload" /> <button type="button" class="admin-tab @(activeTab == "filed" ? "active" : "")" @onclick='() => activeTab = "filed"'>신고 완료</button>
</MudTabPanel> <button type="button" class="admin-tab @(activeTab == "overdue" ? "active" : "")" @onclick='() => activeTab = "overdue"'>기한 초과</button>
<MudTabPanel Text="신고 완료"> </div>
<FilingTable Filings="@filed" OnStatusChange="Reload" />
</MudTabPanel> @if (CurrentFilings.Count == 0)
<MudTabPanel Text="기한 초과"> {
<FilingTable Filings="@overdue" OnStatusChange="Reload" /> <div class="muted">항목이 없습니다.</div>
</MudTabPanel> }
</MudTabs> else
</MudPaper> {
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>고객</th>
<th>신고 유형</th>
<th>기한</th>
<th>D-day</th>
<th>메모</th>
<th>처리</th>
</tr>
</thead>
<tbody>
@foreach (var filing in CurrentFilings)
{
var dday = (filing.DueDate.Date - DateTime.Today).Days;
<tr>
<td>@filing.ClientName</td>
<td>@filing.FilingType</td>
<td>@filing.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday < 0)
{
<span class="status-pill danger">D+@(-dday)</span>
}
else if (dday <= 7)
{
<span class="status-pill warning">D-@dday</span>
}
else
{
<span>D-@dday</span>
}
</td>
<td>@(filing.Memo ?? "")</td>
<td>
<div class="admin-row-actions">
@if (filing.Status == "pending")
{
<button type="button" class="site-button secondary" @onclick="@(() => MarkFiled(filing))">완료</button>
}
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteFiling(filing.Id))">✕</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code { @code {
private List<Domain.Entities.TaxFiling> pending = []; private List<TaxFiling> allFilings = [];
private List<Domain.Entities.TaxFiling> filed = []; private List<Client> clients = [];
private List<Domain.Entities.TaxFiling> overdue = [];
private bool showAddForm; private bool showAddForm;
private Domain.Entities.Client? selectedClient; private string activeTab = "pending";
private int selectedClientId;
private string newFilingType = ""; private string newFilingType = "";
private DateTime? newDueDate = DateTime.Today.AddDays(30); private DateTime? newDueDate = DateTime.Today.AddDays(30);
private string newMemo = ""; private string newMemo = "";
private string SelectedClientIdText { get => selectedClientId > 0 ? selectedClientId.ToString() : ""; set => selectedClientId = int.TryParse(value, out var id) ? id : 0; }
private string DueDateText { get => newDueDate?.ToString("yyyy-MM-dd") ?? ""; set => newDueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
private List<TaxFiling> CurrentFilings => activeTab switch
{
"filed" => allFilings.Where(x => x.Status == "filed").ToList(),
"overdue" => allFilings.Where(x => x.Status == "overdue").ToList(),
_ => allFilings.Where(x => x.Status == "pending").ToList()
};
protected override async Task OnInitializedAsync() => await Reload(); protected override async Task OnInitializedAsync() => await Reload();
private async Task Reload() private async Task Reload()
{ {
try try
{ {
var all = (await FilingClient.GetUpcomingAsync(365)).ToList(); allFilings = (await FilingClient.GetUpcomingAsync(365)).ToList();
pending = all.Where(x => x.Status == "pending").ToList(); var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
filed = all.Where(x => x.Status == "filed").ToList(); clients = clientItems.ToList();
overdue = all.Where(x => x.Status == "overdue").ToList();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
} }
private async Task<IEnumerable<Client>> SearchClients(string value)
{
try
{
var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
return items;
}
catch
{
return [];
}
}
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() private async Task AddFiling()
{ {
try try
{ {
if (selectedClient == null) if (selectedClientId <= 0)
{ {
Snackbar.Add("고객을 선택하세요.", Severity.Warning); await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
return; return;
} }
var filing = new TaxFiling var filing = new TaxFiling
{ {
ClientId = selectedClient.Id, ClientId = selectedClientId,
FilingType = newFilingType, FilingType = newFilingType,
DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow, DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow,
Status = "pending", Status = "pending",
@@ -137,17 +172,36 @@
if (result != null) if (result != null)
{ {
showAddForm = false; showAddForm = false;
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
await Reload(); await Reload();
} }
else else
{ {
Snackbar.Add("추가 실패", Severity.Error); await JS.InvokeVoidAsync("alert", "추가 실패");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"오류: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
} }
} }
private async Task MarkFiled(TaxFiling filing)
{
filing.Status = "filed";
await FilingClient.UpdateAsync(filing.Id, filing);
await JS.InvokeVoidAsync("alert", "신고 완료 처리되었습니다.");
await Reload();
}
private async Task DeleteFiling(int id)
{
if (!await JS.InvokeAsync<bool>("confirm", "삭제하시겠습니까?")) return;
await FilingClient.DeleteAsync(id);
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
await Reload();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
} }
@@ -2,159 +2,128 @@
@using TaxBaik.Web.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@inject ITaxProfileBrowserClient TaxProfileClient @inject ITaxProfileBrowserClient TaxProfileClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ICommonCodeBrowserClient CommonCodeClient @inject IJSRuntime JS
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@attribute [Authorize] @attribute [Authorize]
<PageTitle>세무 프로필</PageTitle> <PageTitle>세무 프로필</PageTitle>
<section class="admin-page-hero"> <section class="admin-page-hero">
<div> <div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText> <div class="admin-eyebrow">CRM & 세무관리</div>
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText> <h1 class="admin-page-title">세무 프로필</h1>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText> <p class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</p>
</div> </div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-profile"> <button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 프로필 추가</button>
새 프로필 추가
</MudButton>
</section> </section>
@if (profiles == null) <div class="admin-surface">
{ @if (profiles is null)
<MudProgressCircular Indeterminate="true" Class="mt-4" /> {
} <Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
else }
{ else if (profiles.Count == 0)
<MudGrid Spacing="2" Class="mt-2"> {
<!-- Left: Dense Grid List --> <div class="muted">세무 프로필이 없습니다.</div>
<MudItem XS="12" MD="8"> }
@if (profiles.Count == 0) else
{ {
<MudAlert Severity="Severity.Info">세무 프로필이 없습니다.</MudAlert> <div class="admin-table-wrap">
} <table class="admin-table">
else <thead>
{ <tr>
<MudDataGrid T="TaxProfile" <th>ID</th>
Items="@profiles" <th>고객</th>
Dense="true" <th>사업 유형</th>
Hover="true" <th>위험도</th>
Striped="true" <th>다음 신고</th>
Virtualize="true" <th>작업</th>
RowsPerPage="30" </tr>
SelectedItem="@selectedProfile" </thead>
SelectedItemChanged="OnRowSelected" <tbody>
Class="admin-grid"> @foreach (var item in profiles)
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
<TemplateColumn Title="위험도">
<CellTemplate>
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
@context.Item.TaxRiskLevel
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="다음 신고">
<CellTemplate>
@if (context.Item.NextFilingDueDate.HasValue)
{
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
@if (isEditMode)
{ {
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate"> <tr>
새로 작성 <td>@item.Id</td>
</MudButton> <td>@(clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}"))</td>
<td>@item.BusinessType</td>
<td><span class="status-pill @(item.TaxRiskLevel == "high" ? "danger" : item.TaxRiskLevel == "normal" ? "warning" : "success")">@item.TaxRiskLevel</span></td>
<td>@(item.NextFilingDueDate?.ToString("yyyy-MM-dd") ?? "—")</td>
<td>
<div class="admin-row-actions">
<button type="button" class="admin-icon-button" @onclick="@(async () => await OpenEditDialog(item))">✎</button>
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteProfile(item.Id))">✕</button>
</div>
</td>
</tr>
} }
</div> </tbody>
<MudForm @ref="form"> </table>
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode"> </div>
@foreach (var client in clients) }
{ </div>
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
} <dialog class="admin-dialog" open="@isDialogOpen">
</MudSelect> <form class="admin-dialog-card" @onsubmit="SaveProfile" @onsubmit:preventDefault="true">
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true"> <h3>@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</h3>
@foreach (var type in businessTypes) <label>고객
{ <select class="admin-input" @bind="ClientIdText">
<MudSelectItem Value="@type.CodeValue">@type.CodeName</MudSelectItem> <option value="">선택하세요</option>
} @foreach (var client in clients)
</MudSelect> {
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3"> <option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
@foreach (var level in riskLevels) }
{ </select>
<MudSelectItem Value="@level.CodeValue">@level.CodeName</MudSelectItem> </label>
} <label>사업 유형
</MudSelect> <select class="admin-input" @bind="profileForm.BusinessType">
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" /> <option value="">선택하세요</option>
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="@true" Lines="3" Class="mb-4" /> <option value="일반제조업">일반제조업</option>
<option value="도소매업">도소매업</option>
<div class="d-flex justify-end gap-2"> <option value="서비스업">서비스업</option>
@if (isEditMode) <option value="정보통신업">정보통신업</option>
{ <option value="부동산업">부동산업</option>
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteProfile(selectedProfile?.Id ?? 0))">삭제</MudButton> <option value="건설업">건설업</option>
} <option value="음식점업">음식점업</option>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveProfile" id="btn-save-profile">저장</MudButton> <option value="프리랜서">프리랜서</option>
</div> <option value="기타">기타</option>
</MudForm> </select>
</MudPaper> </label>
</MudItem> <label>위험도
</MudGrid> <select class="admin-input" @bind="profileForm.TaxRiskLevel">
} <option value="low">낮음</option>
<option value="normal">보통</option>
<option value="high">높음</option>
</select>
</label>
<label>다음 신고 예정일 <input class="admin-input" type="text" placeholder="2026-07-01" @bind="NextFilingText" /></label>
<label>특수 사항 <textarea class="admin-input" rows="3" @bind="profileForm.SpecialNotes"></textarea></label>
<div class="admin-dialog-actions">
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
<button type="submit" class="site-button primary">저장</button>
</div>
</form>
</dialog>
@code { @code {
[CascadingParameter] [CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxProfile>? profiles; private List<TaxProfile>? profiles;
private List<Client> clients = []; private List<Client> clients = [];
private Dictionary<int, string> clientMap = new(); private Dictionary<int, string> clientMap = new();
private List<CommonCode> businessTypes = []; private bool isDialogOpen;
private List<CommonCode> riskLevels = [];
private MudForm? form;
private bool isEditMode; private bool isEditMode;
private TaxProfile? selectedProfile; private TaxProfile? editingProfile;
private TaxProfileForm profileForm = new(); private TaxProfileForm profileForm = new();
private string ClientIdText { get => profileForm.ClientId > 0 ? profileForm.ClientId.ToString() : ""; set => profileForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
private string NextFilingText { get => profileForm.NextFilingDueDate?.ToString("yyyy-MM-dd") ?? ""; set => profileForm.NextFilingDueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true) StateHasChanged();
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
} }
} }
} }
@@ -167,56 +136,25 @@ else
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000); var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList(); clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName); clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
businessTypes = await CommonCodeClient.GetByGroupAsync("BUSINESS_TYPE");
if (businessTypes.Count == 0)
{
businessTypes = [
new() { CodeValue = "일반제조업", CodeName = "일반제조업" },
new() { CodeValue = "도소매업", CodeName = "도소매업" },
new() { CodeValue = "서비스업", CodeName = "서비스업" },
new() { CodeValue = "정보통신업", CodeName = "정보통신업" },
new() { CodeValue = "부동산업", CodeName = "부동산업" },
new() { CodeValue = "건설업", CodeName = "건설업" },
new() { CodeValue = "음식점업", CodeName = "음식점업" },
new() { CodeValue = "프리랜서", CodeName = "프리랜서" },
new() { CodeValue = "기타", CodeName = "기타" }
];
}
riskLevels = await CommonCodeClient.GetByGroupAsync("TAX_RISK_LEVEL");
if (riskLevels.Count == 0)
{
riskLevels = [
new() { CodeValue = "low", CodeName = "낮음" },
new() { CodeValue = "normal", CodeName = "보통" },
new() { CodeValue = "high", CodeName = "높음" }
];
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
} }
} }
private void PrepareCreate() private void OpenCreateDialog()
{ {
selectedProfile = null;
isEditMode = false; isEditMode = false;
profileForm = new TaxProfileForm editingProfile = null;
{ profileForm = new TaxProfileForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, TaxRiskLevel = "normal", NextFilingDueDate = DateTime.Today.AddMonths(1) };
ClientId = clients.FirstOrDefault()?.Id, isDialogOpen = true;
TaxRiskLevel = "normal",
NextFilingDueDate = DateTime.Today.AddMonths(1)
};
} }
private void OnRowSelected(TaxProfile profile) private async Task OpenEditDialog(TaxProfile profile)
{ {
if (profile == null) return;
selectedProfile = profile;
isEditMode = true; isEditMode = true;
editingProfile = profile;
profileForm = new TaxProfileForm profileForm = new TaxProfileForm
{ {
ClientId = profile.ClientId, ClientId = profile.ClientId,
@@ -225,107 +163,58 @@ else
NextFilingDueDate = profile.NextFilingDueDate, NextFilingDueDate = profile.NextFilingDueDate,
SpecialNotes = profile.SpecialNotes SpecialNotes = profile.SpecialNotes
}; };
isDialogOpen = true;
await Task.CompletedTask;
} }
private async Task SaveProfile() private async Task SaveProfile()
{ {
if (form != null) if (profileForm.ClientId <= 0 || string.IsNullOrWhiteSpace(profileForm.BusinessType))
{ {
await form.Validate(); await JS.InvokeVoidAsync("alert", "고객과 사업 유형을 입력하세요.");
if (!form.IsValid) return;
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
} }
try try
{ {
if (isEditMode && selectedProfile != null) if (isEditMode && editingProfile != null)
{ {
await TaxProfileClient.UpdateAsync(selectedProfile.Id, profileForm.BusinessType, await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel); await JS.InvokeVoidAsync("alert", "세무 프로필이 수정되었습니다.");
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success);
} }
else else
{ {
if (!profileForm.ClientId.HasValue) var newId = await TaxProfileClient.CreateAsync(profileForm.ClientId, profileForm.BusinessType);
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
var newId = await TaxProfileClient.CreateAsync(
profileForm.ClientId.Value,
profileForm.BusinessType);
if (newId > 0) if (newId > 0)
{ {
await TaxProfileClient.UpdateAsync( await TaxProfileClient.UpdateAsync(newId, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
newId, await JS.InvokeVoidAsync("alert", "세무 프로필이 추가되었습니다.");
profileForm.BusinessType,
null,
profileForm.NextFilingDueDate,
profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
} }
} }
PrepareCreate(); CloseDialog();
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
} }
} }
private async Task DeleteProfile(int id) private async Task DeleteProfile(int id)
{ {
var parameters = new DialogParameters(); if (!await JS.InvokeAsync<bool>("confirm", "이 세무 프로필을 삭제하시겠습니까?")) return;
parameters.Add("Title", "삭제 확인");
parameters.Add("Message", "이 세무 프로필을 삭제하시겠습니까?");
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
try try
{ {
await TaxProfileClient.DeleteAsync(id); await TaxProfileClient.DeleteAsync(id);
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success); await JS.InvokeVoidAsync("alert", "세무 프로필이 삭제되었습니다.");
if (selectedProfile?.Id == id)
{
PrepareCreate();
}
await LoadData(); await LoadData();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
} }
} }
private Color GetRiskColor(string riskLevel) => riskLevel switch private void CloseDialog() { isDialogOpen = false; isEditMode = false; editingProfile = null; profileForm = new(); }
{ private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
"high" => Color.Error, private sealed class TaxProfileForm { public int ClientId { get; set; } public string BusinessType { get; set; } = ""; public string TaxRiskLevel { get; set; } = "normal"; public DateTime? NextFilingDueDate { get; set; } public string? SpecialNotes { get; set; } }
"normal" => Color.Warning,
"low" => Color.Success,
_ => 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 string BusinessType { get; set; } = "";
public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; }
public string? SpecialNotes { get; set; }
}
} }
@@ -1,20 +1,20 @@
@using MudBlazor @using Microsoft.FluentUI.AspNetCore.Components
<div class="admin-dialog">
<MudDialog> <div class="admin-dialog-title">@Title</div>
<DialogContent> <p class="admin-dialog-message">@Message</p>
<MudText>@Message</MudText> <div class="admin-dialog-actions">
</DialogContent> <FluentButton Appearance="ButtonAppearance.Transparent" @onclick="Cancel">취소</FluentButton>
<DialogActions> <FluentButton Appearance="ButtonAppearance.Primary" @onclick="Confirm">삭제</FluentButton>
<MudButton OnClick="Cancel">취소</MudButton> </div>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Confirm">삭제</MudButton> </div>
</DialogActions>
</MudDialog>
@code { @code {
[CascadingParameter] MudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string Title { get; set; } = ""; [Parameter] public string Title { get; set; } = "";
[Parameter] public string Message { get; set; } = ""; [Parameter] public string Message { get; set; } = "";
private void Cancel() => MudDialog.Cancel(); [Parameter] public EventCallback OnCancel { get; set; }
private void Confirm() => MudDialog.Close(); [Parameter] public EventCallback OnConfirm { get; set; }
private Task Cancel() => OnCancel.InvokeAsync();
private Task Confirm() => OnConfirm.InvokeAsync();
} }
+1 -1
View File
@@ -4,10 +4,10 @@
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.Web.Virtualization
@using TaxBaik.Web.Components.Shared
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@@ -0,0 +1,15 @@
<div class="@CssClass" aria-hidden="true">
@for (var i = 0; i < Count; i++)
{
<div class="taxbaik-skeleton-item">
<div class="taxbaik-skeleton-line taxbaik-skeleton-title"></div>
<div class="taxbaik-skeleton-line"></div>
<div class="taxbaik-skeleton-line taxbaik-skeleton-short"></div>
</div>
}
</div>
@code {
[Parameter] public int Count { get; set; } = 3;
[Parameter] public string CssClass { get; set; } = "";
}
+24
View File
@@ -0,0 +1,24 @@
@using Microsoft.AspNetCore.Components.Web
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>백원숙 세무회계</title>
<base href="/taxbaik/" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link rel="stylesheet" href="css/design-tokens.css" />
<link rel="stylesheet" href="css/ui-primitives.css" />
<link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
<link rel="stylesheet" href="css/site.css" />
<link rel="stylesheet" href="css/admin.css" />
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
</head>
<body class="site-blazor">
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="_content/Microsoft.FluentUI.AspNetCore.Components/js/lib.module.js" type="module" async></script>
<script src="js/admin-session.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,45 @@
@page "/blog"
@using TaxBaik.Application.Services
@inject BlogService BlogService
<PageTitle>블로그</PageTitle>
<section class="site-content">
<div class="site-section-header">
<h1>세무 블로그</h1>
<p>최신 세법 변화와 실무 팁을 확인하세요.</p>
</div>
@if (posts is null)
{
<Skeleton Count="6" CssClass="site-post-grid" />
}
else if (posts.Count == 0)
{
<p>게시물이 없습니다.</p>
}
else
{
<div class="site-post-grid">
@foreach (var post in posts)
{
<article class="site-post-card">
<div class="site-post-meta">@post.CategoryName</div>
<h2>@post.Title</h2>
<p>@(post.PublishedAt ?? post.CreatedAt).ToString("yyyy-MM-dd")</p>
<a class="site-button primary" href="/taxbaik/blog/@post.Slug">글 내용 보기</a>
</article>
}
</div>
}
</section>
@code {
private List<TaxBaik.Domain.Entities.BlogPost>? posts;
protected override async Task OnInitializedAsync()
{
var (items, _) = await BlogService.GetPublishedPagedAsync(1, 12);
posts = items.ToList();
}
}
+18
View File
@@ -0,0 +1,18 @@
@page "/"
@using TaxBaik.Application.Seasonal
@using TaxBaik.Application.Services
@inject SeasonalMarketingService SeasonalMarketingService
<PageTitle>백원숙 세무회계</PageTitle>
<section class="site-hero">
<div class="site-hero-copy">
<div class="site-kicker">사업자 · 부동산 · 증여 세무 상담</div>
<h1>세금과 자산을 한 번에 정리하는 맞춤형 세무 파트너</h1>
<p>사업자 세무, 부동산 거래, 가족자산 관리를 위한 통합 상담을 제공합니다.</p>
<div class="site-actions">
<a class="site-button primary" href="/taxbaik/contact">무료 상담 신청</a>
<a class="site-button secondary" href="/taxbaik/blog">블로그 보기</a>
</div>
</div>
</section>
@@ -0,0 +1,16 @@
@page "/portal"
<PageTitle>마이 포털</PageTitle>
<section class="site-content">
<div class="site-section-header">
<h1>고객 포털</h1>
<p>포털은 다음 단계에서 세무 신고와 상담 이력 데이터에 연결됩니다.</p>
</div>
<div class="site-card">
<p>현재는 인증 연결과 데이터 바인딩을 준비하는 단계입니다.</p>
<div class="site-actions">
<a class="site-button primary" href="/taxbaik/portal/login">로그인</a>
<a class="site-button secondary" href="/taxbaik/portal/register">회원가입</a>
</div>
</div>
</section>
@@ -0,0 +1,6 @@
@page "/portal/login"
<PageTitle>고객 포털 로그인</PageTitle>
<section class="site-content">
<h1>고객 포털 로그인</h1>
<p>로그인 폼은 기존 인증 흐름을 Blazor로 옮기는 다음 단계에서 연결합니다.</p>
</section>
@@ -0,0 +1,6 @@
@page "/portal/register"
<PageTitle>고객 포털 회원가입</PageTitle>
<section class="site-content">
<h1>고객 포털 회원가입</h1>
<p>회원가입 폼은 다음 단계에서 Blazor 입력 컴포넌트로 채워집니다.</p>
</section>
+14
View File
@@ -0,0 +1,14 @@
@using Microsoft.AspNetCore.Components.Routing
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Site.SiteLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>찾을 수 없음</PageTitle>
<LayoutView Layout="@typeof(TaxBaik.Web.Components.Site.SiteLayout)">
<p>요청한 페이지를 찾을 수 없습니다.</p>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,16 @@
@inherits LayoutComponentBase
<div class="site-shell">
<header class="site-topbar">
<a class="site-logo" href="/taxbaik/">백원숙 세무회계</a>
<nav class="site-nav">
<a href="/taxbaik/blog">블로그</a>
<a href="/taxbaik/portal">포털</a>
<a href="/taxbaik/contact">상담</a>
</nav>
</header>
<main class="site-main">
@Body
</main>
</div>
@@ -0,0 +1,3 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using TaxBaik.Web.Components.Shared
@@ -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 });
}
}
}
@@ -43,9 +43,8 @@ public class RevenueTrackingController(RevenueTrackingService service) : Control
{ {
try try
{ {
// GetByIdAsync가 없으면 GetByClientIdAsync를 사용하거나 별도 구현 필요 var revenue = await service.GetByIdAsync(id);
// 임시로 구현 - 실제로는 repository에 GetByIdAsync 추가 필요 return revenue is null ? NotFound(new { error = "조회 실패", message = "해당 청구를 찾을 수 없습니다." }) : Ok(revenue);
return Ok(new { message = "조회됨" });
} }
catch (Exception ex) catch (Exception ex)
{ {
+45 -161
View File
@@ -3,171 +3,55 @@
ViewData["Title"] = "소개 | 백원숙 세무회계"; 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="container py-5">
<!-- 돌아가기 버튼 --> <h1 class="fw-bold mb-5">백원숙 세무사</h1>
<div class="mb-4">
<a href="/taxbaik/" class="btn btn-sm btn-outline-secondary">← 홈으로 돌아가기</a> <div class="row g-5">
<div class="col-md-6">
<p class="lead">사업자 세무, 부동산 거래, 가족 자산 관리 등 종합적인 세무 컨설팅을 제공합니다.</p>
<p>10년 이상의 풍부한 경험과 3개의 국가자격증을 바탕으로, 각 클라이언트의 상황에 맞는 맞춤형 솔루션을 제시합니다.</p>
</div>
<div class="col-md-6">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">보유 자격증</h5>
<div class="mb-3">
<p class="mb-1">🎓 <strong>세무사</strong></p>
<small class="text-muted">2015년 자격취득</small>
</div>
<div class="mb-3">
<p class="mb-1">🏠 <strong>부동산중개사</strong></p>
<small class="text-muted">부동산 거래 전문성</small>
</div>
<div>
<p class="mb-1">📊 <strong>보험설계사</strong></p>
<small class="text-muted">자산관리 전문성</small>
</div>
</div>
</div>
</div> </div>
<!-- Hero Section --> <hr class="my-5" />
<section class="mb-5 pb-5 border-bottom">
<h1 class="fw-bold mb-4" style="font-size: 2.5rem;">안녕하세요, 백원숙 세무사입니다.</h1>
<div class="row g-5">
<div class="col-lg-6">
<p class="lead">사업자 세무, 부동산 거래, 가족 자산 관리 등 종합적인 세무 컨설팅을 제공합니다.</p>
<p>10년 이상의 풍부한 경험과 3개의 국가자격증을 바탕으로, 각 클라이언트의 상황에 맞는 맞춤형 솔루션을 제시합니다.</p>
<p class="text-muted">저도 작게 시작하는 사업가였습니다. 처음 사업을 시작할 때의 막막함을 잘 알고 있습니다. 그 경험이 오늘날 고객분들과 소통하는 원동력입니다.</p>
</div>
<div class="col-lg-6">
<div class="bg-light p-4 rounded">
<h5 class="fw-bold mb-3">보유 자격증</h5>
<div class="mb-3">
<p class="mb-1">🎓 <strong>세무사</strong></p>
<small class="text-muted">2015년 자격취득 · 10년 경력</small>
</div>
<div class="mb-3">
<p class="mb-1">🏠 <strong>부동산중개사</strong></p>
<small class="text-muted">부동산 거래 구조 이해 · 실무 전문성</small>
</div>
<div>
<p class="mb-1">📊 <strong>보험설계사</strong></p>
<small class="text-muted">자산관리·상속 대비 전문성</small>
</div>
</div>
</div>
</div>
</section>
<!-- Expertise Section --> <h2 class="fw-bold mb-4">서비스 철학</h2>
<section class="mb-5 pb-5 border-bottom"> <div class="row g-4">
<h2 class="fw-bold mb-4">세 가지 자격의 시너지</h2> <div class="col-md-4 text-center">
<p class="text-muted mb-4">단순히 세금을 계산하는 것이 아니라, 사업 구조와 자산 흐름을 종합적으로 이해합니다.</p> <div class="mb-3" style="font-size: 2rem;">🎯</div>
<div class="row g-4"> <h5>명확한 설명</h5>
<div class="col-md-6"> <p class="small">어려운 세법을 쉽게 설명하여 이해를 높입니다</p>
<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> </div>
</section> <div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2rem;">💰</div>
<h5>최대 절세</h5>
<p class="small">법적 범위 내에서 세금을 최소화합니다</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2rem;">🤝</div>
<h5>신뢰 관계</h5>
<p class="small">장기적 파트너로서 성장을 함께 합니다</p>
</div>
</div>
<!-- Philosophy Section --> <div class="text-center mt-5">
<section class="mb-5 pb-5 border-bottom"> <a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
<h2 class="fw-bold mb-4">상담 철학</h2> </div>
<div class="row g-4">
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">🎯</div>
<h5>명확한 설명</h5>
<p class="small text-muted">어려운 세법을 쉽게 설명하여 이해를 높입니다. 전문용어로 일방적 설명하지 않습니다.</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">💰</div>
<h5>최대 절세</h5>
<p class="small text-muted">법적 범위 내에서 세금을 최소화합니다. 초기 세무 전략이 연간 수백만 원의 차이를 만듭니다.</p>
</div>
<div class="col-md-4 text-center">
<div class="mb-3" style="font-size: 2.5rem;">🤝</div>
<h5>신뢰 파트너</h5>
<p class="small text-muted">장기적 파트너로서 성장을 함께 합니다. 일회성 상담이 아닌 지속적 관계를 지향합니다.</p>
</div>
</div>
</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">
<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> </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" /> <hr class="my-4" />
<div class="article-body lh-lg markdown-body"> <div class="article-body lh-lg">
@Html.Raw(Model.HtmlContent) @Html.Raw(Model.Post.Content)
</div> </div>
<hr class="my-4" /> <hr class="my-4" />
-3
View File
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
using Markdig;
namespace TaxBaik.Web.Pages.Blog; namespace TaxBaik.Web.Pages.Blog;
@@ -10,7 +9,6 @@ public class BlogPostModel : PageModel
private readonly BlogService _blogService; private readonly BlogService _blogService;
public BlogPost? Post { get; set; } public BlogPost? Post { get; set; }
public string? HtmlContent { get; set; }
public BlogPostModel(BlogService blogService) public BlogPostModel(BlogService blogService)
{ {
@@ -22,7 +20,6 @@ public class BlogPostModel : PageModel
Post = await _blogService.GetBySlugAsync(slug); Post = await _blogService.GetBySlugAsync(slug);
if (Post != null) if (Post != null)
{ {
HtmlContent = Markdown.ToHtml(Post.Content ?? "");
_ = _blogService.IncrementViewCountAsync(Post.Id); _ = _blogService.IncrementViewCountAsync(Post.Id);
} }
} }
-4
View File
@@ -1,4 +0,0 @@
@page "/faq"
@{
Response.Redirect("/taxbaik/#faq");
}
+3 -379
View File
@@ -1,381 +1,5 @@
@page @page "/"
@model TaxBaik.Web.Pages.IndexModel
@{ @{
var season = Model.CurrentSeason; Layout = null;
ViewData["Title"] = season != null await Html.RenderComponentAsync<TaxBaik.Web.Components.Site.App>(RenderMode.ServerPrerendered);
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
} }
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
@if (Model.ActiveAnnouncements.Count > 0)
{
foreach (var notice in Model.ActiveAnnouncements)
{
<div class="announcement-bar announcement-bar--@notice.DisplayType">
<div class="container d-flex align-items-center gap-2 py-2">
<span class="announcement-icon">
@if (notice.DisplayType == "urgent") { <text>🚨</text> }
else if (notice.DisplayType == "banner") { <text>📢</text> }
else { <text>️</text> }
</span>
<span class="announcement-text fw-semibold">@notice.Title</span>
@if (!string.IsNullOrEmpty(notice.Content))
{
<span class="d-none d-md-inline text-muted small ms-2">— @notice.Content</span>
}
</div>
</div>
}
}
@* ─── Hero Section ─── *@
@if (season != null)
{
<section class="hero-section hero-section--seasonal text-white pt-5 pb-4">
<div class="container">
<div class="row align-items-center py-4">
<div class="col-lg-7">
<span class="badge bg-danger-badge mb-3 fs-6 px-3 py-2">
@season.UrgencyBadge
</span>
<h1 class="mb-3" style="white-space: pre-line;">@season.HeroHeadline</h1>
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
@season.HeroSubtext
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg fw-bold">
⏰ @season.CtaText
</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의
</a>
</div>
@if (season.DaysUntilDeadline <= 7)
{
<p class="mt-3 small" style="opacity: 0.8;">
마감까지 <strong>@(season.DaysUntilDeadline)일</strong> 남았습니다.
지금 바로 상담 신청하세요.
</p>
}
</div>
<div class="col-lg-5 d-none d-lg-block text-center">
<div class="seasonal-deadline-badge">
<div class="deadline-label">마감</div>
<div class="deadline-date">@season.Deadline.ToString("M월 d일")</div>
<div class="deadline-days">D-@season.DaysUntilDeadline</div>
</div>
</div>
</div>
</div>
</section>
}
else
{
<section class="hero-section text-white pt-5 pb-4">
<div class="container">
<div class="row align-items-center py-4">
<div class="col-lg-7">
<span class="badge bg-primary-badge mb-3">경험 있는 세무사의 맞춤 전략</span>
<h1 class="mb-3">
세금과 자산<br/>
<span style="color: #E8E4D8;">한 번에 해결하는</span>
</h1>
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
사업자 세무, 부동산 거래, 가족자산 관리를 위한<br/>
통합 솔루션을 제공합니다.
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의
</a>
</div>
</div>
<div class="col-lg-5 d-none d-lg-block text-center">
<div style="font-size: 120px; opacity: 0.15;">📋</div>
</div>
</div>
</div>
</section>
}
<!-- About 링크 배너 -->
<section class="py-3" style="background: rgba(46, 92, 78, 0.05); border-bottom: 1px solid rgba(46, 92, 78, 0.1);">
<div class="container">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<p class="mb-0 small text-muted">세무사의 경력, 자격, 상담 철학을 알아보세요</p>
</div>
<a href="/taxbaik/about" class="btn btn-sm btn-outline-primary">백원숙 세무사 소개 →</a>
</div>
</div>
</section>
<!-- 서비스 영역 -->
<section class="py-5">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">전문 서비스</h2>
<p class="fs-6 text-muted" style="max-width: 600px; margin: 0 auto;">
각 분야의 복잡한 세무 이슈를 경험과 노하우로 해결합니다
</p>
</div>
@{
var focusService = season?.FocusService ?? "";
// 시즌에 따라 서비스 카드 순서 결정
var cardOrder = focusService switch
{
"real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" },
"family-asset" => new[] { "family-asset", "business-tax", "real-estate-tax" },
_ => new[] { "business-tax", "real-estate-tax", "family-asset" }
};
}
<div class="row g-4">
@foreach (var cardKey in cardOrder)
{
var isFeatured = cardKey == focusService;
if (cardKey == "business-tax")
{
<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="card-body pt-0">
<h3 class="card-title">사업자 세무</h3>
<p class="text-muted small">월 기장부터 종합소득세, 신규 사업자 세무까지 — 사업 초기부터 체계적인 세무 관리.</p>
<a href="/taxbaik/services#business-tax" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
else if (cardKey == "real-estate-tax")
{
<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="card-body pt-0">
<h3 class="card-title">부동산 세금</h3>
<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>
</div>
}
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="card-body pt-0">
<h3 class="card-title">가족자산 관리</h3>
<p class="text-muted small">증여·상속 사전 계획부터 대표자 리스크 관리까지 — 가족 자산을 지키는 전략.</p>
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div>
</div>
</div>
}
}
</div>
</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">
<div class="text-center mb-5">
<h2 class="section-title">상담 과정</h2>
</div>
<div class="row align-items-center">
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📞
</div>
<h4>1단계: 무료 상담</h4>
<p class="text-muted small">상황 파악 및<br/>현재 문제점 확인</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📋
</div>
<h4>2단계: 세무진단</h4>
<p class="text-muted small">자료 분석 및<br/>최적 방안 도출</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
💡
</div>
<h4>3단계: 맞춤제안</h4>
<p class="text-muted small">절세 전략 및<br/>실행 계획 제시</p>
</div>
<div class="col-md-3 text-center">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
</div>
<h4>4단계: 실행지원</h4>
<p class="text-muted small">지속적 관리 및<br/>추가 상담 제공</p>
</div>
</div>
<div class="text-center mt-5">
<p class="text-muted mb-3">상담은 온라인 또는 오프라인으로 진행되며, 완전히 비밀이 보장됩니다.</p>
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
</div>
</div>
</section>
<!-- 자주 묻는 질문 (DB 연동) -->
@if (Model.ActiveFaqs.Count > 0)
{
<section class="py-5" style="background: #F9F7F3;">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">자주 묻는 질문</h2>
<p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p>
</div>
<div class="accordion faq-accordion" id="faqAccordion">
@for (int i = 0; i < Model.ActiveFaqs.Count; i++)
{
var faqItem = Model.ActiveFaqs[i];
var collapseId = $"faq-{faqItem.Id}";
<div class="accordion-item faq-item">
<h3 class="accordion-header">
<button class="accordion-button collapsed faq-question" type="button"
data-bs-toggle="collapse" data-bs-target="#@collapseId">
@faqItem.Question
</button>
</h3>
<div id="@collapseId" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body faq-answer">
@faqItem.Answer
</div>
</div>
</div>
}
</div>
<div class="text-center mt-5">
<p class="text-muted mb-3">더 궁금한 점이 있으시면 바로 문의해 주세요</p>
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 문의하기</a>
</div>
</div>
</section>
}
<!-- 최종 CTA -->
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
<div class="container text-center">
@if (season != null)
{
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">@season.Name 마감이 다가옵니다!</h2>
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
마감 <strong>D-@(season.DaysUntilDeadline)일</strong> — 지금 바로 상담을 신청하세요.<br/>
빠른 검토로 불이익 없이 신고를 완료합니다.
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
</div>
}
else
{
<h2 class="mb-3 fw-bold" style="font-size: 2.5rem;">세금 고민은 이제 끝!</h2>
<p class="fs-5 mb-5" style="opacity: 0.95; max-width: 500px; margin-left: auto; margin-right: auto;">
무료 상담으로 현재 상황을 진단하고<br/>
맞춤형 절세 전략을 받아보세요.
</p>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a>
</div>
}
</div>
</section>
-76
View File
@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Seasonal;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Web.Pages;
public class IndexModel : PageModel
{
private readonly BlogService _blogService;
private readonly SeasonalMarketingService _seasonalMarketingService;
private readonly AnnouncementService _announcementService;
private readonly FaqService _faqService;
public List<BlogPost> RecentPosts { get; set; } = [];
public List<BlogPost> SeasonalPosts { get; set; } = [];
public CurrentSeasonDto? CurrentSeason { get; set; }
public List<Announcement> ActiveAnnouncements { get; set; } = [];
public List<Faq> ActiveFaqs { get; set; } = [];
public IndexModel(
BlogService blogService,
SeasonalMarketingService seasonalMarketingService,
AnnouncementService announcementService,
FaqService faqService)
{
_blogService = blogService;
_seasonalMarketingService = seasonalMarketingService;
_announcementService = announcementService;
_faqService = faqService;
}
public async Task OnGetAsync()
{
CurrentSeason = _seasonalMarketingService.GetCurrentSeason();
var announcementsTask = LoadSafeAsync(() => _announcementService.GetActiveAsync());
var faqsTask = LoadSafeAsync(() => _faqService.GetActiveAsync());
var blogTask = LoadBlogAsync();
await Task.WhenAll(announcementsTask, faqsTask, blogTask);
ActiveAnnouncements = (await announcementsTask)?.ToList() ?? [];
ActiveFaqs = (await faqsTask)?.ToList() ?? [];
}
private async Task LoadBlogAsync()
{
try
{
if (CurrentSeason is not null && !string.IsNullOrEmpty(CurrentSeason.RelatedCategorySlug))
{
var (seasonal, latest) = await _blogService.GetSeasonalPostsAsync(
CurrentSeason.RelatedCategorySlug, seasonalCount: 2, totalCount: 3);
SeasonalPosts = seasonal.ToList();
RecentPosts = latest.ToList();
}
else
{
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
RecentPosts = posts.ToList();
}
}
catch
{
RecentPosts = [];
SeasonalPosts = [];
}
}
private static async Task<IEnumerable<T>?> LoadSafeAsync<T>(Func<Task<IEnumerable<T>>> loader)
{
try { return await loader(); }
catch { return null; }
}
}
-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"> <div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) --> <!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card glass-card mb-4"> <div class="card border-0 shadow-sm rounded-3 mb-4">
<div class="card-body p-4"> <div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="h5 fw-bold text-dark mb-0"> <h3 class="h5 fw-bold text-dark mb-0">
@@ -124,7 +124,7 @@
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) --> <!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card glass-card"> <div class="card border-0 shadow-sm rounded-3">
<div class="card-body p-4"> <div class="card-body p-4">
<h3 class="h5 fw-bold text-dark mb-4"> <h3 class="h5 fw-bold text-dark mb-4">
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력 <i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
@@ -139,10 +139,14 @@
} }
else else
{ {
<div class="timeline ps-2"> <div class="timeline">
@foreach (var activity in Model.Consultations) @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"> <div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span> <span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small> <small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
+1 -39
View File
@@ -4,20 +4,7 @@
ViewData["Description"] = "사업자 세무, 부동산 세금, 종합소득세 등 전문 상담 서비스"; 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="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> <h1 class="fw-bold mb-5 text-center">주요 서비스</h1>
<!-- 사업자 세무 --> <!-- 사업자 세무 -->
@@ -137,36 +124,11 @@
</section> </section>
<!-- CTA --> <!-- 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"> <div class="text-center">
<h2 class="fw-bold mb-3">전문 상담받으세요</h2> <h2 class="fw-bold mb-3">전문 상담받으세요</h2>
<p class="lead mb-4">정확한 진단 후 맞춤형 솔루션을 제시합니다</p> <p class="lead mb-4">정확한 진단 후 맞춤형 솔루션을 제시합니다</p>
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">무료 상담 신청</a> <a href="/taxbaik/contact" class="btn btn-warning btn-lg">무료 상담 신청</a>
</div> </div>
</section> </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> </div>
+15 -31
View File
@@ -26,12 +26,10 @@
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<meta name="theme-color" content="#C89D6E" /> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" /> <link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" /> <link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@@ -62,51 +60,37 @@
<main role="main" class="pb-5"> <main role="main" class="pb-5">
@RenderBody() @RenderBody()
</main> </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="container">
<div class="row g-5"> <div class="row g-4">
<div class="col-md-3"> <div class="col-md-4">
<h6 class="fw-bold mb-3">백원숙 세무회계</h6> <h6 class="fw-bold">백원숙 세무회계</h6>
<p class="small text-muted"> <p class="small text-muted">
사업자 기장, 부동산 양도세·증여세,<br /> 사업자 기장, 부동산 양도세·증여세,<br />
종합소득세 전문 상담 종합소득세 전문 상담
</p> </p>
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
<h6 class="fw-bold mb-3">메뉴</h6> <h6 class="fw-bold">연락처</h6>
<ul class="list-unstyled small">
<li class="mb-2"><a href="/taxbaik/" class="text-decoration-none text-muted">홈</a></li>
<li class="mb-2"><a href="/taxbaik/about" class="text-decoration-none text-muted">세무사 소개</a></li>
<li class="mb-2"><a href="/taxbaik/services" class="text-decoration-none text-muted">전문 서비스</a></li>
<li class="mb-2"><a href="/taxbaik/blog" class="text-decoration-none text-muted">세무 정보</a></li>
<li><a href="/taxbaik/contact" class="text-decoration-none text-muted">상담 신청</a></li>
</ul>
</div>
<div class="col-md-3">
<h6 class="fw-bold mb-3">연락처</h6>
<p class="small"> <p class="small">
📞 <a href="tel:010-4122-8268" class="text-decoration-none text-muted">010-4122-8268</a><br /> 📞 <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 text-muted">taxbaik5668@gmail.com</a> 📧 <a href="mailto:taxbaik5668@gmail.com" class="text-decoration-none">taxbaik5668@gmail.com</a>
</p> </p>
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
<h6 class="fw-bold mb-3">채널</h6> <h6 class="fw-bold">채널</h6>
<p class="small"> <p class="small">
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-sm btn-warning me-2">카카오톡</a> <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> <a href="https://www.instagram.com/taxtory5668/" target="_blank" class="btn btn-sm btn-outline-secondary">Instagram</a>
</p> </p>
</div> </div>
</div> </div>
<hr class="my-4" /> <hr class="my-3" />
<div class="text-center small text-muted"> <div class="text-center small text-muted">
<p>© 2026 백원숙 세무회계. All rights reserved.</p> <p>© 2026 백원숙 세무회계. All rights reserved.</p>
<div class="mb-2"> <a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a>
<a href="/taxbaik/privacy" class="text-decoration-none text-muted me-2">개인정보처리방침</a> <a href="/taxbaik/terms" class="text-decoration-none text-muted">이용약관</a>
<span class="text-muted">|</span> <a href="/taxbaik/portal" class="text-decoration-none text-muted ms-2">고객 포털</a>
<a href="/taxbaik/terms" class="text-decoration-none text-muted ms-2 me-2">이용약관</a>
<span class="text-muted">|</span>
<a href="/taxbaik/portal" class="text-decoration-none text-muted ms-2">고객 포털</a>
</div>
@if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version) @if (Context.RequestServices.GetService(typeof(VersionInfo)) is VersionInfo version)
{ {
<div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;"> <div class="mt-2 text-muted" style="font-size: 0.75rem; opacity: 0.6;">
+3
View File
@@ -0,0 +1,3 @@
@using TaxBaik.Web
@namespace TaxBaik.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+16 -44
View File
@@ -8,8 +8,8 @@ using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using MudBlazor.Services;
using Serilog; using Serilog;
using TaxBaik.Application; using TaxBaik.Application;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
@@ -54,9 +54,7 @@ builder.Services.AddHealthChecks();
// Razor Pages + Blazor Server 통합 // Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents().AddInteractiveServerComponents();
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options => builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{ {
options.DetailedErrors = true; options.DetailedErrors = true;
@@ -210,66 +208,56 @@ var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client => builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client => builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
// Phase 5: Tax Accounting & CRM Browser Clients // Phase 5: Tax Accounting & CRM Browser Clients
builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client => builder.Services.AddHttpClient<ITaxProfileBrowserClient, TaxProfileBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client => builder.Services.AddHttpClient<ITaxFilingScheduleBrowserClient, TaxFilingScheduleBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client => builder.Services.AddHttpClient<IConsultingActivityBrowserClient, ConsultingActivityBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client => builder.Services.AddHttpClient<IContractBrowserClient, ContractBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>(); });
builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client => builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBrowserClient>(client =>
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<TokenRefreshHandler>();
// UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
}); });
// UI & 캐시 (Fluent UI Blazor v5 우선)
builder.Services.AddFluentUIComponents();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddResponseCompression(opts => { builder.Services.AddResponseCompression(opts => {
opts.Providers.Add<GzipCompressionProvider>(); opts.Providers.Add<GzipCompressionProvider>();
@@ -315,20 +303,6 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto 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) // Run migrations on startup (non-blocking for development)
try try
{ {
@@ -368,10 +342,8 @@ app.MapRazorPages();
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다. // AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다. // 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>() app.MapRazorComponents<TaxBaik.Web.Components.Site.App>()
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(TaxBaik.WasmClient._Imports).Assembly)
.AllowAnonymous(); .AllowAnonymous();
// 애플리케이션 시작/종료 로깅 // 애플리케이션 시작/종료 로깅

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