Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac8a70a2ca | |||
| 203e674c3f | |||
| 0c014d0bdf | |||
| 904c0972ca | |||
| 7e75aeeec7 | |||
| b13eed7b7e | |||
| 4647b049b8 | |||
| 1a5ebb45bc | |||
| f197663101 | |||
| 70b57f1d4c | |||
| 428eeb6fd8 | |||
| dd68a237a1 | |||
| ef9fd523c6 | |||
| f2ab78dea2 | |||
| 1e0c0b7e1c | |||
| 1b173376ee | |||
| 1a7bc9e209 | |||
| 3be379431f | |||
| 682e2db3a3 | |||
| d9766cb5ef | |||
| 6bcb9effa8 | |||
| 186c6ef7a4 | |||
| c2e8e08f09 | |||
| 3f7cd7cd84 | |||
| 4b352df408 | |||
| a4b1234900 | |||
| a3c81c4f70 | |||
| 6e8b4e76ac | |||
| 5807e1b35e | |||
| 3e1097f585 | |||
| 917600a793 | |||
| 0d3615b44d | |||
| fc339ca9e7 | |||
| da1226994f | |||
| 6bc03ce3d9 | |||
| ecfbfc7cac | |||
| 46cb508bdf | |||
| ecabe8d9cc | |||
| 55c65810c1 | |||
| 7054d397e4 | |||
| 11fb596fc2 | |||
| a04592499c | |||
| ea9478f2f1 | |||
| f569211967 | |||
| c8306e2ac7 | |||
| bad2f47ffe | |||
| 943fe9c819 | |||
| 7b819f4ab0 | |||
| 6a5740ec68 | |||
| 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 |
@@ -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.
|
||||||
|
|||||||
@@ -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) ==="
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
@@ -24,7 +26,7 @@ TaxBaik는 세무사 백원숙의 전문성을 온라인으로 표현하기 위
|
|||||||
|-----|------|
|
|-----|------|
|
||||||
| **백엔드** | ASP.NET Core 10, C# |
|
| **백엔드** | ASP.NET Core 10, C# |
|
||||||
| **공개 사이트** | Razor Pages (SSR) |
|
| **공개 사이트** | Razor Pages (SSR) |
|
||||||
| **관리자** | Blazor Server + MudBlazor |
|
| **관리자** | Blazor Server + Fluent UI Blazor v5 |
|
||||||
| **데이터베이스** | PostgreSQL 18.4 |
|
| **데이터베이스** | PostgreSQL 18.4 |
|
||||||
| **ORM** | Dapper |
|
| **ORM** | Dapper |
|
||||||
| **리버스 프록시** | Nginx |
|
| **리버스 프록시** | Nginx |
|
||||||
@@ -96,6 +98,14 @@ TaxBaik/
|
|||||||
- 연락처 정보
|
- 연락처 정보
|
||||||
- 소셜 미디어 링크
|
- 소셜 미디어 링크
|
||||||
|
|
||||||
|
- **UI 기준**
|
||||||
|
- 기본 디자인 템플릿은 `https://v5.fluentui-blazor.net/`
|
||||||
|
- 기본 로딩 상태는 `Skeleton`
|
||||||
|
- MudBlazor는 레거시 폐기 대상이며 신규 UI에 사용하지 않음
|
||||||
|
- `MudDataGrid`, `MudDialog`, `MudTabs`는 폐기 대상이며 신규 UI에 사용하지 않음
|
||||||
|
- 사이트와 관리자는 `design-tokens.css` / `ui-primitives.css`를 공유
|
||||||
|
- Blazor 라우트는 중복 선언하지 않고 단일 엔트리 기준으로 관리
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|||||||
+16
-16
@@ -425,9 +425,9 @@ Todo:
|
|||||||
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
- [x] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||||
- [ ] 일간/주간 리포트 메시지 템플릿
|
- [x] 일간/주간 리포트 메시지 템플릿
|
||||||
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
- [x] TelegramNotificationService에 리포트 메서드 추가
|
||||||
|
|
||||||
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||||
|
|
||||||
@@ -439,9 +439,9 @@ Todo:
|
|||||||
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
- [x] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||||
- [ ] 고객 전용 Razor Pages 추가
|
- [x] 고객 전용 Razor Pages 추가
|
||||||
- [ ] 세무사 허용 권한 설정 UI
|
- [x] 세무사 허용 권한 설정 UI
|
||||||
|
|
||||||
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||||
|
|
||||||
@@ -485,16 +485,16 @@ DB 스키마:
|
|||||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
- [x] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||||
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
- [x] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||||
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
- [x] V011__CreatePortalUsers.sql 마이그레이션 (실제 V016__CreatePortalUsers.sql로 대체됨)
|
||||||
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
- [x] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||||
- [ ] 네이버 OAuth Handler 구현
|
- [x] 네이버 OAuth Handler 구현
|
||||||
- [ ] 카카오·구글 패키지 추가 및 설정
|
- [x] 카카오·구글 패키지 추가 및 설정
|
||||||
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
- [x] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||||
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
- [x] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||||
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
- [x] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||||
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
- [x] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||||
- [ ] Gitea Secrets에 OAuth 키 추가
|
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||||
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,50 @@
|
|||||||
|
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>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetAllAsync(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,53 @@
|
|||||||
|
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>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetAllAsync(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,58 @@
|
|||||||
|
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<RevenueTracking?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetPendingPaymentsAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetPendingPaymentsAsync(ct);
|
||||||
|
|
||||||
|
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,53 @@
|
|||||||
|
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>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetAllAsync(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,61 @@
|
|||||||
|
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<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
|
||||||
|
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var profile = new TaxProfile { Id = profileId };
|
||||||
|
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,13 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IConsultingActivityRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<ConsultingActivity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<ConsultingActivity>> GetPendingFollowupsAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<ConsultingActivity>> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IContractRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Contract>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<Contract>> GetActiveContractsAsync(CancellationToken cancellationToken = default);
|
||||||
|
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,16 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IRevenueTrackingRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default);
|
||||||
|
Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<RevenueTracking>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<RevenueTracking>> 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,15 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ITaxFilingScheduleRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxFilingSchedule>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxFilingSchedule>> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default);
|
||||||
|
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,13 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface ITaxProfileRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TaxProfile>> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default);
|
||||||
|
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,65 @@
|
|||||||
|
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>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<ConsultingActivity>(
|
||||||
|
@"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at
|
||||||
|
FROM consulting_activities ORDER BY activity_date DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ConsultingActivity>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
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,82 @@
|
|||||||
|
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<IEnumerable<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<Contract>(
|
||||||
|
@"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at
|
||||||
|
FROM contracts ORDER BY contract_date DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Contract?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
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,89 @@
|
|||||||
|
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>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<RevenueTracking>(
|
||||||
|
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||||
|
FROM revenue_tracking ORDER BY invoice_date DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RevenueTracking?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<RevenueTracking>(
|
||||||
|
@"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at
|
||||||
|
FROM revenue_tracking WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RevenueTracking>> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
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,81 @@
|
|||||||
|
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<IEnumerable<TaxFilingSchedule>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxFilingSchedule>(
|
||||||
|
@"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at
|
||||||
|
FROM tax_filing_schedules ORDER BY due_date DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxFilingSchedule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
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,80 @@
|
|||||||
|
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<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryAsync<TaxProfile>(
|
||||||
|
@"SELECT id, client_id, business_registration, business_type, establishment_date,
|
||||||
|
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
|
||||||
|
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
|
||||||
|
FROM tax_profiles ORDER BY id DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.FluentUI.AspNetCore.Components
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
@@ -6,9 +7,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>백원숙 세무회계 - 관리자</title>
|
<title>백원숙 세무회계 - 관리자</title>
|
||||||
<base href="/taxbaik/" />
|
<base href="/taxbaik/" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
<link rel="stylesheet" href="css/design-tokens.css" />
|
||||||
|
<link rel="stylesheet" href="css/ui-primitives.css" />
|
||||||
|
<link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
|
||||||
<script>
|
<script>
|
||||||
document.documentElement.classList.toggle(
|
document.documentElement.classList.toggle(
|
||||||
'admin-login-route',
|
'admin-login-route',
|
||||||
@@ -31,100 +34,15 @@
|
|||||||
<p>로드 중...</p>
|
<p>로드 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
|
|
||||||
<MudDialogProvider />
|
<FluentProviders />
|
||||||
<MudSnackbarProvider />
|
<FluentDialogProvider />
|
||||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
<FluentTooltipProvider />
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
|
||||||
|
<Routes @rendermode="new InteractiveServerRenderMode(prerender: true)" />
|
||||||
|
|
||||||
<script src="js/admin-session.js"></script>
|
<script src="js/admin-session.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
<script>window.taxbaikAdminSession?.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
@using MudBlazor
|
@using Microsoft.FluentUI.AspNetCore.Components
|
||||||
|
<div class="admin-dialog">
|
||||||
<MudDialog>
|
<div class="admin-dialog-title">삭제 확인</div>
|
||||||
<DialogContent>
|
<p class="admin-dialog-message">정말로 삭제하시겠습니까?</p>
|
||||||
<MudText>정말로 삭제하시겠습니까?</MudText>
|
<div class="admin-dialog-actions">
|
||||||
</DialogContent>
|
<FluentButton Appearance="ButtonAppearance.Transparent" @onclick="Cancel">취소</FluentButton>
|
||||||
<DialogActions>
|
<FluentButton Appearance="ButtonAppearance.Primary" @onclick="Confirm">삭제</FluentButton>
|
||||||
<MudButton OnClick="@Cancel">취소</MudButton>
|
</div>
|
||||||
<MudButton Color="Color.Error" OnClick="@Confirm">삭제</MudButton>
|
</div>
|
||||||
</DialogActions>
|
|
||||||
</MudDialog>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter] MudDialogInstance? MudDialog { get; set; }
|
[Parameter] public EventCallback OnCancel { get; set; }
|
||||||
|
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||||
|
|
||||||
void Cancel() => MudDialog?.Cancel();
|
Task Cancel() => OnCancel.InvokeAsync();
|
||||||
void Confirm() => MudDialog?.Close(DialogResult.Ok(true));
|
Task Confirm() => OnConfirm.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using Microsoft.FluentUI.AspNetCore.Components
|
||||||
|
|
||||||
|
<form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
|
||||||
|
<FluentTextInput Label="회사 코드" @bind-CurrentValue="model.CompanyCode" />
|
||||||
|
<FluentTextInput Label="회사명" @bind-CurrentValue="model.CompanyName" />
|
||||||
|
<FluentTextInput Label="담당자명" @bind-CurrentValue="model.ContactPerson" />
|
||||||
|
<FluentTextInput Label="전화번호" @bind-CurrentValue="model.Phone" />
|
||||||
|
<FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
|
||||||
|
<FluentTextArea Label="메모" @bind-CurrentValue="model.Memo" />
|
||||||
|
<label class="admin-checkbox-row">
|
||||||
|
<input type="checkbox" @bind="model.IsActive" />
|
||||||
|
<span>활성</span>
|
||||||
|
</label>
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button type="submit" class="admin-login-submit">@ButtonText</button>
|
||||||
|
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@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 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 Task HandleSubmit() => 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,67 @@
|
|||||||
|
@using TaxBaik.Application.DTOs
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@using Microsoft.FluentUI.AspNetCore.Components
|
||||||
|
|
||||||
|
<form class="admin-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault>
|
||||||
|
<FluentTextInput Label="이름" @bind-CurrentValue="model.Name" />
|
||||||
|
<FluentTextInput Label="전화번호 (예: 010-1234-5678)" @bind-CurrentValue="model.Phone" />
|
||||||
|
<FluentTextInput Label="이메일" @bind-CurrentValue="model.Email" />
|
||||||
|
<FluentSelect TValue="string" TOption="string" Label="문의 유형" @bind-CurrentValue="model.ServiceType">
|
||||||
|
<FluentOption Value="@("사업자세무")">사업자세무</FluentOption>
|
||||||
|
<FluentOption Value="@("부동산세금")">부동산세금</FluentOption>
|
||||||
|
<FluentOption Value="@("가족자산")">가족자산</FluentOption>
|
||||||
|
<FluentOption Value="@("기타")">기타</FluentOption>
|
||||||
|
</FluentSelect>
|
||||||
|
<FluentTextArea Label="문의 내용" @bind-CurrentValue="model.Message" />
|
||||||
|
<FluentSelect TValue="string" TOption="string" Label="상태" @bind-CurrentValue="model.Status">
|
||||||
|
<FluentOption Value="@("new")">신규</FluentOption>
|
||||||
|
<FluentOption Value="@("consulting")">상담중</FluentOption>
|
||||||
|
<FluentOption Value="@("contracted")">계약완료</FluentOption>
|
||||||
|
<FluentOption Value="@("rejected")">거절</FluentOption>
|
||||||
|
<FluentOption Value="@("closed")">종결</FluentOption>
|
||||||
|
</FluentSelect>
|
||||||
|
<FluentTextArea Label="관리 메모" @bind-CurrentValue="model.AdminMemo" />
|
||||||
|
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button type="submit" class="admin-login-submit">@ButtonText</button>
|
||||||
|
<button type="button" class="admin-secondary-button" @onclick="OnCancel">취소</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@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 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 Task HandleSubmit() => 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,4 +1,5 @@
|
|||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table mt-4">
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table mt-4">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
@@ -18,20 +19,19 @@
|
|||||||
<td>@inquiry.Phone</td>
|
<td>@inquiry.Phone</td>
|
||||||
<td>@inquiry.ServiceType</td>
|
<td>@inquiry.ServiceType</td>
|
||||||
<td>
|
<td>
|
||||||
<MudChip Size="Size.Small" Color="@GetStatusColor(inquiry.Status)">
|
<span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span>
|
||||||
@GetStatusLabel(inquiry.Status)
|
|
||||||
</MudChip>
|
|
||||||
</td>
|
</td>
|
||||||
<td>@GetPreview(inquiry.Message)</td>
|
<td>@GetPreview(inquiry.Message)</td>
|
||||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
<td>
|
<td>
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
<a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">보기</a>
|
||||||
Href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">문의 내용 확인</MudButton>
|
<a class="site-button secondary" href="@($"/taxbaik/admin/inquiries/{inquiry.Id}/edit")">수정</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired]
|
[Parameter, EditorRequired]
|
||||||
@@ -64,14 +64,14 @@
|
|||||||
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
|
return trimmed.Length <= 30 ? trimmed : $"{trimmed[..30]}...";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Color GetStatusColor(string status) => status switch
|
private static string GetStatusClass(string status) => status switch
|
||||||
{
|
{
|
||||||
"new" => Color.Warning,
|
"new" => "warning",
|
||||||
"consulting" => Color.Info,
|
"consulting" => "info",
|
||||||
"contracted" => Color.Success,
|
"contracted" => "success",
|
||||||
"rejected" => Color.Error,
|
"rejected" => "danger",
|
||||||
"closed" => Color.Dark,
|
"closed" => "muted",
|
||||||
_ => Color.Default
|
_ => "muted"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||||
|
|||||||
@@ -3,121 +3,110 @@
|
|||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<MudLayout Class="admin-shell">
|
<div class="admin-shell">
|
||||||
<MudAppBar Elevation="0" Class="admin-topbar">
|
<header class="admin-topbar">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Menu"
|
<button type="button" class="admin-icon-button admin-menu-button" @onclick="ToggleDrawer" aria-label="메뉴 열기">
|
||||||
Color="Color.Inherit"
|
<span class="material-icons">menu</span>
|
||||||
Edge="Edge.Start"
|
</button>
|
||||||
Class="admin-menu-button"
|
|
||||||
OnClick="@ToggleDrawer" />
|
|
||||||
<div class="admin-topbar-title">
|
<div class="admin-topbar-title">
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
|
<span class="admin-topbar-kicker">TaxBaik Admin</span>
|
||||||
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
|
<h1>세무회계 관리 대시보드</h1>
|
||||||
</div>
|
</div>
|
||||||
<MudSpacer />
|
|
||||||
|
|
||||||
<!-- 상단 액션 바 -->
|
|
||||||
<div class="admin-topbar-actions">
|
<div class="admin-topbar-actions">
|
||||||
<MudTooltip Text="공개 웹사이트 방문">
|
<a class="admin-topbar-action" href="/taxbaik" target="_blank" rel="noreferrer">
|
||||||
<MudButton Class="admin-topbar-action"
|
<span class="material-icons">open_in_new</span>
|
||||||
Variant="Variant.Text"
|
|
||||||
Color="Color.Inherit"
|
|
||||||
Size="Size.Small"
|
|
||||||
StartIcon="@Icons.Material.Filled.OpenInNew"
|
|
||||||
Href="/taxbaik"
|
|
||||||
Target="_blank">
|
|
||||||
공개 사이트
|
공개 사이트
|
||||||
</MudButton>
|
</a>
|
||||||
</MudTooltip>
|
<a class="admin-topbar-action danger" href="/taxbaik/admin/logout">
|
||||||
|
<span class="material-icons">logout</span>
|
||||||
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
|
|
||||||
|
|
||||||
<MudTooltip Text="로그아웃 (Ctrl+Q)">
|
|
||||||
<MudButton Class="admin-topbar-action"
|
|
||||||
Variant="Variant.Text"
|
|
||||||
Color="Color.Error"
|
|
||||||
Size="Size.Small"
|
|
||||||
StartIcon="@Icons.Material.Filled.Logout"
|
|
||||||
Href="/taxbaik/admin/logout">
|
|
||||||
로그아웃
|
로그아웃
|
||||||
</MudButton>
|
</a>
|
||||||
</MudTooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</MudAppBar>
|
</header>
|
||||||
|
|
||||||
<MudDrawer @bind-open="@drawerOpen"
|
<aside class="@DrawerClass">
|
||||||
Elevation="0"
|
|
||||||
Variant="DrawerVariant.Responsive"
|
|
||||||
Breakpoint="Breakpoint.Md"
|
|
||||||
Class="admin-drawer">
|
|
||||||
<div class="admin-drawer-brand">
|
<div class="admin-drawer-brand">
|
||||||
<div class="admin-brand-mark">T</div>
|
<div class="admin-brand-mark">T</div>
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.subtitle1">TaxBaik</MudText>
|
<div class="admin-brand-title">TaxBaik</div>
|
||||||
<MudText Typo="Typo.caption">세무 운영 콘솔</MudText>
|
<div class="admin-brand-subtitle">세무 운영 콘솔</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MudNavMenu Class="admin-nav">
|
|
||||||
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
|
||||||
<MudNavGroup Title="고객 관리" 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.CalendarMonth">신고 일정</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/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
|
|
||||||
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
|
||||||
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
|
|
||||||
</MudNavGroup>
|
|
||||||
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
|
||||||
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
|
||||||
</MudNavMenu>
|
|
||||||
<div class="admin-drawer-footer">
|
|
||||||
<MudDivider Class="my-2" />
|
|
||||||
<MudStack Spacing="1" Class="px-3 py-2">
|
|
||||||
<div class="admin-footer-item">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Small" />
|
|
||||||
<MudText Typo="Typo.caption" Class="ml-2">시스템</MudText>
|
|
||||||
</div>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
|
||||||
운영 서버: 178.104.200.7
|
|
||||||
</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
|
||||||
업데이트: 자동 배포 시스템
|
|
||||||
</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
|
||||||
상태: 정상
|
|
||||||
</MudText>
|
|
||||||
</MudStack>
|
|
||||||
</div>
|
|
||||||
</MudDrawer>
|
|
||||||
|
|
||||||
<MudMainContent Class="admin-main">
|
<nav class="admin-nav">
|
||||||
<MudContainer MaxWidth="MaxWidth.False" Class="admin-content">
|
<a href="/taxbaik/admin/dashboard" class="admin-nav-link">대시보드</a>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>CRM & 세무관리</summary>
|
||||||
|
<a href="/taxbaik/admin/tax-profiles" class="admin-nav-link">세무 프로필</a>
|
||||||
|
<a href="/taxbaik/admin/tax-filing-schedules" class="admin-nav-link">신고 일정</a>
|
||||||
|
<a href="/taxbaik/admin/contracts" class="admin-nav-link">계약 관리</a>
|
||||||
|
<a href="/taxbaik/admin/consulting-activities" class="admin-nav-link">상담 활동</a>
|
||||||
|
<a href="/taxbaik/admin/revenue-trackings" class="admin-nav-link">수익 추적</a>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>고객 관리</summary>
|
||||||
|
<a href="/taxbaik/admin/clients" class="admin-nav-link">고객 카드</a>
|
||||||
|
<a href="/taxbaik/admin/tax-filings" class="admin-nav-link">세무신고</a>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>홈페이지</summary>
|
||||||
|
<a href="/taxbaik/admin/announcements" class="admin-nav-link">공지사항</a>
|
||||||
|
<a href="/taxbaik/admin/faqs" class="admin-nav-link">FAQ 관리</a>
|
||||||
|
<a href="/taxbaik/admin/blog" class="admin-nav-link">블로그 관리</a>
|
||||||
|
<a href="/taxbaik/admin/season-simulator" class="admin-nav-link">시즌 시뮬레이터</a>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<a href="/taxbaik/admin/inquiries" class="admin-nav-link">문의 관리</a>
|
||||||
|
<a href="/taxbaik/admin/settings" class="admin-nav-link">설정</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="admin-drawer-footer">
|
||||||
|
<div class="admin-footer-item">
|
||||||
|
<span class="material-icons">shield</span>
|
||||||
|
<span>보안 모드</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-footer-meta">Fluent UI Blazor 기반 관리자 콘솔</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="admin-content">
|
||||||
|
<div class="admin-content-inner">
|
||||||
@Body
|
@Body
|
||||||
</MudContainer>
|
</div>
|
||||||
</MudMainContent>
|
</main>
|
||||||
</MudLayout>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool drawerOpen = true;
|
private bool drawerOpen = true;
|
||||||
private bool expandedCustomerGroup = true;
|
|
||||||
private bool expandedWebsiteGroup = false;
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
Navigation.LocationChanged += OnLocationChanged;
|
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 string DrawerClass => drawerOpen ? "admin-drawer open" : "admin-drawer";
|
||||||
|
|
||||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||||
{
|
{
|
||||||
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleDrawer()
|
private void ToggleDrawer() => drawerOpen = !drawerOpen;
|
||||||
{
|
|
||||||
drawerOpen = !drawerOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,101 +5,47 @@
|
|||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
|
<PageTitle>@(Id.HasValue ? "공지 수정" : "공지 등록")</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
|
<div class="admin-eyebrow">Homepage</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</MudText>
|
<h1 class="admin-page-title">@(Id.HasValue ? "공지 수정" : "공지 등록")</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<div class="admin-surface" style="max-width:720px;">
|
||||||
<MudForm @ref="form">
|
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
|
||||||
<MudGrid>
|
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
|
||||||
<MudItem xs="12">
|
<label>상세 내용 (선택) <textarea class="admin-input" rows="3" @bind="model.Content"></textarea></label>
|
||||||
<MudTextField @bind-Value="model.Title"
|
<label>유형
|
||||||
Label="제목"
|
<select class="admin-input" @bind="model.DisplayType">
|
||||||
Variant="Variant.Outlined"
|
<option value="info">일반 (파란색)</option>
|
||||||
Required="true"
|
<option value="banner">배너 (주황색)</option>
|
||||||
RequiredError="제목을 입력하세요."
|
<option value="urgent">긴급 (빨간색)</option>
|
||||||
HelperText="홈페이지 상단 공지 바에 표시되는 텍스트입니다." />
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
|
<label>노출 순서 <input class="admin-input" type="number" @bind="model.SortOrder" /></label>
|
||||||
<MudItem xs="12">
|
<label>게시 시작일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="StartsAtText" /></label>
|
||||||
<MudTextField @bind-Value="model.Content"
|
<label>게시 종료일 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="EndsAtText" /></label>
|
||||||
Label="상세 내용 (선택)"
|
<label><input type="checkbox" @bind="model.IsActive" /> @(model.IsActive ? "활성화" : "비활성화")</label>
|
||||||
Variant="Variant.Outlined"
|
<div class="admin-dialog-actions">
|
||||||
Lines="3"
|
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
|
||||||
HelperText="부가 설명이 있을 경우 입력합니다. 없으면 제목만 표시됩니다." />
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/announcements")'>취소</button>
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudSelect @bind-Value="model.DisplayType"
|
|
||||||
Label="유형"
|
|
||||||
Variant="Variant.Outlined">
|
|
||||||
<MudSelectItem Value="@("info")">일반 (파란색)</MudSelectItem>
|
|
||||||
<MudSelectItem Value="@("banner")">배너 (주황색) — 중요 이벤트</MudSelectItem>
|
|
||||||
<MudSelectItem Value="@("urgent")">긴급 (빨간색) — 마감 임박</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudNumericField @bind-Value="model.SortOrder"
|
|
||||||
Label="노출 순서"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
HelperText="숫자가 클수록 먼저 표시됩니다." />
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudDatePicker @bind-Date="startsAtDate"
|
|
||||||
Label="게시 시작일 (비우면 즉시)"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
DateFormat="yyyy-MM-dd"
|
|
||||||
Clearable="true" />
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudDatePicker @bind-Date="endsAtDate"
|
|
||||||
Label="게시 종료일 (비우면 무기한)"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
DateFormat="yyyy-MM-dd"
|
|
||||||
Clearable="true" />
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12">
|
|
||||||
<MudSwitch @bind-Checked="model.IsActive"
|
|
||||||
Label="@(model.IsActive ? "활성화 (홈페이지에 노출)" : "비활성화 (홈페이지 미노출)")"
|
|
||||||
Color="Color.Primary" />
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2 mt-4">
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Save"
|
|
||||||
Disabled="isSaving"
|
|
||||||
@onclick="SaveAsync">
|
|
||||||
@(isSaving ? "저장 중..." : "저장")
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined"
|
|
||||||
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/announcements"))">
|
|
||||||
취소
|
|
||||||
</MudButton>
|
|
||||||
</div>
|
</div>
|
||||||
</MudForm>
|
</form>
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int? Id { get; set; }
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
private MudForm? form;
|
|
||||||
private bool isSaving;
|
private bool isSaving;
|
||||||
private DateTime? startsAtDate;
|
private DateTime? startsAtDate;
|
||||||
private DateTime? endsAtDate;
|
private DateTime? endsAtDate;
|
||||||
|
|
||||||
private AnnouncementDto model = new();
|
private AnnouncementDto model = new();
|
||||||
|
private string StartsAtText { get => startsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => startsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
private string EndsAtText { get => endsAtDate?.ToString("yyyy-MM-dd") ?? ""; set => endsAtDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -134,41 +80,18 @@
|
|||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (form is null) return;
|
|
||||||
await form.Validate();
|
|
||||||
if (!form.IsValid) return;
|
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
model.StartsAt = startsAtDate.HasValue
|
model.StartsAt = startsAtDate.HasValue ? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() : null;
|
||||||
? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime()
|
model.EndsAt = endsAtDate.HasValue ? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime() : null;
|
||||||
: null;
|
var result = Id.HasValue ? await AnnouncementClient.UpdateAsync(Id.Value, model) : await AnnouncementClient.CreateAsync(model);
|
||||||
model.EndsAt = endsAtDate.HasValue
|
await JS.InvokeVoidAsync("alert", result != null ? "공지사항이 저장되었습니다." : "저장 실패");
|
||||||
? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (Id.HasValue)
|
|
||||||
{
|
|
||||||
var result = await AnnouncementClient.UpdateAsync(Id.Value, model);
|
|
||||||
if (result != null)
|
|
||||||
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
|
|
||||||
else
|
|
||||||
Snackbar.Add("저장 실패", Severity.Error);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var result = await AnnouncementClient.CreateAsync(model);
|
|
||||||
if (result != null)
|
|
||||||
Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success);
|
|
||||||
else
|
|
||||||
Snackbar.Add("저장 실패", Severity.Error);
|
|
||||||
}
|
|
||||||
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
Navigation.NavigateTo("/taxbaik/admin/announcements");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,36 +4,32 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IDialogService DialogService
|
@inject IJSRuntime JS
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
|
|
||||||
<PageTitle>공지사항 관리</PageTitle>
|
<PageTitle>공지사항 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Homepage</MudText>
|
<div class="admin-eyebrow">Homepage</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">공지사항 관리</MudText>
|
<h1 class="admin-page-title">공지사항 관리</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</MudText>
|
<p class="admin-page-subtitle">홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<a class="site-button primary" href="/taxbaik/admin/announcements/create">공지 등록</a>
|
||||||
StartIcon="@Icons.Material.Filled.Add"
|
|
||||||
Href="/taxbaik/admin/announcements/create">
|
|
||||||
공지 등록
|
|
||||||
</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<div class="admin-surface">
|
||||||
@if (announcements is null)
|
@if (announcements is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||||
}
|
}
|
||||||
else if (!announcements.Any())
|
else if (!announcements.Any())
|
||||||
{
|
{
|
||||||
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
|
<div class="muted">등록된 공지사항이 없습니다.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>제목</th>
|
<th>제목</th>
|
||||||
@@ -49,52 +45,54 @@
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@item.Title</td>
|
<td>@item.Title</td>
|
||||||
<td>
|
<td><span class="status-pill info">@GetTypeLabel(item.DisplayType)</span></td>
|
||||||
<MudChip Size="Size.Small" Color="@GetTypeColor(item.DisplayType)">
|
|
||||||
@GetTypeLabel(item.DisplayType)
|
|
||||||
</MudChip>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
@if (IsCurrentlyActive(item))
|
@if (IsCurrentlyActive(item))
|
||||||
{
|
{
|
||||||
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
|
<span class="status-pill success">노출 중</span>
|
||||||
}
|
}
|
||||||
else if (!item.IsActive)
|
else if (!item.IsActive)
|
||||||
{
|
{
|
||||||
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
<span class="status-pill default">비활성</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudChip Size="Size.Small" Color="Color.Warning">기간 외</MudChip>
|
<span class="status-pill warning">기간 외</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">
|
<td class="small">@FormatPeriod(item)</td>
|
||||||
@FormatPeriod(item)
|
|
||||||
</td>
|
|
||||||
<td>@item.SortOrder</td>
|
<td>@item.SortOrder</td>
|
||||||
<td>
|
<td>
|
||||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
<div class="admin-actions">
|
||||||
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">
|
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/announcements/{item.Id}/edit"))">✎</button>
|
||||||
수정
|
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
|
||||||
</MudButton>
|
</div>
|
||||||
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
|
||||||
삭제
|
|
||||||
</MudButton>
|
|
||||||
</MudButtonGroup>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</table>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Announcement>? announcements;
|
private List<Announcement>? announcements;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
@@ -105,36 +103,32 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
announcements = [];
|
announcements = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(Announcement item)
|
private async Task DeleteAsync(Announcement item)
|
||||||
{
|
{
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Title}' 공지를 삭제하시겠습니까?");
|
||||||
"공지 삭제",
|
if (!confirmed) return;
|
||||||
$"'{item.Title}' 공지를 삭제하시겠습니까?",
|
|
||||||
yesText: "삭제", cancelText: "취소");
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await AnnouncementClient.DeleteAsync(item.Id);
|
var success = await AnnouncementClient.DeleteAsync(item.Id);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "공지사항이 삭제되었습니다.");
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("삭제 실패", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +148,6 @@
|
|||||||
return $"{start} ~ {end}";
|
return $"{start} ~ {end}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Color GetTypeColor(string type) => type switch
|
|
||||||
{
|
|
||||||
"urgent" => Color.Error,
|
|
||||||
"banner" => Color.Warning,
|
|
||||||
_ => Color.Info
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string GetTypeLabel(string type) => type switch
|
private static string GetTypeLabel(string type) => type switch
|
||||||
{
|
{
|
||||||
"urgent" => "긴급",
|
"urgent" => "긴급",
|
||||||
|
|||||||
@@ -6,77 +6,53 @@
|
|||||||
@inject BlogService BlogService
|
@inject BlogService BlogService
|
||||||
@inject ICategoryRepository CategoryRepository
|
@inject ICategoryRepository CategoryRepository
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>새 포스트 작성</PageTitle>
|
<PageTitle>새 포스트 작성</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
<div class="admin-eyebrow">Content</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">새 포스트 작성</MudText>
|
<h1 class="admin-page-title">새 포스트 작성</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</MudText>
|
<p class="admin-page-subtitle">새로운 블로그 포스트를 작성합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<div class="admin-surface mt-4">
|
||||||
<MudForm @ref="form">
|
<form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
|
||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
<label>카테고리
|
||||||
|
<select class="admin-input" @bind="CategoryIdText">
|
||||||
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
<option value="">선택하세요</option>
|
||||||
Variant="Variant.Outlined" Class="mb-4">
|
|
||||||
@foreach (var category in categories)
|
@foreach (var category in categories)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
<option value="@category.Id.ToString()">@category.Name</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
|
</label>
|
||||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
|
||||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
<label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
|
||||||
|
<label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
|
||||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
<label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
<label><input type="checkbox" @bind="model.IsPublished" /> 즉시 발행</label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
|
|
||||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
|
||||||
|
|
||||||
<MudCheckBox @bind-Checked="model.IsPublished" Label="즉시 발행" Class="mb-4" />
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
|
||||||
@onclick="SavePost">저장</MudButton>
|
|
||||||
</div>
|
</div>
|
||||||
</MudForm>
|
</form>
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private MudForm? form;
|
|
||||||
private List<Domain.Entities.Category> categories = [];
|
private List<Domain.Entities.Category> categories = [];
|
||||||
private CreatePostModel model = new();
|
private CreatePostModel model = new();
|
||||||
|
private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
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)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await form.Validate();
|
|
||||||
if (!form.IsValid)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await BlogService.CreateAsync(new CreateBlogPostDto
|
await BlogService.CreateAsync(new CreateBlogPostDto
|
||||||
@@ -90,12 +66,12 @@
|
|||||||
IsPublished = model.IsPublished
|
IsPublished = model.IsPublished
|
||||||
});
|
});
|
||||||
|
|
||||||
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
|
||||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add(ex.Message, Severity.Error);
|
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,76 +6,60 @@
|
|||||||
@inject BlogService BlogService
|
@inject BlogService BlogService
|
||||||
@inject ICategoryRepository CategoryRepository
|
@inject ICategoryRepository CategoryRepository
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
@inject IDialogService DialogService
|
|
||||||
|
|
||||||
<PageTitle>포스트 수정</PageTitle>
|
<PageTitle>포스트 수정</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
<div class="admin-eyebrow">Content</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">포스트 수정</MudText>
|
<h1 class="admin-page-title">포스트 수정</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">블로그 포스트를 수정합니다.</MudText>
|
<p class="admin-page-subtitle">블로그 포스트를 수정합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/blog")'>취소</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
|
<div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
|
||||||
}
|
}
|
||||||
else if (post == null)
|
else if (post == null)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
|
<div class="admin-surface mt-4">포스트를 찾을 수 없습니다.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<div class="admin-surface mt-4">
|
||||||
<MudForm @ref="form">
|
<form class="admin-dialog-card" @onsubmit="SavePost" @onsubmit:preventDefault="true">
|
||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<label>제목 * <input class="admin-input" @bind="model.Title" /></label>
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
<label>카테고리
|
||||||
|
<select class="admin-input" @bind="CategoryIdText">
|
||||||
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
<option value="">선택하세요</option>
|
||||||
Variant="Variant.Outlined" Class="mb-4">
|
|
||||||
@foreach (var category in categories)
|
@foreach (var category in categories)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
<option value="@category.Id.ToString()">@category.Name</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
|
</label>
|
||||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
<label>본문 * <textarea class="admin-input" rows="10" @bind="model.Content"></textarea></label>
|
||||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
<label>태그 (쉼표로 구분) <input class="admin-input" @bind="model.Tags" /></label>
|
||||||
|
<label>SEO 제목 <input class="admin-input" @bind="model.SeoTitle" /></label>
|
||||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
<label>SEO 설명 <textarea class="admin-input" rows="3" @bind="model.SeoDescription"></textarea></label>
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
<label><input type="checkbox" @bind="model.IsPublished" /> 발행</label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
<button type="button" class="site-button secondary" @onclick="DeletePost">삭제</button>
|
||||||
|
</div>
|
||||||
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
|
</form>
|
||||||
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>
|
</div>
|
||||||
</MudForm>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter] public int Id { get; set; }
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
private MudForm? form;
|
|
||||||
private Domain.Entities.BlogPost? post;
|
private Domain.Entities.BlogPost? post;
|
||||||
private List<Domain.Entities.Category> categories = [];
|
private List<Domain.Entities.Category> categories = [];
|
||||||
private EditPostModel model = new();
|
private EditPostModel model = new();
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
|
private string CategoryIdText { get => model.CategoryId?.ToString() ?? ""; set => model.CategoryId = int.TryParse(value, out var id) ? id : null; }
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -90,7 +74,7 @@ else
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"포스트 로드 실패: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -109,20 +93,9 @@ else
|
|||||||
model.IsPublished = post.IsPublished;
|
model.IsPublished = post.IsPublished;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GoBack()
|
|
||||||
{
|
|
||||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SavePost()
|
private async Task SavePost()
|
||||||
{
|
{
|
||||||
if (form == null || post == null)
|
if (post == null) return;
|
||||||
return;
|
|
||||||
|
|
||||||
await form.Validate();
|
|
||||||
if (!form.IsValid)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
|
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
|
||||||
@@ -135,44 +108,23 @@ else
|
|||||||
SeoDescription = model.SeoDescription,
|
SeoDescription = model.SeoDescription,
|
||||||
IsPublished = model.IsPublished
|
IsPublished = model.IsPublished
|
||||||
});
|
});
|
||||||
|
await JS.InvokeVoidAsync("alert", "포스트가 저장되었습니다.");
|
||||||
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
|
||||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add(ex.Message, Severity.Error);
|
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeletePost()
|
private async Task DeletePost()
|
||||||
{
|
{
|
||||||
if (post == null)
|
if (post == null) return;
|
||||||
return;
|
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
|
||||||
|
|
||||||
var result = await DialogService.ShowMessageBox(
|
|
||||||
"포스트 삭제",
|
|
||||||
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
|
||||||
"삭제", "취소");
|
|
||||||
|
|
||||||
if (result != true)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await BlogService.DeleteAsync(post.Id);
|
await BlogService.DeleteAsync(post.Id);
|
||||||
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
|
||||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class EditPostModel
|
private class EditPostModel
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,55 +1,72 @@
|
|||||||
@page "/admin/blog"
|
@page "/admin/blog"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IApiClient ApiClient
|
@inject IApiClient ApiClient
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>블로그 관리</PageTitle>
|
<PageTitle>블로그 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Content</MudText>
|
<div class="admin-eyebrow">Content</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">블로그 관리</MudText>
|
<h1 class="admin-page-title">블로그 관리</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</MudText>
|
<p class="admin-page-subtitle">검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote"
|
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
|
||||||
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface mb-4" Elevation="0">
|
<div class="admin-surface mb-4">
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
<div class="admin-summary-bar">
|
||||||
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
|
<span>전체 포스트: @($"{totalPosts}개")</span>
|
||||||
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
<span>페이지 @currentPage / @totalPages</span>
|
||||||
</MudStack>
|
</div>
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
<div class="admin-surface">
|
||||||
<Columns>
|
@if (isLoading)
|
||||||
<PropertyColumn Property="x => x.Title" Title="제목" />
|
{
|
||||||
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||||
<CellTemplate Context="cell">
|
}
|
||||||
<MudCheckBox T="bool" Value="@cell.Item.IsPublished"
|
else
|
||||||
ValueChanged="@(async (bool value) => await TogglePublish(cell.Item, value))" />
|
{
|
||||||
</CellTemplate>
|
<div class="admin-table-wrap">
|
||||||
</PropertyColumn>
|
<table class="admin-table">
|
||||||
<PropertyColumn Property="x => x.ViewCount" Title="조회수" />
|
<thead>
|
||||||
<PropertyColumn Property="x => x.CreatedAt" Title="작성일" Format="yyyy-MM-dd" />
|
<tr>
|
||||||
<TemplateColumn>
|
<th>제목</th>
|
||||||
<CellTemplate Context="cell">
|
<th>발행</th>
|
||||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
|
<th>조회수</th>
|
||||||
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
|
<th>작성일</th>
|
||||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
|
<th></th>
|
||||||
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
|
</tr>
|
||||||
</CellTemplate>
|
</thead>
|
||||||
</TemplateColumn>
|
<tbody>
|
||||||
</Columns>
|
@foreach (var post in posts)
|
||||||
</MudDataGrid>
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@post.Title</td>
|
||||||
|
<td><label><input type="checkbox" checked="@post.IsPublished" @onchange="@(async e => await TogglePublish(post, (bool)e.Value!))" /> 발행</label></td>
|
||||||
|
<td>@post.ViewCount</td>
|
||||||
|
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-row-actions">
|
||||||
|
<a class="site-button secondary" href="@($"/taxbaik/admin/blog/{post.Id}/edit")">수정</a>
|
||||||
|
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeletePost(post.Id))">✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
|
<div class="admin-pagination">
|
||||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
|
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
|
||||||
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
|
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
|
||||||
</MudStack>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private int currentPage = 1;
|
private int currentPage = 1;
|
||||||
@@ -57,10 +74,20 @@
|
|||||||
private int totalPosts = 0;
|
private int totalPosts = 0;
|
||||||
private const int PageSize = 20;
|
private const int PageSize = 20;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
await LoadPosts();
|
await LoadPosts();
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string NavTo(string url) => url;
|
||||||
|
|
||||||
private async Task LoadPosts()
|
private async Task LoadPosts()
|
||||||
{
|
{
|
||||||
@@ -78,58 +105,33 @@
|
|||||||
totalPosts = 0;
|
totalPosts = 0;
|
||||||
totalPages = 1;
|
totalPages = 1;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PreviousPage()
|
|
||||||
{
|
|
||||||
if (currentPage <= 1)
|
|
||||||
return;
|
|
||||||
|
|
||||||
currentPage--;
|
|
||||||
await LoadPosts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task NextPage()
|
private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadPosts(); } }
|
||||||
{
|
private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadPosts(); } }
|
||||||
if (currentPage >= totalPages)
|
|
||||||
return;
|
|
||||||
|
|
||||||
currentPage++;
|
|
||||||
await LoadPosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
|
private async Task TogglePublish(TaxBaik.Domain.Entities.BlogPost post, bool isPublished)
|
||||||
{
|
{
|
||||||
var previous = post.IsPublished;
|
var previous = post.IsPublished;
|
||||||
post.IsPublished = isPublished;
|
post.IsPublished = isPublished;
|
||||||
var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new
|
var result = await ApiClient.PutAsync<TaxBaik.Domain.Entities.BlogPost>($"blog/{post.Id}", new { post.Title, post.Content, post.CategoryId, post.Tags, post.SeoTitle, post.SeoDescription, post.ThumbnailUrl, IsPublished = isPublished, post.AuthorId });
|
||||||
{
|
|
||||||
post.Title,
|
|
||||||
post.Content,
|
|
||||||
post.CategoryId,
|
|
||||||
post.Tags,
|
|
||||||
post.SeoTitle,
|
|
||||||
post.SeoDescription,
|
|
||||||
post.ThumbnailUrl,
|
|
||||||
IsPublished = isPublished,
|
|
||||||
post.AuthorId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result == null)
|
if (result == null)
|
||||||
{
|
{
|
||||||
post.IsPublished = previous;
|
post.IsPublished = previous;
|
||||||
Snackbar.Add("발행 상태 변경에 실패했습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "발행 상태 변경에 실패했습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await JS.InvokeVoidAsync("alert", "발행 상태가 변경되었습니다.");
|
||||||
Snackbar.Add("발행 상태가 변경되었습니다.", Severity.Success);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeletePost(int postId)
|
private async Task DeletePost(int postId)
|
||||||
{
|
{
|
||||||
await ApiClient.DeleteAsync($"blog/{postId}");
|
await ApiClient.DeleteAsync($"blog/{postId}");
|
||||||
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "포스트가 삭제되었습니다.");
|
||||||
await LoadPosts();
|
await LoadPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,185 +4,123 @@
|
|||||||
@inject ClientService ClientService
|
@inject ClientService ClientService
|
||||||
@inject ConsultationService ConsultationService
|
@inject ConsultationService ConsultationService
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>고객 상세</PageTitle>
|
<PageTitle>고객 상세</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Client Details</MudText>
|
<div class="admin-eyebrow">Client Details</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">고객 상세</MudText>
|
<h1 class="admin-page-title">고객 상세</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</MudText>
|
<p class="admin-page-subtitle">고객 정보와 상담 이력을 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (client == null)
|
@if (client == null)
|
||||||
{
|
{
|
||||||
<MudText>고객을 찾을 수 없습니다.</MudText>
|
<div class="admin-surface mt-4">고객을 찾을 수 없습니다.</div>
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-page-actions">
|
||||||
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
|
||||||
|
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{ClientId}/edit")">수정</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4" Spacing="2">
|
<div class="admin-detail-grid">
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
|
<section class="admin-surface">
|
||||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
<h3 class="admin-section-title">고객 정보</h3>
|
||||||
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/clients"))">
|
<div class="admin-kv-grid">
|
||||||
목록으로
|
<div><span>이름</span><strong>@client.Name</strong></div>
|
||||||
</MudButton>
|
<div><span>상호</span><strong>@(client.CompanyName ?? "-")</strong></div>
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning"
|
<div><span>연락처</span><strong>@(client.Phone ?? "-")</strong></div>
|
||||||
StartIcon="@Icons.Material.Filled.Edit"
|
<div><span>이메일</span><strong>@(client.Email ?? "-")</strong></div>
|
||||||
Href="@($"/taxbaik/admin/clients/{ClientId}/edit")">
|
<div><span>서비스</span><strong>@(client.ServiceType ?? "-")</strong></div>
|
||||||
수정
|
<div><span>사업자 유형</span><strong>@(client.TaxType ?? "-")</strong></div>
|
||||||
</MudButton>
|
<div><span>유입 경로</span><strong>@(client.Source ?? "-")</strong></div>
|
||||||
</MudStack>
|
<div><span>등록일</span><strong>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</strong></div>
|
||||||
|
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="12" md="5">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">고객 정보</MudText>
|
|
||||||
<MudGrid Spacing="2">
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
|
|
||||||
<MudText>@client.Name</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">상호</MudText>
|
|
||||||
<MudText>@(client.CompanyName ?? "-")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
|
|
||||||
<MudText>@(client.Phone ?? "-")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
|
|
||||||
<MudText>@(client.Email ?? "-")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">서비스</MudText>
|
|
||||||
<MudText>@(client.ServiceType ?? "-")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">사업자 유형</MudText>
|
|
||||||
<MudText>@(client.TaxType ?? "-")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">유입 경로</MudText>
|
|
||||||
<MudText>@(client.Source ?? "-")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">등록일</MudText>
|
|
||||||
<MudText>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(client.Memo))
|
@if (!string.IsNullOrWhiteSpace(client.Memo))
|
||||||
{
|
{
|
||||||
<MudItem xs="12">
|
<div class="span-2"><span>메모</span><strong style="white-space: pre-wrap;">@client.Memo</strong></div>
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">메모</MudText>
|
|
||||||
<MudText Style="white-space: pre-wrap;">@client.Memo</MudText>
|
|
||||||
</MudItem>
|
|
||||||
}
|
}
|
||||||
</MudGrid>
|
</div>
|
||||||
</MudPaper>
|
</section>
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="7">
|
<section class="admin-surface">
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<div class="admin-section-header compact">
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
|
<div>
|
||||||
<MudText Typo="Typo.h6">상담 이력</MudText>
|
<h3 class="admin-section-title">상담 이력</h3>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
</div>
|
||||||
Size="Size.Small"
|
<button type="button" class="site-button primary" @onclick="OpenAddConsultation">+ 상담 추가</button>
|
||||||
OnClick="OpenAddConsultation">
|
</div>
|
||||||
+ 상담 추가
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
|
|
||||||
@if (showAddForm)
|
@if (showAddForm)
|
||||||
{
|
{
|
||||||
<MudPaper Class="pa-3 mb-3" Outlined="true">
|
<form class="admin-dialog-card mb-4" @onsubmit="AddConsultation" @onsubmit:preventDefault="true">
|
||||||
<MudGrid Spacing="2">
|
<label>상담일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="ConsultationDateText" /></label>
|
||||||
<MudItem xs="12" sm="6">
|
<label>서비스 분야
|
||||||
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
|
<select class="admin-input" @bind="newServiceType">
|
||||||
</MudItem>
|
<option value="">선택하세요</option>
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
|
|
||||||
@foreach (var t in ClientService.ServiceTypes)
|
@foreach (var t in ClientService.ServiceTypes)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
<option value="@t">@t</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
<MudItem xs="12">
|
<label>상담 내용 * <textarea class="admin-input" rows="3" @bind="newSummary"></textarea></label>
|
||||||
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
|
<label>결과
|
||||||
Lines="3" Variant="Variant.Outlined" Required="true" />
|
<select class="admin-input" @bind="newResult">
|
||||||
</MudItem>
|
<option value="">-</option>
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudSelect T="string" @bind-Value="newResult" Label="결과">
|
|
||||||
<MudSelectItem Value="@("")">-</MudSelectItem>
|
|
||||||
@foreach (var r in ConsultationService.Results)
|
@foreach (var r in ConsultationService.Results)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@r">@r</MudSelectItem>
|
<option value="@r">@r</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
<MudItem xs="12" sm="6">
|
<label>수임료 (원) <input class="admin-input" type="text" placeholder="100000" @bind="FeeText" /></label>
|
||||||
<MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
|
<div class="admin-dialog-actions">
|
||||||
Format="N0" />
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
</MudItem>
|
<button type="button" class="site-button secondary" @onclick='() => showAddForm = false'>취소</button>
|
||||||
</MudGrid>
|
</div>
|
||||||
<MudStack Row="true" Class="mt-2" Spacing="2">
|
</form>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddConsultation">저장</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (consultations.Count == 0)
|
@if (consultations.Count == 0)
|
||||||
{
|
{
|
||||||
<MudText Color="Color.Secondary">상담 이력이 없습니다.</MudText>
|
<p class="muted">상담 이력이 없습니다.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudList T="string" Dense="true">
|
<div class="admin-activity-list">
|
||||||
@foreach (var c in consultations)
|
@foreach (var c in consultations)
|
||||||
{
|
{
|
||||||
<MudListItem>
|
<article class="admin-activity-card">
|
||||||
<MudPaper Class="pa-3" Outlined="true" Style="width:100%">
|
<div class="admin-activity-head">
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween">
|
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
<span class="muted">@c.ConsultationDate.ToString("yyyy-MM-dd") @(string.IsNullOrEmpty(c.ServiceType) ? "" : $"· {c.ServiceType}")</span>
|
||||||
@c.ConsultationDate.ToString("yyyy-MM-dd")
|
</div>
|
||||||
@if (!string.IsNullOrEmpty(c.ServiceType)) { <text> · @c.ServiceType</text> }
|
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteConsultation(c.Id))">✕</button>
|
||||||
</MudText>
|
</div>
|
||||||
<MudText Style="white-space: pre-wrap;" Class="mt-1">@c.Summary</MudText>
|
<p style="white-space: pre-wrap;">@c.Summary</p>
|
||||||
@if (!string.IsNullOrEmpty(c.Result))
|
@if (!string.IsNullOrEmpty(c.Result))
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="mt-1">@c.Result</MudChip>
|
<span class="status-pill info">@c.Result</span>
|
||||||
}
|
}
|
||||||
@if (c.Fee.HasValue)
|
@if (c.Fee.HasValue)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
|
<div class="muted">수임료: @c.Fee.Value.ToString("N0")원</div>
|
||||||
수임료: @c.Fee.Value.ToString("N0")원
|
}
|
||||||
</MudText>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
|
||||||
Size="Size.Small" Color="Color.Error"
|
|
||||||
OnClick="@(() => DeleteConsultation(c.Id))" />
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
</MudListItem>
|
|
||||||
}
|
}
|
||||||
</MudList>
|
</section>
|
||||||
}
|
</div>
|
||||||
</MudPaper>
|
}
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter] public int ClientId { get; set; }
|
||||||
public int ClientId { get; set; }
|
|
||||||
|
|
||||||
private Domain.Entities.Client? client;
|
private Domain.Entities.Client? client;
|
||||||
private List<Domain.Entities.Consultation> consultations = [];
|
private List<Domain.Entities.Consultation> consultations = [];
|
||||||
|
|
||||||
private bool showAddForm;
|
private bool showAddForm;
|
||||||
private DateTime? newDate = DateTime.Today;
|
private DateTime? newDate = DateTime.Today;
|
||||||
private string newServiceType = "";
|
private string newServiceType = "";
|
||||||
@@ -190,10 +128,10 @@
|
|||||||
private string newResult = "";
|
private string newResult = "";
|
||||||
private decimal? newFee;
|
private decimal? newFee;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
private string ConsultationDateText { get => newDate?.ToString("yyyy-MM-dd") ?? ""; set => newDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
{
|
private string FeeText { get => newFee?.ToString() ?? ""; set => newFee = decimal.TryParse(value, out var d) ? d : null; }
|
||||||
await LoadAll();
|
|
||||||
}
|
protected override async Task OnInitializedAsync() => await LoadAll();
|
||||||
|
|
||||||
private async Task LoadAll()
|
private async Task LoadAll()
|
||||||
{
|
{
|
||||||
@@ -215,6 +153,12 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newSummary))
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "상담 내용을 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var c = new Domain.Entities.Consultation
|
var c = new Domain.Entities.Consultation
|
||||||
{
|
{
|
||||||
ClientId = ClientId,
|
ClientId = ClientId,
|
||||||
@@ -224,21 +168,23 @@
|
|||||||
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
|
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
|
||||||
Fee = newFee
|
Fee = newFee
|
||||||
};
|
};
|
||||||
|
|
||||||
await ConsultationService.CreateAsync(c);
|
await ConsultationService.CreateAsync(c);
|
||||||
showAddForm = false;
|
showAddForm = false;
|
||||||
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
||||||
Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "상담이 추가되었습니다.");
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add(ex.Message, Severity.Error);
|
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteConsultation(int id)
|
private async Task DeleteConsultation(int id)
|
||||||
{
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "이 상담을 삭제하시겠습니까?")) return;
|
||||||
await ConsultationService.DeleteAsync(id);
|
await ConsultationService.DeleteAsync(id);
|
||||||
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
|
||||||
Snackbar.Add("삭제되었습니다.", Severity.Info);
|
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,117 +6,74 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IClientBrowserClient ClientClient
|
@inject IClientBrowserClient ClientClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
|
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
<div class="admin-eyebrow">CRM</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText>
|
<h1 class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</h1>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients"
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>목록으로</button>
|
||||||
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
<div class="admin-surface" style="max-width:720px;">
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudForm @ref="form" @bind-IsValid="isValid">
|
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
|
||||||
<MudGrid Spacing="3">
|
<label>고객명 * <input class="admin-input" @bind="dto.Name" /></label>
|
||||||
@* 기본 정보 *@
|
<label>회사명 <input class="admin-input" @bind="dto.CompanyName" /></label>
|
||||||
<MudItem xs="12">
|
<label>연락처 <input class="admin-input" @bind="dto.Phone" /></label>
|
||||||
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">기본 정보</MudText>
|
<label>이메일 <input class="admin-input" type="email" @bind="dto.Email" /></label>
|
||||||
<MudDivider />
|
<label>서비스 유형
|
||||||
</MudItem>
|
<select class="admin-input" @bind="dto.ServiceType">
|
||||||
<MudItem xs="12" md="6">
|
<option value="">선택하세요</option>
|
||||||
<MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true"
|
|
||||||
RequiredError="고객명을 입력하세요." />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudTextField @bind-Value="dto.Phone" Label="연락처"
|
|
||||||
Placeholder="010-0000-0000" />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" />
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
@* 세무 정보 *@
|
|
||||||
<MudItem xs="12" Class="mt-2">
|
|
||||||
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">세무 정보</MudText>
|
|
||||||
<MudDivider />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true">
|
|
||||||
@foreach (var t in ClientService.ServiceTypes)
|
@foreach (var t in ClientService.ServiceTypes)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
<option value="@t">@t</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
<MudItem xs="12" md="6">
|
<label>세금 유형
|
||||||
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true">
|
<select class="admin-input" @bind="dto.TaxType">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
@foreach (var t in ClientService.TaxTypes)
|
@foreach (var t in ClientService.TaxTypes)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
<option value="@t">@t</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
|
<label>상태
|
||||||
@* 관리 정보 *@
|
<select class="admin-input" @bind="dto.Status">
|
||||||
<MudItem xs="12" Class="mt-2">
|
<option value="active">활성</option>
|
||||||
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">관리 정보</MudText>
|
<option value="inactive">비활성</option>
|
||||||
<MudDivider />
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
<MudItem xs="12" md="6">
|
<label>유입 경로
|
||||||
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true">
|
<select class="admin-input" @bind="dto.Source">
|
||||||
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
<option value="">선택하세요</option>
|
||||||
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true">
|
|
||||||
@foreach (var s in ClientService.Sources)
|
@foreach (var s in ClientService.Sources)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@s">@s</MudSelectItem>
|
<option value="@s">@s</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
<MudItem xs="12">
|
<label>메모 <textarea class="admin-input" rows="4" @bind="dto.Memo"></textarea></label>
|
||||||
<MudTextField @bind-Value="dto.Memo" Label="메모"
|
<div class="admin-dialog-actions">
|
||||||
Lines="4" AutoGrow="true"
|
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
|
||||||
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients")'>취소</button>
|
||||||
</MudItem>
|
</div>
|
||||||
|
</form>
|
||||||
@* 저장 버튼 *@
|
|
||||||
<MudItem xs="12" Class="d-flex gap-2 mt-2">
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Save"
|
|
||||||
OnClick="@SaveAsync" Disabled="@isSaving">
|
|
||||||
@(isSaving ? "저장 중..." : "저장")
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients">
|
|
||||||
취소
|
|
||||||
</MudButton>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
</MudForm>
|
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int? Id { get; set; }
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
private MudForm form = null!;
|
|
||||||
private CreateClientDto dto = new() { Status = "active" };
|
private CreateClientDto dto = new() { Status = "active" };
|
||||||
private bool isValid;
|
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private bool isSaving;
|
private bool isSaving;
|
||||||
|
|
||||||
@@ -129,7 +86,7 @@
|
|||||||
var client = await ClientClient.GetByIdAsync(Id.Value);
|
var client = await ClientClient.GetByIdAsync(Id.Value);
|
||||||
if (client is null)
|
if (client is null)
|
||||||
{
|
{
|
||||||
Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "고객을 찾을 수 없습니다.");
|
||||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -148,7 +105,7 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,33 +115,29 @@
|
|||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
await form.Validate();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "고객명을 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Id.HasValue)
|
if (Id.HasValue)
|
||||||
{
|
{
|
||||||
var result = await ClientClient.UpdateAsync(Id.Value, dto);
|
var result = await ClientClient.UpdateAsync(Id.Value, dto);
|
||||||
if (result != null)
|
await JS.InvokeVoidAsync("alert", result != null ? "고객 정보가 수정되었습니다." : "수정에 실패했습니다.");
|
||||||
Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success);
|
|
||||||
else
|
|
||||||
Snackbar.Add("수정에 실패했습니다.", Severity.Error);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var result = await ClientClient.CreateAsync(dto);
|
var result = await ClientClient.CreateAsync(dto);
|
||||||
if (result != null)
|
await JS.InvokeVoidAsync("alert", result != null ? "고객이 등록되었습니다." : "등록에 실패했습니다.");
|
||||||
Snackbar.Add("고객이 등록되었습니다.", Severity.Success);
|
|
||||||
else
|
|
||||||
Snackbar.Add("등록에 실패했습니다.", Severity.Error);
|
|
||||||
}
|
}
|
||||||
Navigation.NavigateTo("/taxbaik/admin/clients");
|
Navigation.NavigateTo("/taxbaik/admin/clients");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,63 +4,44 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IClientBrowserClient ClientClient
|
@inject IClientBrowserClient ClientClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IDialogService DialogService
|
@inject IJSRuntime JS
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
|
|
||||||
<PageTitle>고객 관리</PageTitle>
|
<PageTitle>고객 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
<div class="admin-eyebrow">CRM</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText>
|
<h1 class="admin-page-title">고객 관리</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText>
|
<p class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/clients/create")'>고객 등록</button>
|
||||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
|
||||||
Href="/taxbaik/admin/clients/create">
|
|
||||||
고객 등록
|
|
||||||
</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@* 검색/필터 바 *@
|
<div class="admin-surface mb-3 pa-3">
|
||||||
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
|
<div class="admin-filter-grid">
|
||||||
<MudGrid>
|
<input class="admin-input" placeholder="검색 (이름·연락처·회사명)" @bind="searchText" @onkeyup="OnSearchKeyUp" />
|
||||||
<MudItem xs="12" md="5">
|
<select class="admin-input" @bind="statusFilter">
|
||||||
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
|
<option value="">전체</option>
|
||||||
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
|
<option value="active">활성</option>
|
||||||
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
|
<option value="inactive">비활성</option>
|
||||||
</MudItem>
|
</select>
|
||||||
<MudItem xs="12" md="3">
|
<button type="button" class="site-button secondary" @onclick="SearchAsync">검색</button>
|
||||||
<MudSelect @bind-Value="statusFilter" Label="상태" T="string">
|
<button type="button" class="site-button secondary" @onclick="ResetAsync">초기화</button>
|
||||||
<MudSelectItem Value="@("")">전체</MudSelectItem>
|
</div>
|
||||||
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
</div>
|
||||||
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="2" Class="d-flex align-center">
|
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="2" Class="d-flex align-center">
|
|
||||||
<MudButton Variant="Variant.Text" OnClick="@ResetAsync" FullWidth="true">초기화</MudButton>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<div class="admin-surface">
|
||||||
@if (clients is null)
|
@if (clients is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||||
}
|
}
|
||||||
else if (!clients.Any())
|
else if (!clients.Any())
|
||||||
{
|
{
|
||||||
<div class="pa-6 text-center">
|
<div class="muted mt-4">등록된 고객이 없습니다.</div>
|
||||||
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
|
|
||||||
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
@@ -81,54 +62,36 @@
|
|||||||
<td><strong>@c.Name</strong></td>
|
<td><strong>@c.Name</strong></td>
|
||||||
<td>@(c.CompanyName ?? "—")</td>
|
<td>@(c.CompanyName ?? "—")</td>
|
||||||
<td>@(c.Phone ?? "—")</td>
|
<td>@(c.Phone ?? "—")</td>
|
||||||
<td>
|
<td>@(c.ServiceType ?? "—")</td>
|
||||||
@if (!string.IsNullOrEmpty(c.ServiceType))
|
|
||||||
{
|
|
||||||
<MudChip Size="Size.Small" Color="Color.Primary">@c.ServiceType</MudChip>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>@(c.TaxType ?? "—")</td>
|
<td>@(c.TaxType ?? "—")</td>
|
||||||
<td>
|
<td>@(c.Status == "active" ? "활성" : "비활성")</td>
|
||||||
@if (c.Status == "active")
|
|
||||||
{
|
|
||||||
<MudChip Size="Size.Small" Color="Color.Success">활성</MudChip>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>@(c.Source ?? "—")</td>
|
<td>@(c.Source ?? "—")</td>
|
||||||
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
|
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
|
||||||
<td>
|
<td>
|
||||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
<div class="admin-row-actions">
|
||||||
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">
|
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">✎</button>
|
||||||
수정
|
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(c))">✕</button>
|
||||||
</MudButton>
|
</div>
|
||||||
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(c))">
|
|
||||||
삭제
|
|
||||||
</MudButton>
|
|
||||||
</MudButtonGroup>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</table>
|
||||||
|
</div>
|
||||||
@* 페이징 *@
|
|
||||||
@if (totalPages > 1)
|
@if (totalPages > 1)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-center pa-3">
|
<div class="admin-pagination">
|
||||||
<MudPagination BoundaryCount="1" MiddleCount="3"
|
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1)" @onclick="PreviousPage">이전</button>
|
||||||
Count="@totalPages" Selected="@currentPage"
|
<span>@currentPage / @totalPages</span>
|
||||||
SelectedChanged="@OnPageChanged" />
|
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages)" @onclick="NextPage">다음</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText>
|
<div class="admin-table-footer">총 @(totalCount)명</div>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
private List<Client>? clients;
|
private List<Client>? clients;
|
||||||
private string searchText = "";
|
private string searchText = "";
|
||||||
private string statusFilter = "";
|
private string statusFilter = "";
|
||||||
@@ -137,81 +100,56 @@
|
|||||||
private int totalPages;
|
private int totalPages;
|
||||||
private const int PageSize = 20;
|
private const int PageSize = 20;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (items, total) = await ClientClient.GetPagedAsync(
|
var (items, total) = await ClientClient.GetPagedAsync(currentPage, PageSize, string.IsNullOrEmpty(statusFilter) ? null : statusFilter, string.IsNullOrEmpty(searchText) ? null : searchText);
|
||||||
currentPage, PageSize,
|
|
||||||
string.IsNullOrEmpty(statusFilter) ? null : statusFilter,
|
|
||||||
string.IsNullOrEmpty(searchText) ? null : searchText);
|
|
||||||
|
|
||||||
clients = items.ToList();
|
clients = items.ToList();
|
||||||
totalCount = total;
|
totalCount = total;
|
||||||
totalPages = (int)Math.Ceiling((double)total / PageSize);
|
totalPages = (int)Math.Ceiling((double)total / PageSize);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
clients = [];
|
clients = [];
|
||||||
totalCount = 0;
|
|
||||||
totalPages = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SearchAsync()
|
private async Task SearchAsync() { currentPage = 1; await LoadAsync(); }
|
||||||
{
|
private async Task ResetAsync() { searchText = ""; statusFilter = ""; currentPage = 1; await LoadAsync(); }
|
||||||
currentPage = 1;
|
private async Task PreviousPage() { if (currentPage > 1) { currentPage--; await LoadAsync(); } }
|
||||||
await LoadAsync();
|
private async Task NextPage() { if (currentPage < totalPages) { currentPage++; await LoadAsync(); } }
|
||||||
}
|
private async Task OnSearchKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") await SearchAsync(); }
|
||||||
|
|
||||||
private async Task ResetAsync()
|
|
||||||
{
|
|
||||||
searchText = "";
|
|
||||||
statusFilter = "";
|
|
||||||
currentPage = 1;
|
|
||||||
await LoadAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnPageChanged(int page)
|
|
||||||
{
|
|
||||||
currentPage = page;
|
|
||||||
await LoadAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnSearchKeyUp(KeyboardEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key == "Enter") await SearchAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync(Client client)
|
private async Task DeleteAsync(Client client)
|
||||||
{
|
{
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.");
|
||||||
"고객 삭제",
|
if (!confirmed) return;
|
||||||
$"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.",
|
|
||||||
yesText: "삭제", cancelText: "취소");
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await ClientClient.DeleteAsync(client.Id);
|
var success = await ClientClient.DeleteAsync(client.Id);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", $"{client.Name} 고객이 삭제되었습니다.");
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
Snackbar.Add("삭제에 실패했습니다.", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
await LoadAsync();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@page "/admin/companies/create"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject IApiClient ApiClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
<PageTitle>고객사 등록</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">Settings</div>
|
||||||
|
<h1 class="admin-page-title">새 고객사 등록</h1>
|
||||||
|
<p class="admin-page-subtitle">새로운 고객사를 추가합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
|
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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
|
||||||
|
});
|
||||||
|
|
||||||
|
await JS.InvokeVoidAsync("alert", "고객사가 등록되었습니다.");
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@page "/admin/companies/{id:int}/edit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using TaxBaik.Web.Components.Admin.Forms
|
||||||
|
@inject IApiClient ApiClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
<PageTitle>고객사 수정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">Settings</div>
|
||||||
|
<h1 class="admin-page-title">고객사 수정</h1>
|
||||||
|
<p class="admin-page-subtitle">고객사 정보를 수정합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
|
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (formModel == null)
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">고객사를 찾을 수 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
|
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button" class="site-button secondary danger" @onclick="DeleteCompany">고객사 삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
await JS.InvokeVoidAsync("alert", "고객사가 수정되었습니다.");
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteCompany()
|
||||||
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApiClient.DeleteAsync($"company/{Id}");
|
||||||
|
await JS.InvokeVoidAsync("alert", "고객사가 삭제되었습니다.");
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/companies");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
@page "/admin/companies"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IApiClient ApiClient
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
<PageTitle>고객사 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">Settings</div>
|
||||||
|
<h1 class="admin-page-title">고객사 관리</h1>
|
||||||
|
<p class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button primary" @onclick='() => NavTo("/taxbaik/admin/companies/create")'>새 고객사 등록</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface mb-4 mt-4">
|
||||||
|
<div class="admin-summary-bar">
|
||||||
|
<span>@($"전체 고객사 {totalCompanies}개")</span>
|
||||||
|
<span>페이지 @currentPage / @totalPages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-surface">
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>회사코드</th>
|
||||||
|
<th>회사명</th>
|
||||||
|
<th>담당자</th>
|
||||||
|
<th>전화</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th>활성</th>
|
||||||
|
<th>등록일</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in companies)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@item.CompanyCode</td>
|
||||||
|
<td>@item.CompanyName</td>
|
||||||
|
<td>@(item.ContactPerson ?? "—")</td>
|
||||||
|
<td>@(item.Phone ?? "—")</td>
|
||||||
|
<td>@(item.Email ?? "—")</td>
|
||||||
|
<td>@(item.IsActive ? "활성" : "비활성")</td>
|
||||||
|
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td><a class="site-button secondary" href="@($"/taxbaik/admin/companies/{item.Id}/edit")">수정</a></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-pagination">
|
||||||
|
<button type="button" class="site-button secondary" disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</button>
|
||||||
|
<button type="button" class="site-button secondary" disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"고객사 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string NavTo(string url) => url;
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
@page "/admin/consulting-activities"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject IConsultingActivityBrowserClient ActivityClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>상담 활동 관리</PageTitle>
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||||
|
<h1 class="admin-page-title">상담 활동 관리</h1>
|
||||||
|
<p class="admin-page-subtitle">고객별 상담 이력과 팔로업을 추적합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 활동 기록</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface">
|
||||||
|
@if (activities is null)
|
||||||
|
{
|
||||||
|
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
}
|
||||||
|
else if (activities.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="muted">상담 활동이 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>고객</th>
|
||||||
|
<th>활동 유형</th>
|
||||||
|
<th>활동일시</th>
|
||||||
|
<th>설명</th>
|
||||||
|
<th>다음 팔로업</th>
|
||||||
|
<th>작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in activities)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@item.Id</td>
|
||||||
|
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
|
||||||
|
<td>@item.ActivityType</td>
|
||||||
|
<td>@item.ActivityDate.ToString("g")</td>
|
||||||
|
<td>@Truncate(item.Description)</td>
|
||||||
|
<td>@(item.NextFollowupDate?.ToString("yyyy-MM-dd") ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-row-actions">
|
||||||
|
<button type="button" class="admin-icon-button" @onclick="@(async () => await OpenEditDialog(item))">✎</button>
|
||||||
|
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteActivity(item.Id))">✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog class="admin-dialog" open="@isDialogOpen">
|
||||||
|
<form class="admin-dialog-card" @onsubmit="SaveActivity" @onsubmit:preventDefault="true">
|
||||||
|
<h3>@(editingActivity == null ? "새 활동 기록" : "활동 기록 수정")</h3>
|
||||||
|
<label>고객
|
||||||
|
<select class="admin-input" @bind="ClientIdText">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>활동 유형
|
||||||
|
<select class="admin-input" @bind="activityForm.ActivityType">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="방문 상담">방문 상담</option>
|
||||||
|
<option value="전화 상담">전화 상담</option>
|
||||||
|
<option value="세무조사 대응 미팅">세무조사 대응 미팅</option>
|
||||||
|
<option value="카카오톡 상담">카카오톡 상담</option>
|
||||||
|
<option value="이메일 자료 접수">이메일 자료 접수</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>활동일 <input class="admin-input" type="text" placeholder="2026-06-29 14:00" @bind="ActivityDateText" /></label>
|
||||||
|
<label>설명 <textarea class="admin-input" rows="4" @bind="activityForm.Description"></textarea></label>
|
||||||
|
<label>다음 팔로업일 <input class="admin-input" type="text" placeholder="2026-07-10" @bind="NextFollowupText" /></label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
|
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
|
||||||
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
private List<ConsultingActivity>? activities;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private ConsultingActivity? editingActivity;
|
||||||
|
private ConsultingActivityForm activityForm = new();
|
||||||
|
|
||||||
|
private string ClientIdText { get => activityForm.ClientId > 0 ? activityForm.ClientId.ToString() : ""; set => activityForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
|
||||||
|
private string ActivityDateText { get => activityForm.ActivityDate?.ToString("yyyy-MM-dd HH:mm") ?? ""; set => activityForm.ActivityDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
private string NextFollowupText { get => activityForm.NextFollowupDate?.ToString("yyyy-MM-dd") ?? ""; set => activityForm.NextFollowupDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
activities = await ActivityClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
editingActivity = null;
|
||||||
|
activityForm = new ConsultingActivityForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, 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;
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveActivity()
|
||||||
|
{
|
||||||
|
if (activityForm.ClientId <= 0 || string.IsNullOrWhiteSpace(activityForm.ActivityType) || string.IsNullOrWhiteSpace(activityForm.Description))
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (editingActivity == null)
|
||||||
|
{
|
||||||
|
var newId = await ActivityClient.CreateAsync(activityForm.ClientId, activityForm.ActivityType, activityForm.ActivityDate ?? DateTime.Now, activityForm.Description, null, activityForm.NextFollowupDate);
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "활동이 기록되었습니다.");
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ActivityClient.UpdateAsync(editingActivity.Id, null, activityForm.NextFollowupDate);
|
||||||
|
await JS.InvokeVoidAsync("alert", "활동이 업데이트되었습니다.");
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteActivity(int id)
|
||||||
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "이 활동을 삭제하시겠습니까?")) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ActivityClient.DeleteAsync(id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "활동이 삭제되었습니다.");
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog() { isDialogOpen = false; editingActivity = null; activityForm = new(); }
|
||||||
|
private static string Truncate(string? text) => string.IsNullOrWhiteSpace(text) ? "—" : text.Length > 30 ? text[..30] + "..." : text;
|
||||||
|
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||||
|
private sealed class ConsultingActivityForm { public int ClientId { get; set; } public string ActivityType { get; set; } = ""; public DateTime? ActivityDate { get; set; } = DateTime.Now; public string Description { get; set; } = ""; public DateTime? NextFollowupDate { get; set; } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
@page "/admin/contracts"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject IContractBrowserClient ContractClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>계약 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||||
|
<h1 class="admin-page-title">계약 관리</h1>
|
||||||
|
<p class="admin-page-subtitle">고객 계약과 월 정기수익을 함께 관리합니다.</p>
|
||||||
|
@if (mrr > 0)
|
||||||
|
{
|
||||||
|
<p class="admin-page-subtitle mt-2">월 정기수익: <strong>₩@mrr.ToString("N0")</strong></p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 계약 추가</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface">
|
||||||
|
@if (contracts is null)
|
||||||
|
{
|
||||||
|
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
}
|
||||||
|
else if (contracts.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="muted">계약이 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>고객</th>
|
||||||
|
<th>계약번호</th>
|
||||||
|
<th>서비스 유형</th>
|
||||||
|
<th>월 수수료</th>
|
||||||
|
<th>계약기간</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in contracts)
|
||||||
|
{
|
||||||
|
var isActive = !item.EndDate.HasValue || item.EndDate.Value >= DateTime.Today;
|
||||||
|
<tr>
|
||||||
|
<td>@item.Id</td>
|
||||||
|
<td>@(clientMap.TryGetValue(item.ClientId, out var clientName) ? clientName : "")</td>
|
||||||
|
<td>@item.ContractNumber</td>
|
||||||
|
<td>@item.ServiceType</td>
|
||||||
|
<td>@(item.MonthlyFee?.ToString("C") ?? "—")</td>
|
||||||
|
<td>@item.StartDate@if (item.EndDate.HasValue){<span>~ @item.EndDate.Value</span>}</td>
|
||||||
|
<td><span class="status-pill @(isActive ? "success" : "muted")">@(isActive ? "활성" : "만료")</span></td>
|
||||||
|
<td><button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteContract(item.Id))">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog class="admin-dialog" open="@isDialogOpen">
|
||||||
|
<form class="admin-dialog-card" @onsubmit="SaveContract" @onsubmit:preventDefault="true">
|
||||||
|
<h3>새 계약 추가</h3>
|
||||||
|
<label>고객
|
||||||
|
<select class="admin-input" @bind="ClientIdText">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>계약번호 <input class="admin-input" @bind="contractForm.ContractNumber" /></label>
|
||||||
|
<label>서비스 유형
|
||||||
|
<select class="admin-input" @bind="contractForm.ServiceType">
|
||||||
|
<option value="개인 기장대리">개인 기장대리</option>
|
||||||
|
<option value="법인 기장대리">법인 기장대리</option>
|
||||||
|
<option value="세무조정 대행">세무조정 대행</option>
|
||||||
|
<option value="양도세 신고대리">양도세 신고대리</option>
|
||||||
|
<option value="상속·증여 자문">상속·증여 자문</option>
|
||||||
|
<option value="세무조사 대응">세무조사 대응</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>계약 시작일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="StartDateText" /></label>
|
||||||
|
<label>월 수수료 <input class="admin-input" type="text" placeholder="100000" @bind="MonthlyFeeText" /></label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
|
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
|
||||||
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
private List<Contract>? contracts;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private decimal mrr = 0;
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private ContractForm contractForm = new();
|
||||||
|
private string ClientIdText { get => contractForm.ClientId > 0 ? contractForm.ClientId.ToString() : ""; set => contractForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
|
||||||
|
private string StartDateText { get => contractForm.StartDate?.ToString("yyyy-MM-dd") ?? ""; set => contractForm.StartDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
private string MonthlyFeeText { get => contractForm.MonthlyFee?.ToString() ?? ""; set => contractForm.MonthlyFee = decimal.TryParse(value, out var amount) ? amount : null; }
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
contracts = await ContractClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||||
|
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
contractForm = new ContractForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, StartDate = DateTime.Today };
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveContract()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (contractForm.ClientId <= 0)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newId = await ContractClient.CreateAsync(contractForm.ClientId, contractForm.ContractNumber, contractForm.ServiceType, contractForm.StartDate ?? DateTime.Today, contractForm.MonthlyFee);
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "계약이 추가되었습니다.");
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteContract(int id)
|
||||||
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "이 계약을 삭제하시겠습니까?")) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ContractClient.DeleteAsync(id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "계약이 삭제되었습니다.");
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog() { isDialogOpen = false; contractForm = new(); }
|
||||||
|
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||||
|
private sealed class ContractForm { public int ClientId { get; set; } public string ContractNumber { get; set; } = ""; public string ServiceType { get; set; } = ""; public DateTime? StartDate { get; set; } public decimal? MonthlyFee { get; set; } }
|
||||||
|
}
|
||||||
@@ -8,73 +8,90 @@
|
|||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Overview</MudText>
|
<div class="admin-eyebrow">Overview</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">대시보드</MudText>
|
<h1 class="admin-page-title">대시보드</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</MudText>
|
<p class="admin-page-subtitle">문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="/taxbaik/admin/blog/create">
|
<button type="button" class="site-button primary" @onclick='() => Nav.NavigateTo("/taxbaik/admin/blog/create")'>새 포스트 작성</button>
|
||||||
새 포스트 작성
|
|
||||||
</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Metrics Grid - Pure HTML div instead of MudGrid to ensure proper layout -->
|
@if (summary is null)
|
||||||
<div class="admin-metric-grid">
|
|
||||||
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))' style="cursor: pointer;">
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
|
||||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">이번달 문의</span>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
|
||||||
<span style="font-size: 2rem; font-weight: 700; color: #1565c0;">@summary.ThisMonthInquiries</span>
|
|
||||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #1976d2;">💬</span>
|
|
||||||
</div>
|
|
||||||
<span style="font-size: 0.9rem; color: #666;">월간 상담 유입 (클릭 시 이동)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))' style="cursor: pointer;">
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
|
||||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">신규 문의</span>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
|
||||||
<span style="font-size: 2rem; font-weight: 700; color: #e65100;">@summary.NewInquiries</span>
|
|
||||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #f57c00;">⚠️</span>
|
|
||||||
</div>
|
|
||||||
<span style="font-size: 0.9rem; color: #666;">처리 대기 (클릭 시 이동)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
|
||||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">전체 포스트</span>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
|
||||||
<span style="font-size: 2rem; font-weight: 700; color: #455a64;">@summary.TotalPosts</span>
|
|
||||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #607d8b;">📄</span>
|
|
||||||
</div>
|
|
||||||
<span style="font-size: 0.9rem; color: #666;">콘텐츠 자산 (클릭 시 이동)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
|
||||||
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">발행된 포스트</span>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
|
|
||||||
<span style="font-size: 2rem; font-weight: 700; color: #2e7d32;">@summary.PublishedPosts</span>
|
|
||||||
<span style="font-size: 2.5rem; opacity: 0.15; color: #388e3c;">🌐</span>
|
|
||||||
</div>
|
|
||||||
<span style="font-size: 0.9rem; color: #666;">검색 노출 대상 (클릭 시 이동)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (upcomingFilings.Count > 0)
|
|
||||||
{
|
{
|
||||||
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
<div class="admin-metric-grid">
|
||||||
|
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
</div>
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
|
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
</div>
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
|
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-metric-grid">
|
||||||
|
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
|
||||||
|
<div class="metric-card-inner">
|
||||||
|
<span class="metric-label">이번달 문의</span>
|
||||||
|
<div class="metric-value-row">
|
||||||
|
<span class="metric-value blue">@summary.ThisMonthInquiries</span>
|
||||||
|
<span class="metric-icon">💬</span>
|
||||||
|
</div>
|
||||||
|
<span class="metric-hint">월간 상담 유입</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
|
||||||
|
<div class="metric-card-inner">
|
||||||
|
<span class="metric-label">신규 문의</span>
|
||||||
|
<div class="metric-value-row">
|
||||||
|
<span class="metric-value amber">@summary.NewInquiries</span>
|
||||||
|
<span class="metric-icon">⚠️</span>
|
||||||
|
</div>
|
||||||
|
<span class="metric-hint">처리 대기</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
|
||||||
|
<div class="metric-card-inner">
|
||||||
|
<span class="metric-label">전체 포스트</span>
|
||||||
|
<div class="metric-value-row">
|
||||||
|
<span class="metric-value slate">@summary.TotalPosts</span>
|
||||||
|
<span class="metric-icon">📄</span>
|
||||||
|
</div>
|
||||||
|
<span class="metric-hint">콘텐츠 자산</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
|
||||||
|
<div class="metric-card-inner">
|
||||||
|
<span class="metric-label">발행된 포스트</span>
|
||||||
|
<div class="metric-value-row">
|
||||||
|
<span class="metric-value green">@summary.PublishedPosts</span>
|
||||||
|
<span class="metric-icon">🌐</span>
|
||||||
|
</div>
|
||||||
|
<span class="metric-hint">검색 노출 대상</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (upcomingFilings.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">이번 달 마감 임박 신고가 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
<div class="admin-section-header">
|
<div class="admin-section-header">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
|
<h3 class="admin-section-title">이번 달 마감 임박 신고</h3>
|
||||||
<MudText Typo="Typo.body2">30일 이내 신고 예정 건 (고객명 클릭 시 상세 카드로 연결)</MudText>
|
<p class="muted">30일 이내 신고 예정 건</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
|
<a class="site-button secondary" href="/taxbaik/admin/tax-filings">전체 일정 보기</a>
|
||||||
</div>
|
</div>
|
||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>고객</th>
|
<th>고객</th>
|
||||||
@@ -88,21 +105,17 @@
|
|||||||
{
|
{
|
||||||
var dday = (f.DueDate.Date - DateTime.Today).Days;
|
var dday = (f.DueDate.Date - DateTime.Today).Days;
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td><a href="@($"/taxbaik/admin/clients/{f.ClientId}")">@f.ClientName</a></td>
|
||||||
<MudLink Href="@($"/taxbaik/admin/clients/{f.ClientId}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
|
|
||||||
@f.ClientName
|
|
||||||
</MudLink>
|
|
||||||
</td>
|
|
||||||
<td>@f.FilingType</td>
|
<td>@f.FilingType</td>
|
||||||
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
|
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (dday < 0)
|
@if (dday < 0)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Dark">기한 초과 (@(-dday)일)</MudChip>
|
<span class="status-pill dark">기한 초과 (@(-dday)일)</span>
|
||||||
}
|
}
|
||||||
else if (dday <= 7)
|
else if (dday <= 7)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
|
<span class="status-pill danger">D-@dday</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -112,19 +125,23 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</table>
|
||||||
</MudPaper>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<MudPaper Class="admin-surface mt-4" Elevation="0">
|
@if (summary is not null)
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
<div class="admin-section-header">
|
<div class="admin-section-header">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.h6">최근 문의</MudText>
|
<h3 class="admin-section-title">최근 문의</h3>
|
||||||
<MudText Typo="Typo.body2">최근 유입된 상담 요청을 빠르게 확인합니다. (이름 클릭 시 상세 관리 화면으로 연계)</MudText>
|
<p class="muted">최근 유입된 상담 요청을 빠르게 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/inquiries">문의 전체 보기</MudButton>
|
<a class="site-button secondary" href="/taxbaik/admin/inquiries">문의 전체 보기</a>
|
||||||
</div>
|
</div>
|
||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
@@ -138,63 +155,58 @@
|
|||||||
@foreach (var inquiry in summary.RecentInquiries)
|
@foreach (var inquiry in summary.RecentInquiries)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td><a href="@($"/taxbaik/admin/inquiries/{inquiry.Id}")">@inquiry.Name</a></td>
|
||||||
<MudLink Href="@($"/taxbaik/admin/inquiries?id={inquiry.Id}")" Underline="Underline.Hover" Color="Color.Primary" Class="font-weight-bold">
|
|
||||||
@inquiry.Name
|
|
||||||
</MudLink>
|
|
||||||
</td>
|
|
||||||
<td>@inquiry.Phone</td>
|
<td>@inquiry.Phone</td>
|
||||||
<td>@inquiry.ServiceType</td>
|
<td>@inquiry.ServiceType</td>
|
||||||
<td>
|
<td><span class="status-pill @GetStatusClass(inquiry.Status)">@GetStatusLabel(inquiry.Status)</span></td>
|
||||||
<MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
|
|
||||||
@GetStatusLabel(inquiry.Status)
|
|
||||||
</MudChip>
|
|
||||||
</td>
|
|
||||||
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
<td>@inquiry.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</table>
|
||||||
</MudPaper>
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
|
[CascadingParameter]
|
||||||
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
private string? errorMessage;
|
|
||||||
private bool isLoading = true;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
private AdminDashboardSummary? summary;
|
||||||
|
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// API 클라이언트 사용 (서비스 직접 호출 X)
|
|
||||||
var summaryTask = DashboardClient.GetSummaryAsync();
|
var summaryTask = DashboardClient.GetSummaryAsync();
|
||||||
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
var filingsTask = DashboardClient.GetUpcomingFilingsAsync(30);
|
||||||
|
|
||||||
await Task.WhenAll(summaryTask, filingsTask);
|
await Task.WhenAll(summaryTask, filingsTask);
|
||||||
summary = await summaryTask;
|
summary = await summaryTask;
|
||||||
upcomingFilings = (await filingsTask).ToList();
|
upcomingFilings = (await filingsTask).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
errorMessage = "대시보드 데이터를 불러올 수 없습니다.";
|
|
||||||
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
Console.Error.WriteLine($"Dashboard error: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
StateHasChanged();
|
||||||
{
|
}
|
||||||
isLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
|
||||||
|
private static string GetStatusClass(string status) => status switch
|
||||||
private static Color StatusColor(string status) => status switch
|
|
||||||
{
|
{
|
||||||
"new" => Color.Warning,
|
"new" => "warning",
|
||||||
"consulting" => Color.Info,
|
"consulting" => "info",
|
||||||
"contracted" => Color.Success,
|
"contracted" => "success",
|
||||||
"rejected" => Color.Error,
|
"rejected" => "danger",
|
||||||
"closed" => Color.Dark,
|
"closed" => "dark",
|
||||||
_ => Color.Default
|
_ => "default"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,85 +5,52 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IFaqBrowserClient FaqClient
|
@inject IFaqBrowserClient FaqClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
|
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
|
<div class="admin-eyebrow">홈페이지</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText>
|
<h1 class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</h1>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs"
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>목록으로</button>
|
||||||
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
<div class="admin-surface" style="max-width:720px;">
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<Skeleton Count="4" CssClass="taxbaik-skeleton-grid" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudForm @ref="form" @bind-IsValid="isValid">
|
<form class="admin-dialog-card" @onsubmit="SaveAsync" @onsubmit:preventDefault="true">
|
||||||
<MudGrid Spacing="3">
|
<label>질문 * <textarea class="admin-input" rows="3" @bind="faq.Question"></textarea></label>
|
||||||
<MudItem xs="12">
|
<label>답변 * <textarea class="admin-input" rows="6" @bind="faq.Answer"></textarea></label>
|
||||||
<MudTextField @bind-Value="faq.Question"
|
<label>카테고리
|
||||||
Label="질문 *" Required="true"
|
<select class="admin-input" @bind="faq.Category">
|
||||||
RequiredError="질문을 입력하세요."
|
<option value="">선택하세요</option>
|
||||||
Counter="300" MaxLength="300"
|
|
||||||
Lines="2" AutoGrow="true"
|
|
||||||
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12">
|
|
||||||
<MudTextField @bind-Value="faq.Answer"
|
|
||||||
Label="답변 *" Required="true"
|
|
||||||
RequiredError="답변을 입력하세요."
|
|
||||||
Lines="5" AutoGrow="true"
|
|
||||||
Placeholder="방문자에게 보여질 답변을 입력하세요." />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudSelect @bind-Value="faq.Category" Label="카테고리" T="string" Clearable="true">
|
|
||||||
@foreach (var cat in FaqService.Categories)
|
@foreach (var cat in FaqService.Categories)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@cat">@cat</MudSelectItem>
|
<option value="@cat">@cat</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
<MudItem xs="12" md="3">
|
<label>정렬 순서 <input class="admin-input" type="number" min="0" max="9999" @bind="SortOrderText" /></label>
|
||||||
<MudNumericField @bind-Value="faq.SortOrder"
|
<label><input type="checkbox" @bind="faq.IsActive" /> 노출 중</label>
|
||||||
Label="정렬 순서"
|
<div class="admin-dialog-actions">
|
||||||
HelperText="작을수록 위에 노출"
|
<button type="submit" class="site-button primary" disabled="@isSaving">저장</button>
|
||||||
Min="0" Max="9999" />
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/faqs")'>취소</button>
|
||||||
</MudItem>
|
</div>
|
||||||
<MudItem xs="12" md="3" Class="d-flex align-center">
|
</form>
|
||||||
<MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
|
|
||||||
Label="@(faq.IsActive ? "노출 중" : "비활성")" />
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" Class="d-flex gap-2 mt-2">
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Save"
|
|
||||||
OnClick="@SaveAsync" Disabled="@isSaving">
|
|
||||||
@(isSaving ? "저장 중..." : "저장")
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs">
|
|
||||||
취소
|
|
||||||
</MudButton>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
</MudForm>
|
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public int? Id { get; set; }
|
[Parameter] public int? Id { get; set; }
|
||||||
|
|
||||||
private MudForm form = null!;
|
|
||||||
private Faq faq = new() { SortOrder = 10, IsActive = true };
|
private Faq faq = new() { SortOrder = 10, IsActive = true };
|
||||||
private bool isValid;
|
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private bool isSaving;
|
private bool isSaving;
|
||||||
|
private string SortOrderText { get => faq.SortOrder.ToString(); set => faq.SortOrder = int.TryParse(value, out var n) ? n : 0; }
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -94,7 +61,7 @@
|
|||||||
var existing = await FaqClient.GetByIdAsync(Id.Value);
|
var existing = await FaqClient.GetByIdAsync(Id.Value);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "FAQ를 찾을 수 없습니다.");
|
||||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -102,7 +69,7 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,33 +79,30 @@
|
|||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
await form.Validate();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(faq.Question) || string.IsNullOrWhiteSpace(faq.Answer))
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "질문과 답변을 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Id.HasValue)
|
if (Id.HasValue)
|
||||||
{
|
{
|
||||||
var result = await FaqClient.UpdateAsync(Id.Value, faq);
|
var result = await FaqClient.UpdateAsync(Id.Value, faq);
|
||||||
if (result != null)
|
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 수정되었습니다." : "수정 실패");
|
||||||
Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success);
|
|
||||||
else
|
|
||||||
Snackbar.Add("수정 실패", Severity.Error);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var result = await FaqClient.CreateAsync(faq);
|
var result = await FaqClient.CreateAsync(faq);
|
||||||
if (result != null)
|
await JS.InvokeVoidAsync("alert", result != null ? "FAQ가 등록되었습니다." : "등록 실패");
|
||||||
Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success);
|
|
||||||
else
|
|
||||||
Snackbar.Add("등록 실패", Severity.Error);
|
|
||||||
}
|
}
|
||||||
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
Navigation.NavigateTo("/taxbaik/admin/faqs");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,100 +4,82 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject IFaqBrowserClient FaqClient
|
@inject IFaqBrowserClient FaqClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IDialogService DialogService
|
@inject IJSRuntime JS
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
|
|
||||||
<PageTitle>FAQ 관리</PageTitle>
|
<PageTitle>FAQ 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
|
<div class="admin-eyebrow">홈페이지</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">FAQ 관리</MudText>
|
<h1 class="admin-page-title">FAQ 관리</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</MudText>
|
<p class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<a class="site-button primary" href="/taxbaik/admin/faqs/create">FAQ 등록</a>
|
||||||
StartIcon="@Icons.Material.Filled.Add"
|
|
||||||
Href="/taxbaik/admin/faqs/create">
|
|
||||||
FAQ 등록
|
|
||||||
</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<div class="admin-surface">
|
||||||
@if (faqs is null)
|
@if (faqs is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||||
}
|
}
|
||||||
else if (!faqs.Any())
|
else if (!faqs.Any())
|
||||||
{
|
{
|
||||||
<div class="pa-6 text-center">
|
<div class="muted">등록된 FAQ가 없습니다.</div>
|
||||||
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
|
|
||||||
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:60px;">순서</th>
|
<th>순서</th>
|
||||||
<th>질문</th>
|
<th>질문</th>
|
||||||
<th style="width:130px;">카테고리</th>
|
<th>카테고리</th>
|
||||||
<th style="width:90px;">상태</th>
|
<th>상태</th>
|
||||||
<th style="width:160px;"></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in faqs)
|
@foreach (var item in faqs)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center">
|
<td>@item.SortOrder</td>
|
||||||
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
|
<td>@item.Question</td>
|
||||||
</td>
|
<td>@(string.IsNullOrEmpty(item.Category) ? "" : item.Category)</td>
|
||||||
|
<td><span class="status-pill @(item.IsActive ? "success" : "default")">@(item.IsActive ? "노출 중" : "비활성")</span></td>
|
||||||
<td>
|
<td>
|
||||||
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
<div class="admin-actions">
|
||||||
@item.Question
|
<button type="button" class="admin-icon-button" @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">✎</button>
|
||||||
</MudText>
|
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteAsync(item))">✕</button>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
|
||||||
@if (!string.IsNullOrEmpty(item.Category))
|
|
||||||
{
|
|
||||||
<MudChip Size="Size.Small" Color="Color.Default">@item.Category</MudChip>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@if (item.IsActive)
|
|
||||||
{
|
|
||||||
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
|
||||||
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
|
|
||||||
수정
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
|
||||||
삭제
|
|
||||||
</MudButton>
|
|
||||||
</MudButtonGroup>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</table>
|
||||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
</div>
|
||||||
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
<div class="muted mt-2">총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개</div>
|
||||||
</MudText>
|
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
|
||||||
private List<Faq>? faqs;
|
private List<Faq>? faqs;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
@@ -107,36 +89,32 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
faqs = [];
|
faqs = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(Faq item)
|
private async Task DeleteAsync(Faq item)
|
||||||
{
|
{
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
var confirmed = await JS.InvokeAsync<bool>("confirm", $"'{item.Question}' 항목을 삭제하시겠습니까?");
|
||||||
"FAQ 삭제",
|
if (!confirmed) return;
|
||||||
$"'{item.Question}' 항목을 삭제하시겠습니까?",
|
|
||||||
yesText: "삭제", cancelText: "취소");
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await FaqClient.DeleteAsync(item.Id);
|
var success = await FaqClient.DeleteAsync(item.Id);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "FAQ가 삭제되었습니다.");
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("삭제 실패", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@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 IJSRuntime JS
|
||||||
|
|
||||||
|
<PageTitle>문의 등록</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">Customer Relations</div>
|
||||||
|
<h1 class="admin-page-title">새 문의 등록</h1>
|
||||||
|
<p class="admin-page-subtitle">고객 문의를 등록합니다. (전화, 오프라인 등)</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
|
<InquiryForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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");
|
||||||
|
await JS.InvokeVoidAsync("alert", "문의가 등록되었습니다.");
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"등록 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,113 +3,75 @@
|
|||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
@inject IInquiryBrowserClient InquiryClient
|
@inject IInquiryBrowserClient InquiryClient
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>문의 상세</PageTitle>
|
<PageTitle>문의 상세</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Inquiry Details</MudText>
|
<div class="admin-eyebrow">Inquiry Details</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">문의 상세</MudText>
|
<h1 class="admin-page-title">문의 상세</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</MudText>
|
<p class="admin-page-subtitle">문의 정보를 확인하고 처리 상태를 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (inquiry != null)
|
@if (inquiry != null)
|
||||||
{
|
{
|
||||||
<MudButton Variant="Variant.Outlined"
|
<div class="admin-page-actions">
|
||||||
Color="Color.Primary"
|
<button type="button" class="site-button secondary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries")'>문의 목록으로</button>
|
||||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
</div>
|
||||||
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
|
|
||||||
문의 목록으로
|
|
||||||
</MudButton>
|
|
||||||
|
|
||||||
<MudGrid Class="mt-4">
|
<div class="admin-detail-grid">
|
||||||
<MudItem xs="12" md="8">
|
<section class="admin-surface">
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<h3 class="admin-section-title">문의 정보</h3>
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">문의 정보</MudText>
|
<div class="admin-kv-grid">
|
||||||
<MudGrid>
|
<div><span>이름</span><strong>@inquiry.Name</strong></div>
|
||||||
<MudItem xs="12" sm="6">
|
<div><span>연락처</span><strong>@inquiry.Phone</strong></div>
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
|
<div><span>이메일</span><strong>@(inquiry.Email ?? "-")</strong></div>
|
||||||
<MudText>@inquiry.Name</MudText>
|
<div><span>분야</span><strong>@inquiry.ServiceType</strong></div>
|
||||||
</MudItem>
|
<div class="span-2"><span>문의 내용</span><strong style="white-space: pre-wrap;">@inquiry.Message</strong></div>
|
||||||
<MudItem xs="12" sm="6">
|
<div><span>접수일시</span><strong>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</strong></div>
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
|
</div>
|
||||||
<MudText>@inquiry.Phone</MudText>
|
</section>
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
|
|
||||||
<MudText>@(inquiry.Email ?? "-")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">분야</MudText>
|
|
||||||
<MudText>@inquiry.ServiceType</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">문의 내용</MudText>
|
|
||||||
<MudPaper Class="pa-3 mt-1" Outlined="true">
|
|
||||||
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">접수일시</MudText>
|
|
||||||
<MudText>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</MudText>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<section class="admin-surface">
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">담당자 메모</MudText>
|
<h3 class="admin-section-title">담당자 메모</h3>
|
||||||
<MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)"
|
<textarea class="admin-input" rows="6" @bind="adminMemo"></textarea>
|
||||||
Lines="4" Variant="Variant.Outlined" />
|
<div class="admin-dialog-actions mt-3">
|
||||||
<MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
|
<button type="button" class="site-button primary" @onclick="SaveMemo">메모 저장</button>
|
||||||
OnClick="SaveMemo">메모 저장</MudButton>
|
</div>
|
||||||
</MudPaper>
|
</section>
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="4">
|
<section class="admin-surface">
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<h3 class="admin-section-title">처리 상태</h3>
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">처리 상태</MudText>
|
<div class="admin-stack">
|
||||||
<MudStack Spacing="2">
|
|
||||||
@foreach (var (key, label) in InquiryStatusMapper.Labels)
|
@foreach (var (key, label) in InquiryStatusMapper.Labels)
|
||||||
{
|
{
|
||||||
<MudButton Variant="@(inquiry.Status == key ? Variant.Filled : Variant.Outlined)"
|
<button type="button" class="@GetStatusButtonClass(key)" @onclick="@(() => OnStatusChanged(key))">@label</button>
|
||||||
Color="@StatusColor(key)"
|
|
||||||
FullWidth="true"
|
|
||||||
OnClick="@(() => OnStatusChanged(key))">
|
|
||||||
@label
|
|
||||||
</MudButton>
|
|
||||||
}
|
}
|
||||||
</MudStack>
|
</div>
|
||||||
</MudPaper>
|
</section>
|
||||||
|
|
||||||
@if (inquiry.ClientId == null)
|
@if (inquiry.ClientId == null)
|
||||||
{
|
{
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<section class="admin-surface">
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">고객 카드 생성</MudText>
|
<h3 class="admin-section-title">고객 카드 생성</h3>
|
||||||
<MudText Typo="Typo.body2" Class="mb-3">이 문의를 고객 카드로 등록합니다.</MudText>
|
<p class="muted">이 문의를 고객 카드로 등록합니다.</p>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
|
<button type="button" class="site-button primary" @onclick="ConvertToClient">고객으로 등록</button>
|
||||||
OnClick="ConvertToClient">
|
</section>
|
||||||
고객으로 등록
|
|
||||||
</MudButton>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<section class="admin-surface">
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">연결된 고객</MudText>
|
<h3 class="admin-section-title">연결된 고객</h3>
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
|
<a class="site-button secondary" href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">고객 카드 보기</a>
|
||||||
Href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">
|
</section>
|
||||||
고객 카드 보기
|
|
||||||
</MudButton>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
</MudItem>
|
</div>
|
||||||
</MudGrid>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudText>문의를 찾을 수 없습니다.</MudText>
|
<div class="admin-surface">문의를 찾을 수 없습니다.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -134,16 +96,16 @@ else
|
|||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
inquiry.Status = status;
|
inquiry.Status = status;
|
||||||
Snackbar.Add("상태가 변경되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "상태가 변경되었습니다.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("상태 변경에 실패했습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "상태 변경에 실패했습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,16 +118,16 @@ else
|
|||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
inquiry.AdminMemo = adminMemo;
|
inquiry.AdminMemo = adminMemo;
|
||||||
Snackbar.Add("메모가 저장되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "메모가 저장되었습니다.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("메모 저장에 실패했습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "메모 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,26 +146,19 @@ else
|
|||||||
{
|
{
|
||||||
inquiry.ClientId = clientId;
|
inquiry.ClientId = clientId;
|
||||||
inquiry.Status = "consulting";
|
inquiry.Status = "consulting";
|
||||||
Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "고객 카드가 생성되었습니다.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("고객 카드 생성에 실패했습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "고객 카드 생성에 실패했습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Color StatusColor(string status) => status switch
|
private string GetStatusButtonClass(string status)
|
||||||
{
|
=> inquiry?.Status == status ? "site-button primary" : "site-button secondary";
|
||||||
"new" => Color.Default,
|
|
||||||
"consulting" => Color.Info,
|
|
||||||
"contracted" => Color.Success,
|
|
||||||
"rejected" => Color.Error,
|
|
||||||
"closed" => Color.Dark,
|
|
||||||
_ => Color.Default
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
@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 IJSRuntime JS
|
||||||
|
|
||||||
|
<PageTitle>문의 수정</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">Customer Relations</div>
|
||||||
|
<h1 class="admin-page-title">문의 수정</h1>
|
||||||
|
<p class="admin-page-subtitle">고객 문의 정보를 수정합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button secondary" @onclick="GoBack">취소</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4"><Skeleton Count="4" CssClass="taxbaik-skeleton-grid" /></div>
|
||||||
|
}
|
||||||
|
else if (inquiry == null)
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">문의를 찾을 수 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-surface mt-4">
|
||||||
|
<InquiryForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button" class="site-button secondary danger" @onclick="DeleteInquiry">문의 삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"문의 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
await JS.InvokeVoidAsync("alert", "문의가 수정되었습니다.");
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"수정 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteInquiry()
|
||||||
|
{
|
||||||
|
if (inquiry == null) return;
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.")) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InquiryService.DeleteAsync(inquiry.Id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "문의가 삭제되었습니다.");
|
||||||
|
Navigation.NavigateTo("/taxbaik/admin/inquiries");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,48 +7,59 @@
|
|||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Customer Requests</MudText>
|
<div class="admin-eyebrow">Customer Requests</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">문의 관리</MudText>
|
<h1 class="admin-page-title">문의 관리</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</MudText>
|
<p class="admin-page-subtitle">상담 요청을 상태별로 확인하고 후속 조치를 기록합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="site-button primary" @onclick='() => Navigation.NavigateTo("/taxbaik/admin/inquiries/create")'>새 문의 등록</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<div class="admin-surface">
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Indeterminate="true" Class="ma-4" />
|
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
<div class="admin-tabbar">
|
||||||
<MudTabPanel Text="전체">
|
<button type="button" class="admin-tab active">전체</button>
|
||||||
|
<button type="button" class="admin-tab">신규</button>
|
||||||
|
<button type="button" class="admin-tab">상담중</button>
|
||||||
|
<button type="button" class="admin-tab">계약완료</button>
|
||||||
|
<button type="button" class="admin-tab">거절</button>
|
||||||
|
<button type="button" class="admin-tab">종결</button>
|
||||||
|
</div>
|
||||||
<InquiryTable Inquiries="allInquiries" Status="" />
|
<InquiryTable Inquiries="allInquiries" Status="" />
|
||||||
</MudTabPanel>
|
}
|
||||||
<MudTabPanel Text="신규">
|
</div>
|
||||||
<InquiryTable Inquiries="allInquiries" Status="new" />
|
|
||||||
</MudTabPanel>
|
|
||||||
<MudTabPanel Text="상담중">
|
|
||||||
<InquiryTable Inquiries="allInquiries" Status="consulting" />
|
|
||||||
</MudTabPanel>
|
|
||||||
<MudTabPanel Text="계약완료">
|
|
||||||
<InquiryTable Inquiries="allInquiries" Status="contracted" />
|
|
||||||
</MudTabPanel>
|
|
||||||
<MudTabPanel Text="거절">
|
|
||||||
<InquiryTable Inquiries="allInquiries" Status="rejected" />
|
|
||||||
</MudTabPanel>
|
|
||||||
<MudTabPanel Text="종결">
|
|
||||||
<InquiryTable Inquiries="allInquiries" Status="closed" />
|
|
||||||
</MudTabPanel>
|
|
||||||
</MudTabs>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
[Inject] private NavigationManager Navigation { get; set; } = default!;
|
||||||
|
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
|
private IReadOnlyList<Domain.Entities.Inquiry> allInquiries = [];
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
if (AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
|
var (items, _) = await InquiryClient.GetPagedAsync(1, 200);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/admin/login"
|
@page "/admin/login"
|
||||||
|
@using Microsoft.FluentUI.AspNetCore.Components
|
||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
|
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
|
||||||
@attribute [AllowAnonymous]
|
@attribute [AllowAnonymous]
|
||||||
@@ -6,39 +7,44 @@
|
|||||||
@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>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Small" Class="admin-login-page d-flex align-center justify-center" Style="min-height: 100vh;">
|
<div class="admin-login-page">
|
||||||
<MudPaper Class="pa-8" Elevation="3" Style="width: 100%; max-width: 400px;">
|
<div class="admin-login-card admin-surface">
|
||||||
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>
|
<div class="admin-login-brand">
|
||||||
|
<span class="admin-brand-mark">T</span>
|
||||||
|
<div>
|
||||||
|
<div class="admin-brand-title">TaxBaik</div>
|
||||||
|
<div class="admin-brand-subtitle">관리자 로그인</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form @onsubmit="HandleLogin" @onsubmit:preventDefault>
|
<form class="admin-login-form" @onsubmit="HandleLogin" @onsubmit:preventDefault>
|
||||||
<InputText class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
<label class="admin-field">
|
||||||
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
<span class="admin-field-label">사용자명</span>
|
||||||
placeholder="사용자명"
|
<input class="admin-input" type="text" placeholder="사용자명" @bind="model.Username" autocomplete="username" />
|
||||||
autocomplete="username"
|
</label>
|
||||||
@bind-Value="model.Username" />
|
|
||||||
|
|
||||||
<InputText type="password"
|
<label class="admin-field">
|
||||||
class="mud-input mud-input-outlined mud-input-root mud-input-root-adorned-start mb-4"
|
<span class="admin-field-label">비밀번호</span>
|
||||||
style="width: 100%; min-height: 56px; padding: 16px 14px;"
|
<input class="admin-input" type="password" placeholder="비밀번호" @bind="model.Password" autocomplete="current-password" />
|
||||||
placeholder="비밀번호"
|
</label>
|
||||||
autocomplete="current-password"
|
|
||||||
@bind-Value="model.Password" />
|
<label class="admin-login-remember">
|
||||||
|
<input type="checkbox" @bind="model.RememberMe" />
|
||||||
|
<span>아이디 저장</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
|
<div class="admin-inline-alert error" role="alert">@errorMessage</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="submit"
|
<button type="submit" class="site-button primary admin-login-submit" disabled="@isLoading">
|
||||||
class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-elevation-0"
|
|
||||||
style="width: 100%; min-height: 52px; border: 0; border-radius: 4px; color: white;"
|
|
||||||
disabled="@isLoading">
|
|
||||||
@if (isLoading)
|
@if (isLoading)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
|
||||||
<span>로그인 중...</span>
|
<span>로그인 중...</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -47,14 +53,31 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</MudPaper>
|
</div>
|
||||||
</MudContainer>
|
</div>
|
||||||
|
|
||||||
@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)
|
||||||
{
|
{
|
||||||
@@ -82,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);
|
||||||
@@ -104,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,204 @@
|
|||||||
|
@page "/admin/revenue-trackings"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject IRevenueTrackingBrowserClient RevenueClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>수익 추적 관리</PageTitle>
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||||
|
<h1 class="admin-page-title">수익 추적 관리</h1>
|
||||||
|
<p class="admin-page-subtitle">청구, 납부, 미수금 상태를 한 화면에서 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 청구 추가</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface">
|
||||||
|
@if (revenues is null)
|
||||||
|
{
|
||||||
|
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
}
|
||||||
|
else if (revenues.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="muted">청구 기록이 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>고객</th>
|
||||||
|
<th>청구번호</th>
|
||||||
|
<th>청구일</th>
|
||||||
|
<th>청구액</th>
|
||||||
|
<th>납부여부</th>
|
||||||
|
<th>작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in revenues)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@item.Id</td>
|
||||||
|
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
|
||||||
|
<td>@item.InvoiceNumber</td>
|
||||||
|
<td>@item.InvoiceDate.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td>@item.Amount.ToString("C")</td>
|
||||||
|
<td><span class="status-pill @(item.PaymentStatus == "paid" ? "success" : "warning")">@(item.PaymentStatus == "paid" ? "납부" : "미납")</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-row-actions">
|
||||||
|
@if (item.PaymentStatus != "paid")
|
||||||
|
{
|
||||||
|
<button type="button" class="site-button secondary" @onclick="@(async () => await MarkPaid(item.Id))">완료</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteRevenue(item.Id))">✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog class="admin-dialog" open="@isDialogOpen">
|
||||||
|
<form class="admin-dialog-card" @onsubmit="SaveRevenue" @onsubmit:preventDefault="true">
|
||||||
|
<h3>새 청구 추가</h3>
|
||||||
|
<label>고객
|
||||||
|
<select class="admin-input" @bind="ClientIdText">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>청구번호 <input class="admin-input" @bind="revenueForm.InvoiceNumber" /></label>
|
||||||
|
<label>청구일 <input class="admin-input" type="text" placeholder="2026-06-29" @bind="InvoiceDateText" /></label>
|
||||||
|
<label>청구액 <input class="admin-input" type="text" placeholder="100000" @bind="AmountText" /></label>
|
||||||
|
<label>서비스 유형
|
||||||
|
<select class="admin-input" @bind="revenueForm.ServiceType">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="기장 수수료">기장 수수료</option>
|
||||||
|
<option value="세무조정료">세무조정료</option>
|
||||||
|
<option value="세무상담료">세무상담료</option>
|
||||||
|
<option value="신고 대행료">신고 대행료</option>
|
||||||
|
<option value="자문 수수료">자문 수수료</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>납부예정일 <input class="admin-input" type="text" placeholder="2026-07-13" @bind="DueDateText" /></label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
|
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
|
||||||
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
private List<RevenueTracking>? revenues;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private RevenueForm revenueForm = new();
|
||||||
|
|
||||||
|
private string ClientIdText { get => revenueForm.ClientId > 0 ? revenueForm.ClientId.ToString() : ""; set => revenueForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
|
||||||
|
private string InvoiceDateText { get => revenueForm.InvoiceDate?.ToString("yyyy-MM-dd") ?? ""; set => revenueForm.InvoiceDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
private string AmountText { get => revenueForm.Amount?.ToString() ?? ""; set => revenueForm.Amount = decimal.TryParse(value, out var amt) ? amt : null; }
|
||||||
|
private string DueDateText { get => revenueForm.DueDate?.ToString("yyyy-MM-dd") ?? ""; set => revenueForm.DueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
revenues = await RevenueClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
revenueForm = new RevenueForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, InvoiceDate = DateTime.Today, DueDate = DateTime.Today.AddDays(14) };
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveRevenue()
|
||||||
|
{
|
||||||
|
if (revenueForm.ClientId <= 0 || string.IsNullOrWhiteSpace(revenueForm.InvoiceNumber) || revenueForm.Amount is null)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var newId = await RevenueClient.CreateAsync(revenueForm.ClientId, revenueForm.InvoiceNumber, revenueForm.InvoiceDate ?? DateTime.Today, revenueForm.Amount.Value, revenueForm.ServiceType, revenueForm.DueDate);
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "청구가 추가되었습니다.");
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkPaid(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RevenueClient.MarkPaidAsync(id, DateTime.Now);
|
||||||
|
await JS.InvokeVoidAsync("alert", "납부가 처리되었습니다.");
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRevenue(int id)
|
||||||
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "이 청구를 삭제하시겠습니까?")) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RevenueClient.DeleteAsync(id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "청구가 삭제되었습니다.");
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog() { isDialogOpen = false; revenueForm = new(); }
|
||||||
|
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||||
|
private sealed class RevenueForm { public int ClientId { get; set; } public string InvoiceNumber { get; set; } = ""; public DateTime? InvoiceDate { get; set; } public decimal? Amount { get; set; } public string? ServiceType { get; set; } public DateTime? DueDate { get; set; } }
|
||||||
|
}
|
||||||
@@ -7,123 +7,64 @@
|
|||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Season Preview</MudText>
|
<div class="admin-eyebrow">Season Preview</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">시즌 시뮬레이터</MudText>
|
<h1 class="admin-page-title">시즌 시뮬레이터</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</MudText>
|
<p class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MudGrid>
|
<div class="admin-detail-grid">
|
||||||
<MudItem xs="12" md="4">
|
<section class="admin-surface">
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<h3 class="admin-section-title">시뮬레이션 날짜</h3>
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">시뮬레이션 날짜</MudText>
|
<input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="SimulationDateText" />
|
||||||
<MudDatePicker @bind-Date="simulationDate" Label="날짜 선택" DateFormat="yyyy-MM-dd" PickerVariant="PickerVariant.Static" />
|
<div class="admin-divider"></div>
|
||||||
<MudDivider Class="my-3" />
|
<div class="admin-stack">
|
||||||
<MudText Typo="Typo.subtitle2" Class="mb-2">연간 세무 캘린더</MudText>
|
|
||||||
@foreach (var season in TaxSeasonCalendar.Seasons)
|
@foreach (var season in TaxSeasonCalendar.Seasons)
|
||||||
{
|
{
|
||||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" FullWidth="true"
|
<button type="button" class="site-button secondary" @onclick="@(() => JumpToSeason(season))">@season.StartMonth/@season.StartDay - @season.Name</button>
|
||||||
Class="mb-1" Color="Color.Primary"
|
|
||||||
OnClick="@(() => JumpToSeason(season))">
|
|
||||||
@season.StartMonth/@season.StartDay — @season.Name
|
|
||||||
</MudButton>
|
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</div>
|
||||||
</MudItem>
|
</section>
|
||||||
|
|
||||||
<MudItem xs="12" md="8">
|
<section class="admin-surface">
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
<h3 class="admin-section-title">홈페이지 미리보기</h3>
|
||||||
<MudText Typo="Typo.h6" Class="mb-1">
|
<p class="muted">@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요")</p>
|
||||||
@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요") 홈페이지 미리보기
|
|
||||||
</MudText>
|
|
||||||
@if (activeSeason != null)
|
@if (activeSeason != null)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Class="mb-3">
|
<span class="status-pill warning">@activeSeason.Name 시즌 활성</span>
|
||||||
@activeSeason.Name 시즌 활성
|
<div class="season-preview">
|
||||||
</MudChip>
|
|
||||||
<MudDivider Class="mb-4" />
|
|
||||||
<!-- Hero 섹션 미리보기 -->
|
|
||||||
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 1.5rem;">
|
|
||||||
@if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0)
|
@if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0)
|
||||||
{
|
{
|
||||||
<div style="background: #f59e0b; color: #1a202c; display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; margin-bottom: 1rem;">
|
<div class="season-badge">D-@activeSeason.DaysUntilDeadline 마감 임박</div>
|
||||||
D-@activeSeason.DaysUntilDeadline 마감 임박
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div style="font-size: 1.8rem; font-weight: 800; white-space: pre-line; margin-bottom: 0.5rem; line-height: 1.3;">
|
<div class="season-headline">@activeSeason.HeroHeadline</div>
|
||||||
@activeSeason.HeroHeadline
|
<div class="season-subtext">@activeSeason.HeroSubtext</div>
|
||||||
|
<div class="season-cta">@activeSeason.CtaText</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
|
<div class="admin-kv-grid mt-4">
|
||||||
@activeSeason.HeroSubtext
|
<div><span>활성 시즌 키</span><strong><code>@activeSeason.Key</code></strong></div>
|
||||||
|
<div><span>마감까지</span><strong>@(activeSeason.DaysUntilDeadline >= 0 ? $"D-{activeSeason.DaysUntilDeadline}" : $"마감 후 @(-activeSeason.DaysUntilDeadline)일")</strong></div>
|
||||||
|
<div><span>포커스 서비스</span><strong>@activeSeason.FocusService</strong></div>
|
||||||
|
<div><span>블로그 카테고리</span><strong>@activeSeason.RelatedCategorySlug</strong></div>
|
||||||
|
<div class="span-2"><span>긴박감 배지 문구</span><strong><code>@activeSeason.UrgencyBadge</code></strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
|
||||||
<div style="background: #e53e3e; color: white; padding: 10px 20px; border-radius: 8px; font-weight: 700; font-size: 0.95rem;">
|
|
||||||
@activeSeason.CtaText
|
|
||||||
</div>
|
|
||||||
<div style="background: transparent; border: 2px solid rgba(255,255,255,0.5); color: white; padding: 10px 20px; border-radius: 8px; font-size: 0.95rem;">
|
|
||||||
서비스 안내
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MudGrid Spacing="2">
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">활성 시즌 키</MudText>
|
|
||||||
<MudText><code>@activeSeason.Key</code></MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">마감까지</MudText>
|
|
||||||
<MudText>
|
|
||||||
@if (activeSeason.DaysUntilDeadline >= 0)
|
|
||||||
{
|
|
||||||
<MudChip T="string" Size="Size.Small"
|
|
||||||
Color="@(activeSeason.DaysUntilDeadline <= 7 ? Color.Error : Color.Warning)">
|
|
||||||
D-@activeSeason.DaysUntilDeadline
|
|
||||||
</MudChip>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>마감 후 @(-activeSeason.DaysUntilDeadline)일</span>
|
<div class="muted">선택한 날짜는 시즌 비활성 기간입니다. 홈페이지는 기본 Hero를 표시합니다.</div>
|
||||||
}
|
<div class="season-preview mt-4">
|
||||||
</MudText>
|
<div class="season-headline">사업자 세금, 부동산,<br />가족자산까지</div>
|
||||||
</MudItem>
|
<div class="season-subtext">세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담</div>
|
||||||
<MudItem xs="6">
|
<div class="season-cta">무료 상담 신청</div>
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">포커스 서비스</MudText>
|
|
||||||
<MudText>@activeSeason.FocusService</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="6">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">블로그 카테고리</MudText>
|
|
||||||
<MudText>@activeSeason.RelatedCategorySlug</MudText>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12">
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">긴박감 배지 문구</MudText>
|
|
||||||
<MudText><code>@activeSeason.UrgencyBadge</code></MudText>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">
|
|
||||||
선택한 날짜(@(simulationDate?.ToString("MM월 dd일") ?? "-"))는 시즌 비활성 기간입니다.
|
|
||||||
홈페이지는 기본 Hero를 표시합니다.
|
|
||||||
</MudAlert>
|
|
||||||
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-top: 1.5rem;">
|
|
||||||
<div style="font-size: 1.8rem; font-weight: 800; margin-bottom: 0.5rem;">
|
|
||||||
사업자 세금, 부동산,<br/>가족자산까지
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
|
|
||||||
세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담
|
|
||||||
</div>
|
|
||||||
<div style="background: #e53e3e; color: white; display: inline-block; padding: 10px 20px; border-radius: 8px; font-weight: 700;">
|
|
||||||
무료 상담 신청
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
<div class="admin-surface mt-4">
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">연간 시즌 타임라인</MudText>
|
<h3 class="admin-section-title">연간 시즌 타임라인</h3>
|
||||||
<MudSimpleTable Dense="true">
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>기간</th>
|
<th>기간</th>
|
||||||
@@ -136,33 +77,22 @@
|
|||||||
@foreach (var s in TaxSeasonCalendar.Seasons)
|
@foreach (var s in TaxSeasonCalendar.Seasons)
|
||||||
{
|
{
|
||||||
var isActive = activeSeason?.Key == s.Key;
|
var isActive = activeSeason?.Key == s.Key;
|
||||||
<tr style="@(isActive ? "background: rgba(66,153,225,0.1);" : "")">
|
<tr>
|
||||||
<td style="white-space: nowrap;">
|
<td>@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay</td>
|
||||||
@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay
|
|
||||||
</td>
|
|
||||||
<td>@s.Name</td>
|
<td>@s.Name</td>
|
||||||
<td><code style="font-size:0.8rem;">@s.RelatedCategorySlug</code></td>
|
<td><code>@s.RelatedCategorySlug</code></td>
|
||||||
<td>
|
<td>@(isActive ? "활성" : "비활성")</td>
|
||||||
@if (isActive)
|
|
||||||
{
|
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">활성</MudChip>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span style="color: #a0aec0; font-size: 0.85rem;">비활성</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</MudSimpleTable>
|
</table>
|
||||||
</MudPaper>
|
</div>
|
||||||
</MudItem>
|
</div>
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private DateTime? simulationDate = DateTime.Today;
|
private DateTime? simulationDate = DateTime.Today;
|
||||||
private CurrentSeasonDto? activeSeason;
|
private CurrentSeasonDto? activeSeason;
|
||||||
|
private string SimulationDateText { get => simulationDate?.ToString("yyyy-MM-dd") ?? ""; set { simulationDate = DateTime.TryParse(value, out var dt) ? dt : null; ComputeSeason(); } }
|
||||||
|
|
||||||
protected override void OnInitialized() => ComputeSeason();
|
protected override void OnInitialized() => ComputeSeason();
|
||||||
|
|
||||||
@@ -183,10 +113,7 @@
|
|||||||
var endYearCalc = (season.EndMonth < season.StartMonth) ? date.Year + 1 : date.Year;
|
var endYearCalc = (season.EndMonth < season.StartMonth) ? date.Year + 1 : date.Year;
|
||||||
var deadline = new DateTime(endYearCalc, season.EndMonth, season.EndDay);
|
var deadline = new DateTime(endYearCalc, season.EndMonth, season.EndDay);
|
||||||
var ddays = (deadline.Date - date.Date).Days;
|
var ddays = (deadline.Date - date.Date).Days;
|
||||||
|
var badge = ddays <= 7 && ddays >= 0 ? season.UrgencyBadge.Replace("{n}", ddays.ToString()) : season.UrgencyBadge;
|
||||||
var badge = ddays <= 7 && ddays >= 0
|
|
||||||
? season.UrgencyBadge.Replace("{n}", ddays.ToString())
|
|
||||||
: season.UrgencyBadge;
|
|
||||||
|
|
||||||
activeSeason = new CurrentSeasonDto
|
activeSeason = new CurrentSeasonDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,78 +5,58 @@
|
|||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
@using TaxBaik.Domain.Interfaces
|
@using TaxBaik.Domain.Interfaces
|
||||||
@inject IApiClient ApiClient
|
@inject IApiClient ApiClient
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>설정</PageTitle>
|
<PageTitle>설정</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-6">
|
<section class="admin-page-hero">
|
||||||
<section class="admin-page-hero">
|
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">System</MudText>
|
<div class="admin-eyebrow">System</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">설정</MudText>
|
<h1 class="admin-page-title">설정</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</MudText>
|
<p class="admin-page-subtitle">공개 사이트 연락처와 관리자 계정 보안을 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-detail-grid">
|
||||||
|
<section class="admin-surface">
|
||||||
|
<div class="admin-section-header compact">
|
||||||
|
<div>
|
||||||
|
<h3 class="admin-section-title">사이트 정보</h3>
|
||||||
|
<p class="muted">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="admin-form" @onsubmit="SaveSettings" @onsubmit:preventDefault="true">
|
||||||
|
<label>전화번호<input class="admin-input" @bind="phone" /></label>
|
||||||
|
<label>이메일<input class="admin-input" @bind="email" /></label>
|
||||||
|
<label>카카오 채널 URL<input class="admin-input" @bind="kakaoUrl" /></label>
|
||||||
|
<label>인스타그램<input class="admin-input" @bind="instagramUrl" /></label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
|
<button type="submit" class="site-button primary">사이트 정보 저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</MudContainer>
|
|
||||||
|
|
||||||
<MudGrid>
|
<section class="admin-surface">
|
||||||
<MudItem xs="12" md="7">
|
|
||||||
<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>
|
<h3 class="admin-section-title">계정 관리</h3>
|
||||||
<MudText Typo="Typo.body2">홈페이지와 문의 알림에 노출되는 기본 정보입니다.</MudText>
|
<p class="muted">비밀번호는 12자 이상으로 관리합니다.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MudForm>
|
|
||||||
<MudTextField @bind-Value="phone" Label="전화번호"
|
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="email" Label="이메일"
|
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="kakaoUrl" Label="카카오 채널 URL"
|
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="instagramUrl" Label="인스타그램"
|
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
|
||||||
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Save"
|
|
||||||
@onclick="SaveSettings">사이트 정보 저장</MudButton>
|
|
||||||
</MudForm>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="5">
|
|
||||||
<MudPaper Class="admin-surface admin-account-card" Elevation="0">
|
|
||||||
<div class="admin-section-header compact">
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.h6">계정 관리</MudText>
|
|
||||||
<MudText Typo="Typo.body2">비밀번호는 12자 이상으로 관리합니다.</MudText>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MudForm>
|
<form class="admin-form" @onsubmit="ChangePassword" @onsubmit:preventDefault="true">
|
||||||
<MudTextField @bind-Value="currentPassword" Label="현재 비밀번호" InputType="InputType.Password"
|
<label>현재 비밀번호<input class="admin-input" type="password" @bind="currentPassword" /></label>
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
<label>새 비밀번호<input class="admin-input" type="password" @bind="newPassword" /></label>
|
||||||
|
<label>새 비밀번호 확인<input class="admin-input" type="password" @bind="confirmNewPassword" /></label>
|
||||||
<MudTextField @bind-Value="newPassword" Label="새 비밀번호" InputType="InputType.Password"
|
<div class="admin-dialog-actions">
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
<button type="submit" class="site-button primary" disabled="@isChangingPassword">
|
||||||
|
|
||||||
<MudTextField @bind-Value="confirmNewPassword" Label="새 비밀번호 확인" InputType="InputType.Password"
|
|
||||||
Variant="Variant.Outlined" Class="mb-4" />
|
|
||||||
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
|
||||||
Disabled="@isChangingPassword"
|
|
||||||
StartIcon="@Icons.Material.Filled.LockReset"
|
|
||||||
@onclick="ChangePassword">
|
|
||||||
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
|
@(isChangingPassword ? "변경 중..." : "비밀번호 변경")
|
||||||
</MudButton>
|
</button>
|
||||||
</MudForm>
|
</div>
|
||||||
</MudPaper>
|
</form>
|
||||||
</MudItem>
|
</section>
|
||||||
</MudGrid>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string phone = "010-4122-8268";
|
private string phone = "010-4122-8268";
|
||||||
@@ -118,7 +98,7 @@
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
Snackbar.Add("사이트 설정을 불러오지 못했습니다.", Severity.Warning);
|
await JS.InvokeVoidAsync("alert", "사이트 설정을 불러오지 못했습니다.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -141,11 +121,11 @@
|
|||||||
|
|
||||||
if (response?.Message is null)
|
if (response?.Message is null)
|
||||||
{
|
{
|
||||||
Snackbar.Add("설정 저장에 실패했습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "설정 저장에 실패했습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Snackbar.Add(response.Message, Severity.Success);
|
await JS.InvokeVoidAsync("alert", response.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ChangePassword()
|
private async Task ChangePassword()
|
||||||
@@ -155,13 +135,13 @@
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
|
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
|
||||||
{
|
{
|
||||||
Snackbar.Add("현재 비밀번호와 새 비밀번호를 입력하세요.", Severity.Warning);
|
await JS.InvokeVoidAsync("alert", "현재 비밀번호와 새 비밀번호를 입력하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword != confirmNewPassword)
|
if (newPassword != confirmNewPassword)
|
||||||
{
|
{
|
||||||
Snackbar.Add("새 비밀번호 확인이 일치하지 않습니다.", Severity.Warning);
|
await JS.InvokeVoidAsync("alert", "새 비밀번호 확인이 일치하지 않습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,18 +157,18 @@
|
|||||||
|
|
||||||
if (response?.Message == null)
|
if (response?.Message == null)
|
||||||
{
|
{
|
||||||
Snackbar.Add("비밀번호 변경에 실패했습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "비밀번호 변경에 실패했습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Snackbar.Add(response.Message, Severity.Success);
|
await JS.InvokeVoidAsync("alert", response.Message);
|
||||||
currentPassword = "";
|
currentPassword = "";
|
||||||
newPassword = "";
|
newPassword = "";
|
||||||
confirmNewPassword = "";
|
confirmNewPassword = "";
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
Snackbar.Add("비밀번호 변경 중 오류가 발생했습니다.", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "비밀번호 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
@page "/admin/tax-filing-schedules"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject ITaxFilingScheduleBrowserClient TaxFilingClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>신고 일정</PageTitle>
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||||
|
<h1 class="admin-page-title">신고 일정</h1>
|
||||||
|
<p class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 일정 추가</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface">
|
||||||
|
@if (schedules is null)
|
||||||
|
{
|
||||||
|
<Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
}
|
||||||
|
else if (schedules.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="muted">신고 일정이 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>고객</th>
|
||||||
|
<th>신고 유형</th>
|
||||||
|
<th>마감일</th>
|
||||||
|
<th>신고연도</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in schedules)
|
||||||
|
{
|
||||||
|
var daysLeft = (item.DueDate.Date - DateTime.Today).Days;
|
||||||
|
<tr>
|
||||||
|
<td>@item.Id</td>
|
||||||
|
<td>@clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}")</td>
|
||||||
|
<td>@item.FilingType</td>
|
||||||
|
<td>@item.DueDate.ToString("yyyy-MM-dd") @(daysLeft >= 0 ? $"(D-{daysLeft})" : $"(마감 {Math.Abs(daysLeft)}일 경과)")</td>
|
||||||
|
<td>@item.FilingYear</td>
|
||||||
|
<td>@(item.Status == "completed" ? "완료" : "대기")</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-row-actions">
|
||||||
|
@if (item.Status != "completed")
|
||||||
|
{
|
||||||
|
<button type="button" class="site-button secondary" @onclick="@(async () => await CompleteSchedule(item.Id))">완료</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteSchedule(item.Id))">✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog class="admin-dialog" open="@isDialogOpen">
|
||||||
|
<form class="admin-dialog-card" @onsubmit="SaveSchedule" @onsubmit:preventDefault="true">
|
||||||
|
<h3>새 신고 일정 추가</h3>
|
||||||
|
<label>고객
|
||||||
|
<select class="admin-input" @bind="ClientIdText">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>신고 유형
|
||||||
|
<select class="admin-input" @bind="scheduleForm.FilingType">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="종합소득세">종합소득세</option>
|
||||||
|
<option value="부가가치세">부가가치세</option>
|
||||||
|
<option value="법인세">법인세</option>
|
||||||
|
<option value="원천세">원천세</option>
|
||||||
|
<option value="종합부동산세">종합부동산세</option>
|
||||||
|
<option value="양도소득세">양도소득세</option>
|
||||||
|
<option value="상속·증여세">상속·증여세</option>
|
||||||
|
<option value="세무조정">세무조정</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>마감일 <input class="admin-input" type="text" placeholder="2026-07-01" @bind="DueDateText" /></label>
|
||||||
|
<label>신고연도 <input class="admin-input" type="text" placeholder="2026" @bind="FilingYearText" /></label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
|
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
|
||||||
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
private List<TaxFilingSchedule>? schedules;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private TaxFilingScheduleForm scheduleForm = new();
|
||||||
|
private string ClientIdText { get => scheduleForm.ClientId > 0 ? scheduleForm.ClientId.ToString() : ""; set => scheduleForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
|
||||||
|
private string DueDateText { get => scheduleForm.DueDate?.ToString("yyyy-MM-dd") ?? ""; set => scheduleForm.DueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
private string FilingYearText { get => scheduleForm.FilingYear.ToString(); set => scheduleForm.FilingYear = int.TryParse(value, out var year) ? year : DateTime.Now.Year; }
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
schedules = await TaxFilingClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
scheduleForm = new TaxFilingScheduleForm { FilingYear = DateTime.Now.Year, DueDate = DateTime.Today, ClientId = clients.FirstOrDefault()?.Id ?? 0 };
|
||||||
|
isDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSchedule()
|
||||||
|
{
|
||||||
|
if (scheduleForm.ClientId <= 0 || string.IsNullOrWhiteSpace(scheduleForm.FilingType))
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "필수 항목을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var newId = await TaxFilingClient.CreateAsync(scheduleForm.ClientId, scheduleForm.FilingType, scheduleForm.DueDate ?? DateTime.Today, scheduleForm.FilingYear);
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteSchedule(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TaxFilingClient.MarkCompletedAsync(id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "신고 일정이 완료 처리되었습니다.");
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"처리 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSchedule(int id)
|
||||||
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "이 신고 일정을 삭제하시겠습니까?")) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TaxFilingClient.DeleteAsync(id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "신고 일정이 삭제되었습니다.");
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog() { isDialogOpen = false; scheduleForm = new(); }
|
||||||
|
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||||
|
private sealed class TaxFilingScheduleForm { public int ClientId { get; set; } public string FilingType { get; set; } = ""; public DateTime? DueDate { get; set; } public int FilingYear { get; set; } = DateTime.Now.Year; }
|
||||||
|
}
|
||||||
@@ -1,60 +1,66 @@
|
|||||||
@using TaxBaik.Web.Services
|
|
||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject ITaxFilingBrowserClient FilingClient
|
@inject ITaxFilingBrowserClient FilingClient
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
@if (Filings == null || Filings.Count == 0)
|
@if (Filings == null || Filings.Count == 0)
|
||||||
{
|
{
|
||||||
<MudText Class="pa-4" Color="Color.Secondary">항목이 없습니다.</MudText>
|
<div class="muted">항목이 없습니다.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTable Items="Filings" Hover="true" Dense="true" Class="mt-2">
|
<div class="admin-table-wrap">
|
||||||
<HeaderContent>
|
<table class="admin-table">
|
||||||
<MudTh>고객</MudTh>
|
<thead>
|
||||||
<MudTh>신고 유형</MudTh>
|
<tr>
|
||||||
<MudTh>기한</MudTh>
|
<th>고객</th>
|
||||||
<MudTh>D-day</MudTh>
|
<th>신고 유형</th>
|
||||||
<MudTh>메모</MudTh>
|
<th>기한</th>
|
||||||
<MudTh>처리</MudTh>
|
<th>D-day</th>
|
||||||
</HeaderContent>
|
<th>메모</th>
|
||||||
<RowTemplate>
|
<th>처리</th>
|
||||||
<MudTd>@context.ClientName</MudTd>
|
</tr>
|
||||||
<MudTd>@context.FilingType</MudTd>
|
</thead>
|
||||||
<MudTd>@context.DueDate.ToString("yyyy-MM-dd")</MudTd>
|
<tbody>
|
||||||
<MudTd>
|
@foreach (var filing in Filings)
|
||||||
@{
|
{
|
||||||
var dday = (context.DueDate.Date - DateTime.Today).Days;
|
var dday = (filing.DueDate.Date - DateTime.Today).Days;
|
||||||
}
|
<tr>
|
||||||
|
<td>@filing.ClientName</td>
|
||||||
|
<td>@filing.FilingType</td>
|
||||||
|
<td>@filing.DueDate.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td>
|
||||||
@if (dday < 0)
|
@if (dday < 0)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Error">D+@(-dday)</MudChip>
|
<span class="status-pill danger">D+@(-dday)</span>
|
||||||
}
|
}
|
||||||
else if (dday <= 7)
|
else if (dday <= 7)
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">D-@dday</MudChip>
|
<span class="status-pill warning">D-@dday</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2">D-@dday</MudText>
|
<span>D-@dday</span>
|
||||||
}
|
}
|
||||||
</MudTd>
|
</td>
|
||||||
<MudTd>@(context.Memo ?? "")</MudTd>
|
<td>@(filing.Memo ?? "")</td>
|
||||||
<MudTd>
|
<td>
|
||||||
@if (context.Status == "pending")
|
<div class="admin-row-actions">
|
||||||
|
@if (filing.Status == "pending")
|
||||||
{
|
{
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Success"
|
<button type="button" class="site-button secondary" @onclick="@(() => MarkFiled(filing))">완료</button>
|
||||||
OnClick="@(() => MarkFiled(context))">완료</MudButton>
|
|
||||||
}
|
}
|
||||||
else if (context.Status == "filed")
|
else
|
||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">완료</MudChip>
|
<span class="status-pill success">완료</span>
|
||||||
}
|
}
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteFiling(filing.Id))">✕</button>
|
||||||
OnClick="@(() => DeleteFiling(context.Id))" />
|
</div>
|
||||||
</MudTd>
|
</td>
|
||||||
</RowTemplate>
|
</tr>
|
||||||
</MudTable>
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -65,45 +71,34 @@ else
|
|||||||
public EventCallback OnStatusChange { get; set; }
|
public EventCallback OnStatusChange { get; set; }
|
||||||
|
|
||||||
private async Task MarkFiled(TaxFiling filing)
|
private async Task MarkFiled(TaxFiling filing)
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
filing.Status = "filed";
|
filing.Status = "filed";
|
||||||
var result = await FilingClient.UpdateAsync(filing.Id, filing);
|
var result = await FilingClient.UpdateAsync(filing.Id, filing);
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
Snackbar.Add("신고 완료 처리되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "신고 완료 처리되었습니다.");
|
||||||
await OnStatusChange.InvokeAsync();
|
await OnStatusChange.InvokeAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("처리 실패", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "처리 실패");
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteFiling(int id)
|
private async Task DeleteFiling(int id)
|
||||||
{
|
{
|
||||||
try
|
var confirmed = await JS.InvokeAsync<bool>("confirm", "이 항목을 삭제하시겠습니까?");
|
||||||
{
|
if (!confirmed) return;
|
||||||
|
|
||||||
var success = await FilingClient.DeleteAsync(id);
|
var success = await FilingClient.DeleteAsync(id);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
Snackbar.Add("삭제되었습니다.", Severity.Info);
|
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
|
||||||
await OnStatusChange.InvokeAsync();
|
await OnStatusChange.InvokeAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("삭제 실패", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "삭제 실패");
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,109 +4,149 @@
|
|||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@inject ITaxFilingBrowserClient FilingClient
|
@inject ITaxFilingBrowserClient FilingClient
|
||||||
@inject IClientBrowserClient ClientClient
|
@inject IClientBrowserClient ClientClient
|
||||||
@inject ISnackbar Snackbar
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>신고 일정 관리</PageTitle>
|
<PageTitle>신고 일정 관리</PageTitle>
|
||||||
|
|
||||||
<section class="admin-page-hero">
|
<section class="admin-page-hero">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">Tax Schedule</MudText>
|
<div class="admin-eyebrow">Tax Schedule</div>
|
||||||
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
|
<h1 class="admin-page-title">신고 일정</h1>
|
||||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</MudText>
|
<p class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
<button type="button" class="site-button primary" @onclick="@(() => showAddForm = !showAddForm)">일정 추가</button>
|
||||||
OnClick="@(() => showAddForm = !showAddForm)"
|
|
||||||
StartIcon="@Icons.Material.Filled.Add">
|
|
||||||
일정 추가
|
|
||||||
</MudButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (showAddForm)
|
@if (showAddForm)
|
||||||
{
|
{
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
<div class="admin-surface mb-4">
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText>
|
<h3 class="admin-section-title">새 신고 일정</h3>
|
||||||
<MudGrid Spacing="2">
|
<form class="admin-dialog-card" @onsubmit="AddFiling" @onsubmit:preventDefault="true">
|
||||||
<MudItem xs="12" sm="6" md="4">
|
<label>고객 검색
|
||||||
<MudAutocomplete T="Domain.Entities.Client" @bind-Value="selectedClient"
|
<select class="admin-input" @bind="SelectedClientIdText">
|
||||||
Label="고객 검색 *"
|
<option value="">선택하세요</option>
|
||||||
SearchFunc="SearchClients"
|
@foreach (var client in clients)
|
||||||
ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")"
|
{
|
||||||
Variant="Variant.Outlined" />
|
<option value="@client.Id">@GetClientDisplayName(client)</option>
|
||||||
</MudItem>
|
}
|
||||||
<MudItem xs="12" sm="6" md="4">
|
</select>
|
||||||
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined">
|
</label>
|
||||||
|
<label>신고 유형
|
||||||
|
<select class="admin-input" @bind="newFilingType">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
@foreach (var t in TaxFilingService.FilingTypes)
|
@foreach (var t in TaxFilingService.FilingTypes)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
<option value="@t">@t</option>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</select>
|
||||||
</MudItem>
|
</label>
|
||||||
<MudItem xs="12" sm="6" md="4">
|
<label>신고 기한 <input class="admin-input" type="text" placeholder="yyyy-MM-dd" @bind="DueDateText" /></label>
|
||||||
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
|
<label>메모 <textarea class="admin-input" rows="3" @bind="newMemo"></textarea></label>
|
||||||
</MudItem>
|
<div class="admin-dialog-actions">
|
||||||
<MudItem xs="12">
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
<MudTextField T="string" @bind-Value="newMemo" Label="메모" Variant="Variant.Outlined" />
|
<button type="button" class="site-button secondary" @onclick='() => showAddForm = false'>취소</button>
|
||||||
</MudItem>
|
</div>
|
||||||
</MudGrid>
|
</form>
|
||||||
<MudStack Row="true" Class="mt-3" Spacing="2">
|
</div>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddFiling">저장</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<MudPaper Class="admin-surface" Elevation="0">
|
<div class="admin-surface">
|
||||||
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
|
<div class="admin-tabbar">
|
||||||
<MudTabPanel Text="신고 예정">
|
<button type="button" class="admin-tab @(activeTab == "pending" ? "active" : "")" @onclick='() => activeTab = "pending"'>신고 예정</button>
|
||||||
<FilingTable Filings="@pending" OnStatusChange="Reload" />
|
<button type="button" class="admin-tab @(activeTab == "filed" ? "active" : "")" @onclick='() => activeTab = "filed"'>신고 완료</button>
|
||||||
</MudTabPanel>
|
<button type="button" class="admin-tab @(activeTab == "overdue" ? "active" : "")" @onclick='() => activeTab = "overdue"'>기한 초과</button>
|
||||||
<MudTabPanel Text="신고 완료">
|
</div>
|
||||||
<FilingTable Filings="@filed" OnStatusChange="Reload" />
|
|
||||||
</MudTabPanel>
|
@if (CurrentFilings.Count == 0)
|
||||||
<MudTabPanel Text="기한 초과">
|
{
|
||||||
<FilingTable Filings="@overdue" OnStatusChange="Reload" />
|
<div class="muted">항목이 없습니다.</div>
|
||||||
</MudTabPanel>
|
}
|
||||||
</MudTabs>
|
else
|
||||||
</MudPaper>
|
{
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>고객</th>
|
||||||
|
<th>신고 유형</th>
|
||||||
|
<th>기한</th>
|
||||||
|
<th>D-day</th>
|
||||||
|
<th>메모</th>
|
||||||
|
<th>처리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var filing in CurrentFilings)
|
||||||
|
{
|
||||||
|
var dday = (filing.DueDate.Date - DateTime.Today).Days;
|
||||||
|
<tr>
|
||||||
|
<td>@filing.ClientName</td>
|
||||||
|
<td>@filing.FilingType</td>
|
||||||
|
<td>@filing.DueDate.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td>
|
||||||
|
@if (dday < 0)
|
||||||
|
{
|
||||||
|
<span class="status-pill danger">D+@(-dday)</span>
|
||||||
|
}
|
||||||
|
else if (dday <= 7)
|
||||||
|
{
|
||||||
|
<span class="status-pill warning">D-@dday</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>D-@dday</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@(filing.Memo ?? "")</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-row-actions">
|
||||||
|
@if (filing.Status == "pending")
|
||||||
|
{
|
||||||
|
<button type="button" class="site-button secondary" @onclick="@(() => MarkFiled(filing))">완료</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="admin-icon-button danger" @onclick="@(() => DeleteFiling(filing.Id))">✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Domain.Entities.TaxFiling> pending = [];
|
private List<TaxFiling> allFilings = [];
|
||||||
private List<Domain.Entities.TaxFiling> filed = [];
|
private List<Client> clients = [];
|
||||||
private List<Domain.Entities.TaxFiling> overdue = [];
|
|
||||||
|
|
||||||
private bool showAddForm;
|
private bool showAddForm;
|
||||||
private Domain.Entities.Client? selectedClient;
|
private string activeTab = "pending";
|
||||||
|
private int selectedClientId;
|
||||||
private string newFilingType = "";
|
private string newFilingType = "";
|
||||||
private DateTime? newDueDate = DateTime.Today.AddDays(30);
|
private DateTime? newDueDate = DateTime.Today.AddDays(30);
|
||||||
private string newMemo = "";
|
private string newMemo = "";
|
||||||
|
|
||||||
|
private string SelectedClientIdText { get => selectedClientId > 0 ? selectedClientId.ToString() : ""; set => selectedClientId = int.TryParse(value, out var id) ? id : 0; }
|
||||||
|
private string DueDateText { get => newDueDate?.ToString("yyyy-MM-dd") ?? ""; set => newDueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
private List<TaxFiling> CurrentFilings => activeTab switch
|
||||||
|
{
|
||||||
|
"filed" => allFilings.Where(x => x.Status == "filed").ToList(),
|
||||||
|
"overdue" => allFilings.Where(x => x.Status == "overdue").ToList(),
|
||||||
|
_ => allFilings.Where(x => x.Status == "pending").ToList()
|
||||||
|
};
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync() => await Reload();
|
protected override async Task OnInitializedAsync() => await Reload();
|
||||||
|
|
||||||
private async Task Reload()
|
private async Task Reload()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var all = (await FilingClient.GetUpcomingAsync(365)).ToList();
|
allFilings = (await FilingClient.GetUpcomingAsync(365)).ToList();
|
||||||
pending = all.Where(x => x.Status == "pending").ToList();
|
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||||
filed = all.Where(x => x.Status == "filed").ToList();
|
clients = clientItems.ToList();
|
||||||
overdue = all.Where(x => x.Status == "overdue").ToList();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IEnumerable<Client>> SearchClients(string value)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +154,15 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (selectedClient == null)
|
if (selectedClientId <= 0)
|
||||||
{
|
{
|
||||||
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
|
await JS.InvokeVoidAsync("alert", "고객을 선택하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var filing = new TaxFiling
|
var filing = new TaxFiling
|
||||||
{
|
{
|
||||||
ClientId = selectedClient.Id,
|
ClientId = selectedClientId,
|
||||||
FilingType = newFilingType,
|
FilingType = newFilingType,
|
||||||
DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow,
|
DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow,
|
||||||
Status = "pending",
|
Status = "pending",
|
||||||
@@ -131,17 +172,36 @@
|
|||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
showAddForm = false;
|
showAddForm = false;
|
||||||
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
|
await JS.InvokeVoidAsync("alert", "신고 일정이 추가되었습니다.");
|
||||||
await Reload();
|
await Reload();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Snackbar.Add("추가 실패", Severity.Error);
|
await JS.InvokeVoidAsync("alert", "추가 실패");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
|
await JS.InvokeVoidAsync("alert", $"오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task MarkFiled(TaxFiling filing)
|
||||||
|
{
|
||||||
|
filing.Status = "filed";
|
||||||
|
await FilingClient.UpdateAsync(filing.Id, filing);
|
||||||
|
await JS.InvokeVoidAsync("alert", "신고 완료 처리되었습니다.");
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteFiling(int id)
|
||||||
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "삭제하시겠습니까?")) return;
|
||||||
|
await FilingClient.DeleteAsync(id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "삭제되었습니다.");
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetClientDisplayName(Client client)
|
||||||
|
=> !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
@page "/admin/tax-profiles"
|
||||||
|
@using TaxBaik.Web.Services.AdminClients
|
||||||
|
@inject ITaxProfileBrowserClient TaxProfileClient
|
||||||
|
@inject IClientBrowserClient ClientClient
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>세무 프로필</PageTitle>
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<div class="admin-eyebrow">CRM & 세무관리</div>
|
||||||
|
<h1 class="admin-page-title">세무 프로필</h1>
|
||||||
|
<p class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="site-button primary" @onclick="OpenCreateDialog">새 프로필 추가</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-surface">
|
||||||
|
@if (profiles is null)
|
||||||
|
{
|
||||||
|
<Skeleton Count="5" CssClass="taxbaik-skeleton-grid" />
|
||||||
|
}
|
||||||
|
else if (profiles.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="muted">세무 프로필이 없습니다.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>고객</th>
|
||||||
|
<th>사업 유형</th>
|
||||||
|
<th>위험도</th>
|
||||||
|
<th>다음 신고</th>
|
||||||
|
<th>작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in profiles)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@item.Id</td>
|
||||||
|
<td>@(clientMap.GetValueOrDefault(item.ClientId, $"Client #{item.ClientId}"))</td>
|
||||||
|
<td>@item.BusinessType</td>
|
||||||
|
<td><span class="status-pill @(item.TaxRiskLevel == "high" ? "danger" : item.TaxRiskLevel == "normal" ? "warning" : "success")">@item.TaxRiskLevel</span></td>
|
||||||
|
<td>@(item.NextFilingDueDate?.ToString("yyyy-MM-dd") ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-row-actions">
|
||||||
|
<button type="button" class="admin-icon-button" @onclick="@(async () => await OpenEditDialog(item))">✎</button>
|
||||||
|
<button type="button" class="admin-icon-button danger" @onclick="@(async () => await DeleteProfile(item.Id))">✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog class="admin-dialog" open="@isDialogOpen">
|
||||||
|
<form class="admin-dialog-card" @onsubmit="SaveProfile" @onsubmit:preventDefault="true">
|
||||||
|
<h3>@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</h3>
|
||||||
|
<label>고객
|
||||||
|
<select class="admin-input" @bind="ClientIdText">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
@foreach (var client in clients)
|
||||||
|
{
|
||||||
|
<option value="@client.Id.ToString()">@GetClientDisplayName(client)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>사업 유형
|
||||||
|
<select class="admin-input" @bind="profileForm.BusinessType">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="일반제조업">일반제조업</option>
|
||||||
|
<option value="도소매업">도소매업</option>
|
||||||
|
<option value="서비스업">서비스업</option>
|
||||||
|
<option value="정보통신업">정보통신업</option>
|
||||||
|
<option value="부동산업">부동산업</option>
|
||||||
|
<option value="건설업">건설업</option>
|
||||||
|
<option value="음식점업">음식점업</option>
|
||||||
|
<option value="프리랜서">프리랜서</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>위험도
|
||||||
|
<select class="admin-input" @bind="profileForm.TaxRiskLevel">
|
||||||
|
<option value="low">낮음</option>
|
||||||
|
<option value="normal">보통</option>
|
||||||
|
<option value="high">높음</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>다음 신고 예정일 <input class="admin-input" type="text" placeholder="2026-07-01" @bind="NextFilingText" /></label>
|
||||||
|
<label>특수 사항 <textarea class="admin-input" rows="3" @bind="profileForm.SpecialNotes"></textarea></label>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
|
<button type="button" class="site-button secondary" @onclick="CloseDialog">취소</button>
|
||||||
|
<button type="submit" class="site-button primary">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||||
|
private List<TaxProfile>? profiles;
|
||||||
|
private List<Client> clients = [];
|
||||||
|
private Dictionary<int, string> clientMap = new();
|
||||||
|
private bool isDialogOpen;
|
||||||
|
private bool isEditMode;
|
||||||
|
private TaxProfile? editingProfile;
|
||||||
|
private TaxProfileForm profileForm = new();
|
||||||
|
private string ClientIdText { get => profileForm.ClientId > 0 ? profileForm.ClientId.ToString() : ""; set => profileForm.ClientId = int.TryParse(value, out var id) ? id : 0; }
|
||||||
|
private string NextFilingText { get => profileForm.NextFilingDueDate?.ToString("yyyy-MM-dd") ?? ""; set => profileForm.NextFilingDueDate = DateTime.TryParse(value, out var dt) ? dt : null; }
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && AuthStateTask != null)
|
||||||
|
{
|
||||||
|
var authState = await AuthStateTask;
|
||||||
|
if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
profiles = await TaxProfileClient.GetAllAsync();
|
||||||
|
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
|
||||||
|
clients = clientItems.ToList();
|
||||||
|
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"데이터 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateDialog()
|
||||||
|
{
|
||||||
|
isEditMode = false;
|
||||||
|
editingProfile = null;
|
||||||
|
profileForm = new TaxProfileForm { ClientId = clients.FirstOrDefault()?.Id ?? 0, TaxRiskLevel = "normal", NextFilingDueDate = DateTime.Today.AddMonths(1) };
|
||||||
|
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;
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveProfile()
|
||||||
|
{
|
||||||
|
if (profileForm.ClientId <= 0 || string.IsNullOrWhiteSpace(profileForm.BusinessType))
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", "고객과 사업 유형을 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (isEditMode && editingProfile != null)
|
||||||
|
{
|
||||||
|
await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
|
||||||
|
await JS.InvokeVoidAsync("alert", "세무 프로필이 수정되었습니다.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newId = await TaxProfileClient.CreateAsync(profileForm.ClientId, profileForm.BusinessType);
|
||||||
|
if (newId > 0)
|
||||||
|
{
|
||||||
|
await TaxProfileClient.UpdateAsync(newId, profileForm.BusinessType, null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
|
||||||
|
await JS.InvokeVoidAsync("alert", "세무 프로필이 추가되었습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseDialog();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"저장 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteProfile(int id)
|
||||||
|
{
|
||||||
|
if (!await JS.InvokeAsync<bool>("confirm", "이 세무 프로필을 삭제하시겠습니까?")) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TaxProfileClient.DeleteAsync(id);
|
||||||
|
await JS.InvokeVoidAsync("alert", "세무 프로필이 삭제되었습니다.");
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("alert", $"삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog() { isDialogOpen = false; isEditMode = false; editingProfile = null; profileForm = new(); }
|
||||||
|
private static string GetClientDisplayName(Client client) => !string.IsNullOrWhiteSpace(client.CompanyName) ? client.CompanyName : !string.IsNullOrWhiteSpace(client.Name) ? client.Name : $"Client #{client.Id}";
|
||||||
|
private sealed class TaxProfileForm { public int ClientId { get; set; } public string BusinessType { get; set; } = ""; public string TaxRiskLevel { get; set; } = "normal"; public DateTime? NextFilingDueDate { get; set; } public string? SpecialNotes { get; set; } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@using Microsoft.FluentUI.AspNetCore.Components
|
||||||
|
<div class="admin-dialog">
|
||||||
|
<div class="admin-dialog-title">@Title</div>
|
||||||
|
<p class="admin-dialog-message">@Message</p>
|
||||||
|
<div class="admin-dialog-actions">
|
||||||
|
<FluentButton Appearance="ButtonAppearance.Transparent" @onclick="Cancel">취소</FluentButton>
|
||||||
|
<FluentButton Appearance="ButtonAppearance.Primary" @onclick="Confirm">삭제</FluentButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Title { get; set; } = "";
|
||||||
|
[Parameter] public string Message { get; set; } = "";
|
||||||
|
|
||||||
|
[Parameter] public EventCallback OnCancel { get; set; }
|
||||||
|
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||||
|
|
||||||
|
private Task Cancel() => OnCancel.InvokeAsync();
|
||||||
|
private Task Confirm() => OnConfirm.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using TaxBaik.Web.Components.Shared
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using MudBlazor
|
|
||||||
@using TaxBaik.Web.Services
|
@using TaxBaik.Web.Services
|
||||||
@using TaxBaik.Domain.Entities
|
@using TaxBaik.Domain.Entities
|
||||||
@using TaxBaik.Application.Services
|
@using TaxBaik.Application.Services
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<div class="@CssClass" aria-hidden="true">
|
||||||
|
@for (var i = 0; i < Count; i++)
|
||||||
|
{
|
||||||
|
<div class="taxbaik-skeleton-item">
|
||||||
|
<div class="taxbaik-skeleton-line taxbaik-skeleton-title"></div>
|
||||||
|
<div class="taxbaik-skeleton-line"></div>
|
||||||
|
<div class="taxbaik-skeleton-line taxbaik-skeleton-short"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int Count { get; set; } = 3;
|
||||||
|
[Parameter] public string CssClass { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>백원숙 세무회계</title>
|
||||||
|
<base href="/taxbaik/" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="css/design-tokens.css" />
|
||||||
|
<link rel="stylesheet" href="css/ui-primitives.css" />
|
||||||
|
<link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="css/site.css" />
|
||||||
|
<link rel="stylesheet" href="css/admin.css" />
|
||||||
|
<component type="typeof(HeadOutlet)" render-mode="InteractiveServer" />
|
||||||
|
</head>
|
||||||
|
<body class="site-blazor">
|
||||||
|
<Routes @rendermode="new InteractiveServerRenderMode(prerender: true)" />
|
||||||
|
<script src="_content/Microsoft.FluentUI.AspNetCore.Components/js/lib.module.js" type="module" async></script>
|
||||||
|
<script src="js/admin-session.js"></script>
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@page "/blog"
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@inject BlogService BlogService
|
||||||
|
|
||||||
|
<PageTitle>블로그</PageTitle>
|
||||||
|
|
||||||
|
<section class="site-content">
|
||||||
|
<div class="site-section-header">
|
||||||
|
<h1>세무 블로그</h1>
|
||||||
|
<p>최신 세법 변화와 실무 팁을 확인하세요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (posts is null)
|
||||||
|
{
|
||||||
|
<Skeleton Count="6" CssClass="site-post-grid" />
|
||||||
|
}
|
||||||
|
else if (posts.Count == 0)
|
||||||
|
{
|
||||||
|
<p>게시물이 없습니다.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="site-post-grid">
|
||||||
|
@foreach (var post in posts)
|
||||||
|
{
|
||||||
|
<article class="site-post-card">
|
||||||
|
<div class="site-post-meta">@post.CategoryName</div>
|
||||||
|
<h2>@post.Title</h2>
|
||||||
|
<p>@(post.PublishedAt ?? post.CreatedAt).ToString("yyyy-MM-dd")</p>
|
||||||
|
<a class="site-button primary" href="/taxbaik/blog/@post.Slug">글 내용 보기</a>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<TaxBaik.Domain.Entities.BlogPost>? posts;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var (items, _) = await BlogService.GetPublishedPagedAsync(1, 12);
|
||||||
|
posts = items.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@page "/"
|
||||||
|
@using TaxBaik.Application.Seasonal
|
||||||
|
@using TaxBaik.Application.Services
|
||||||
|
@inject SeasonalMarketingService SeasonalMarketingService
|
||||||
|
|
||||||
|
<PageTitle>백원숙 세무회계</PageTitle>
|
||||||
|
|
||||||
|
<section class="site-hero">
|
||||||
|
<div class="site-hero-copy">
|
||||||
|
<div class="site-kicker">사업자 · 부동산 · 증여 세무 상담</div>
|
||||||
|
<h1>세금과 자산을 한 번에 정리하는 맞춤형 세무 파트너</h1>
|
||||||
|
<p>사업자 세무, 부동산 거래, 가족자산 관리를 위한 통합 상담을 제공합니다.</p>
|
||||||
|
<div class="site-actions">
|
||||||
|
<a class="site-button primary" href="/taxbaik/contact">무료 상담 신청</a>
|
||||||
|
<a class="site-button secondary" href="/taxbaik/blog">블로그 보기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@page "/portal"
|
||||||
|
<PageTitle>마이 포털</PageTitle>
|
||||||
|
|
||||||
|
<section class="site-content">
|
||||||
|
<div class="site-section-header">
|
||||||
|
<h1>고객 포털</h1>
|
||||||
|
<p>포털은 다음 단계에서 세무 신고와 상담 이력 데이터에 연결됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="site-card">
|
||||||
|
<p>현재는 인증 연결과 데이터 바인딩을 준비하는 단계입니다.</p>
|
||||||
|
<div class="site-actions">
|
||||||
|
<a class="site-button primary" href="/taxbaik/portal/login">로그인</a>
|
||||||
|
<a class="site-button secondary" href="/taxbaik/portal/register">회원가입</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@page "/portal/login"
|
||||||
|
<PageTitle>고객 포털 로그인</PageTitle>
|
||||||
|
<section class="site-content">
|
||||||
|
<h1>고객 포털 로그인</h1>
|
||||||
|
<p>로그인 폼은 기존 인증 흐름을 Blazor로 옮기는 다음 단계에서 연결합니다.</p>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@page "/portal/register"
|
||||||
|
<PageTitle>고객 포털 회원가입</PageTitle>
|
||||||
|
<section class="site-content">
|
||||||
|
<h1>고객 포털 회원가입</h1>
|
||||||
|
<p>회원가입 폼은 다음 단계에서 Blazor 입력 컴포넌트로 채워집니다.</p>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
|
||||||
|
<Router AppAssembly="@typeof(Program).Assembly">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Site.SiteLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
<NotFound>
|
||||||
|
<PageTitle>찾을 수 없음</PageTitle>
|
||||||
|
<LayoutView Layout="@typeof(TaxBaik.Web.Components.Site.SiteLayout)">
|
||||||
|
<p>요청한 페이지를 찾을 수 없습니다.</p>
|
||||||
|
</LayoutView>
|
||||||
|
</NotFound>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="site-shell">
|
||||||
|
<header class="site-topbar">
|
||||||
|
<a class="site-logo" href="/taxbaik/">백원숙 세무회계</a>
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="/taxbaik/blog">블로그</a>
|
||||||
|
<a href="/taxbaik/portal">포털</a>
|
||||||
|
<a href="/taxbaik/contact">상담</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="site-main">
|
||||||
|
@Body
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using TaxBaik.Web.Components.Shared
|
||||||
@@ -9,7 +9,7 @@ namespace TaxBaik.Web.Controllers;
|
|||||||
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
|
/// SOLID: Single Responsibility - 대시보드 데이터만 담당
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/admin-dashboard")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class AdminDashboardController : ControllerBase
|
public class AdminDashboardController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,120 @@
|
|||||||
|
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]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activities = await service.GetAllAsync();
|
||||||
|
return Ok(activities);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
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,116 @@
|
|||||||
|
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]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contracts = await service.GetAllAsync();
|
||||||
|
return Ok(contracts);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
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,131 @@
|
|||||||
|
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]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var revenues = await service.GetAllAsync();
|
||||||
|
return Ok(revenues);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var revenue = await service.GetByIdAsync(id);
|
||||||
|
return revenue is null ? NotFound(new { error = "조회 실패", message = "해당 청구를 찾을 수 없습니다." }) : Ok(revenue);
|
||||||
|
}
|
||||||
|
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,116 @@
|
|||||||
|
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]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var schedules = await service.GetAllAsync();
|
||||||
|
return Ok(schedules);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
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,111 @@
|
|||||||
|
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]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profiles = await taxProfileService.GetAllAsync();
|
||||||
|
return Ok(profiles);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "조회 실패", message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{clientId:int}")]
|
||||||
|
public async Task<IActionResult> GetByClientId(int clientId)
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
|
|
||||||
namespace TaxBaik.Web.Hubs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Real-time notification hub for admin dashboard
|
|
||||||
/// SOLID: Single Responsibility - Only broadcasts change notifications
|
|
||||||
/// No state management - stateless broadcast pattern
|
|
||||||
/// </summary>
|
|
||||||
[Authorize]
|
|
||||||
public class NotificationHub : Hub
|
|
||||||
{
|
|
||||||
private const string AdminGroup = "admins";
|
|
||||||
|
|
||||||
public override async Task OnConnectedAsync()
|
|
||||||
{
|
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
|
|
||||||
await base.OnConnectedAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Broadcast inquiry status changed to all connected admins
|
|
||||||
/// Clients should re-fetch from API to verify
|
|
||||||
/// </summary>
|
|
||||||
public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
|
|
||||||
{
|
|
||||||
await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
|
|
||||||
{
|
|
||||||
InquiryId = inquiryId,
|
|
||||||
Status = newStatus,
|
|
||||||
ChangedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Broadcast inquiry submitted (new inquiry created)
|
|
||||||
/// </summary>
|
|
||||||
public async Task NotifyInquiryCreated(int inquiryId, string name)
|
|
||||||
{
|
|
||||||
await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
|
|
||||||
{
|
|
||||||
InquiryId = inquiryId,
|
|
||||||
Name = name,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Broadcast client created
|
|
||||||
/// </summary>
|
|
||||||
public async Task NotifyClientCreated(int clientId, string name)
|
|
||||||
{
|
|
||||||
await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
|
|
||||||
{
|
|
||||||
ClientId = clientId,
|
|
||||||
Name = name,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Broadcast announcement published
|
|
||||||
/// </summary>
|
|
||||||
public async Task NotifyAnnouncementPublished(int announcementId, string title)
|
|
||||||
{
|
|
||||||
await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
|
|
||||||
{
|
|
||||||
AnnouncementId = announcementId,
|
|
||||||
Title = title,
|
|
||||||
PublishedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Broadcast tax filing completed
|
|
||||||
/// </summary>
|
|
||||||
public async Task NotifyFilingCompleted(int filingId, string filingType)
|
|
||||||
{
|
|
||||||
await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
|
|
||||||
{
|
|
||||||
FilingId = filingId,
|
|
||||||
FilingType = filingType,
|
|
||||||
CompletedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user