Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c8f30af6d | |||
| 7e3b4e2229 | |||
| 67bd5dc666 | |||
| 84161ee2d9 | |||
| 5aec36b155 | |||
| 3ab8971025 | |||
| db30e71e0a | |||
| e4c2758dea | |||
| 75661aa0ef | |||
| 3303ba2e96 | |||
| 43c2ff6ad9 | |||
| a7bb8d7149 | |||
| 791ce6d526 | |||
| 61083a5bb1 | |||
| 66fb86d23c | |||
| 16f7c6097c | |||
| 7232635ed0 | |||
| b42b98d560 | |||
| f216660afa | |||
| b31b43e30e | |||
| 86bd9ef8ff | |||
| 2fd9984a45 | |||
| 91330ec94c | |||
| 08102c8684 | |||
| e2472b7ea1 | |||
| 033883aac5 | |||
| d2cfcd90f0 | |||
| 42e73fa694 | |||
| f8f8f869fc | |||
| db7f903054 | |||
| 0d7a081f5a | |||
| 0bd36ae26f | |||
| 447a62c0fb | |||
| a16438dcc6 | |||
| ebd12b78a0 | |||
| 4b62d35266 | |||
| c38b97377a | |||
| 59f1509368 | |||
| c2955ad02f | |||
| ea40e5c002 | |||
| 7dd51a1169 | |||
| c65742a0c7 | |||
| 52f1790acb | |||
| cd3bc8357c | |||
| 53beb8a6e4 | |||
| d3b4d59f3c | |||
| 691e4406f3 | |||
| db2af15a07 | |||
| 2bde490e9e | |||
| e797da6140 | |||
| 0265d7ec8c | |||
| 09420dca0e | |||
| e3a0ea03f0 | |||
| ba2cb85fd2 | |||
| 74ee47a269 | |||
| 2af7050800 | |||
| fb9c77943f | |||
| 27f57ff925 | |||
| 79d99cfd7a | |||
| 1a761e8e15 | |||
| c01933e295 | |||
| 73da1859fe | |||
| 68588a8491 | |||
| 0b6a64fbad | |||
| 96df0dd9b1 | |||
| 351c7ac82c | |||
| ad48befb9a | |||
| 804725a785 | |||
| 41c8106a10 | |||
| 472431d45a | |||
| 33ea84fb2b | |||
| 73a564c307 | |||
| 223f365dfd | |||
| 61931ab8eb | |||
| 71d5d2cc1f | |||
| db81f94051 | |||
| 700cdaed4f | |||
| 65241c453c | |||
| b3baef012d | |||
| 0d07b2d26a | |||
| 65c2dce8fe |
@@ -3,3 +3,10 @@ ASPNETCORE_URLS=http://0.0.0.0:5001
|
|||||||
ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me
|
ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me
|
||||||
Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars!
|
Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars!
|
||||||
Admin__PasswordResetToken=change-this-reset-token
|
Admin__PasswordResetToken=change-this-reset-token
|
||||||
|
Authentication__Google__ClientId=
|
||||||
|
Authentication__Google__ClientSecret=
|
||||||
|
Authentication__Naver__ClientId=
|
||||||
|
Authentication__Naver__ClientSecret=
|
||||||
|
Authentication__Kakao__ClientId=
|
||||||
|
Authentication__Kakao__ClientSecret=
|
||||||
|
# CI deploy trigger requires a real push on master.
|
||||||
|
|||||||
@@ -45,26 +45,32 @@ jobs:
|
|||||||
# Extract short commit hash (first 7 characters)
|
# Extract short commit hash (first 7 characters)
|
||||||
SHORT_VERSION=$(echo "$EXPECTED_VERSION" | cut -c1-7)
|
SHORT_VERSION=$(echo "$EXPECTED_VERSION" | cut -c1-7)
|
||||||
echo "Expected short version: $SHORT_VERSION"
|
echo "Expected short version: $SHORT_VERSION"
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 20); do
|
||||||
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
# Suppress stderr and allow failures to handle transition/down periods cleanly
|
||||||
VERSION_BODY="$(curl -fsS "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)"
|
||||||
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" ]; then
|
||||||
echo "Deployment is ready for ${SHORT_VERSION}"
|
echo "✓ Deployment ready for ${SHORT_VERSION} (attempt $i/20)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Waiting for deployment ${SHORT_VERSION} (attempt $i/30); blog status=${BLOG_STATUS:-down}; version=${VERSION_BODY:-unknown}"
|
if [ $i -lt 20 ]; then
|
||||||
sleep 5
|
echo " Attempt $i/20: waiting for deployment... (blog=${BLOG_STATUS:-?}, version=${VERSION_BODY:0:30}...)"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
echo "Deployment did not publish expected version ${SHORT_VERSION} in time" >&2
|
echo "✗ TIMEOUT: Deployment failed to publish ${SHORT_VERSION} within 60 seconds" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Browser E2E verification
|
- name: Browser E2E verification
|
||||||
env:
|
env:
|
||||||
|
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
|
||||||
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
||||||
E2E_ADMIN_USERNAME: admin
|
# E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리)
|
||||||
E2E_ADMIN_PASSWORD: ${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}
|
E2E_ADMIN_USERNAME: test_admin
|
||||||
run: npm run test:e2e
|
E2E_ADMIN_PASSWORD: TestAdmin@123456
|
||||||
|
run: |
|
||||||
|
echo "Running E2E tests on Desktop Chrome (production verification)"
|
||||||
|
npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list
|
||||||
|
|
||||||
- name: Browser E2E summary
|
- name: Browser E2E summary
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: TaxBaik CI/CD
|
name: TaxBaik CI/CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@@ -38,18 +39,29 @@ jobs:
|
|||||||
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
|
||||||
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||||
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
|
||||||
|
TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
|
||||||
|
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||||
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
|
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
|
||||||
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
|
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
|
||||||
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
|
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
|
||||||
|
[ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
|
||||||
|
[ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
|
||||||
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||||
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
|
||||||
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
|
||||||
|
TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
|
||||||
|
TELEGRAM_SYSTEM_CHAT_ID="$TELEGRAM_SYSTEM_CHAT_ID" \
|
||||||
python3 -c '
|
python3 -c '
|
||||||
import json, os, pathlib
|
import json, os, pathlib
|
||||||
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
pathlib.Path("./publish/appsettings.Production.json").write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
|
||||||
"Telegram": {"BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "ChatId": os.environ["TELEGRAM_CHAT_ID"]}
|
"Telegram": {
|
||||||
|
"BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
|
||||||
|
"ChatId": os.environ["TELEGRAM_CHAT_ID"],
|
||||||
|
"InquiryChatId": os.environ["TELEGRAM_INQUIRY_CHAT_ID"],
|
||||||
|
"SystemChatId": os.environ["TELEGRAM_SYSTEM_CHAT_ID"]
|
||||||
|
}
|
||||||
}, ensure_ascii=False, indent=2),
|
}, ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)'
|
)'
|
||||||
@@ -98,6 +110,34 @@ jobs:
|
|||||||
COMMIT=$(git rev-parse --short HEAD)
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
|
||||||
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
|
||||||
|
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
|
||||||
|
TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
|
||||||
|
TELEGRAM_CHAT_ID="${TELEGRAM_SYSTEM_CHAT_ID:--5585148480}"
|
||||||
|
|
||||||
|
send_telegram() {
|
||||||
|
local text="$1"
|
||||||
|
if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
|
||||||
|
echo "Skipping Telegram notification: missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||||
|
--data-urlencode "text=${text}" \
|
||||||
|
-d "parse_mode=HTML" >/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_failure() {
|
||||||
|
local exit_code=$?
|
||||||
|
send_telegram "❌ <b>TaxBaik 배포 실패</b>
|
||||||
|
|
||||||
|
커밋: <code>${COMMIT}</code>
|
||||||
|
시간: <code>${TIMESTAMP}</code>
|
||||||
|
단계: CI/CD deploy"
|
||||||
|
exit "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap notify_failure ERR
|
||||||
|
|
||||||
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
|
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
|
||||||
|
|
||||||
@@ -105,7 +145,7 @@ jobs:
|
|||||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||||
taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz"
|
taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz"
|
||||||
|
|
||||||
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리)
|
# 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" bash << REMOTE
|
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
||||||
@@ -129,8 +169,8 @@ jobs:
|
|||||||
echo "--- [4/5] 서비스 재시작 ---"
|
echo "--- [4/5] 서비스 재시작 ---"
|
||||||
sudo /usr/bin/systemctl restart taxbaik
|
sudo /usr/bin/systemctl restart taxbaik
|
||||||
|
|
||||||
echo "--- [5/5] 헬스 체크 (최대 120초) ---"
|
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
|
||||||
ATTEMPTS=40
|
ATTEMPTS=20
|
||||||
for i in \$(seq 1 \$ATTEMPTS); do
|
for i in \$(seq 1 \$ATTEMPTS); do
|
||||||
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/ 2>/dev/null || echo "000")
|
STATUS=\$(curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:5001/taxbaik/ 2>/dev/null || echo "000")
|
||||||
if [ "\$STATUS" = "200" ]; then
|
if [ "\$STATUS" = "200" ]; then
|
||||||
@@ -179,3 +219,9 @@ jobs:
|
|||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||||
|
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
||||||
|
|
||||||
|
커밋: <code>${COMMIT}</code>
|
||||||
|
시간: <code>${TIMESTAMP}</code>
|
||||||
|
대상: <code>${DEPLOY_HOST}</code>
|
||||||
|
채널: <code>${TELEGRAM_CHAT_ID}</code>"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
||||||
|
|
||||||
|
CI deploy trigger verification note.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ public class InquiryServiceTests
|
|||||||
inquiry.ClientId = clientId;
|
inquiry.ClientId = clientId;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
|
||||||
|
if (inquiry != null)
|
||||||
|
Inquiries.Remove(inquiry);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeInquiryNotificationService : IInquiryNotificationService
|
private sealed class FakeInquiryNotificationService : IInquiryNotificationService
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<FaqService>();
|
services.AddScoped<FaqService>();
|
||||||
services.AddScoped<ConsultationService>();
|
services.AddScoped<ConsultationService>();
|
||||||
services.AddScoped<TaxFilingService>();
|
services.AddScoped<TaxFilingService>();
|
||||||
|
services.AddScoped<CompanyService>();
|
||||||
|
services.AddScoped<TaxProfileService>();
|
||||||
|
services.AddScoped<TaxFilingScheduleService>();
|
||||||
|
services.AddScoped<ConsultingActivityService>();
|
||||||
|
services.AddScoped<ContractService>();
|
||||||
|
services.AddScoped<RevenueTrackingService>();
|
||||||
|
services.AddScoped<TelegramReportService>();
|
||||||
|
services.AddScoped<PortalUserService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ using Microsoft.Extensions.Caching.Memory;
|
|||||||
|
|
||||||
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache)
|
public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCache)
|
||||||
{
|
{
|
||||||
|
public async Task<BlogPost?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
||||||
await repository.GetBySlugAsync(slug, ct);
|
await repository.GetBySlugAsync(slug, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ public class ClientService(IClientRepository repository)
|
|||||||
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
await repository.GetByIdAsync(id, ct);
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByEmailAsync(email, ct);
|
||||||
|
|
||||||
|
public async Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByPhoneAsync(phone, ct);
|
||||||
|
|
||||||
|
public async Task<int> CountCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default) =>
|
||||||
|
await repository.CountByCreatedAtRangeAsync(startDateUtc, endDateUtc, ct);
|
||||||
|
|
||||||
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
|
public async Task<int> CreateAsync(CreateClientDto dto, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class CompanyService(ICompanyRepository repository)
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(string companyCode, string companyName, string? contactPerson = null,
|
||||||
|
string? phone = null, string? email = null, string? memo = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(companyCode))
|
||||||
|
throw new ValidationException("회사 코드를 입력하세요.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(companyName))
|
||||||
|
throw new ValidationException("회사명을 입력하세요.");
|
||||||
|
|
||||||
|
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||||
|
if (existing != null)
|
||||||
|
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||||
|
|
||||||
|
var company = new Company
|
||||||
|
{
|
||||||
|
CompanyCode = companyCode.Trim(),
|
||||||
|
CompanyName = companyName.Trim(),
|
||||||
|
ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim(),
|
||||||
|
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
|
||||||
|
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||||
|
Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim(),
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(company, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Company?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<Company?> GetByCodeAsync(string code, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByCodeAsync(code, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetAllActiveAsync(ct);
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<Company>, int)> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (items, total) = await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||||
|
return (items, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, string companyCode, string companyName, string? contactPerson = null,
|
||||||
|
string? phone = null, string? email = null, string? memo = null, bool isActive = true, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(companyCode))
|
||||||
|
throw new ValidationException("회사 코드를 입력하세요.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(companyName))
|
||||||
|
throw new ValidationException("회사명을 입력하세요.");
|
||||||
|
|
||||||
|
var company = await repository.GetByIdAsync(id, ct);
|
||||||
|
if (company == null)
|
||||||
|
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||||
|
|
||||||
|
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||||
|
if (existing != null && existing.Id != id)
|
||||||
|
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||||
|
|
||||||
|
company.CompanyCode = companyCode.Trim();
|
||||||
|
company.CompanyName = companyName.Trim();
|
||||||
|
company.ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim();
|
||||||
|
company.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
|
||||||
|
company.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
|
||||||
|
company.Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim();
|
||||||
|
company.IsActive = isActive;
|
||||||
|
company.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await repository.UpdateAsync(company, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var company = await repository.GetByIdAsync(id, ct);
|
||||||
|
if (company == null)
|
||||||
|
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||||
|
|
||||||
|
if (company.CompanyCode == "DEFAULT")
|
||||||
|
throw new ValidationException("기본 회사는 삭제할 수 없습니다.");
|
||||||
|
|
||||||
|
await repository.DeleteAsync(id, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||||
|
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class ConsultingActivityService(IConsultingActivityRepository repository)
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate,
|
||||||
|
string description, int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (clientId <= 0)
|
||||||
|
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(activityType))
|
||||||
|
throw new ValidationException("활동 유형을 입력하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
throw new ValidationException("활동 내용을 입력하세요.");
|
||||||
|
|
||||||
|
var activity = new ConsultingActivity
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
ActivityType = activityType.Trim(),
|
||||||
|
ActivityDate = activityDate,
|
||||||
|
Description = description.Trim(),
|
||||||
|
AssignedConsultantId = consultantId,
|
||||||
|
NextFollowupDate = nextFollowupDate,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(activity, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetPendingFollowupsAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ConsultingActivity>> GetConsultantActivityAsync(int consultantId, DateTime fromDate, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByConsultantAsync(consultantId, fromDate, ct);
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, string? outcome, DateTime? nextFollowupDate, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var activity = new ConsultingActivity { Id = id, Outcome = outcome, NextFollowupDate = nextFollowupDate, UpdatedAt = DateTime.UtcNow };
|
||||||
|
await repository.UpdateAsync(activity, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class ContractService(IContractRepository repository)
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType,
|
||||||
|
DateTime startDate, decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (clientId <= 0)
|
||||||
|
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(contractNumber))
|
||||||
|
throw new ValidationException("계약 번호를 입력하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(serviceType))
|
||||||
|
throw new ValidationException("서비스 유형을 입력하세요.");
|
||||||
|
|
||||||
|
var contract = new Contract
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
ContractNumber = contractNumber.Trim(),
|
||||||
|
ServiceType = serviceType.Trim(),
|
||||||
|
ContractDate = DateTime.Today,
|
||||||
|
StartDate = startDate,
|
||||||
|
MonthlyFee = monthlyFee,
|
||||||
|
TotalAmount = totalAmount,
|
||||||
|
Status = "active",
|
||||||
|
PaymentStatus = "pending",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(contract, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetActiveContractsAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||||
|
await repository.GetExpiringContractsAsync(daysAhead, ct);
|
||||||
|
|
||||||
|
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetMonthlyRecurringRevenueAsync(ct);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ public class InquiryService(
|
|||||||
|
|
||||||
public async Task<int> SubmitAsync(
|
public async Task<int> SubmitAsync(
|
||||||
string name, string phone, string serviceType, string message,
|
string name, string phone, string serviceType, string message,
|
||||||
string? email = null, string? ipAddress = null, CancellationToken ct = default)
|
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
throw new ValidationException("이름을 입력하세요.");
|
throw new ValidationException("이름을 입력하세요.");
|
||||||
@@ -39,7 +39,10 @@ public class InquiryService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
||||||
|
if (!suppressNotification)
|
||||||
|
{
|
||||||
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
|
||||||
|
}
|
||||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
return inquiryId;
|
return inquiryId;
|
||||||
}
|
}
|
||||||
@@ -89,6 +92,12 @@ public class InquiryService(
|
|||||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await repository.DeleteAsync(id, ct);
|
||||||
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||||
|
|
||||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class PortalUserService(IPortalUserRepository repository)
|
||||||
|
{
|
||||||
|
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByEmailAsync(email.Trim(), ct);
|
||||||
|
|
||||||
|
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByProviderAsync(provider.Trim(), providerId.Trim(), ct);
|
||||||
|
|
||||||
|
public async Task<int> RegisterLocalAsync(string name, string email, string phone, string? passwordHash, int? clientId = null, CancellationToken ct = default) =>
|
||||||
|
await RegisterAsync(name, email, phone, "local", null, passwordHash, clientId, ct);
|
||||||
|
|
||||||
|
public async Task<int> RegisterOAuthAsync(string name, string email, string? phone, string provider, string providerId, int? clientId = null, CancellationToken ct = default) =>
|
||||||
|
await RegisterAsync(name, email, phone, provider, providerId, null, clientId, ct);
|
||||||
|
|
||||||
|
public async Task LinkOAuthAsync(PortalUser user, string provider, string providerId, string? displayName = null, string? email = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
user.Name = string.IsNullOrWhiteSpace(displayName) ? user.Name : displayName.Trim();
|
||||||
|
user.Email = string.IsNullOrWhiteSpace(email) ? user.Email : email.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(user.PasswordHash))
|
||||||
|
{
|
||||||
|
user.Provider = provider.Trim();
|
||||||
|
user.ProviderId = providerId.Trim();
|
||||||
|
}
|
||||||
|
await repository.UpdateAsync(user, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AttachClientAsync(PortalUser user, int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
user.ClientId = clientId;
|
||||||
|
await repository.UpdateAsync(user, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> RegisterAsync(string name, string email, string? phone, string provider, string? providerId, string? passwordHash, int? clientId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
throw new ValidationException("이름을 입력하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
throw new ValidationException("이메일을 입력하세요.");
|
||||||
|
|
||||||
|
var user = new PortalUser
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
Name = name.Trim(),
|
||||||
|
Email = email.Trim(),
|
||||||
|
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
|
||||||
|
Provider = provider,
|
||||||
|
ProviderId = providerId,
|
||||||
|
PasswordHash = passwordHash,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(user, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class RevenueTrackingService(IRevenueTrackingRepository repository)
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate,
|
||||||
|
decimal amount, string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (clientId <= 0)
|
||||||
|
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||||
|
throw new ValidationException("인보이스 번호를 입력하세요.");
|
||||||
|
if (amount <= 0)
|
||||||
|
throw new ValidationException("금액은 0보다 커야 합니다.");
|
||||||
|
|
||||||
|
var revenue = new RevenueTracking
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
InvoiceNumber = invoiceNumber.Trim(),
|
||||||
|
InvoiceDate = invoiceDate,
|
||||||
|
Amount = amount,
|
||||||
|
ServiceType = string.IsNullOrWhiteSpace(serviceType) ? null : serviceType.Trim(),
|
||||||
|
DueDate = dueDate,
|
||||||
|
PaymentStatus = "pending",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(revenue, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetPendingPaymentsAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetMonthlyRevenueAsync(DateTime month, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var startDate = new DateTime(month.Year, month.Month, 1);
|
||||||
|
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||||
|
return await repository.GetByDateRangeAsync(startDate, endDate, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default) =>
|
||||||
|
await repository.MarkPaidAsync(id, paymentDate, ct);
|
||||||
|
|
||||||
|
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default) =>
|
||||||
|
await repository.GetTotalRevenueAsync(startDate, endDate, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository)
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||||
|
int? assignedToId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (clientId <= 0)
|
||||||
|
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(filingType))
|
||||||
|
throw new ValidationException("신고 유형을 입력하세요.");
|
||||||
|
if (dueDate < DateTime.Today)
|
||||||
|
throw new ValidationException("마감일은 오늘 이후여야 합니다.");
|
||||||
|
|
||||||
|
var schedule = new TaxFilingSchedule
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
FilingType = filingType.Trim(),
|
||||||
|
DueDate = dueDate,
|
||||||
|
FilingYear = filingYear,
|
||||||
|
Status = "pending",
|
||||||
|
AssignedToId = assignedToId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(schedule, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default) =>
|
||||||
|
await repository.GetUpcomingDuesAsync(daysAhead, ct);
|
||||||
|
|
||||||
|
public async Task MarkCompletedAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.MarkCompletedAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<int> GetPendingCountAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var pending = await repository.GetByStatusAsync("pending", ct);
|
||||||
|
return pending.Count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class TaxProfileService(ITaxProfileRepository repository)
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(int clientId, string? businessType, string? businessRegistration = null,
|
||||||
|
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (clientId <= 0)
|
||||||
|
throw new ValidationException("유효한 고객을 선택하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(businessType))
|
||||||
|
throw new ValidationException("사업 유형을 입력하세요.");
|
||||||
|
|
||||||
|
var profile = new TaxProfile
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
BusinessType = businessType.Trim(),
|
||||||
|
BusinessRegistration = string.IsNullOrWhiteSpace(businessRegistration) ? null : businessRegistration.Trim(),
|
||||||
|
EstablishmentDate = establishmentDate,
|
||||||
|
AccountingMethod = string.IsNullOrWhiteSpace(accountingMethod) ? null : accountingMethod.Trim(),
|
||||||
|
TaxRiskLevel = "normal",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
return await repository.CreateAsync(profile, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByClientIdAsync(clientId, ct);
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||||
|
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var profile = new TaxProfile { Id = profileId };
|
||||||
|
if (!string.IsNullOrWhiteSpace(businessType))
|
||||||
|
profile.BusinessType = businessType.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(accountingMethod))
|
||||||
|
profile.AccountingMethod = accountingMethod.Trim();
|
||||||
|
profile.NextFilingDueDate = nextFilingDueDate;
|
||||||
|
profile.TaxRiskLevel = taxRiskLevel;
|
||||||
|
profile.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await repository.UpdateAsync(profile, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetByRiskLevelAsync("high", ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var startDate = DateTime.Today;
|
||||||
|
var endDate = startDate.AddDays(daysAhead);
|
||||||
|
return await repository.GetUpcomingFilingDuesAsync(startDate, endDate, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
public record TelegramDailyReport(
|
||||||
|
DateOnly Date,
|
||||||
|
int NewInquiries,
|
||||||
|
int PendingInquiries,
|
||||||
|
int NewClients,
|
||||||
|
int PendingTaxFilings,
|
||||||
|
int PendingPayments);
|
||||||
|
|
||||||
|
public record TelegramWeeklyReport(
|
||||||
|
DateOnly WeekStart,
|
||||||
|
DateOnly WeekEnd,
|
||||||
|
int NewInquiries,
|
||||||
|
int NewClients,
|
||||||
|
int UpcomingTaxFilings,
|
||||||
|
decimal RevenueThisWeek);
|
||||||
|
|
||||||
|
public class TelegramReportService(
|
||||||
|
InquiryService inquiryService,
|
||||||
|
ClientService clientService,
|
||||||
|
TaxFilingScheduleService taxFilingScheduleService,
|
||||||
|
RevenueTrackingService revenueTrackingService)
|
||||||
|
{
|
||||||
|
public async Task<TelegramDailyReport> BuildDailyReportAsync(DateOnly date, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var start = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||||
|
var end = date.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
return new TelegramDailyReport(
|
||||||
|
Date: date,
|
||||||
|
NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
|
||||||
|
PendingInquiries: await inquiryService.CountByStatusAsync("new", ct),
|
||||||
|
NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
|
||||||
|
PendingTaxFilings: await taxFilingScheduleService.GetPendingCountAsync(ct),
|
||||||
|
PendingPayments: (await revenueTrackingService.GetPendingPaymentsAsync(ct)).Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TelegramWeeklyReport> BuildWeeklyReportAsync(DateOnly weekStart, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var weekEnd = weekStart.AddDays(6);
|
||||||
|
var start = weekStart.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||||
|
var end = weekEnd.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||||
|
var upcomingEnd = weekEnd.AddDays(7).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var revenue = await revenueTrackingService.GetTotalRevenueAsync(start, end, ct);
|
||||||
|
|
||||||
|
return new TelegramWeeklyReport(
|
||||||
|
WeekStart: weekStart,
|
||||||
|
WeekEnd: weekEnd,
|
||||||
|
NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
|
||||||
|
NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
|
||||||
|
UpcomingTaxFilings: (await taxFilingScheduleService.GetUpcomingDuesAsync(14, ct))
|
||||||
|
.Count(x => x.DueDate >= start && x.DueDate <= upcomingEnd),
|
||||||
|
RevenueThisWeek: revenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatDailyMessage(TelegramDailyReport report) =>
|
||||||
|
$"<b>📊 일간 리포트</b>\n\n" +
|
||||||
|
$"기준일: <code>{report.Date:yyyy-MM-dd}</code>\n" +
|
||||||
|
$"신규 문의: <code>{report.NewInquiries}</code>\n" +
|
||||||
|
$"처리 대기 문의: <code>{report.PendingInquiries}</code>\n" +
|
||||||
|
$"신규 고객: <code>{report.NewClients}</code>\n" +
|
||||||
|
$"신고 대기: <code>{report.PendingTaxFilings}</code>\n" +
|
||||||
|
$"미수 청구: <code>{report.PendingPayments}</code>";
|
||||||
|
|
||||||
|
public static string FormatWeeklyMessage(TelegramWeeklyReport report) =>
|
||||||
|
$"<b>📈 주간 리포트</b>\n\n" +
|
||||||
|
$"기간: <code>{report.WeekStart:yyyy-MM-dd}</code> ~ <code>{report.WeekEnd:yyyy-MM-dd}</code>\n" +
|
||||||
|
$"신규 문의: <code>{report.NewInquiries}</code>\n" +
|
||||||
|
$"신규 고객: <code>{report.NewClients}</code>\n" +
|
||||||
|
$"다가오는 신고: <code>{report.UpcomingTaxFilings}</code>\n" +
|
||||||
|
$"주간 매출: <code>₩{report.RevenueThisWeek:N0}</code>";
|
||||||
|
}
|
||||||
@@ -3,15 +3,28 @@ namespace TaxBaik.Domain.Entities;
|
|||||||
public class Client
|
public class Client
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = null!;
|
public int? CompanyId { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
public string? CompanyName { get; set; }
|
public string? CompanyName { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
public string? ServiceType { get; set; }
|
public string? ServiceType { get; set; }
|
||||||
public string? TaxType { get; set; }
|
public string? TaxType { get; set; }
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
public string? Source { get; set; }
|
public string? Source { get; set; }
|
||||||
public string? Memo { get; set; }
|
public string? Memo { get; set; }
|
||||||
|
|
||||||
|
// Tax-specific fields
|
||||||
|
public string? BusinessRegistrationNumber { get; set; }
|
||||||
|
public string? BusinessType { get; set; }
|
||||||
|
public DateTime? EstablishmentDate { get; set; }
|
||||||
|
public string? AnnualRevenueRange { get; set; }
|
||||||
|
public int? EmployeeCount { get; set; }
|
||||||
|
public DateTime? LastTaxFilingDate { get; set; }
|
||||||
|
public string TaxRiskLevel { get; set; } = "normal";
|
||||||
|
public DateTime? NextFilingDueDate { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class Company
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string CompanyCode { get; set; } = "";
|
||||||
|
public string CompanyName { get; set; } = "";
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class ConsultingActivity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public string ActivityType { get; set; } = "";
|
||||||
|
public DateTime ActivityDate { get; set; }
|
||||||
|
public TimeOnly? ActivityTime { get; set; }
|
||||||
|
public int? AssignedConsultantId { get; set; }
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public string? Outcome { get; set; }
|
||||||
|
public DateTime? NextFollowupDate { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class Contract
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public string ContractNumber { get; set; } = "";
|
||||||
|
public string ServiceType { get; set; } = "";
|
||||||
|
public DateTime ContractDate { get; set; }
|
||||||
|
public DateTime StartDate { get; set; }
|
||||||
|
public DateTime? EndDate { get; set; }
|
||||||
|
public decimal? MonthlyFee { get; set; }
|
||||||
|
public decimal? TotalAmount { get; set; }
|
||||||
|
public string PaymentStatus { get; set; } = "pending";
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class PortalUser
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? ClientId { get; set; }
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string Provider { get; set; } = "local";
|
||||||
|
public string? ProviderId { get; set; }
|
||||||
|
public string? PasswordHash { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class RevenueTracking
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public string InvoiceNumber { get; set; } = "";
|
||||||
|
public DateTime InvoiceDate { get; set; }
|
||||||
|
public string? ServiceType { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string PaymentStatus { get; set; } = "pending";
|
||||||
|
public DateTime? PaymentDate { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class TaxFilingSchedule
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public string FilingType { get; set; } = "";
|
||||||
|
public DateTime DueDate { get; set; }
|
||||||
|
public int FilingYear { get; set; }
|
||||||
|
public string Status { get; set; } = "pending";
|
||||||
|
public int? AssignedToId { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public class TaxProfile
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
public string? BusinessRegistration { get; set; }
|
||||||
|
public string? BusinessType { get; set; }
|
||||||
|
public DateTime? EstablishmentDate { get; set; }
|
||||||
|
public string? AnnualRevenueRange { get; set; }
|
||||||
|
public int? EmployeeCount { get; set; }
|
||||||
|
public string? AccountingMethod { get; set; }
|
||||||
|
public string? FiscalYearEnd { get; set; }
|
||||||
|
public DateTime? LastFilingDate { get; set; }
|
||||||
|
public DateTime? NextFilingDueDate { get; set; }
|
||||||
|
public string TaxRiskLevel { get; set; } = "normal";
|
||||||
|
public bool PreviousAuditHistory { get; set; }
|
||||||
|
public string? SpecialNotes { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ public interface IClientRepository
|
|||||||
int page, int pageSize, string? status = null, string? search = null,
|
int page, int pageSize, string? status = null, string? search = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
|
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default);
|
||||||
|
Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default);
|
||||||
|
Task<int> CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default);
|
||||||
Task<int> CreateAsync(Client client, CancellationToken ct = default);
|
Task<int> CreateAsync(Client client, CancellationToken ct = default);
|
||||||
Task UpdateAsync(Client client, CancellationToken ct = default);
|
Task UpdateAsync(Client client, CancellationToken ct = default);
|
||||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ICompanyRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default);
|
||||||
|
Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(Company company, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IConsultingActivityRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IContractRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||||
|
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||||
|
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -16,4 +16,5 @@ public interface IInquiryRepository
|
|||||||
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
|
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
|
||||||
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IPortalUserRepository
|
||||||
|
{
|
||||||
|
Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default);
|
||||||
|
Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(PortalUser user, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(PortalUser user, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IRevenueTrackingRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken cancellationToken = default);
|
||||||
|
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ITaxFilingScheduleRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||||
|
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxFilingSchedule>> GetByStatusAsync(string status, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkCompletedAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ITaxProfileRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||||
|
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -19,7 +19,14 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IClientRepository, ClientRepository>();
|
services.AddScoped<IClientRepository, ClientRepository>();
|
||||||
services.AddScoped<IFaqRepository, FaqRepository>();
|
services.AddScoped<IFaqRepository, FaqRepository>();
|
||||||
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
||||||
|
services.AddScoped<IPortalUserRepository, PortalUserRepository>();
|
||||||
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
||||||
|
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||||
|
services.AddScoped<ITaxProfileRepository, TaxProfileRepository>();
|
||||||
|
services.AddScoped<ITaxFilingScheduleRepository, TaxFilingScheduleRepository>();
|
||||||
|
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
|
||||||
|
services.AddScoped<IContractRepository, ContractRepository>();
|
||||||
|
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,33 @@ public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepo
|
|||||||
new { Id = id });
|
new { Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Client?> GetByEmailAsync(string email, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||||
|
$"SELECT {SelectColumns} FROM clients WHERE email = @Email",
|
||||||
|
new { Email = email });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Client?> GetByPhoneAsync(string phone, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||||
|
$"SELECT {SelectColumns} FROM clients WHERE phone = @Phone",
|
||||||
|
new { Phone = phone });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.ExecuteScalarAsync<int>(
|
||||||
|
@"SELECT COUNT(*)
|
||||||
|
FROM clients
|
||||||
|
WHERE created_at >= @StartDateUtc
|
||||||
|
AND created_at <= @EndDateUtc",
|
||||||
|
new { StartDateUtc = startDateUtc, EndDateUtc = endDateUtc });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> CreateAsync(Client client, CancellationToken ct = default)
|
public async Task<int> CreateAsync(Client client, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var conn = Conn();
|
using var conn = Conn();
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class CompanyRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICompanyRepository
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO companies (company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at)
|
||||||
|
VALUES (@CompanyCode, @CompanyName, @ContactPerson, @Phone, @Email, @Memo, @IsActive, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
company);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||||
|
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||||
|
FROM companies WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||||
|
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||||
|
FROM companies WHERE company_code = @Code",
|
||||||
|
new { Code = code });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Company>(
|
||||||
|
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||||
|
FROM companies WHERE is_active = TRUE ORDER BY company_name");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
using var reader = await conn.QueryMultipleAsync(
|
||||||
|
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||||
|
FROM companies
|
||||||
|
ORDER BY company_name
|
||||||
|
LIMIT @PageSize OFFSET @Offset;
|
||||||
|
|
||||||
|
SELECT COUNT(*) FROM companies;",
|
||||||
|
new { PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
|
var items = (await reader.ReadAsync<Company>()).ToList();
|
||||||
|
var total = await reader.ReadFirstAsync<int>();
|
||||||
|
|
||||||
|
return (items, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Company company, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE companies
|
||||||
|
SET company_code = @CompanyCode, company_name = @CompanyName,
|
||||||
|
contact_person = @ContactPerson, phone = @Phone, email = @Email,
|
||||||
|
memo = @Memo, is_active = @IsActive, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
company);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM companies WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IConsultingActivityRepository
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO consulting_activities (client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at)
|
||||||
|
VALUES (@ClientId, @ActivityType, @ActivityDate, @ActivityTime, @AssignedConsultantId, @Description, @Outcome, @NextFollowupDate, @Notes, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<ConsultingActivity>(
|
||||||
|
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||||
|
FROM consulting_activities WHERE client_id = @ClientId ORDER BY activity_date DESC",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<ConsultingActivity>(
|
||||||
|
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||||
|
FROM consulting_activities WHERE next_followup_date IS NOT NULL AND next_followup_date <= CURRENT_DATE
|
||||||
|
ORDER BY next_followup_date ASC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<ConsultingActivity>(
|
||||||
|
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||||
|
FROM consulting_activities WHERE assigned_consultant = @ConsultantId AND activity_date >= @FromDate
|
||||||
|
ORDER BY activity_date DESC",
|
||||||
|
new { ConsultantId = consultantId, FromDate = fromDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE consulting_activities SET activity_type = @ActivityType, activity_date = @ActivityDate,
|
||||||
|
activity_time = @ActivityTime, assigned_consultant = @AssignedConsultantId, description = @Description,
|
||||||
|
outcome = @Outcome, next_followup_date = @NextFollowupDate, notes = @Notes, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IContractRepository
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO contracts (client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at)
|
||||||
|
VALUES (@ClientId, @ContractNumber, @ServiceType, @ContractDate, @StartDate, @EndDate, @MonthlyFee, @TotalAmount, @PaymentStatus, @Status, @Notes, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Contract>(
|
||||||
|
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||||
|
FROM contracts WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Contract>(
|
||||||
|
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||||
|
FROM contracts WHERE client_id = @ClientId ORDER BY contract_date DESC",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Contract>(
|
||||||
|
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||||
|
FROM contracts WHERE status = 'active' ORDER BY client_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Contract>(
|
||||||
|
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||||
|
FROM contracts
|
||||||
|
WHERE status = 'active' AND end_date IS NOT NULL AND end_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead
|
||||||
|
ORDER BY end_date ASC",
|
||||||
|
new { DaysAhead = daysAhead });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE contracts SET contract_number = @ContractNumber, service_type = @ServiceType, contract_date = @ContractDate,
|
||||||
|
start_date = @StartDate, end_date = @EndDate, monthly_fee = @MonthlyFee, total_amount = @TotalAmount,
|
||||||
|
payment_status = @PaymentStatus, status = @Status, notes = @Notes, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var result = await conn.QueryFirstAsync<decimal>(
|
||||||
|
@"SELECT COALESCE(SUM(monthly_fee), 0) FROM contracts WHERE status = 'active' AND monthly_fee IS NOT NULL");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,4 +119,10 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
|
|||||||
"UPDATE inquiries SET client_id = @ClientId, updated_at = NOW() WHERE id = @Id",
|
"UPDATE inquiries SET client_id = @ClientId, updated_at = NOW() WHERE id = @Id",
|
||||||
new { Id = inquiryId, ClientId = clientId });
|
new { Id = inquiryId, ClientId = clientId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM inquiries WHERE id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class PortalUserRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IPortalUserRepository
|
||||||
|
{
|
||||||
|
public async Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||||
|
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||||
|
FROM portal_users
|
||||||
|
WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||||
|
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||||
|
FROM portal_users
|
||||||
|
WHERE email = @Email",
|
||||||
|
new { Email = email });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
|
||||||
|
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
|
||||||
|
FROM portal_users
|
||||||
|
WHERE provider = @Provider AND provider_id = @ProviderId",
|
||||||
|
new { Provider = provider, ProviderId = providerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(PortalUser user, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO portal_users (client_id, email, name, phone, provider, provider_id, password_hash, created_at)
|
||||||
|
VALUES (@ClientId, @Email, @Name, @Phone, @Provider, @ProviderId, @PasswordHash, NOW())
|
||||||
|
RETURNING id",
|
||||||
|
user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(PortalUser user, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE portal_users
|
||||||
|
SET client_id = @ClientId,
|
||||||
|
email = @Email,
|
||||||
|
name = @Name,
|
||||||
|
phone = @Phone,
|
||||||
|
provider = @Provider,
|
||||||
|
provider_id = @ProviderId,
|
||||||
|
password_hash = @PasswordHash
|
||||||
|
WHERE id = @Id",
|
||||||
|
user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IRevenueTrackingRepository
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO revenue_tracking (client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at)
|
||||||
|
VALUES (@ClientId, @InvoiceNumber, @InvoiceDate, @ServiceType, @Amount, @PaymentStatus, @PaymentDate, @DueDate, @Notes, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
revenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<RevenueTracking>(
|
||||||
|
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||||
|
FROM revenue_tracking WHERE client_id = @ClientId ORDER BY invoice_date DESC",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<RevenueTracking>(
|
||||||
|
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||||
|
FROM revenue_tracking WHERE payment_status = 'pending' ORDER BY due_date ASC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<RevenueTracking>(
|
||||||
|
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||||
|
FROM revenue_tracking WHERE invoice_date BETWEEN @StartDate AND @EndDate ORDER BY invoice_date DESC",
|
||||||
|
new { StartDate = startDate, EndDate = endDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE revenue_tracking SET invoice_number = @InvoiceNumber, invoice_date = @InvoiceDate,
|
||||||
|
service_type = @ServiceType, amount = @Amount, payment_status = @PaymentStatus,
|
||||||
|
payment_date = @PaymentDate, due_date = @DueDate, notes = @Notes, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
revenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE revenue_tracking SET payment_status = 'paid', payment_date = @PaymentDate, updated_at = NOW() WHERE id = @Id",
|
||||||
|
new { Id = id, PaymentDate = paymentDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
var result = await conn.QueryFirstAsync<decimal>(
|
||||||
|
@"SELECT COALESCE(SUM(amount), 0) FROM revenue_tracking WHERE invoice_date BETWEEN @StartDate AND @EndDate",
|
||||||
|
new { StartDate = startDate, EndDate = endDate });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxFilingScheduleRepository
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO tax_filing_schedules (client_id, filing_type, due_date, filing_year, status, assigned_to, notes, created_at, updated_at)
|
||||||
|
VALUES (@ClientId, @FilingType, @DueDate, @FilingYear, @Status, @AssignedToId, @Notes, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<TaxFilingSchedule>(
|
||||||
|
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||||
|
FROM tax_filing_schedules WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||||
|
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||||
|
FROM tax_filing_schedules WHERE client_id = @ClientId ORDER BY due_date DESC",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||||
|
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||||
|
FROM tax_filing_schedules
|
||||||
|
WHERE status = 'pending' AND due_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead
|
||||||
|
ORDER BY due_date ASC",
|
||||||
|
new { DaysAhead = daysAhead });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxFilingSchedule>> GetByStatusAsync(string status, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||||
|
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||||
|
FROM tax_filing_schedules WHERE status = @Status ORDER BY due_date",
|
||||||
|
new { Status = status });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE tax_filing_schedules SET filing_type = @FilingType, due_date = @DueDate, status = @Status,
|
||||||
|
assigned_to = @AssignedToId, notes = @Notes, updated_at = NOW() WHERE id = @Id",
|
||||||
|
schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkCompletedAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE tax_filing_schedules SET status = 'completed', completed_date = NOW(), updated_at = NOW() WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace TaxBaik.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
using Dapper;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
using TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxProfileRepository
|
||||||
|
{
|
||||||
|
public async Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"INSERT INTO tax_profiles (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)
|
||||||
|
VALUES (@ClientId, @BusinessRegistration, @BusinessType, @EstablishmentDate, @AnnualRevenueRange,
|
||||||
|
@EmployeeCount, @AccountingMethod, @FiscalYearEnd, @LastFilingDate, @NextFilingDueDate,
|
||||||
|
@TaxRiskLevel, @PreviousAuditHistory, @SpecialNotes, NOW(), NOW())
|
||||||
|
RETURNING id",
|
||||||
|
profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, 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 client_id = @ClientId",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE tax_profiles SET business_registration = @BusinessRegistration, business_type = @BusinessType,
|
||||||
|
establishment_date = @EstablishmentDate, annual_revenue_range = @AnnualRevenueRange,
|
||||||
|
employee_count = @EmployeeCount, accounting_method = @AccountingMethod, fiscal_year_end = @FiscalYearEnd,
|
||||||
|
last_filing_date = @LastFilingDate, next_filing_due_date = @NextFilingDueDate,
|
||||||
|
tax_risk_level = @TaxRiskLevel, previous_audit_history = @PreviousAuditHistory,
|
||||||
|
special_notes = @SpecialNotes, updated_at = NOW()
|
||||||
|
WHERE id = @Id",
|
||||||
|
profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxProfile>(
|
||||||
|
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||||
|
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||||
|
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||||
|
FROM tax_profiles WHERE tax_risk_level = @RiskLevel ORDER BY client_id",
|
||||||
|
new { RiskLevel = riskLevel });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaxProfile>> GetUpcomingFilingDuesAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxProfile>(
|
||||||
|
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||||
|
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||||
|
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||||
|
FROM tax_profiles WHERE next_filing_due_date BETWEEN @StartDate AND @EndDate
|
||||||
|
ORDER BY next_filing_due_date",
|
||||||
|
new { StartDate = startDate, EndDate = endDate });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,20 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="components-reconnect-modal" class="admin-reconnect-modal">
|
<div id="components-reconnect-modal" class="admin-reconnect-modal">
|
||||||
<div class="admin-reconnect-card">
|
<div class="admin-reconnect-card">
|
||||||
<strong>관리자 세션을 다시 연결하고 있습니다.</strong>
|
<strong>연결 재설정 중...</strong>
|
||||||
<span>배포 또는 서버 재시작 중이면 잠시 후 자동으로 새로고침됩니다.</span>
|
<span>새로운 버전으로 업데이트되었습니다.</span>
|
||||||
|
<span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="blazor-loading" class="blazor-loading-overlay show">
|
||||||
|
<div class="blazor-loading-spinner">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>로드 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
||||||
|
<MudDialogProvider />
|
||||||
|
<MudSnackbarProvider />
|
||||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
<script src="js/admin-session.js"></script>
|
<script src="js/admin-session.js"></script>
|
||||||
@@ -31,3 +41,90 @@
|
|||||||
<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 = "8px"
|
||||||
|
},
|
||||||
|
Typography = new Typography()
|
||||||
|
{
|
||||||
|
Default = new Default()
|
||||||
|
{
|
||||||
|
FontSize = ".875rem",
|
||||||
|
FontWeight = 400,
|
||||||
|
LineHeight = 1.5
|
||||||
|
},
|
||||||
|
H1 = new H1()
|
||||||
|
{
|
||||||
|
FontSize = "2.5rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.2
|
||||||
|
},
|
||||||
|
H2 = new H2()
|
||||||
|
{
|
||||||
|
FontSize = "2rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.3
|
||||||
|
},
|
||||||
|
H3 = new H3()
|
||||||
|
{
|
||||||
|
FontSize = "1.75rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.3
|
||||||
|
},
|
||||||
|
H4 = new H4()
|
||||||
|
{
|
||||||
|
FontSize = "1.5rem",
|
||||||
|
FontWeight = 600,
|
||||||
|
LineHeight = 1.4
|
||||||
|
},
|
||||||
|
H5 = new H5()
|
||||||
|
{
|
||||||
|
FontSize = "1.25rem",
|
||||||
|
FontWeight = 500,
|
||||||
|
LineHeight = 1.4
|
||||||
|
},
|
||||||
|
H6 = new H6()
|
||||||
|
{
|
||||||
|
FontSize = "1rem",
|
||||||
|
FontWeight = 500,
|
||||||
|
LineHeight = 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@using TaxBaik.Application.Services
|
||||||
|
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true"
|
||||||
|
HelperText="영문/숫자, 최대 50자" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.CompanyName" Label="회사명"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.ContactPerson" Label="담당자명"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Phone" Label="전화번호"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</MudForm>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string ButtonText { get; set; } = "저장";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
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();
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (InitialData != null)
|
||||||
|
{
|
||||||
|
model = new CompanyFormModel
|
||||||
|
{
|
||||||
|
CompanyCode = InitialData.CompanyCode,
|
||||||
|
CompanyName = InitialData.CompanyName,
|
||||||
|
ContactPerson = InitialData.ContactPerson,
|
||||||
|
Phone = InitialData.Phone,
|
||||||
|
Email = InitialData.Email,
|
||||||
|
Memo = InitialData.Memo,
|
||||||
|
IsActive = InitialData.IsActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubmit()
|
||||||
|
{
|
||||||
|
if (form == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await OnSubmit.InvokeAsync(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompanyFormModel
|
||||||
|
{
|
||||||
|
public string CompanyCode { get; set; } = "";
|
||||||
|
public string CompanyName { get; set; } = "";
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudTextField @bind-Value="model.Name" Label="이름"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Phone" Label="전화번호 (예: 010-1234-5678)"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</MudForm>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string ButtonText { get; set; } = "저장";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
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();
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (InitialData != null)
|
||||||
|
{
|
||||||
|
model = new InquiryFormModel
|
||||||
|
{
|
||||||
|
Name = InitialData.Name,
|
||||||
|
Phone = InitialData.Phone,
|
||||||
|
Email = InitialData.Email,
|
||||||
|
ServiceType = InitialData.ServiceType,
|
||||||
|
Message = InitialData.Message,
|
||||||
|
Status = InitialData.Status,
|
||||||
|
AdminMemo = InitialData.AdminMemo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubmit()
|
||||||
|
{
|
||||||
|
if (form == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await OnSubmit.InvokeAsync(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InquiryFormModel
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Phone { get; set; } = "";
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string ServiceType { get; set; } = "기타";
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
public string Status { get; set; } = "new";
|
||||||
|
public string? AdminMemo { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
@using TaxBaik.Web.Services
|
|
||||||
@inject IInquiryBrowserClient InquiryClient
|
|
||||||
|
|
||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -29,7 +26,9 @@
|
|||||||
<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"
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">문의 내용 확인</MudButton>
|
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
|
||||||
|
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</MudButton>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -37,24 +36,25 @@
|
|||||||
</MudSimpleTable>
|
</MudSimpleTable>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<Domain.Entities.Inquiry> Inquiries { get; set; } = [];
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string Status { get; set; } = "";
|
public string Status { get; set; } = "";
|
||||||
|
|
||||||
private List<Domain.Entities.Inquiry> inquiries = [];
|
private IReadOnlyList<Domain.Entities.Inquiry> filteredInquiries = [];
|
||||||
private List<Domain.Entities.Inquiry> filteredInquiries = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
var (items, _) = await InquiryClient.GetPagedAsync(1, 100);
|
if (Inquiries == null || Inquiries.Count == 0)
|
||||||
inquiries = items.ToList();
|
{
|
||||||
FilterInquiries();
|
filteredInquiries = [];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FilterInquiries()
|
|
||||||
{
|
|
||||||
filteredInquiries = string.IsNullOrEmpty(Status)
|
filteredInquiries = string.IsNullOrEmpty(Status)
|
||||||
? inquiries
|
? Inquiries
|
||||||
: inquiries.Where(x => x.Status == Status).ToList();
|
: Inquiries.Where(x => x.Status == Status).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetPreview(string message)
|
private static string GetPreview(string message)
|
||||||
@@ -77,9 +77,4 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
|
||||||
{
|
|
||||||
FilterInquiries();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<MudLayout Class="admin-shell">
|
<MudLayout Class="admin-shell">
|
||||||
<MudAppBar Elevation="0" Class="admin-topbar">
|
<MudAppBar Elevation="0" Class="admin-topbar">
|
||||||
@@ -8,24 +11,38 @@
|
|||||||
Class="admin-menu-button"
|
Class="admin-menu-button"
|
||||||
OnClick="@ToggleDrawer" />
|
OnClick="@ToggleDrawer" />
|
||||||
<div class="admin-topbar-title">
|
<div class="admin-topbar-title">
|
||||||
<MudText Typo="Typo.caption">TaxBaik Backoffice</MudText>
|
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
|
||||||
<MudText Typo="Typo.h6">백원숙 세무회계 관리자</MudText>
|
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
|
||||||
</div>
|
</div>
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
|
|
||||||
|
<!-- 상단 액션 바 -->
|
||||||
|
<div class="admin-topbar-actions">
|
||||||
|
<MudTooltip Text="공개 웹사이트 방문">
|
||||||
<MudButton Class="admin-topbar-action"
|
<MudButton Class="admin-topbar-action"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Text"
|
||||||
Color="Color.Inherit"
|
Color="Color.Inherit"
|
||||||
|
Size="Size.Small"
|
||||||
StartIcon="@Icons.Material.Filled.OpenInNew"
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
Href="/taxbaik">
|
Href="/taxbaik"
|
||||||
|
Target="_blank">
|
||||||
공개 사이트
|
공개 사이트
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
|
||||||
|
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
|
||||||
|
|
||||||
|
<MudTooltip Text="로그아웃 (Ctrl+Q)">
|
||||||
<MudButton Class="admin-topbar-action"
|
<MudButton Class="admin-topbar-action"
|
||||||
Variant="Variant.Filled"
|
Variant="Variant.Text"
|
||||||
Color="Color.Primary"
|
Color="Color.Error"
|
||||||
|
Size="Size.Small"
|
||||||
StartIcon="@Icons.Material.Filled.Logout"
|
StartIcon="@Icons.Material.Filled.Logout"
|
||||||
Href="/taxbaik/admin/logout">
|
Href="/taxbaik/admin/logout">
|
||||||
로그아웃
|
로그아웃
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</div>
|
||||||
</MudAppBar>
|
</MudAppBar>
|
||||||
|
|
||||||
<MudDrawer @bind-open="@drawerOpen"
|
<MudDrawer @bind-open="@drawerOpen"
|
||||||
@@ -42,23 +59,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<MudNavMenu Class="admin-nav">
|
<MudNavMenu Class="admin-nav">
|
||||||
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
||||||
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" Expanded="true">
|
|
||||||
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
|
<MudNavGroup Title="CRM & 세무관리" Icon="@Icons.Material.Filled.BusinessCenter" @bind-Expanded="@expandedCRMGroup">
|
||||||
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/tax-profiles" Icon="@Icons.Material.Filled.Assignment">세무 프로필</MudNavLink>
|
||||||
|
<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>
|
||||||
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" Expanded="false">
|
|
||||||
|
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" @bind-Expanded="@expandedCustomerGroup">
|
||||||
|
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
|
||||||
|
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.Assessment">세무신고</MudNavLink>
|
||||||
|
</MudNavGroup>
|
||||||
|
|
||||||
|
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" @bind-Expanded="@expandedWebsiteGroup">
|
||||||
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
|
|
||||||
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
<div class="admin-drawer-footer">
|
|
||||||
<MudText Typo="Typo.caption">운영 기준</MudText>
|
|
||||||
<MudText Typo="Typo.body2">변경 사항은 배포 후 Playwright로 검증합니다.</MudText>
|
|
||||||
</div>
|
|
||||||
</MudDrawer>
|
</MudDrawer>
|
||||||
|
|
||||||
<MudMainContent Class="admin-main">
|
<MudMainContent Class="admin-main">
|
||||||
@@ -70,9 +94,37 @@
|
|||||||
|
|
||||||
@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()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var viewportWidth = await JS.InvokeAsync<int>("taxbaikAdminSession.getViewportWidth");
|
||||||
|
drawerOpen = viewportWidth >= 960;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||||
|
{
|
||||||
|
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
||||||
|
}
|
||||||
|
|
||||||
private void ToggleDrawer()
|
private void ToggleDrawer()
|
||||||
{
|
{
|
||||||
drawerOpen = !drawerOpen;
|
drawerOpen = !drawerOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,16 @@
|
|||||||
|
|
||||||
<PageTitle>새 포스트 작성</PageTitle>
|
<PageTitle>새 포스트 작성</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">📝 새 포스트</MudText>
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">새 포스트 작성</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
<MudForm @ref="form">
|
<MudForm @ref="form">
|
||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
@@ -42,9 +49,6 @@
|
|||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
@onclick="SavePost">저장</MudButton>
|
@onclick="SavePost">저장</MudButton>
|
||||||
<MudButton Variant="Variant.Outlined" @onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/blog"))">
|
|
||||||
취소
|
|
||||||
</MudButton>
|
|
||||||
</div>
|
</div>
|
||||||
</MudForm>
|
</MudForm>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -59,6 +63,11 @@
|
|||||||
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SavePost()
|
private async Task SavePost()
|
||||||
{
|
{
|
||||||
if (form == null)
|
if (form == null)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
@page "/admin/blog/{id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using TaxBaik.Domain.Interfaces
|
||||||
|
@inject BlogService BlogService
|
||||||
|
@inject ICategoryRepository CategoryRepository
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<PageTitle>포스트 수정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">포스트 수정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">블로그 포스트를 수정합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||||
|
}
|
||||||
|
else if (post == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
|
@foreach (var category in categories)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Content" Label="본문"
|
||||||
|
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||||
|
Variant="Variant.Outlined" Class="mb-4" />
|
||||||
|
|
||||||
|
<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 {
|
||||||
|
[Parameter]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
private MudForm? form;
|
||||||
|
private Domain.Entities.BlogPost? post;
|
||||||
|
private List<Domain.Entities.Category> categories = [];
|
||||||
|
private EditPostModel model = new();
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
post = await BlogService.GetByIdAsync(Id);
|
||||||
|
if (post != null)
|
||||||
|
{
|
||||||
|
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
||||||
|
MapPostToModel(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MapPostToModel(Domain.Entities.BlogPost post)
|
||||||
|
{
|
||||||
|
model.Title = post.Title;
|
||||||
|
model.Content = post.Content;
|
||||||
|
model.CategoryId = post.CategoryId;
|
||||||
|
model.Tags = post.Tags;
|
||||||
|
model.SeoTitle = post.SeoTitle;
|
||||||
|
model.SeoDescription = post.SeoDescription;
|
||||||
|
model.IsPublished = post.IsPublished;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SavePost()
|
||||||
|
{
|
||||||
|
if (form == null || post == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await form.Validate();
|
||||||
|
if (!form.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
|
||||||
|
{
|
||||||
|
Title = model.Title,
|
||||||
|
Content = model.Content,
|
||||||
|
CategoryId = model.CategoryId,
|
||||||
|
Tags = model.Tags,
|
||||||
|
SeoTitle = model.SeoTitle,
|
||||||
|
SeoDescription = model.SeoDescription,
|
||||||
|
IsPublished = model.IsPublished
|
||||||
|
});
|
||||||
|
|
||||||
|
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeletePost()
|
||||||
|
{
|
||||||
|
if (post == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"포스트 삭제",
|
||||||
|
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"삭제", "취소");
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Content { get; set; } = "";
|
||||||
|
public int? CategoryId { get; set; }
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? SeoTitle { get; set; }
|
||||||
|
public string? SeoDescription { get; set; }
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@
|
|||||||
|
|
||||||
<PageTitle>고객 상세</PageTitle>
|
<PageTitle>고객 상세</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Client Details</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객 상세</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@if (client == null)
|
@if (client == null)
|
||||||
{
|
{
|
||||||
<MudText>고객을 찾을 수 없습니다.</MudText>
|
<MudText>고객을 찾을 수 없습니다.</MudText>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@page "/admin/companies/create"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject IApiClient ApiClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>고객사 등록</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">새 고객사 등록</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 고객사를 추가합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCreate(CompanyForm.CompanyFormModel model)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApiClient.PostAsync<object>("company", new
|
||||||
|
{
|
||||||
|
companyCode = model.CompanyCode,
|
||||||
|
companyName = model.CompanyName,
|
||||||
|
contactPerson = model.ContactPerson,
|
||||||
|
phone = model.Phone,
|
||||||
|
email = model.Email,
|
||||||
|
memo = model.Memo
|
||||||
|
});
|
||||||
|
|
||||||
|
Snackbar.Add("고객사가 등록되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
@page "/admin/companies/{id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject IApiClient ApiClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<PageTitle>고객사 수정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 수정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객사 정보를 수정합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||||
|
}
|
||||||
|
else if (formModel == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||||
|
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
|
||||||
|
고객사 삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
private CompanyForm.CompanyFormModel? formModel;
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var company = await ApiClient.GetAsync<dynamic>($"company/{Id}");
|
||||||
|
IDictionary<string, object>? dict = company as IDictionary<string, object>;
|
||||||
|
if (dict != null)
|
||||||
|
{
|
||||||
|
formModel = new CompanyForm.CompanyFormModel
|
||||||
|
{
|
||||||
|
CompanyCode = (string)dict["companyCode"],
|
||||||
|
CompanyName = (string)dict["companyName"],
|
||||||
|
ContactPerson = (string?)dict["contactPerson"],
|
||||||
|
Phone = (string?)dict["phone"],
|
||||||
|
Email = (string?)dict["email"],
|
||||||
|
Memo = (string?)dict["memo"],
|
||||||
|
IsActive = (bool)(dynamic)dict["isActive"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleUpdate(CompanyForm.CompanyFormModel model)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApiClient.PutAsync<object>($"company/{Id}", new
|
||||||
|
{
|
||||||
|
companyCode = model.CompanyCode,
|
||||||
|
companyName = model.CompanyName,
|
||||||
|
contactPerson = model.ContactPerson,
|
||||||
|
phone = model.Phone,
|
||||||
|
email = model.Email,
|
||||||
|
memo = model.Memo,
|
||||||
|
isActive = model.IsActive
|
||||||
|
});
|
||||||
|
|
||||||
|
Snackbar.Add("고객사가 수정되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteCompany()
|
||||||
|
{
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"고객사 삭제",
|
||||||
|
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"삭제", "취소");
|
||||||
|
|
||||||
|
if (result != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApiClient.DeleteAsync($"company/{Id}");
|
||||||
|
Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
@page "/admin/companies"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IApiClient ApiClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>고객사 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface mb-4 mt-4" Elevation="0">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||||
|
<MudText Typo="Typo.subtitle1">@($"전체 고객사 {totalCompanies}개")</MudText>
|
||||||
|
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudDataGrid Items="@companies" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||||
|
<Columns>
|
||||||
|
<PropertyColumn Property="x => x.CompanyCode" Title="회사코드" />
|
||||||
|
<PropertyColumn Property="x => x.CompanyName" Title="회사명" />
|
||||||
|
<PropertyColumn Property="x => x.ContactPerson" Title="담당자" />
|
||||||
|
<PropertyColumn Property="x => x.Phone" Title="전화" />
|
||||||
|
<PropertyColumn Property="x => x.Email" Title="이메일" />
|
||||||
|
<PropertyColumn Property="x => x.IsActive" Title="활성">
|
||||||
|
<CellTemplate Context="cell">
|
||||||
|
<MudCheckBox T="bool" Value="@cell.Item.IsActive" Disabled="true" />
|
||||||
|
</CellTemplate>
|
||||||
|
</PropertyColumn>
|
||||||
|
<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/companies/{cell.Item.Id}/edit")">수정</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 {
|
||||||
|
private List<CompanyDto> companies = [];
|
||||||
|
private bool isLoading = true;
|
||||||
|
private int currentPage = 1;
|
||||||
|
private int totalPages = 1;
|
||||||
|
private int totalCompanies = 0;
|
||||||
|
private const int PageSize = 20;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
var response = await ApiClient.GetAsync<dynamic>($"company?page={currentPage}&pageSize={PageSize}");
|
||||||
|
|
||||||
|
IDictionary<string, object>? dict = response as IDictionary<string, object>;
|
||||||
|
if (dict != null)
|
||||||
|
{
|
||||||
|
totalCompanies = (int)(dynamic)dict["total"];
|
||||||
|
totalPages = (totalCompanies + PageSize - 1) / PageSize;
|
||||||
|
|
||||||
|
if (dict["data"] is System.Collections.IEnumerable dataList)
|
||||||
|
{
|
||||||
|
companies = new List<CompanyDto>();
|
||||||
|
foreach (var item in dataList)
|
||||||
|
{
|
||||||
|
if (item is IDictionary<string, object> companyDict)
|
||||||
|
{
|
||||||
|
companies.Add(new CompanyDto
|
||||||
|
{
|
||||||
|
Id = (int)(dynamic)companyDict["id"],
|
||||||
|
CompanyCode = (string)companyDict["companyCode"],
|
||||||
|
CompanyName = (string)companyDict["companyName"],
|
||||||
|
ContactPerson = (string?)companyDict["contactPerson"],
|
||||||
|
Phone = (string?)companyDict["phone"],
|
||||||
|
Email = (string?)companyDict["email"],
|
||||||
|
IsActive = (bool)(dynamic)companyDict["isActive"],
|
||||||
|
CreatedAt = DateTime.Parse(companyDict["createdAt"].ToString()!)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NextPage()
|
||||||
|
{
|
||||||
|
currentPage++;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PreviousPage()
|
||||||
|
{
|
||||||
|
currentPage = Math.Max(1, currentPage - 1);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CompanyDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string CompanyCode { get; set; } = "";
|
||||||
|
public string CompanyName { get; set; } = "";
|
||||||
|
public string? ContactPerson { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
@page "/admin/consulting-activities"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject IConsultingActivityBrowserClient ActivityClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>상담 활동 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">상담 활동 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
새 활동 기록
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (activities is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (activities.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="me-2" />
|
||||||
|
상담 활동이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudDataGrid T="ConsultingActivity"
|
||||||
|
Items="@activities"
|
||||||
|
Dense="true"
|
||||||
|
Hover="true"
|
||||||
|
Striped="true"
|
||||||
|
Virtualize="true"
|
||||||
|
RowsPerPage="30"
|
||||||
|
Class="admin-grid">
|
||||||
|
<Columns>
|
||||||
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
|
<TemplateColumn Title="고객">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||||
|
{
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||||
|
@clientName
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<PropertyColumn Property="x => x.ActivityType" Title="활동 유형" />
|
||||||
|
<PropertyColumn Property="x => x.ActivityDate" Title="활동일시" Format="g" />
|
||||||
|
<TemplateColumn Title="설명">
|
||||||
|
<CellTemplate>
|
||||||
|
@{
|
||||||
|
var desc = context.Item.Description ?? "";
|
||||||
|
if (desc.Length > 30) desc = desc.Substring(0, 30) + "...";
|
||||||
|
}
|
||||||
|
<span>@desc</span>
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<TemplateColumn Title="다음 팔로업">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (context.Item.NextFollowupDate.HasValue)
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
|
||||||
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudSelect T="int" @bind-Value="activityForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
|
||||||
|
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||||
|
</MudForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" OnClick="SaveActivity">저장</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ConsultingActivity>? activities;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private MudForm? form;
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private ConsultingActivity? editingActivity;
|
||||||
|
private ConsultingActivityForm activityForm = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
activities = await ActivityClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
editingActivity = null;
|
||||||
|
activityForm = new ConsultingActivityForm { ActivityDate = DateTime.Now };
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenEditDialog(ConsultingActivity activity)
|
||||||
|
{
|
||||||
|
editingActivity = activity;
|
||||||
|
activityForm = new ConsultingActivityForm
|
||||||
|
{
|
||||||
|
ClientId = activity.ClientId,
|
||||||
|
ActivityType = activity.ActivityType,
|
||||||
|
ActivityDate = activity.ActivityDate,
|
||||||
|
Description = activity.Description,
|
||||||
|
NextFollowupDate = activity.NextFollowupDate
|
||||||
|
};
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveActivity()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (editingActivity == null)
|
||||||
|
{
|
||||||
|
var actDate = activityForm.ActivityDate ?? DateTime.Now;
|
||||||
|
var newId = await ActivityClient.CreateAsync(
|
||||||
|
activityForm.ClientId,
|
||||||
|
activityForm.ActivityType,
|
||||||
|
actDate,
|
||||||
|
activityForm.Description,
|
||||||
|
null,
|
||||||
|
activityForm.NextFollowupDate);
|
||||||
|
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("활동이 기록되었습니다.", Severity.Success);
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ActivityClient.UpdateAsync(
|
||||||
|
editingActivity.Id,
|
||||||
|
null,
|
||||||
|
activityForm.NextFollowupDate);
|
||||||
|
|
||||||
|
Snackbar.Add("활동이 업데이트되었습니다.", Severity.Success);
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteActivity(int id)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 활동을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (result?.Canceled ?? true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ActivityClient.DeleteAsync(id);
|
||||||
|
Snackbar.Add("활동이 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog()
|
||||||
|
{
|
||||||
|
isDialogOpen = false;
|
||||||
|
editingActivity = null;
|
||||||
|
activityForm = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
@page "/admin/contracts"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject IContractBrowserClient ContractClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>계약 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">계약 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</MudText>
|
||||||
|
@if (mrr > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mt-2">
|
||||||
|
월 정기수익:
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">₩@mrr.ToString("N0")</MudChip>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
새 계약 추가
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (contracts is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (contracts.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
|
||||||
|
계약이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudDataGrid T="Contract"
|
||||||
|
Items="@contracts"
|
||||||
|
Dense="true"
|
||||||
|
Hover="true"
|
||||||
|
Striped="true"
|
||||||
|
Virtualize="true"
|
||||||
|
RowsPerPage="30"
|
||||||
|
Class="admin-grid">
|
||||||
|
<Columns>
|
||||||
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
|
<TemplateColumn Title="고객">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||||
|
{
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||||
|
@clientName
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||||
|
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
|
||||||
|
</MudButtonGroup>
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
</Columns>
|
||||||
|
</MudDataGrid>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<!-- Create Dialog -->
|
||||||
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">새 계약 추가</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudSelect T="int" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudTextField T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||||
|
</MudForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" OnClick="SaveContract">저장</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Contract>? contracts;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private decimal mrr = 0;
|
||||||
|
private MudForm? form;
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private ContractForm contractForm = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
contracts = await ContractClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||||
|
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
contractForm = new();
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveContract()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var newId = await ContractClient.CreateAsync(
|
||||||
|
contractForm.ClientId,
|
||||||
|
contractForm.ContractNumber,
|
||||||
|
contractForm.ServiceType,
|
||||||
|
contractForm.StartDate ?? DateTime.Now,
|
||||||
|
contractForm.MonthlyFee);
|
||||||
|
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteContract(int id)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 계약을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (result?.Canceled ?? true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ContractClient.DeleteAsync(id);
|
||||||
|
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog()
|
||||||
|
{
|
||||||
|
isDialogOpen = false;
|
||||||
|
contractForm = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@page "/admin/inquiries/create"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject InquiryService InquiryService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>문의 등록</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">새 문의 등록</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCreate(InquiryForm.InquiryFormModel model)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InquiryService.SubmitAsync(
|
||||||
|
model.Name,
|
||||||
|
model.Phone,
|
||||||
|
model.ServiceType,
|
||||||
|
model.Message,
|
||||||
|
model.Email,
|
||||||
|
ipAddress: "admin-registered");
|
||||||
|
|
||||||
|
Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
<PageTitle>문의 상세</PageTitle>
|
<PageTitle>문의 상세</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Inquiry Details</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">문의 상세</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@if (inquiry != null)
|
@if (inquiry != null)
|
||||||
{
|
{
|
||||||
<MudButton Variant="Variant.Outlined"
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
@page "/admin/inquiries/{id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject InquiryService InquiryService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<PageTitle>문의 수정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Relations</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">문의 수정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 문의 정보를 수정합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
||||||
|
}
|
||||||
|
else if (inquiry == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mt-4">문의를 찾을 수 없습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||||
|
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteInquiry" Class="mt-2">
|
||||||
|
문의 삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
private Domain.Entities.Inquiry? inquiry;
|
||||||
|
private InquiryForm.InquiryFormModel? formModel;
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
inquiry = await InquiryService.GetByIdAsync(Id);
|
||||||
|
if (inquiry != null)
|
||||||
|
{
|
||||||
|
formModel = new InquiryForm.InquiryFormModel
|
||||||
|
{
|
||||||
|
Name = inquiry.Name,
|
||||||
|
Phone = inquiry.Phone,
|
||||||
|
Email = inquiry.Email,
|
||||||
|
ServiceType = inquiry.ServiceType,
|
||||||
|
Message = inquiry.Message,
|
||||||
|
Status = inquiry.Status,
|
||||||
|
AdminMemo = inquiry.AdminMemo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"문의 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoBack()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleUpdate(InquiryForm.InquiryFormModel model)
|
||||||
|
{
|
||||||
|
if (inquiry == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
inquiry.Name = model.Name;
|
||||||
|
inquiry.Phone = model.Phone;
|
||||||
|
inquiry.Email = model.Email;
|
||||||
|
inquiry.ServiceType = model.ServiceType;
|
||||||
|
inquiry.Message = model.Message;
|
||||||
|
inquiry.AdminMemo = model.AdminMemo;
|
||||||
|
|
||||||
|
if (inquiry.Status != model.Status)
|
||||||
|
{
|
||||||
|
await InquiryService.UpdateStatusAsync(inquiry.Id, model.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo);
|
||||||
|
|
||||||
|
Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteInquiry()
|
||||||
|
{
|
||||||
|
if (inquiry == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"문의 삭제",
|
||||||
|
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"삭제", "취소");
|
||||||
|
|
||||||
|
if (result != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InquiryService.DeleteAsync(inquiry.Id);
|
||||||
|
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@page "/admin/inquiries"
|
@page "/admin/inquiries"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using TaxBaik.Domain.Interfaces
|
@using TaxBaik.Web.Services
|
||||||
@inject IInquiryRepository InquiryRepository
|
@inject IInquiryBrowserClient InquiryClient
|
||||||
|
|
||||||
<PageTitle>문의 관리</PageTitle>
|
<PageTitle>문의 관리</PageTitle>
|
||||||
|
|
||||||
@@ -11,27 +11,58 @@
|
|||||||
<MudText Typo="Typo.h4" Class="admin-page-title">문의 관리</MudText>
|
<MudText Typo="Typo.h4" Class="admin-page-title">문의 관리</MudText>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</MudText>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/taxbaik/admin/inquiries/create">새 문의 등록</MudButton>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" Class="ma-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
||||||
<MudTabPanel Text="전체">
|
<MudTabPanel Text="전체">
|
||||||
<InquiryTable Status="" />
|
<InquiryTable Inquiries="allInquiries" Status="" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="신규">
|
<MudTabPanel Text="신규">
|
||||||
<InquiryTable Status="new" />
|
<InquiryTable Inquiries="allInquiries" Status="new" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="상담중">
|
<MudTabPanel Text="상담중">
|
||||||
<InquiryTable Status="consulting" />
|
<InquiryTable Inquiries="allInquiries" Status="consulting" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="계약완료">
|
<MudTabPanel Text="계약완료">
|
||||||
<InquiryTable Status="contracted" />
|
<InquiryTable Inquiries="allInquiries" Status="contracted" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="거절">
|
<MudTabPanel Text="거절">
|
||||||
<InquiryTable Status="rejected" />
|
<InquiryTable Inquiries="allInquiries" Status="rejected" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="종결">
|
<MudTabPanel Text="종결">
|
||||||
<InquiryTable Status="closed" />
|
<InquiryTable Inquiries="allInquiries" Status="closed" />
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
</MudTabs>
|
</MudTabs>
|
||||||
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool isLoading = true;
|
||||||
|
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
|
||||||
|
allInquiries = items.ToList();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
allInquiries = [];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,16 +6,15 @@
|
|||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject CustomAuthenticationStateProvider AuthStateProvider
|
@inject CustomAuthenticationStateProvider AuthStateProvider
|
||||||
@inject IJSRuntime Js
|
@inject IJSRuntime Js
|
||||||
|
@inject ILocalStorageService LocalStorageService
|
||||||
|
|
||||||
<PageTitle>로그인</PageTitle>
|
<PageTitle>로그인</PageTitle>
|
||||||
|
|
||||||
<MudThemeProvider />
|
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
|
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
|
||||||
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
||||||
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
||||||
|
|
||||||
<div>
|
<form @onsubmit="HandleLogin" @onsubmit:preventDefault>
|
||||||
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
||||||
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
||||||
placeholder="사용자명"
|
placeholder="사용자명"
|
||||||
@@ -29,15 +28,19 @@
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
@bind-Value="model.Password" />
|
@bind-Value="model.Password" />
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<InputCheckbox class="mud-checkbox" @bind-Value="model.RememberMe" />
|
||||||
|
<label style="margin-left: 8px; cursor: pointer;">아이디 저장</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="button"
|
<button type="submit"
|
||||||
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
|
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
|
||||||
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
|
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
|
||||||
@onclick="HandleLogin"
|
|
||||||
disabled="@isLoading">
|
disabled="@isLoading">
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
@@ -49,15 +52,32 @@
|
|||||||
<span>로그인</span>
|
<span>로그인</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool isLoading = false;
|
private bool isLoading = false;
|
||||||
private string errorMessage = "";
|
private string errorMessage = "";
|
||||||
|
|
||||||
private LoginModel model = new();
|
private LoginModel model = new();
|
||||||
|
private const string RememberedUsernameKey = "admin-remembered-username";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remembered = await LocalStorageService.GetItemAsStringAsync(RememberedUsernameKey);
|
||||||
|
if (!string.IsNullOrEmpty(remembered))
|
||||||
|
{
|
||||||
|
model.Username = remembered;
|
||||||
|
model.RememberMe = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// LocalStorage not available in pre-render
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
@@ -85,6 +105,15 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.RememberMe)
|
||||||
|
{
|
||||||
|
await LocalStorageService.SetItemAsStringAsync(RememberedUsernameKey, model.Username);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await LocalStorageService.RemoveItemAsync(RememberedUsernameKey);
|
||||||
|
}
|
||||||
|
|
||||||
await ApiClient.SetAuthToken(response.AccessToken);
|
await ApiClient.SetAuthToken(response.AccessToken);
|
||||||
await AuthStateProvider.LoginAsync(response.AccessToken, response.RefreshToken, response.ExpiresIn);
|
await AuthStateProvider.LoginAsync(response.AccessToken, response.RefreshToken, response.ExpiresIn);
|
||||||
NavigationManager.NavigateTo(GetReturnUrl(), forceLoad: false);
|
NavigationManager.NavigateTo(GetReturnUrl(), forceLoad: false);
|
||||||
@@ -107,6 +136,7 @@
|
|||||||
{
|
{
|
||||||
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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetReturnUrl()
|
private string GetReturnUrl()
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@page "/admin/logout"
|
||||||
|
@using TaxBaik.Web.Services
|
||||||
|
@inject CustomAuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
<PageTitle>로그아웃</PageTitle>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// 사용자 로그아웃
|
||||||
|
await AuthStateProvider.LogoutAsync();
|
||||||
|
|
||||||
|
// 로그인 페이지로 리다이렉트
|
||||||
|
NavigationManager.NavigateTo("/taxbaik/admin/login", forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
@page "/admin/revenue-trackings"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject IRevenueTrackingBrowserClient RevenueClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>수익 추적 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">수익 추적 관리</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
새 청구 추가
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (revenues is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (revenues.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Payments" Class="me-2" />
|
||||||
|
청구 기록이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudDataGrid T="RevenueTracking"
|
||||||
|
Items="@revenues"
|
||||||
|
Dense="true"
|
||||||
|
Hover="true"
|
||||||
|
Striped="true"
|
||||||
|
Virtualize="true"
|
||||||
|
RowsPerPage="30"
|
||||||
|
Class="admin-grid">
|
||||||
|
<Columns>
|
||||||
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
|
<TemplateColumn Title="고객">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||||
|
{
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||||
|
@clientName
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<PropertyColumn Property="x => x.InvoiceNumber" Title="청구번호" />
|
||||||
|
<PropertyColumn Property="x => x.InvoiceDate" Title="청구일" Format="yyyy-MM-dd" />
|
||||||
|
<PropertyColumn Property="x => x.Amount" Title="청구액" Format="C" />
|
||||||
|
<TemplateColumn Title="납부여부">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (context.Item.PaymentStatus == "paid")
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">납부</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">미납</MudChip>
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<TemplateColumn Title="작업" Sortable="false">
|
||||||
|
<CellTemplate>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Create Dialog -->
|
||||||
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">새 청구 추가</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudSelect T="int" @bind-Value="revenueForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudTextField T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||||
|
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||||
|
</MudForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" OnClick="SaveRevenue">저장</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<RevenueTracking>? revenues;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private MudForm? form;
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private RevenueForm revenueForm = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
revenues = await RevenueClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
revenueForm = new();
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveRevenue()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var newId = await RevenueClient.CreateAsync(
|
||||||
|
revenueForm.ClientId,
|
||||||
|
revenueForm.InvoiceNumber,
|
||||||
|
revenueForm.InvoiceDate ?? DateTime.Now,
|
||||||
|
revenueForm.Amount,
|
||||||
|
revenueForm.ServiceType,
|
||||||
|
revenueForm.DueDate);
|
||||||
|
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("청구가 추가되었습니다.", Severity.Success);
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkPaid(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RevenueClient.MarkPaidAsync(id, DateTime.Now);
|
||||||
|
Snackbar.Add("납부가 처리되었습니다.", Severity.Success);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRevenue(int id)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 청구를 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (result?.Canceled ?? true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RevenueClient.DeleteAsync(id);
|
||||||
|
Snackbar.Add("청구가 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog()
|
||||||
|
{
|
||||||
|
isDialogOpen = false;
|
||||||
|
revenueForm = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,17 +9,19 @@
|
|||||||
|
|
||||||
<PageTitle>설정</PageTitle>
|
<PageTitle>설정</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-6">
|
||||||
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">System</MudText>
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">System</MudText>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">설정</MudText>
|
<MudText Typo="Typo.h4" Class="admin-page-title">설정</MudText>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</MudText>
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</MudText>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" md="7">
|
<MudItem xs="12" md="7">
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
<div class="admin-section-header compact">
|
<div class="admin-section-header compact">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.h6">사이트 정보</MudText>
|
<MudText Typo="Typo.h6">사이트 정보</MudText>
|
||||||
@@ -43,11 +45,11 @@
|
|||||||
StartIcon="@Icons.Material.Filled.Save"
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
@onclick="SaveSettings">사이트 정보 저장</MudButton>
|
@onclick="SaveSettings">사이트 정보 저장</MudButton>
|
||||||
</MudForm>
|
</MudForm>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
<MudItem xs="12" md="5">
|
<MudItem xs="12" md="5">
|
||||||
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
|
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
|
||||||
<div class="admin-section-header compact">
|
<div class="admin-section-header compact">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.h6">계정 관리</MudText>
|
<MudText Typo="Typo.h6">계정 관리</MudText>
|
||||||
@@ -72,7 +74,7 @@
|
|||||||
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
|
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudForm>
|
</MudForm>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
@page "/admin/tax-filing-schedules"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject ITaxFilingScheduleBrowserClient TaxFilingClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>신고 일정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="OpenCreateDialog"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
새 일정 추가
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (schedules is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (schedules.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
|
||||||
|
신고 일정이 없습니다.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudDataGrid T="TaxFilingSchedule"
|
||||||
|
Items="@schedules"
|
||||||
|
Dense="true"
|
||||||
|
Hover="true"
|
||||||
|
Striped="true"
|
||||||
|
Virtualize="true"
|
||||||
|
RowsPerPage="30"
|
||||||
|
Class="admin-grid">
|
||||||
|
<Columns>
|
||||||
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
|
<TemplateColumn Title="고객">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||||
|
{
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||||
|
@clientName
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">새 신고 일정 추가</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudSelect T="int"
|
||||||
|
@bind-Value="scheduleForm.ClientId"
|
||||||
|
Label="고객"
|
||||||
|
Required="true"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
FullWidth="true"
|
||||||
|
Class="mb-4">
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||||
|
</MudForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" OnClick="SaveSchedule">저장</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<TaxFilingSchedule>? schedules;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private MudForm? form;
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private TaxFilingScheduleForm scheduleForm = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await LoadData();
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
schedules = await TaxFilingClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year };
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSchedule()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var newId = await TaxFilingClient.CreateAsync(
|
||||||
|
scheduleForm.ClientId,
|
||||||
|
scheduleForm.FilingType,
|
||||||
|
scheduleForm.DueDate ?? DateTime.Today,
|
||||||
|
scheduleForm.FilingYear);
|
||||||
|
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteSchedule(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TaxFilingClient.MarkCompletedAsync(id);
|
||||||
|
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"처리 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSchedule(int id)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
{ "Title", "삭제 확인" },
|
||||||
|
{ "Message", "이 신고 일정을 삭제하시겠습니까?" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
if (result?.Canceled ?? true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TaxFilingClient.DeleteAsync(id);
|
||||||
|
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog()
|
||||||
|
{
|
||||||
|
isDialogOpen = false;
|
||||||
|
scheduleForm = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
@page "/admin/tax-profiles"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject ITaxProfileBrowserClient TaxProfileClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>세무 프로필</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM & 세무관리</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
새 프로필 추가
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (profiles == null)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||||
|
}
|
||||||
|
else if (profiles.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">세무 프로필이 없습니다.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudDataGrid T="TaxProfile"
|
||||||
|
Items="@profiles"
|
||||||
|
Dense="true"
|
||||||
|
Hover="true"
|
||||||
|
Striped="true"
|
||||||
|
Virtualize="true"
|
||||||
|
RowsPerPage="30"
|
||||||
|
Class="admin-grid mt-4">
|
||||||
|
<Columns>
|
||||||
|
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
|
||||||
|
<TemplateColumn Title="고객">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
|
||||||
|
{
|
||||||
|
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
|
||||||
|
@clientName
|
||||||
|
</MudLink>
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
|
||||||
|
<TemplateColumn Title="위험도">
|
||||||
|
<CellTemplate>
|
||||||
|
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
|
||||||
|
@context.Item.TaxRiskLevel
|
||||||
|
</MudChip>
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<TemplateColumn Title="다음 신고">
|
||||||
|
<CellTemplate>
|
||||||
|
@if (context.Item.NextFilingDueDate.HasValue)
|
||||||
|
{
|
||||||
|
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
|
||||||
|
}
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
<TemplateColumn Title="작업" Sortable="false">
|
||||||
|
<CellTemplate>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
|
||||||
|
</MudButtonGroup>
|
||||||
|
</CellTemplate>
|
||||||
|
</TemplateColumn>
|
||||||
|
</Columns>
|
||||||
|
</MudDataGrid>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Create/Edit Dialog -->
|
||||||
|
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudForm @ref="form">
|
||||||
|
<MudSelect T="int" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@client.Id">@client.CompanyName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||||
|
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||||
|
<MudSelectItem Value="@("low")">낮음</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("normal")">보통</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("high")">높음</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||||
|
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="true" Lines="2" />
|
||||||
|
</MudForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseDialog">취소</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" OnClick="SaveProfile">저장</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<TaxProfile>? profiles;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private MudForm? form;
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private bool isEditMode;
|
||||||
|
private TaxProfile? editingProfile;
|
||||||
|
private TaxProfileForm profileForm = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
profiles = await TaxProfileClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync();
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
isEditMode = false;
|
||||||
|
editingProfile = null;
|
||||||
|
profileForm = new();
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenEditDialog(TaxProfile profile)
|
||||||
|
{
|
||||||
|
isEditMode = true;
|
||||||
|
editingProfile = profile;
|
||||||
|
profileForm = new TaxProfileForm
|
||||||
|
{
|
||||||
|
ClientId = profile.ClientId,
|
||||||
|
BusinessType = profile.BusinessType ?? "",
|
||||||
|
TaxRiskLevel = profile.TaxRiskLevel,
|
||||||
|
NextFilingDueDate = profile.NextFilingDueDate,
|
||||||
|
SpecialNotes = profile.SpecialNotes
|
||||||
|
};
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveProfile()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (isEditMode)
|
||||||
|
{
|
||||||
|
await TaxProfileClient.UpdateAsync(
|
||||||
|
editingProfile!.Id,
|
||||||
|
profileForm.BusinessType,
|
||||||
|
null,
|
||||||
|
profileForm.NextFilingDueDate,
|
||||||
|
profileForm.TaxRiskLevel);
|
||||||
|
Snackbar.Add("세무 프로필이 업데이트되었습니다.", Severity.Success);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newId = await TaxProfileClient.CreateAsync(
|
||||||
|
profileForm.ClientId,
|
||||||
|
profileForm.BusinessType);
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteProfile(int id)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters();
|
||||||
|
parameters.Add("Title", "삭제 확인");
|
||||||
|
parameters.Add("Message", "이 세무 프로필을 삭제하시겠습니까?");
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (result?.Canceled ?? true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TaxProfileClient.DeleteAsync(id);
|
||||||
|
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog()
|
||||||
|
{
|
||||||
|
isDialogOpen = false;
|
||||||
|
isEditMode = false;
|
||||||
|
editingProfile = null;
|
||||||
|
profileForm = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Color GetRiskColor(string riskLevel) => riskLevel switch
|
||||||
|
{
|
||||||
|
"high" => Color.Error,
|
||||||
|
"normal" => Color.Warning,
|
||||||
|
"low" => Color.Success,
|
||||||
|
_ => Color.Default
|
||||||
|
};
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
<MudText>@Message</MudText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="Cancel">취소</MudButton>
|
||||||
|
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Confirm">삭제</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] MudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
[Parameter] public string Title { get; set; } = "";
|
||||||
|
[Parameter] public string Message { get; set; } = "";
|
||||||
|
|
||||||
|
private void Cancel() => MudDialog.Cancel();
|
||||||
|
private void Confirm() => MudDialog.Close();
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
token = tokenPair.AccessToken,
|
||||||
accessToken = tokenPair.AccessToken,
|
accessToken = tokenPair.AccessToken,
|
||||||
refreshToken = tokenPair.RefreshToken,
|
refreshToken = tokenPair.RefreshToken,
|
||||||
expiresIn = tokenPair.ExpiresIn
|
expiresIn = tokenPair.ExpiresIn
|
||||||
@@ -45,6 +46,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
token = tokenPair.AccessToken,
|
||||||
accessToken = tokenPair.AccessToken,
|
accessToken = tokenPair.AccessToken,
|
||||||
refreshToken = tokenPair.RefreshToken,
|
refreshToken = tokenPair.RefreshToken,
|
||||||
expiresIn = tokenPair.ExpiresIn
|
expiresIn = tokenPair.ExpiresIn
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class CompanyController(CompanyService companyService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var company = await companyService.GetByIdAsync(id);
|
||||||
|
if (company == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
return Ok(company);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("code/{code}")]
|
||||||
|
public async Task<IActionResult> GetByCode(string code)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var company = await companyService.GetByCodeAsync(code);
|
||||||
|
if (company == null)
|
||||||
|
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
|
||||||
|
return Ok(company);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (companies, total) = await companyService.GetPagedAsync(page, pageSize);
|
||||||
|
return Ok(new { data = companies, total, page, pageSize });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails { Title = "회사 목록 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateCompanyRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await companyService.CreateAsync(
|
||||||
|
request.CompanyCode, request.CompanyName, request.ContactPerson,
|
||||||
|
request.Phone, request.Email, request.Memo);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { message = "회사가 등록되었습니다.", id });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails { Title = "회사 등록 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateCompanyRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await companyService.UpdateAsync(id, request.CompanyCode, request.CompanyName,
|
||||||
|
request.ContactPerson, request.Phone, request.Email, request.Memo, request.IsActive);
|
||||||
|
return Ok(new { message = "회사가 수정되었습니다." });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails { Title = "회사 수정 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await companyService.DeleteAsync(id);
|
||||||
|
return Ok(new { message = "회사가 삭제되었습니다." });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ProblemDetails { Title = "회사 삭제 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo);
|
||||||
|
public record UpdateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo, bool IsActive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class ConsultingActivityController(ConsultingActivityService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateConsultingActivityRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await service.CreateAsync(request.ClientId, request.ActivityType, request.ActivityDate,
|
||||||
|
request.Description, request.ConsultantId, request.NextFollowupDate);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activity = await service.GetByClientIdAsync(id);
|
||||||
|
if (activity == null)
|
||||||
|
return NotFound(new { error = "상담 활동을 찾을 수 없습니다." });
|
||||||
|
return Ok(activity);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{clientId:int}")]
|
||||||
|
public async Task<IActionResult> GetByClientId(int clientId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activities = await service.GetByClientIdAsync(clientId);
|
||||||
|
return Ok(new { data = activities });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("pending-followups")]
|
||||||
|
public async Task<IActionResult> GetPendingFollowups()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activities = await service.GetPendingFollowupsAsync();
|
||||||
|
return Ok(new { data = activities });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("consultant/{consultantId:int}")]
|
||||||
|
public async Task<IActionResult> GetByConsultant(int consultantId, [FromQuery] int daysBack = 30)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fromDate = DateTime.Today.AddDays(-daysBack);
|
||||||
|
var activities = await service.GetConsultantActivityAsync(consultantId, fromDate);
|
||||||
|
return Ok(new { data = activities, daysBack });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateConsultingActivityRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.UpdateAsync(id, request.Outcome, request.NextFollowupDate);
|
||||||
|
return Ok(new { message = "상담 활동이 수정되었습니다." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateConsultingActivityRequest(
|
||||||
|
int ClientId, string ActivityType, DateTime ActivityDate, string Description,
|
||||||
|
int? ConsultantId = null, DateTime? NextFollowupDate = null);
|
||||||
|
|
||||||
|
public record UpdateConsultingActivityRequest(
|
||||||
|
string? Outcome = null, DateTime? NextFollowupDate = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class ContractController(ContractService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateContractRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await service.CreateAsync(request.ClientId, request.ContractNumber, request.ServiceType,
|
||||||
|
request.StartDate, request.MonthlyFee, request.TotalAmount);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contract = await service.GetByIdAsync(id);
|
||||||
|
if (contract == null)
|
||||||
|
return NotFound(new { error = "계약을 찾을 수 없습니다." });
|
||||||
|
return Ok(contract);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{clientId:int}")]
|
||||||
|
public async Task<IActionResult> GetByClientId(int clientId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contracts = await service.GetByClientIdAsync(clientId);
|
||||||
|
return Ok(new { data = contracts });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("active")]
|
||||||
|
public async Task<IActionResult> GetActiveContracts()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contracts = await service.GetActiveContractsAsync();
|
||||||
|
return Ok(new { data = contracts });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("expiring")]
|
||||||
|
public async Task<IActionResult> GetExpiringContracts([FromQuery] int daysAhead = 30)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contracts = await service.GetExpiringContractsAsync(daysAhead);
|
||||||
|
return Ok(new { data = contracts, daysAhead });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("mrr")]
|
||||||
|
public async Task<IActionResult> GetMonthlyRecurringRevenue()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mrr = await service.GetMonthlyRecurringRevenueAsync();
|
||||||
|
return Ok(new { mrr });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateContractRequest(
|
||||||
|
int ClientId, string ContractNumber, string ServiceType, DateTime StartDate,
|
||||||
|
decimal? MonthlyFee = null, decimal? TotalAmount = null);
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@ public class InquiryController : ControllerBase
|
|||||||
request.ServiceType,
|
request.ServiceType,
|
||||||
request.Message,
|
request.Message,
|
||||||
request.Email,
|
request.Email,
|
||||||
HttpContext.Connection.RemoteIpAddress?.ToString());
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
request.SuppressNotification);
|
||||||
return Ok(new { message = "상담 신청이 접수되었습니다." });
|
return Ok(new { message = "상담 신청이 접수되었습니다." });
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
@@ -135,6 +136,7 @@ public class SubmitInquiryRequest
|
|||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string ServiceType { get; set; } = string.Empty;
|
public string ServiceType { get; set; } = string.Empty;
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public bool SuppressNotification { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateStatusRequest
|
public class UpdateStatusRequest
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class RevenueTrackingController(RevenueTrackingService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateRevenueTrackingRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await service.CreateAsync(request.ClientId, request.InvoiceNumber, request.InvoiceDate,
|
||||||
|
request.Amount, request.ServiceType, request.DueDate);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// GetByIdAsync가 없으면 GetByClientIdAsync를 사용하거나 별도 구현 필요
|
||||||
|
// 임시로 구현 - 실제로는 repository에 GetByIdAsync 추가 필요
|
||||||
|
return Ok(new { message = "조회됨" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{clientId:int}")]
|
||||||
|
public async Task<IActionResult> GetByClientId(int clientId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var revenues = await service.GetByClientIdAsync(clientId);
|
||||||
|
return Ok(new { data = revenues });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("pending")]
|
||||||
|
public async Task<IActionResult> GetPendingPayments()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var revenues = await service.GetPendingPaymentsAsync();
|
||||||
|
return Ok(new { data = revenues });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("monthly")]
|
||||||
|
public async Task<IActionResult> GetMonthlyRevenue([FromQuery] int year, [FromQuery] int month)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var monthDate = new DateTime(year, month, 1);
|
||||||
|
var revenues = await service.GetMonthlyRevenueAsync(monthDate);
|
||||||
|
return Ok(new { data = revenues, year, month });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("total")]
|
||||||
|
public async Task<IActionResult> GetTotalRevenue([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var total = await service.GetTotalRevenueAsync(startDate, endDate);
|
||||||
|
return Ok(new { total, startDate, endDate });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}/paid")]
|
||||||
|
public async Task<IActionResult> MarkPaid(int id, [FromBody] MarkPaidRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.MarkPaidAsync(id, request.PaymentDate);
|
||||||
|
return Ok(new { message = "결제가 완료됨으로 표시되었습니다." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateRevenueTrackingRequest(
|
||||||
|
int ClientId, string InvoiceNumber, DateTime InvoiceDate, decimal Amount,
|
||||||
|
string? ServiceType = null, DateTime? DueDate = null);
|
||||||
|
|
||||||
|
public record MarkPaidRequest(DateTime PaymentDate);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class TaxFilingScheduleController(TaxFilingScheduleService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateTaxFilingScheduleRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await service.CreateAsync(request.ClientId, request.FilingType, request.DueDate,
|
||||||
|
request.FilingYear, request.AssignedTo);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var schedule = await service.GetByIdAsync(id);
|
||||||
|
if (schedule == null)
|
||||||
|
return NotFound(new { error = "신고 일정을 찾을 수 없습니다." });
|
||||||
|
return Ok(schedule);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{clientId:int}")]
|
||||||
|
public async Task<IActionResult> GetByClientId(int clientId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var schedules = await service.GetByClientIdAsync(clientId);
|
||||||
|
return Ok(new { data = schedules });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("upcoming")]
|
||||||
|
public async Task<IActionResult> GetUpcomingDues([FromQuery] int daysAhead = 30)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var schedules = await service.GetUpcomingDuesAsync(daysAhead);
|
||||||
|
return Ok(new { data = schedules, daysAhead });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("pending-count")]
|
||||||
|
public async Task<IActionResult> GetPendingCount()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var count = await service.GetPendingCountAsync();
|
||||||
|
return Ok(new { count });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}/complete")]
|
||||||
|
public async Task<IActionResult> MarkCompleted(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.MarkCompletedAsync(id);
|
||||||
|
return Ok(new { message = "신고 일정이 완료되었습니다." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateTaxFilingScheduleRequest(
|
||||||
|
int ClientId, string FilingType, DateTime DueDate, int FilingYear,
|
||||||
|
int? AssignedTo = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class TaxProfileController(TaxProfileService taxProfileService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateTaxProfileRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await taxProfileService.CreateAsync(request.ClientId, request.BusinessType,
|
||||||
|
request.BusinessRegistration, request.AccountingMethod, request.EstablishmentDate);
|
||||||
|
return CreatedAtAction(nameof(GetByClientId), new { clientId = request.ClientId }, new { id });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{clientId:int}")]
|
||||||
|
public async Task<IActionResult> GetByClientId(int clientId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profile = await taxProfileService.GetByClientIdAsync(clientId);
|
||||||
|
if (profile == null)
|
||||||
|
return NotFound(new { error = "세무 프로필을 찾을 수 없습니다." });
|
||||||
|
return Ok(profile);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("high-risk")]
|
||||||
|
public async Task<IActionResult> GetHighRiskProfiles()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profiles = await taxProfileService.GetHighRiskProfilesAsync();
|
||||||
|
return Ok(new { data = profiles });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("upcoming-filings")]
|
||||||
|
public async Task<IActionResult> GetUpcomingFiliings([FromQuery] int daysAhead = 30)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profiles = await taxProfileService.GetUpcomingFilingDuesAsync(daysAhead);
|
||||||
|
return Ok(new { data = profiles, daysAhead });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateTaxProfileRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await taxProfileService.UpdateAsync(id, request.BusinessType, request.AccountingMethod,
|
||||||
|
request.NextFilingDueDate, request.TaxRiskLevel);
|
||||||
|
return Ok(new { message = "세무 프로필이 수정되었습니다." });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "수정 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateTaxProfileRequest(
|
||||||
|
int ClientId, string BusinessType, string? BusinessRegistration = null,
|
||||||
|
string? AccountingMethod = null, DateTime? EstablishmentDate = null);
|
||||||
|
|
||||||
|
public record UpdateTaxProfileRequest(
|
||||||
|
string? BusinessType = null, string? AccountingMethod = null,
|
||||||
|
DateTime? NextFilingDueDate = null, string TaxRiskLevel = "normal");
|
||||||
|
}
|
||||||
@@ -5,7 +5,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="container py-5" style="max-width: 600px;">
|
<div class="container py-5" style="max-width: 600px;">
|
||||||
<h1 class="fw-bold mb-5">상담 신청</h1>
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||||
|
<h1 class="fw-bold mb-0">상담 신청</h1>
|
||||||
|
<a href="/taxbaik" class="btn btn-outline-secondary btn-sm"
|
||||||
|
onclick="if (history.length > 1) { history.back(); return false; }">
|
||||||
|
뒤로가기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (TempData["Success"] != null)
|
@if (TempData["Success"] != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@page "/portal/external-callback"
|
||||||
|
@model TaxBaik.Web.Pages.Portal.ExternalCallbackModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "포털 인증 처리";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="container py-5">
|
||||||
|
<div class="alert alert-info">인증을 처리하는 중입니다...</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Portal;
|
||||||
|
|
||||||
|
public class ExternalCallbackModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly PortalUserService _portalUserService;
|
||||||
|
private readonly ClientService _clientService;
|
||||||
|
|
||||||
|
public ExternalCallbackModel(PortalUserService portalUserService, ClientService clientService)
|
||||||
|
{
|
||||||
|
_portalUserService = portalUserService;
|
||||||
|
_clientService = clientService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(string provider)
|
||||||
|
{
|
||||||
|
var external = await HttpContext.AuthenticateAsync(PortalOAuthDefaults.ExternalScheme);
|
||||||
|
if (external?.Principal is null)
|
||||||
|
return RedirectToPage("/Portal/Login");
|
||||||
|
|
||||||
|
var email = external.Principal.FindFirstValue(ClaimTypes.Email);
|
||||||
|
var name = external.Principal.FindFirstValue(ClaimTypes.Name) ?? "고객";
|
||||||
|
var providerId = external.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(providerId))
|
||||||
|
return RedirectToPage("/Portal/Login");
|
||||||
|
|
||||||
|
var existing = await _portalUserService.GetByProviderAsync(provider, providerId);
|
||||||
|
if (existing is null && !string.IsNullOrWhiteSpace(email))
|
||||||
|
{
|
||||||
|
existing = await _portalUserService.GetByEmailAsync(email);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
int? clientId = null;
|
||||||
|
var linkedClient = await _clientService.GetByEmailAsync(email);
|
||||||
|
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
|
||||||
|
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
|
||||||
|
if (linkedClient is not null)
|
||||||
|
clientId = linkedClient.Id;
|
||||||
|
|
||||||
|
await _portalUserService.RegisterOAuthAsync(
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
external.Principal.FindFirstValue("phone") ?? "",
|
||||||
|
provider,
|
||||||
|
providerId,
|
||||||
|
clientId);
|
||||||
|
existing = await _portalUserService.GetByEmailAsync(email);
|
||||||
|
}
|
||||||
|
else if (!string.Equals(existing.Provider, provider, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!string.Equals(existing.ProviderId, providerId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await _portalUserService.LinkOAuthAsync(existing, provider, providerId, name, email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing is not null && !existing.ClientId.HasValue && !string.IsNullOrWhiteSpace(email))
|
||||||
|
{
|
||||||
|
var linkedClient = await _clientService.GetByEmailAsync(email);
|
||||||
|
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
|
||||||
|
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
|
||||||
|
if (linkedClient is not null)
|
||||||
|
{
|
||||||
|
await _portalUserService.AttachClientAsync(existing, linkedClient.Id);
|
||||||
|
existing.ClientId = linkedClient.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
return RedirectToPage("/Portal/Login");
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, existing.Id.ToString()),
|
||||||
|
new(ClaimTypes.Name, existing.Name),
|
||||||
|
new(ClaimTypes.Email, existing.Email),
|
||||||
|
new("portal_user_id", existing.Id.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing.ClientId.HasValue)
|
||||||
|
claims.Add(new("client_id", existing.ClientId.Value.ToString()));
|
||||||
|
|
||||||
|
await HttpContext.SignInAsync(
|
||||||
|
PortalAuthDefaults.Scheme,
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity(claims, PortalAuthDefaults.Scheme)),
|
||||||
|
new AuthenticationProperties { IsPersistent = true });
|
||||||
|
|
||||||
|
await HttpContext.SignOutAsync(PortalOAuthDefaults.ExternalScheme);
|
||||||
|
return RedirectToPage("/Portal/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@page "/portal"
|
||||||
|
@model TaxBaik.Web.Pages.Portal.IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "고객 포털";
|
||||||
|
ViewData["Description"] = "고객이 신고 일정, 상담 요약, 중요 알림을 확인하는 전용 포털입니다.";
|
||||||
|
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="container py-5">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<p class="text-uppercase text-muted small mb-2">Portal</p>
|
||||||
|
<h1 class="display-6 fw-bold mb-3">고객 포털</h1>
|
||||||
|
<p class="lead text-muted mb-4">
|
||||||
|
신고 일정, 상담 요약, 승인된 알림을 확인할 수 있는 전용 공간입니다.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-dark" href="/taxbaik/portal/login">로그인</a>
|
||||||
|
<a class="btn btn-outline-dark" href="/taxbaik/portal/register">회원가입</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="p-4 bg-light border rounded-3">
|
||||||
|
<h2 class="h5 fw-bold mb-3">제공 예정 기능</h2>
|
||||||
|
<ul class="mb-0 text-muted">
|
||||||
|
<li>본인 신고 일정 확인</li>
|
||||||
|
<li>상담 요약 열람</li>
|
||||||
|
<li>중요 알림 수신</li>
|
||||||
|
<li>관리자 승인 범위 내 정보 제공</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Portal;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@page "/portal/login"
|
||||||
|
@model TaxBaik.Web.Pages.Portal.LoginModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "고객 포털 로그인";
|
||||||
|
ViewData["Description"] = "고객 포털 로그인 페이지입니다.";
|
||||||
|
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="container py-5" style="max-width: 560px;">
|
||||||
|
<h1 class="h3 fw-bold mb-4">고객 포털 로그인</h1>
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다.
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@Model.ErrorMessage</div>
|
||||||
|
}
|
||||||
|
<form method="post" class="vstack gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">이메일</label>
|
||||||
|
<input class="form-control" asp-for="Email" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">비밀번호</label>
|
||||||
|
<input class="form-control" asp-for="Password" type="password" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-dark" type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
<div class="d-grid gap-2 mt-4">
|
||||||
|
<form method="post" asp-page-handler="Google">
|
||||||
|
<button class="btn btn-outline-dark w-100" type="submit">Google로 로그인</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" asp-page-handler="Naver">
|
||||||
|
<button class="btn btn-outline-success w-100" type="submit">Naver로 로그인</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" asp-page-handler="Kakao">
|
||||||
|
<button class="btn btn-outline-warning w-100" type="submit">Kakao로 로그인</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Portal;
|
||||||
|
|
||||||
|
public class LoginModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly PortalAuthService _portalAuthService;
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public LoginModel(PortalAuthService portalAuthService)
|
||||||
|
{
|
||||||
|
_portalAuthService = portalAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password))
|
||||||
|
{
|
||||||
|
ErrorMessage = "이메일과 비밀번호를 입력하세요.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var signedIn = await _portalAuthService.SignInAsync(Email, Password);
|
||||||
|
if (!signedIn)
|
||||||
|
{
|
||||||
|
ErrorMessage = "로그인 정보를 확인할 수 없습니다.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage("/Portal/Index");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IActionResult OnPostGoogle() => Challenge(BuildProps("google"), PortalOAuthDefaults.GoogleScheme);
|
||||||
|
|
||||||
|
public IActionResult OnPostNaver() => Challenge(BuildProps("naver"), PortalOAuthDefaults.NaverScheme);
|
||||||
|
|
||||||
|
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
|
||||||
|
|
||||||
|
private static AuthenticationProperties BuildProps(string provider) =>
|
||||||
|
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@page "/portal/register"
|
||||||
|
@model TaxBaik.Web.Pages.Portal.RegisterModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "고객 포털 회원가입";
|
||||||
|
ViewData["Description"] = "고객 포털 회원가입 페이지입니다.";
|
||||||
|
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/register";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="container py-5" style="max-width: 640px;">
|
||||||
|
<h1 class="h3 fw-bold mb-4">고객 포털 회원가입</h1>
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다.
|
||||||
|
</div>
|
||||||
|
<form method="post" class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">이름</label>
|
||||||
|
<input class="form-control" asp-for="Name" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">연락처</label>
|
||||||
|
<input class="form-control" asp-for="Phone" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">이메일</label>
|
||||||
|
<input class="form-control" asp-for="Email" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">비밀번호</label>
|
||||||
|
<input class="form-control" asp-for="Password" type="password" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button class="btn btn-dark" type="submit">가입하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Pages.Portal;
|
||||||
|
|
||||||
|
public class RegisterModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly PortalUserService _portalUserService;
|
||||||
|
private readonly ClientService _clientService;
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Phone { get; set; } = "";
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public RegisterModel(PortalUserService portalUserService, ClientService clientService)
|
||||||
|
{
|
||||||
|
_portalUserService = portalUserService;
|
||||||
|
_clientService = clientService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Name) || string.IsNullOrWhiteSpace(Email))
|
||||||
|
{
|
||||||
|
ErrorMessage = "이름과 이메일을 입력하세요.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Password) || Password.Length < 8)
|
||||||
|
{
|
||||||
|
ErrorMessage = "비밀번호는 8자 이상이어야 합니다.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await _portalUserService.GetByEmailAsync(Email);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
ErrorMessage = "이미 등록된 이메일입니다.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
int? clientId = null;
|
||||||
|
var linkedClient = await _clientService.GetByEmailAsync(Email);
|
||||||
|
if (linkedClient is null && !string.IsNullOrWhiteSpace(Phone))
|
||||||
|
linkedClient = await _clientService.GetByPhoneAsync(Phone);
|
||||||
|
if (linkedClient is not null)
|
||||||
|
clientId = linkedClient.Id;
|
||||||
|
|
||||||
|
await _portalUserService.RegisterLocalAsync(
|
||||||
|
Name,
|
||||||
|
Email,
|
||||||
|
Phone,
|
||||||
|
PortalAuthService.HashPassword(Password),
|
||||||
|
clientId: clientId);
|
||||||
|
|
||||||
|
return RedirectToPage("/Portal/Login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
|
<p>© 2026 백원숙 세무회계. All rights reserved.</p>
|
||||||
<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>
|
<a href="/taxbaik/terms" class="text-decoration-none text-muted">이용약관</a>
|
||||||
|
<a href="/taxbaik/portal" class="text-decoration-none text-muted ms-2">고객 포털</a>
|
||||||
@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;">
|
||||||
|
|||||||
+211
-11
@@ -3,19 +3,43 @@ using System.Text;
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Unicode;
|
using System.Text.Unicode;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
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.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
|
using Serilog;
|
||||||
using TaxBaik.Application;
|
using TaxBaik.Application;
|
||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
using TaxBaik.Infrastructure;
|
using TaxBaik.Infrastructure;
|
||||||
using TaxBaik.Web.Services;
|
using TaxBaik.Web.Services;
|
||||||
|
using TaxBaik.Web.Services.AdminClients;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var isProduction = builder.Environment.IsProduction();
|
var isProduction = builder.Environment.IsProduction();
|
||||||
|
|
||||||
|
// HTTP 요청 헤더/쿠키 크기 제한 증가 (400 Bad Request 해결)
|
||||||
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
|
{
|
||||||
|
options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100MB
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serilog 설정
|
||||||
|
builder.Host.UseSerilog((context, config) =>
|
||||||
|
{
|
||||||
|
config
|
||||||
|
.MinimumLevel.Information()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.WriteTo.File(
|
||||||
|
path: "logs/taxbaik-.log",
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
|
||||||
|
});
|
||||||
|
|
||||||
// Controllers (API)
|
// Controllers (API)
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
@@ -40,7 +64,7 @@ if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnore
|
|||||||
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
|
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
|
||||||
var key = Encoding.ASCII.GetBytes(jwtKey);
|
var key = Encoding.ASCII.GetBytes(jwtKey);
|
||||||
|
|
||||||
builder.Services.AddAuthentication(opts =>
|
var authenticationBuilder = builder.Services.AddAuthentication(opts =>
|
||||||
{
|
{
|
||||||
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
@@ -58,8 +82,105 @@ builder.Services.AddAuthentication(opts =>
|
|||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.FromMinutes(1)
|
ClockSkew = TimeSpan.FromMinutes(1)
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
.AddCookie(PortalAuthDefaults.Scheme, opts =>
|
||||||
|
{
|
||||||
|
opts.Cookie.Name = PortalAuthDefaults.CookieName;
|
||||||
|
opts.Cookie.HttpOnly = true;
|
||||||
|
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||||
|
opts.LoginPath = "/taxbaik/portal/login";
|
||||||
|
opts.AccessDeniedPath = "/taxbaik/portal/login";
|
||||||
|
opts.SlidingExpiration = true;
|
||||||
|
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
|
})
|
||||||
|
.AddCookie(PortalOAuthDefaults.ExternalScheme, opts =>
|
||||||
|
{
|
||||||
|
opts.Cookie.Name = "TaxBaik.Portal.External";
|
||||||
|
opts.Cookie.HttpOnly = true;
|
||||||
|
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
|
||||||
|
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
|
||||||
|
{
|
||||||
|
authenticationBuilder.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
|
||||||
|
{
|
||||||
|
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||||
|
opts.ClientId = googleClientId;
|
||||||
|
opts.ClientSecret = googleClientSecret;
|
||||||
|
opts.CallbackPath = "/taxbaik/portal/signin-google";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var naverClientId = builder.Configuration["Authentication:Naver:ClientId"];
|
||||||
|
var naverClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(naverClientSecret))
|
||||||
|
{
|
||||||
|
authenticationBuilder.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
|
||||||
|
{
|
||||||
|
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||||
|
opts.ClientId = naverClientId;
|
||||||
|
opts.ClientSecret = naverClientSecret;
|
||||||
|
opts.CallbackPath = "/taxbaik/portal/signin-naver";
|
||||||
|
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
|
||||||
|
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
|
||||||
|
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
|
||||||
|
opts.SaveTokens = true;
|
||||||
|
opts.Events = new OAuthEvents
|
||||||
|
{
|
||||||
|
OnCreatingTicket = async context =>
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||||
|
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
||||||
|
var responseRoot = payload.RootElement.GetProperty("response");
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var kakaoClientId = builder.Configuration["Authentication:Kakao:ClientId"];
|
||||||
|
var kakaoClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kakaoClientSecret))
|
||||||
|
{
|
||||||
|
authenticationBuilder.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
|
||||||
|
{
|
||||||
|
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||||
|
opts.ClientId = kakaoClientId;
|
||||||
|
opts.ClientSecret = kakaoClientSecret;
|
||||||
|
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
|
||||||
|
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
|
||||||
|
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
|
||||||
|
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
|
||||||
|
opts.SaveTokens = true;
|
||||||
|
opts.Events = new OAuthEvents
|
||||||
|
{
|
||||||
|
OnCreatingTicket = async context =>
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||||
|
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
||||||
|
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
|
||||||
|
var profile = kakaoAccount.GetProperty("profile");
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
|
||||||
|
if (kakaoAccount.TryGetProperty("email", out var emailProp))
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Blazor 인증
|
// Blazor 인증
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddScoped<AuthService>();
|
||||||
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
||||||
@@ -72,54 +193,101 @@ builder.Services.AddAuthorizationCore();
|
|||||||
// Notifications (SignalR)
|
// Notifications (SignalR)
|
||||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
|
||||||
|
// Telegram Notification
|
||||||
|
builder.Services.AddHttpClient<ITelegramNotificationService, TelegramNotificationService>();
|
||||||
|
|
||||||
// HTTP Client for API (with automatic token refresh)
|
// HTTP Client for API (with automatic token refresh)
|
||||||
builder.Services.AddScoped<ITokenStore, TokenStore>();
|
builder.Services.AddScoped<ITokenStore, TokenStore>();
|
||||||
builder.Services.AddScoped<TokenRefreshHandler>();
|
builder.Services.AddScoped<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IApiClient, ApiClient>();
|
builder.Services.AddHttpClient<IApiClient, ApiClient>();
|
||||||
|
|
||||||
|
var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration: ApiClient:BaseUrl");
|
||||||
|
|
||||||
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
|
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
|
|
||||||
// UI & 캐시
|
// Phase 5: Tax Accounting & CRM Browser Clients
|
||||||
builder.Services.AddMudServices();
|
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>();
|
||||||
|
|
||||||
|
// UI & 캐시 (MudBlazor Theme Customization)
|
||||||
|
builder.Services.AddMudServices(config =>
|
||||||
|
{
|
||||||
|
config.SnackbarConfiguration.HideTransitionDuration = 400;
|
||||||
|
config.SnackbarConfiguration.ShowTransitionDuration = 300;
|
||||||
|
});
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddResponseCompression(opts => {
|
builder.Services.AddResponseCompression(opts => {
|
||||||
opts.Providers.Add<GzipCompressionProvider>();
|
opts.Providers.Add<GzipCompressionProvider>();
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
builder.Services.AddHostedService<TelegramReportBackgroundService>();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<PortalAuthService>();
|
||||||
|
|
||||||
|
builder.Services.Configure<PortalAuthOptions>(builder.Configuration.GetSection("Authentication"));
|
||||||
|
|
||||||
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
|
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
|
||||||
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
||||||
|
|
||||||
builder.Services.AddInfrastructure();
|
builder.Services.AddInfrastructure();
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
||||||
|
|
||||||
// Register version info
|
// Register version info
|
||||||
var versionInfo = new VersionInfo();
|
var versionInfo = new VersionInfo();
|
||||||
@@ -194,4 +362,36 @@ app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
|||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode()
|
||||||
.AllowAnonymous();
|
.AllowAnonymous();
|
||||||
|
|
||||||
app.Run();
|
// 애플리케이션 시작/종료 로깅
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "애플리케이션 강종");
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var telegramService = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||||
|
await telegramService.SendErrorAsync(
|
||||||
|
"❌ 서버 오류",
|
||||||
|
$"환경: {app.Environment.EnvironmentName}\n오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception telegramEx)
|
||||||
|
{
|
||||||
|
Log.Error(telegramEx, "오류 알림 전송 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.Information("애플리케이션 종료");
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
namespace TaxBaik.Web.Services.AdminClients;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IConsultingActivityBrowserClient
|
||||||
|
{
|
||||||
|
Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||||
|
Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
|
||||||
|
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConsultingActivityBrowserClient(HttpClient httpClient, ILogger<ConsultingActivityBrowserClient> logger)
|
||||||
|
: IConsultingActivityBrowserClient
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "/api/consultingactivity";
|
||||||
|
|
||||||
|
public async Task<List<ConsultingActivity>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get consulting activities");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<ConsultingActivity>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get activities for client {ClientId}", clientId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending-followups", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<ConsultingActivity>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get pending followups");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(int clientId, string activityType, DateTime activityDate, string description,
|
||||||
|
int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { clientId, activityType, activityDate, description, consultantId, nextFollowupDate };
|
||||||
|
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||||
|
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create consulting activity");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, string? outcome = null, DateTime? nextFollowupDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { outcome, nextFollowupDate };
|
||||||
|
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to update consulting activity {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to delete consulting activity {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
namespace TaxBaik.Web.Services.AdminClients;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IContractBrowserClient
|
||||||
|
{
|
||||||
|
Task<List<Contract>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||||
|
Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default);
|
||||||
|
Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||||
|
Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
|
||||||
|
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ContractBrowserClient(HttpClient httpClient, ILogger<ContractBrowserClient> logger)
|
||||||
|
: IContractBrowserClient
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "/api/contract";
|
||||||
|
|
||||||
|
public async Task<List<Contract>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get contracts");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Contract?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<Contract>($"{BaseUrl}/{id}", ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get contract {Id}", id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Contract>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<Contract>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get contracts for client {ClientId}", clientId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Contract>> GetActiveContractsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/active", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get active contracts");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Contract>> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/expiring?daysAhead={daysAhead}", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<Contract>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get expiring contracts");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetMonthlyRecurringRevenueAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/mrr", ct);
|
||||||
|
if (response.TryGetProperty("mrr", out var mrrValue))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<decimal>(mrrValue.GetRawText());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get MRR");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(int clientId, string contractNumber, string serviceType, DateTime startDate,
|
||||||
|
decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { clientId, contractNumber, serviceType, startDate, monthlyFee, totalAmount };
|
||||||
|
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||||
|
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create contract");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to delete contract {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
namespace TaxBaik.Web.Services.AdminClients;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IRevenueTrackingBrowserClient
|
||||||
|
{
|
||||||
|
Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||||
|
Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default);
|
||||||
|
Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default);
|
||||||
|
Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
|
||||||
|
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default);
|
||||||
|
Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RevenueTrackingBrowserClient(HttpClient httpClient, ILogger<RevenueTrackingBrowserClient> logger)
|
||||||
|
: IRevenueTrackingBrowserClient
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "/api/revenuetracking";
|
||||||
|
|
||||||
|
public async Task<List<RevenueTracking>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get revenue tracking");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<RevenueTracking>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get revenue for client {ClientId}", clientId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/pending", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get pending payments");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<RevenueTracking>> GetMonthlyRevenueAsync(int year, int month, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/monthly?year={year}&month={month}", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<RevenueTracking>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get monthly revenue {Year}-{Month}", year, month);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>(
|
||||||
|
$"{BaseUrl}/total?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}", ct);
|
||||||
|
if (response.TryGetProperty("total", out var totalValue))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<decimal>(totalValue.GetRawText());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get total revenue");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, decimal amount,
|
||||||
|
string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { clientId, invoiceNumber, invoiceDate, amount, serviceType, dueDate };
|
||||||
|
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||||
|
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create revenue tracking");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { paymentDate };
|
||||||
|
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/paid", request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to mark payment {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to delete revenue tracking {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
namespace TaxBaik.Web.Services.AdminClients;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ITaxFilingScheduleBrowserClient
|
||||||
|
{
|
||||||
|
Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||||
|
Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||||
|
int? assignedTo = null, CancellationToken ct = default);
|
||||||
|
Task MarkCompletedAsync(int id, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TaxFilingScheduleBrowserClient(HttpClient httpClient, ILogger<TaxFilingScheduleBrowserClient> logger)
|
||||||
|
: ITaxFilingScheduleBrowserClient
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "/api/taxfilingschedule";
|
||||||
|
|
||||||
|
public async Task<List<TaxFilingSchedule>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get tax filing schedules");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<TaxFilingSchedule>($"{BaseUrl}/{id}", ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get tax filing schedule {Id}", id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<TaxFilingSchedule>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get filing schedules for client {ClientId}", clientId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming?daysAhead={daysAhead}", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<TaxFilingSchedule>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get upcoming filings");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear,
|
||||||
|
int? assignedTo = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { clientId, filingType, dueDate, filingYear, assignedTo };
|
||||||
|
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||||
|
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create tax filing schedule");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkCompletedAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}/complete", new { }, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to mark filing as completed {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to delete tax filing schedule {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
namespace TaxBaik.Web.Services.AdminClients;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ITaxProfileBrowserClient
|
||||||
|
{
|
||||||
|
Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
|
||||||
|
Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default);
|
||||||
|
Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
|
||||||
|
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
|
||||||
|
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TaxProfileBrowserClient(HttpClient httpClient, ILogger<TaxProfileBrowserClient> logger) : ITaxProfileBrowserClient
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "/api/taxprofile";
|
||||||
|
|
||||||
|
public async Task<List<TaxProfile>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get tax profiles");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<TaxProfile>($"{BaseUrl}/{id}", ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get tax profile {Id}", id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TaxProfile>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.GetFromJsonAsync<List<TaxProfile>>($"{BaseUrl}/client/{clientId}", ct) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get tax profiles for client {ClientId}", clientId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TaxProfile>> GetHighRiskProfilesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/high-risk", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get high-risk profiles");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TaxProfile>> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetFromJsonAsync<JsonElement>($"{BaseUrl}/upcoming-filings?daysAhead={daysAhead}", ct);
|
||||||
|
if (response.TryGetProperty("data", out var data))
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<List<TaxProfile>>(data.GetRawText()) ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get upcoming filings");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(int clientId, string businessType, string? businessRegistration = null,
|
||||||
|
string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { clientId, businessType, businessRegistration, accountingMethod, establishmentDate };
|
||||||
|
var response = await httpClient.PostAsJsonAsync(BaseUrl, request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||||
|
return result.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create tax profile");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, string? businessType = null, string? accountingMethod = null,
|
||||||
|
DateTime? nextFilingDueDate = null, string? taxRiskLevel = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new { businessType, accountingMethod, nextFilingDueDate, taxRiskLevel };
|
||||||
|
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to update tax profile {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}", ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to delete tax profile {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using TaxBaik.Application.Services;
|
using TaxBaik.Application.Services;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
@@ -21,17 +22,28 @@ public class AdminDashboardClient : IAdminDashboardClient
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<AdminDashboardClient> _logger;
|
private readonly ILogger<AdminDashboardClient> _logger;
|
||||||
|
private readonly ITokenStore _tokenStore;
|
||||||
|
|
||||||
public AdminDashboardClient(HttpClient http, ILogger<AdminDashboardClient> logger)
|
public AdminDashboardClient(HttpClient http, ILogger<AdminDashboardClient> logger, ITokenStore tokenStore)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAuthHeader()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||||
|
{
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
public async Task<AdminDashboardSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var result = await _http.GetFromJsonAsync<AdminDashboardSummary>(
|
var result = await _http.GetFromJsonAsync<AdminDashboardSummary>(
|
||||||
"admin-dashboard/summary", cancellationToken: ct);
|
"admin-dashboard/summary", cancellationToken: ct);
|
||||||
return result ?? new(0, 0, 0, 0, []);
|
return result ?? new(0, 0, 0, 0, []);
|
||||||
@@ -47,6 +59,7 @@ public class AdminDashboardClient : IAdminDashboardClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var result = await _http.GetFromJsonAsync<ApiResponse<TaxFiling>>(
|
var result = await _http.GetFromJsonAsync<ApiResponse<TaxFiling>>(
|
||||||
$"admin-dashboard/upcoming-filings?days={days}", cancellationToken: ct);
|
$"admin-dashboard/upcoming-filings?days={days}", cancellationToken: ct);
|
||||||
return result?.Data ?? [];
|
return result?.Data ?? [];
|
||||||
@@ -62,6 +75,7 @@ public class AdminDashboardClient : IAdminDashboardClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var result = await _http.GetFromJsonAsync<ApiResponse<Inquiry>>(
|
var result = await _http.GetFromJsonAsync<ApiResponse<Inquiry>>(
|
||||||
$"admin-dashboard/recent-inquiries?limit={limit}", cancellationToken: ct);
|
$"admin-dashboard/recent-inquiries?limit={limit}", cancellationToken: ct);
|
||||||
return result?.Data ?? [];
|
return result?.Data ?? [];
|
||||||
@@ -77,6 +91,7 @@ public class AdminDashboardClient : IAdminDashboardClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var url = "admin-dashboard/monthly-stats";
|
var url = "admin-dashboard/monthly-stats";
|
||||||
if (!string.IsNullOrEmpty(month))
|
if (!string.IsNullOrEmpty(month))
|
||||||
url += $"?month={month}";
|
url += $"?month={month}";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
namespace TaxBaik.Web.Services;
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using TaxBaik.Application.DTOs;
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
@@ -17,17 +18,28 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<AnnouncementBrowserClient> _logger;
|
private readonly ILogger<AnnouncementBrowserClient> _logger;
|
||||||
|
private readonly ITokenStore _tokenStore;
|
||||||
|
|
||||||
public AnnouncementBrowserClient(HttpClient http, ILogger<AnnouncementBrowserClient> logger)
|
public AnnouncementBrowserClient(HttpClient http, ILogger<AnnouncementBrowserClient> logger, ITokenStore tokenStore)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAuthHeader()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||||
|
{
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
public async Task<IEnumerable<Announcement>> GetAllAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var result = await _http.GetFromJsonAsync<AnnouncementListResponse>("announcement", cancellationToken: ct);
|
var result = await _http.GetFromJsonAsync<AnnouncementListResponse>("announcement", cancellationToken: ct);
|
||||||
return result?.Data ?? [];
|
return result?.Data ?? [];
|
||||||
}
|
}
|
||||||
@@ -42,6 +54,7 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
return await _http.GetFromJsonAsync<Announcement>($"announcement/{id}", cancellationToken: ct);
|
return await _http.GetFromJsonAsync<Announcement>($"announcement/{id}", cancellationToken: ct);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
@@ -55,6 +68,7 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.PostAsJsonAsync("announcement", dto, cancellationToken: ct);
|
var response = await _http.PostAsJsonAsync("announcement", dto, cancellationToken: ct);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
@@ -74,6 +88,7 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.PutAsJsonAsync($"announcement/{id}", dto, cancellationToken: ct);
|
var response = await _http.PutAsJsonAsync($"announcement/{id}", dto, cancellationToken: ct);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
@@ -93,6 +108,7 @@ public class AnnouncementBrowserClient : IAnnouncementBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.DeleteAsync($"announcement/{id}", cancellationToken: ct);
|
var response = await _http.DeleteAsync($"announcement/{id}", cancellationToken: ct);
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ public class AuthService
|
|||||||
{
|
{
|
||||||
private readonly IAdminUserRepository _adminUserRepository;
|
private readonly IAdminUserRepository _adminUserRepository;
|
||||||
private readonly ILogger<AuthService> _logger;
|
private readonly ILogger<AuthService> _logger;
|
||||||
|
private readonly ITelegramNotificationService _telegramService;
|
||||||
private readonly string _jwtSecretKey;
|
private readonly string _jwtSecretKey;
|
||||||
private readonly string? _passwordResetToken;
|
private readonly string? _passwordResetToken;
|
||||||
private readonly int _accessTokenExpirationMinutes = 15; // Access Token: 15분
|
private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상)
|
||||||
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
|
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
|
||||||
|
|
||||||
public AuthService(IAdminUserRepository adminUserRepository, ILogger<AuthService> logger, IConfiguration configuration)
|
public AuthService(
|
||||||
|
IAdminUserRepository adminUserRepository,
|
||||||
|
ILogger<AuthService> logger,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ITelegramNotificationService telegramService)
|
||||||
{
|
{
|
||||||
_adminUserRepository = adminUserRepository;
|
_adminUserRepository = adminUserRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_telegramService = telegramService;
|
||||||
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
|
_jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration.");
|
||||||
_passwordResetToken = configuration["Admin:PasswordResetToken"];
|
_passwordResetToken = configuration["Admin:PasswordResetToken"];
|
||||||
}
|
}
|
||||||
@@ -46,10 +52,12 @@ public class AuthService
|
|||||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
|
_logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username);
|
||||||
|
// 실패한 로그인은 알림하지 않음 (로그만 남김)
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("로그인 성공: {Username}", username);
|
_logger.LogInformation("로그인 성공: {Username}", username);
|
||||||
|
// 로그인 알림은 제거 (로그만 남김)
|
||||||
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
|
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
|
||||||
return GenerateTokenPair(user);
|
return GenerateTokenPair(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
namespace TaxBaik.Web.Services;
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using TaxBaik.Application.DTOs;
|
using TaxBaik.Application.DTOs;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
@@ -22,11 +23,21 @@ public class ClientBrowserClient : IClientBrowserClient
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<ClientBrowserClient> _logger;
|
private readonly ILogger<ClientBrowserClient> _logger;
|
||||||
|
private readonly ITokenStore _tokenStore;
|
||||||
|
|
||||||
public ClientBrowserClient(HttpClient http, ILogger<ClientBrowserClient> logger)
|
public ClientBrowserClient(HttpClient http, ILogger<ClientBrowserClient> logger, ITokenStore tokenStore)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAuthHeader()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||||
|
{
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||||
@@ -34,6 +45,7 @@ public class ClientBrowserClient : IClientBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var query = $"client?page={page}&pageSize={pageSize}";
|
var query = $"client?page={page}&pageSize={pageSize}";
|
||||||
if (!string.IsNullOrEmpty(status))
|
if (!string.IsNullOrEmpty(status))
|
||||||
query += $"&status={status}";
|
query += $"&status={status}";
|
||||||
@@ -54,6 +66,7 @@ public class ClientBrowserClient : IClientBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
return await _http.GetFromJsonAsync<Client>($"client/{id}", cancellationToken: ct);
|
return await _http.GetFromJsonAsync<Client>($"client/{id}", cancellationToken: ct);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
@@ -67,6 +80,7 @@ public class ClientBrowserClient : IClientBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.PostAsJsonAsync("client", dto, cancellationToken: ct);
|
var response = await _http.PostAsJsonAsync("client", dto, cancellationToken: ct);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return null;
|
return null;
|
||||||
@@ -87,6 +101,7 @@ public class ClientBrowserClient : IClientBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.PutAsJsonAsync($"client/{id}", dto, cancellationToken: ct);
|
var response = await _http.PutAsJsonAsync($"client/{id}", dto, cancellationToken: ct);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return null;
|
return null;
|
||||||
@@ -107,6 +122,7 @@ public class ClientBrowserClient : IClientBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.DeleteAsync($"client/{id}", cancellationToken: ct);
|
var response = await _http.DeleteAsync($"client/{id}", cancellationToken: ct);
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,13 +51,33 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 토큰이 만료되면 로그아웃
|
||||||
if (_tokenStore.IsAccessTokenExpired())
|
if (_tokenStore.IsAccessTokenExpired())
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Access token 만료됨");
|
_logger.LogWarning("Access token 만료됨 - 자동 로그아웃");
|
||||||
await LogoutAsync();
|
await LogoutAsync();
|
||||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 토큰이 5분 이내로 만료되면 자동 갱신 시도 (사용자 경험 향상)
|
||||||
|
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
|
||||||
|
var newTokenPair = await _authService.RefreshAccessTokenAsync(_tokenStore.RefreshToken);
|
||||||
|
if (newTokenPair != null)
|
||||||
|
{
|
||||||
|
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
|
||||||
|
_logger.LogInformation("토큰 자동 갱신 성공");
|
||||||
|
accessToken = newTokenPair.AccessToken;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("토큰 자동 갱신 실패 - 로그아웃");
|
||||||
|
await LogoutAsync();
|
||||||
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var principal = _authService.ValidateToken(accessToken);
|
var principal = _authService.ValidateToken(accessToken);
|
||||||
if (principal == null)
|
if (principal == null)
|
||||||
{
|
{
|
||||||
@@ -91,6 +111,25 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool ShouldRefreshToken()
|
||||||
|
{
|
||||||
|
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
|
||||||
|
if (_tokenStore.TokenExpiryTicks <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const int refreshThresholdSeconds = 300;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var expiryTime = new DateTime((long)_tokenStore.TokenExpiryTicks, DateTimeKind.Utc);
|
||||||
|
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
|
||||||
|
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
// TokenStore 초기화
|
// TokenStore 초기화
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
namespace TaxBaik.Web.Services;
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
@@ -16,17 +17,28 @@ public class FaqBrowserClient : IFaqBrowserClient
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<FaqBrowserClient> _logger;
|
private readonly ILogger<FaqBrowserClient> _logger;
|
||||||
|
private readonly ITokenStore _tokenStore;
|
||||||
|
|
||||||
public FaqBrowserClient(HttpClient http, ILogger<FaqBrowserClient> logger)
|
public FaqBrowserClient(HttpClient http, ILogger<FaqBrowserClient> logger, ITokenStore tokenStore)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAuthHeader()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||||
|
{
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var result = await _http.GetFromJsonAsync<FaqListResponse>("faq", cancellationToken: ct);
|
var result = await _http.GetFromJsonAsync<FaqListResponse>("faq", cancellationToken: ct);
|
||||||
return result?.Data ?? [];
|
return result?.Data ?? [];
|
||||||
}
|
}
|
||||||
@@ -41,6 +53,7 @@ public class FaqBrowserClient : IFaqBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
return await _http.GetFromJsonAsync<Faq>($"faq/{id}", cancellationToken: ct);
|
return await _http.GetFromJsonAsync<Faq>($"faq/{id}", cancellationToken: ct);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
@@ -54,6 +67,7 @@ public class FaqBrowserClient : IFaqBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.PostAsJsonAsync("faq", faq, cancellationToken: ct);
|
var response = await _http.PostAsJsonAsync("faq", faq, cancellationToken: ct);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
@@ -73,6 +87,7 @@ public class FaqBrowserClient : IFaqBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.PutAsJsonAsync($"faq/{id}", faq, cancellationToken: ct);
|
var response = await _http.PutAsJsonAsync($"faq/{id}", faq, cancellationToken: ct);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
@@ -92,6 +107,7 @@ public class FaqBrowserClient : IFaqBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.DeleteAsync($"faq/{id}", cancellationToken: ct);
|
var response = await _http.DeleteAsync($"faq/{id}", cancellationToken: ct);
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
namespace TaxBaik.Web.Services;
|
namespace TaxBaik.Web.Services;
|
||||||
|
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using TaxBaik.Domain.Entities;
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
@@ -21,11 +22,21 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<InquiryBrowserClient> _logger;
|
private readonly ILogger<InquiryBrowserClient> _logger;
|
||||||
|
private readonly ITokenStore _tokenStore;
|
||||||
|
|
||||||
public InquiryBrowserClient(HttpClient http, ILogger<InquiryBrowserClient> logger)
|
public InquiryBrowserClient(HttpClient http, ILogger<InquiryBrowserClient> logger, ITokenStore tokenStore)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAuthHeader()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_tokenStore.AccessToken) && !_http.DefaultRequestHeaders.Contains("Authorization"))
|
||||||
|
{
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
public async Task<(IEnumerable<Inquiry> Items, int Total)> GetPagedAsync(
|
||||||
@@ -33,6 +44,7 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var result = await _http.GetFromJsonAsync<InquiryPagedResponse>(
|
var result = await _http.GetFromJsonAsync<InquiryPagedResponse>(
|
||||||
$"inquiry?page={page}&pageSize={pageSize}",
|
$"inquiry?page={page}&pageSize={pageSize}",
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
@@ -52,6 +64,7 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
return await _http.GetFromJsonAsync<Inquiry>(
|
return await _http.GetFromJsonAsync<Inquiry>(
|
||||||
$"inquiry/{id}",
|
$"inquiry/{id}",
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
@@ -67,6 +80,7 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var request = new { status };
|
var request = new { status };
|
||||||
var response = await _http.PutAsJsonAsync(
|
var response = await _http.PutAsJsonAsync(
|
||||||
$"inquiry/{id}/status",
|
$"inquiry/{id}/status",
|
||||||
@@ -86,6 +100,7 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var request = new { adminMemo };
|
var request = new { adminMemo };
|
||||||
var response = await _http.PutAsJsonAsync(
|
var response = await _http.PutAsJsonAsync(
|
||||||
$"inquiry/{id}/memo",
|
$"inquiry/{id}/memo",
|
||||||
@@ -105,6 +120,7 @@ public class InquiryBrowserClient : IInquiryBrowserClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
EnsureAuthHeader();
|
||||||
var response = await _http.PostAsJsonAsync(
|
var response = await _http.PostAsJsonAsync(
|
||||||
$"inquiry/{id}/convert-to-client",
|
$"inquiry/{id}/convert-to-client",
|
||||||
new { name, phone, serviceType },
|
new { name, phone, serviceType },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user