Compare commits

...

18 Commits

Author SHA1 Message Date
kjh2064 e2d3eb9195 fix(web): use direct kakao channel link
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
2026-07-02 12:51:48 +09:00
kjh2064 77aaed814c fix(db): make remaining blog migrations idempotent
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
2026-07-02 12:50:26 +09:00
kjh2064 d7ca51b741 fix(db): make blog accuracy migration idempotent
TaxBaik CI/CD / build-and-deploy (push) Failing after 3m1s
2026-07-02 12:29:06 +09:00
kjh2064 bc210969e2 docs: harness gitea token and canonical docs
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m38s
2026-07-02 12:22:19 +09:00
kjh2064 6642f3d6f1 ci: retrigger deploy
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m44s
2026-07-02 11:39:37 +09:00
kjh2064 67f2f4b5d6 fix(db): make blog cleanup migration idempotent
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m46s
2026-07-02 11:31:15 +09:00
kjh2064 faf4273e6d fix(admin): normalize faq category combo
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m44s
2026-07-02 11:27:14 +09:00
kjh2064 15c261a49d fix(blog): align soft delete with deleted_at
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m44s
2026-07-02 11:23:18 +09:00
kjh2064 b06c0f99fb feat(blog): add archived post restore workflow
TaxBaik CI/CD / build-and-deploy (push) Failing after 5m38s
2026-07-02 11:08:39 +09:00
kjh2064 ad55bd1884 fix(blog): add restore path for archived posts
TaxBaik CI/CD / build-and-deploy (push) Successful in 4m57s
2026-07-02 11:05:53 +09:00
kjh2064 e0b8d4e370 fix(home): keep blog entry visible when empty 2026-07-02 11:03:37 +09:00
kjh2064 e65f01b196 fix(admin): align holiday tests and loading flow
TaxBaik CI/CD / build-and-deploy (push) Successful in 4m14s
2026-07-02 11:02:20 +09:00
kjh2064 124b3b4dfc feat(admin): normalize combo and holiday policies
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
2026-07-02 10:57:14 +09:00
kjh2064 3785bc7a70 ci: use kst for build timestamps
TaxBaik CI/CD / build-and-deploy (push) Successful in 3m29s
2026-07-02 10:53:24 +09:00
kjh2064 bd44ec7c5f fix(common-code): enforce storage policy
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
2026-07-02 10:51:58 +09:00
kjh2064 cb47349a25 feat(admin): stabilize blog and admin patterns
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
2026-07-02 10:46:27 +09:00
kjh2064 b3cab87539 fix(admin): restore blog client imports for build
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
2026-07-02 10:42:21 +09:00
kjh2064 1fc3b6c0a4 merge: admin midpoint changes
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
2026-07-02 10:37:03 +09:00
86 changed files with 1783 additions and 663 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
- name: Generate build info - name: Generate build info
run: | run: |
COMMIT_HASH=$(git rev-parse --short HEAD) COMMIT_HASH=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC') BUILD_TIME=$(TZ=Asia/Seoul date +'%Y-%m-%d %H:%M:%S KST')
mkdir -p ./publish/wwwroot mkdir -p ./publish/wwwroot
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME" echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
+21 -5
View File
@@ -1,4 +1,20 @@
# CLAUDE.md — TaxBaik 개발 지침 # CLAUDE.md — TaxBaik 운영 메모
## 우선 기준
1. [docs/INDEX.md](./docs/INDEX.md)
2. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
3. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md)
4. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
5. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)
이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보 기준은 위 문서를 따른다.
## Gitea Token Rule
- `GITEA_TOKEN_TAXBAIK`만 사용한다.
- `GITEA_TOKEN`은 사용하지 않는다.
- dispatch 전에는 `GET /api/v1/user`로 토큰 유효성을 먼저 확인한다.
## 🏗️ **아키텍처 리팩토링 (API-First 전환)** ## 🏗️ **아키텍처 리팩토링 (API-First 전환)**
@@ -76,7 +92,7 @@ _refreshTokenExpirationMinutes = 10080;
- 모든 API 엔드포인트 구현됨 - 모든 API 엔드포인트 구현됨
- 모든 Browser Client 구현됨 - 모든 Browser Client 구현됨
- 16개 Blazor 페이지 API-First 마이그레이션 완료 - 16개 Blazor 페이지 API-First 마이그레이션 완료
- MudDataGrid Douzone ERP 수준 UX 적용 - MudDataGrid 더존 세무회계프로그램 UX 수준 적용
- MudDialog 모달 패턴 (흰 화면 플래시 제거) - MudDialog 모달 패턴 (흰 화면 플래시 제거)
- ConfirmDialog 삭제 확인 컴포넌트 - ConfirmDialog 삭제 확인 컴포넌트
@@ -119,7 +135,7 @@ _refreshTokenExpirationMinutes = 10080;
- 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking) - 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking)
- 5개 Browser Client (API-First 패턴) - 5개 Browser Client (API-First 패턴)
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog) - 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog)
- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화) - 더존 세무회계프로그램 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)
| 페이지 | API | Client | Blazor | 핵심 기능 | | 페이지 | API | Client | Blazor | 핵심 기능 |
|------|---|---|---|---------| |------|---|---|---|---------|
@@ -972,9 +988,9 @@ Admin 로그인 페이지만 [AllowAnonymous]:
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기 - 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
- 업데이트는 `StateHasChanged()` 호출 - 업데이트는 `StateHasChanged()` 호출
### 8.6 어드민 그리드 UX (Dorsum ERP 수준) ### 8.6 어드민 그리드 UX (더존 세무회계프로그램 수준)
**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + ERP 수준의 상호작용성 **목표**: 패드/PC에 특화된 고밀도 데이터 표시 + 더존식 상호작용성
#### 그리드 기본 원칙 #### 그리드 기본 원칙
- **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거) - **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거)
+7 -1
View File
@@ -270,7 +270,13 @@ echo $ConnectionStrings__Default
## 문서 ## 문서
- [CLAUDE.md](./CLAUDE.md) - LLM 개발 지침 (9개 섹션) - [docs/INDEX.md](./docs/INDEX.md) - 현재 개발 기준 인덱스
- [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md) - 코드 품질, API-first, CI/CD 하네스
- [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md) - 더존식 어드민 UX 원칙과 템플릿 기준
- [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md) - 공통코드 저장값/컬럼 길이/하드코딩 금지 기준
- [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md) - 콤보/검색/선택 입력 정책
- [docs/ADMIN_PATTERN_CRITIQUE_WBS.md](./docs/ADMIN_PATTERN_CRITIQUE_WBS.md) - 어드민 패턴 비판 및 정량 WBS
- [CLAUDE.md](./CLAUDE.md) - 보조 LLM 개발 지침
- [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 배포 완전 가이드 - [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 배포 완전 가이드
- [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트 - [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트
+45 -3
View File
@@ -44,15 +44,34 @@ public class BlogServiceTests
Assert.Equal("같은-제목-2", post.Slug); Assert.Equal("같은-제목-2", post.Slug);
} }
[Fact]
public async Task DeleteAsync_SoftDeletesPost_AndExcludesFromSlugLookup()
{
var repository = new FakeBlogPostRepository
{
Posts =
[
new BlogPost { Id = 1, Title = "삭제 대상", Content = "본문", Slug = "delete-me", IsPublished = true }
]
};
var service = new BlogService(repository, new MemoryCache(new MemoryCacheOptions()));
await service.DeleteAsync(1);
Assert.NotNull(repository.Posts.Single().DeletedAt);
Assert.Null(await service.GetBySlugAsync("delete-me"));
Assert.Null(await service.GetByIdAsync(1));
}
private sealed class FakeBlogPostRepository : IBlogPostRepository private sealed class FakeBlogPostRepository : IBlogPostRepository
{ {
public List<BlogPost> Posts { get; init; } = []; public List<BlogPost> Posts { get; init; } = [];
public Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default) => public Task<BlogPost?> GetByIdAsync(int id, CancellationToken cancellationToken = default) =>
Task.FromResult(Posts.FirstOrDefault(x => x.Id == id)); Task.FromResult(Posts.FirstOrDefault(x => x.Id == id && x.DeletedAt == null));
public Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) => public Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default) =>
Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished)); Task.FromResult(Posts.FirstOrDefault(x => x.Slug == slug && x.IsPublished && x.DeletedAt == null));
public Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync( public Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default) int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
@@ -74,6 +93,13 @@ public class BlogServiceTests
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count)); return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
} }
public Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default)
{
var items = Posts.Where(x => x.DeletedAt != null).ToList();
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
}
public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default) public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
{ {
post.Id = Posts.Count + 1; post.Id = Posts.Count + 1;
@@ -83,7 +109,23 @@ public class BlogServiceTests
public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DeleteAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var post = Posts.FirstOrDefault(x => x.Id == id);
if (post != null)
post.DeletedAt = DateTime.UtcNow;
return Task.CompletedTask;
}
public Task ArchiveAsync(int id, CancellationToken cancellationToken = default) => DeleteAsync(id, cancellationToken);
public Task RestoreAsync(int id, CancellationToken cancellationToken = default)
{
var post = Posts.FirstOrDefault(x => x.Id == id);
if (post != null)
post.DeletedAt = null;
return Task.CompletedTask;
}
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
} }
@@ -7,9 +7,10 @@ public class BusinessDayCalculatorTests
{ {
[Theory] [Theory]
[InlineData(2026, 2, 14, 2026, 2, 19)] [InlineData(2026, 2, 14, 2026, 2, 19)]
[InlineData(2026, 8, 15, 2026, 8, 20)] [InlineData(2026, 8, 15, 2026, 8, 18)]
[InlineData(2026, 9, 24, 2026, 9, 29)] [InlineData(2026, 9, 24, 2026, 9, 28)]
[InlineData(2026, 10, 3, 2026, 10, 8)] [InlineData(2026, 10, 3, 2026, 10, 6)]
[InlineData(2027, 2, 6, 2027, 2, 10)]
public void GetEffectiveDueDate_SkipsWeekendHolidayAndSubstituteHoliday( public void GetEffectiveDueDate_SkipsWeekendHolidayAndSubstituteHoliday(
int dueYear, int dueMonth, int dueDay, int dueYear, int dueMonth, int dueDay,
int expectedYear, int expectedMonth, int expectedDay) int expectedYear, int expectedMonth, int expectedDay)
@@ -0,0 +1,76 @@
namespace TaxBaik.Application.Tests;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
using Xunit;
public class CommonCodeServiceTests
{
[Fact]
public async Task UpsertAsync_TrimsAndRejectsWhitespaceInCodeValue()
{
var repository = new FakeCommonCodeRepository();
var service = new CommonCodeService(repository);
await Assert.ThrowsAsync<ValidationException>(() => service.UpsertAsync(new CommonCode
{
CodeGroup = " CLIENT_STATUS ",
CodeValue = "active code",
CodeName = " 활성 "
}));
}
[Fact]
public async Task UpsertAsync_TrimsAndPersistsNormalizedValues()
{
var repository = new FakeCommonCodeRepository();
var service = new CommonCodeService(repository);
await service.UpsertAsync(new CommonCode
{
CodeGroup = " CLIENT_STATUS ",
CodeValue = "active",
CodeName = " 활성 ",
SortOrder = 10
});
var saved = Assert.Single(repository.SavedCodes);
Assert.Equal("CLIENT_STATUS", saved.CodeGroup);
Assert.Equal("active", saved.CodeValue);
Assert.Equal("활성", saved.CodeName);
}
private sealed class FakeCommonCodeRepository : ICommonCodeRepository
{
public List<CommonCode> SavedCodes { get; } = [];
public Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default) =>
Task.FromResult<IEnumerable<string>>([]);
public Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default) =>
Task.FromResult<IEnumerable<CommonCode>>([]);
public Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default) =>
Task.FromResult<IEnumerable<CommonCode>>([]);
public Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
Task.FromResult<CommonCode?>(null);
public Task UpsertAsync(CommonCode code, CancellationToken ct = default)
{
SavedCodes.Add(new CommonCode
{
CodeGroup = code.CodeGroup,
CodeValue = code.CodeValue,
CodeName = code.CodeName,
SortOrder = code.SortOrder,
IsActive = code.IsActive
});
return Task.CompletedTask;
}
public Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
Task.CompletedTask;
}
}
@@ -80,6 +80,22 @@ public class InquiryServiceTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
{
var existing = Inquiries.FirstOrDefault(x => x.Id == inquiry.Id);
if (existing != null)
{
existing.Name = inquiry.Name;
existing.Phone = inquiry.Phone;
existing.Email = inquiry.Email;
existing.ServiceType = inquiry.ServiceType;
existing.Message = inquiry.Message;
existing.Status = inquiry.Status;
existing.AdminMemo = inquiry.AdminMemo;
}
return Task.CompletedTask;
}
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default) public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
{ {
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId); var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
@@ -12,3 +12,21 @@ public class CreateBlogPostDto
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
public int? AuthorId { get; set; } public int? AuthorId { get; set; }
} }
public class BlogPostResponseDto
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public string? ThumbnailUrl { get; set; }
public bool IsPublished { get; set; }
public int? AuthorId { get; set; }
public int ViewCount { get; set; }
public string Slug { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
}
@@ -0,0 +1,11 @@
namespace TaxBaik.Application.DTOs;
public class SubmitInquiryDto
{
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string? Email { get; set; }
public string ServiceType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public bool SuppressNotification { get; set; }
}
@@ -0,0 +1,12 @@
namespace TaxBaik.Application.DTOs;
public class UpdateInquiryDto
{
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string? Email { get; set; }
public string ServiceType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? AdminMemo { get; set; }
}
@@ -42,6 +42,10 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
int page, int pageSize, CancellationToken ct = default) => int page, int pageSize, CancellationToken ct = default) =>
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct); await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
public async Task<(IEnumerable<BlogPost>, int)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken ct = default) =>
await repository.GetArchivedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default) public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
{ {
ValidatePost(post); ValidatePost(post);
@@ -110,6 +114,18 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
memoryCache.Remove(AdminDashboardService.CacheKey); memoryCache.Remove(AdminDashboardService.CacheKey);
} }
public async Task ArchiveAsync(int id, CancellationToken ct = default)
{
await repository.ArchiveAsync(id, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task RestoreAsync(int id, CancellationToken ct = default)
{
await repository.RestoreAsync(id, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
}
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) => public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
await repository.IncrementViewCountAsync(id, ct); await repository.IncrementViewCountAsync(id, ct);
+1 -10
View File
@@ -6,15 +6,6 @@ using TaxBaik.Domain.Interfaces;
public class ClientService(IClientRepository repository) public class ClientService(IClientRepository repository)
{ {
public static readonly string[] ServiceTypes =
["기장", "부동산", "증여·상속", "종합소득세", "법인세", "부가가치세", "기타"];
public static readonly string[] TaxTypes =
["개인사업자", "법인사업자", "면세사업자", "근로소득자", "기타"];
public static readonly string[] Sources =
["홈페이지 문의", "소개", "직접 방문", "카카오 채널", "블로그", "기타"];
public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync( public async Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) => int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) =>
await repository.GetPagedAsync(Math.Max(1, page), Math.Clamp(pageSize, 1, 100), status, search, ct); await repository.GetPagedAsync(Math.Max(1, page), Math.Clamp(pageSize, 1, 100), status, search, ct);
@@ -81,7 +72,7 @@ public class ClientService(IClientRepository repository)
Phone = phone?.Trim(), Phone = phone?.Trim(),
ServiceType = serviceType, ServiceType = serviceType,
Status = "active", Status = "active",
Source = "홈페이지 문의" Source = "홈페이지문의"
}; };
return await repository.CreateAsync(client, ct); return await repository.CreateAsync(client, ct);
} }
@@ -8,6 +8,10 @@ namespace TaxBaik.Application.Services;
public class CommonCodeService(ICommonCodeRepository commonCodeRepository) public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
{ {
private const int MaxCodeGroupLength = 80;
private const int MaxCodeValueLength = 120;
private const int MaxCodeNameLength = 200;
public async Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default) public async Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default)
{ {
return await commonCodeRepository.GetAllGroupsAsync(ct); return await commonCodeRepository.GetAllGroupsAsync(ct);
@@ -36,13 +40,28 @@ public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default)
{ {
await commonCodeRepository.DeleteAsync(codeGroup.Trim(), codeValue.Trim(), ct); await commonCodeRepository.DeleteAsync(NormalizeToken(codeGroup, nameof(codeGroup), MaxCodeGroupLength), NormalizeToken(codeValue, nameof(codeValue), MaxCodeValueLength), ct);
} }
private static void Normalize(CommonCode code) private static void Normalize(CommonCode code)
{ {
code.CodeGroup = code.CodeGroup.Trim(); code.CodeGroup = NormalizeToken(code.CodeGroup, nameof(code.CodeGroup), MaxCodeGroupLength);
code.CodeValue = code.CodeValue.Trim(); code.CodeValue = NormalizeToken(code.CodeValue, nameof(code.CodeValue), MaxCodeValueLength, disallowWhitespace: true);
code.CodeName = code.CodeName.Trim(); code.CodeName = NormalizeToken(code.CodeName, nameof(code.CodeName), MaxCodeNameLength);
}
private static string NormalizeToken(string value, string fieldName, int maxLength, bool disallowWhitespace = false)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
throw new ValidationException($"{fieldName}은(는) 필수입니다.");
if (normalized.Length > maxLength)
throw new ValidationException($"{fieldName}은(는) 최대 {maxLength}자까지 입력할 수 있습니다.");
if (disallowWhitespace && normalized.Any(char.IsWhiteSpace))
throw new ValidationException($"{fieldName}에는 공백을 사용할 수 없습니다.");
return normalized;
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ using TaxBaik.Domain.Interfaces;
public class FaqService(IFaqRepository repository) public class FaqService(IFaqRepository repository)
{ {
public static readonly string[] Categories = public static readonly string[] Categories =
["기장·세금신고", "부동산", "증여·상속", "기타"]; ["기장세금신고", "부동산", "증여상속", "기타"];
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) => public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
await repository.GetActiveAsync(ct); await repository.GetActiveAsync(ct);
@@ -2,6 +2,7 @@ namespace TaxBaik.Application.Services;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Enums; using TaxBaik.Domain.Enums;
using TaxBaik.Domain.Interfaces; using TaxBaik.Domain.Interfaces;
@@ -72,6 +73,37 @@ public class InquiryService(
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) => public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) =>
await repository.UpdateAdminMemoAsync(id, adminMemo, ct); await repository.UpdateAdminMemoAsync(id, adminMemo, ct);
public async Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default)
{
var inquiry = await repository.GetByIdAsync(id, ct);
if (inquiry == null)
return null;
if (string.IsNullOrWhiteSpace(dto.Name))
throw new ValidationException("이름을 입력하세요.");
if (!PhoneRegex.IsMatch(dto.Phone))
throw new ValidationException("올바른 전화번호를 입력하세요. (예: 010-1234-5678)");
if (string.IsNullOrWhiteSpace(dto.Message))
throw new ValidationException("문의 내용을 입력하세요.");
if (!InquiryStatusMapper.TryParse(dto.Status, out var parsedStatus))
throw new ValidationException("지원하지 않는 문의 상태입니다.");
inquiry.Name = dto.Name.Trim();
inquiry.Phone = dto.Phone.Trim();
inquiry.Email = string.IsNullOrWhiteSpace(dto.Email) ? null : dto.Email.Trim();
inquiry.ServiceType = string.IsNullOrWhiteSpace(dto.ServiceType) ? "기타" : dto.ServiceType.Trim();
inquiry.Message = dto.Message.Trim();
inquiry.Status = InquiryStatusMapper.ToStorageValue(parsedStatus);
inquiry.AdminMemo = dto.AdminMemo;
await repository.UpdateAsync(inquiry, ct);
memoryCache.Remove(AdminDashboardService.CacheKey);
return inquiry;
}
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) => public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) =>
await repository.LinkClientAsync(inquiryId, clientId, ct); await repository.LinkClientAsync(inquiryId, clientId, ct);
@@ -5,9 +5,6 @@ using TaxBaik.Domain.Interfaces;
public class TaxFilingService(ITaxFilingRepository repository) public class TaxFilingService(ITaxFilingRepository repository)
{ {
public static readonly string[] FilingTypes =
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
public static readonly string[] Statuses = public static readonly string[] Statuses =
["pending", "filed", "overdue"]; ["pending", "filed", "overdue"];
+1
View File
@@ -17,6 +17,7 @@ public class BlogPost
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
// Navigation property (populated via LEFT JOIN, not stored in DB) // Navigation property (populated via LEFT JOIN, not stored in DB)
public string? CategoryName { get; set; } public string? CategoryName { get; set; }
@@ -12,8 +12,12 @@ public interface IBlogPostRepository
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default); Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync( Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default); int page, int pageSize, CancellationToken cancellationToken = default);
Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default);
Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default); Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default); Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default); Task DeleteAsync(int id, CancellationToken cancellationToken = default);
Task ArchiveAsync(int id, CancellationToken cancellationToken = default);
Task RestoreAsync(int id, CancellationToken cancellationToken = default);
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default); Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
} }
@@ -15,6 +15,7 @@ public interface IInquiryRepository
Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); Task<int> CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default);
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 UpdateAsync(Inquiry inquiry, 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); Task DeleteAsync(int id, CancellationToken cancellationToken = default);
} }
@@ -12,10 +12,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return await conn.QueryFirstOrDefaultAsync<BlogPost>( return await conn.QueryFirstOrDefaultAsync<BlogPost>(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id, @"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url, bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
FROM blog_posts bp FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.id = @Id", WHERE bp.id = @Id AND bp.deleted_at IS NULL",
new { Id = id }); new { Id = id });
} }
@@ -25,10 +25,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return await conn.QueryFirstOrDefaultAsync<BlogPost>( return await conn.QueryFirstOrDefaultAsync<BlogPost>(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id, @"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url, bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
FROM blog_posts bp FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.slug = @Slug AND bp.is_published = TRUE", WHERE bp.slug = @Slug AND bp.is_published = TRUE AND bp.deleted_at IS NULL",
new { Slug = slug }); new { Slug = slug });
} }
@@ -41,15 +41,15 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
using var reader = await conn.QueryMultipleAsync( using var reader = await conn.QueryMultipleAsync(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id, @"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url, bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
FROM blog_posts bp FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.is_published = TRUE AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId) WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND (@CategoryId::int IS NULL OR bp.category_id = @CategoryId)
ORDER BY bp.published_at DESC ORDER BY bp.published_at DESC
LIMIT @PageSize OFFSET @Offset; LIMIT @PageSize OFFSET @Offset;
SELECT COUNT(*) FROM blog_posts SELECT COUNT(*) FROM blog_posts
WHERE is_published = TRUE AND (@CategoryId::int IS NULL OR category_id = @CategoryId);", WHERE is_published = TRUE AND deleted_at IS NULL AND (@CategoryId::int IS NULL OR category_id = @CategoryId);",
new { CategoryId = categoryId, PageSize = pageSize, Offset = offset }); new { CategoryId = categoryId, PageSize = pageSize, Offset = offset });
var items = (await reader.ReadAsync<BlogPost>()).ToList(); var items = (await reader.ReadAsync<BlogPost>()).ToList();
@@ -64,10 +64,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return await conn.QueryAsync<BlogPost>( return await conn.QueryAsync<BlogPost>(
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags, @"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url, bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
FROM blog_posts bp FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.is_published = TRUE AND c.slug = @CategorySlug WHERE bp.is_published = TRUE AND bp.deleted_at IS NULL AND c.slug = @CategorySlug
ORDER BY bp.published_at DESC ORDER BY bp.published_at DESC
LIMIT @Limit", LIMIT @Limit",
new { CategorySlug = categorySlug, Limit = limit }); new { CategorySlug = categorySlug, Limit = limit });
@@ -82,6 +82,7 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
FROM blog_posts bp FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.deleted_at IS NULL
ORDER BY bp.created_at DESC"); ORDER BY bp.created_at DESC");
} }
@@ -94,13 +95,14 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
using var reader = await conn.QueryMultipleAsync( using var reader = await conn.QueryMultipleAsync(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id, @"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url, bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
FROM blog_posts bp FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.deleted_at IS NULL
ORDER BY bp.created_at DESC ORDER BY bp.created_at DESC
LIMIT @PageSize OFFSET @Offset; LIMIT @PageSize OFFSET @Offset;
SELECT COUNT(*) FROM blog_posts;", SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NULL;",
new { PageSize = pageSize, Offset = offset }); new { PageSize = pageSize, Offset = offset });
var items = (await reader.ReadAsync<BlogPost>()).ToList(); var items = (await reader.ReadAsync<BlogPost>()).ToList();
@@ -109,6 +111,30 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
return (items, total); return (items, total);
} }
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
int page, int pageSize, CancellationToken cancellationToken = default)
{
using var conn = Conn();
var offset = (page - 1) * pageSize;
using var reader = await conn.QueryMultipleAsync(
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
FROM blog_posts bp
LEFT JOIN categories c ON bp.category_id = c.id
WHERE bp.deleted_at IS NOT NULL
ORDER BY bp.deleted_at DESC
LIMIT @PageSize OFFSET @Offset;
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NOT NULL;",
new { PageSize = pageSize, Offset = offset });
var items = (await reader.ReadAsync<BlogPost>()).ToList();
var total = await reader.ReadFirstAsync<int>();
return (items, total);
}
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default) public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
@@ -130,19 +156,34 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt, tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt,
seo_title = @SeoTitle, seo_description = @SeoDescription, seo_title = @SeoTitle, seo_description = @SeoDescription,
thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW() thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW()
WHERE id = @Id", WHERE id = @Id AND deleted_at IS NULL",
post); post);
} }
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
await ArchiveAsync(id, cancellationToken);
}
public async Task ArchiveAsync(int id, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
await conn.ExecuteAsync("DELETE FROM blog_posts WHERE id = @Id", new { Id = id }); await conn.ExecuteAsync(
"UPDATE blog_posts SET deleted_at = NOW(), updated_at = NOW() WHERE id = @Id AND deleted_at IS NULL",
new { Id = id });
}
public async Task RestoreAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
"UPDATE blog_posts SET deleted_at = NULL, updated_at = NOW() WHERE id = @Id AND deleted_at IS NOT NULL",
new { Id = id });
} }
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id", new { Id = id }); await conn.ExecuteAsync("UPDATE blog_posts SET view_count = view_count + 1 WHERE id = @Id AND deleted_at IS NULL", new { Id = id });
} }
} }
@@ -112,6 +112,23 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
new { Id = id, AdminMemo = adminMemo }); new { Id = id, AdminMemo = adminMemo });
} }
public async Task UpdateAsync(Inquiry inquiry, CancellationToken cancellationToken = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
@"UPDATE inquiries
SET name = @Name,
phone = @Phone,
email = @Email,
service_type = @ServiceType,
message = @Message,
status = @Status,
admin_memo = @AdminMemo,
updated_at = NOW()
WHERE id = @Id",
inquiry);
}
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default) public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
+2
View File
@@ -29,6 +29,8 @@ builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
// 각 Browser API Client 등록 // 각 Browser API Client 등록
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>(); builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IBlogBrowserClient, BlogBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ICategoryBrowserClient, CategoryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl));
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>(); builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>(); builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>(); builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
@@ -0,0 +1,104 @@
namespace TaxBaik.Web.Services;
using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
public interface IBlogBrowserClient
{
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default);
Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default);
Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
Task<bool> RestoreAsync(int id, CancellationToken ct = default);
Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
}
public class BlogBrowserClient : IBlogBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<BlogBrowserClient> _logger;
private readonly ITokenStore _tokenStore;
public BlogBrowserClient(HttpClient http, ILogger<BlogBrowserClient> logger, ITokenStore tokenStore)
{
_http = http;
_logger = logger;
_tokenStore = tokenStore;
}
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
else
_http.DefaultRequestHeaders.Authorization = null;
}
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin?page={page}&pageSize={pageSize}", ct);
return result != null ? (result.Data, result.Total) : ([], 0);
}
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
{
EnsureAuthHeader();
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin/archived?page={page}&pageSize={pageSize}", ct);
return result != null ? (result.Data, result.Total) : ([], 0);
}
public async Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default)
{
EnsureAuthHeader();
return await _http.GetFromJsonAsync<BlogPostResponseDto>($"blog/{id}", ct);
}
public async Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("blog", dto, ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
public async Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"blog/{id}", dto, ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"blog/{id}", ct);
return response.IsSuccessStatusCode;
}
public async Task<bool> RestoreAsync(int id, CancellationToken ct = default)
{
EnsureAuthHeader();
var response = await _http.PostAsync($"blog/{id}/restore", null, ct);
return response.IsSuccessStatusCode;
}
public async Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
{
var result = await UpdateAsync(id, dto, ct);
return result != null;
}
private sealed class PagedResponse
{
public List<BlogPostResponseDto> Data { get; set; } = [];
public int Total { get; set; }
}
}
@@ -0,0 +1,35 @@
namespace TaxBaik.Web.Services;
using System.Net.Http.Json;
using TaxBaik.Domain.Entities;
public interface ICategoryBrowserClient
{
Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default);
}
public class CategoryBrowserClient : ICategoryBrowserClient
{
private readonly HttpClient _http;
private readonly ILogger<CategoryBrowserClient> _logger;
public CategoryBrowserClient(HttpClient http, ILogger<CategoryBrowserClient> logger)
{
_http = http;
_logger = logger;
}
public async Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default)
{
try
{
var result = await _http.GetFromJsonAsync<List<Category>>("category", cancellationToken: ct);
return result ?? [];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch categories");
throw;
}
}
}
@@ -2,6 +2,7 @@ namespace TaxBaik.Web.Services;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using TaxBaik.Application.DTOs;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
/// <summary> /// <summary>
@@ -15,7 +16,10 @@ public interface IInquiryBrowserClient
Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default); Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default); Task<bool> UpdateStatusAsync(int id, string status, CancellationToken ct = default);
Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default); Task<bool> UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default);
Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default);
Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default); Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default);
Task<Inquiry?> CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
} }
public class InquiryBrowserClient : IInquiryBrowserClient public class InquiryBrowserClient : IInquiryBrowserClient
@@ -116,6 +120,27 @@ public class InquiryBrowserClient : IInquiryBrowserClient
} }
} }
public async Task<Inquiry?> UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PutAsJsonAsync($"inquiry/{id}", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Inquiry>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update inquiry {InquiryId}", id);
throw;
}
}
public async Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default) public async Task<int> ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default)
{ {
try try
@@ -143,6 +168,42 @@ public class InquiryBrowserClient : IInquiryBrowserClient
} }
} }
public async Task<Inquiry?> CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.PostAsJsonAsync("inquiry", dto, cancellationToken: ct);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(ct);
return System.Text.Json.JsonSerializer.Deserialize<Inquiry>(
content,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to create inquiry");
throw;
}
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
var response = await _http.DeleteAsync($"inquiry/{id}", ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to delete inquiry {InquiryId}", id);
throw;
}
}
private class InquiryPagedResponse private class InquiryPagedResponse
{ {
public List<Inquiry> Data { get; set; } = []; public List<Inquiry> Data { get; set; } = [];
+1 -6
View File
@@ -11,11 +11,6 @@
<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&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 href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<!-- EasyMDE 마크다운 에디터 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css" />
<script src="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.js"></script>
<!-- Marked 라이브러리 (EasyMDE 미리보기용) -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script> <script>
document.documentElement.classList.toggle( document.documentElement.classList.toggle(
'admin-login-route', 'admin-login-route',
@@ -32,7 +27,7 @@
<span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span> <span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span>
</div> </div>
</div> </div>
<div id="blazor-loading" class="blazor-loading-overlay show"> <div id="blazor-loading" class="blazor-loading-overlay">
<div class="blazor-loading-spinner"> <div class="blazor-loading-spinner">
<div class="spinner"></div> <div class="spinner"></div>
<p>로드 중...</p> <p>로드 중...</p>
@@ -1,5 +1,6 @@
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Shared
<MudForm @ref="form"> <MudForm @ref="form">
<MudTextField @bind-Value="model.Name" Label="이름" <MudTextField @bind-Value="model.Name" Label="이름"
@@ -11,25 +12,12 @@
<MudTextField @bind-Value="model.Email" Label="이메일" <MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" /> Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudSelect @bind-Value="model.ServiceType" Label="문의 유형" <CommonCodeSelect @bind-Value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형" Class="mb-4" />
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("사업자세무")">사업자세무</MudSelectItem>
<MudSelectItem Value="@("부동산세금")">부동산세금</MudSelectItem>
<MudSelectItem Value="@("가족자산")">가족자산</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="model.Message" Label="문의 내용" <MudTextField @bind-Value="model.Message" Label="문의 내용"
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" /> Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
<MudSelect @bind-Value="model.Status" Label="상태" <CommonCodeSelect @bind-Value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" />
Variant="Variant.Outlined" Class="mb-4">
<MudSelectItem Value="@("new")">신규</MudSelectItem>
<MudSelectItem Value="@("consulting")">상담중</MudSelectItem>
<MudSelectItem Value="@("contracted")">계약완료</MudSelectItem>
<MudSelectItem Value="@("rejected")">거절</MudSelectItem>
<MudSelectItem Value="@("closed")">종결</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모" <MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" /> Variant="Variant.Outlined" Lines="3" Class="mb-4" />
@@ -57,8 +45,7 @@
private MudForm? form; private MudForm? form;
private InquiryFormModel model = new(); private InquiryFormModel model = new();
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{ {
if (InitialData != null) if (InitialData != null)
{ {
@@ -73,6 +60,7 @@
AdminMemo = InitialData.AdminMemo AdminMemo = InitialData.AdminMemo
}; };
} }
} }
private async Task HandleSubmit() private async Task HandleSubmit()
@@ -116,16 +116,6 @@
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 void OnLocationChanged(object? sender, LocationChangedEventArgs args) private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{ {
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading")); _ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading"));
@@ -3,6 +3,7 @@
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Web.Components.Admin.Shared
@inject IAnnouncementBrowserClient AnnouncementClient @inject IAnnouncementBrowserClient AnnouncementClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -37,13 +38,10 @@
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudSelect @bind-Value="model.DisplayType" <CommonCodeSelect @bind-Value="model.DisplayType"
Label="유형" Group="ANNOUNCEMENT_DISPLAY_TYPE"
Variant="Variant.Outlined"> Label="유형"
<MudSelectItem Value="@("info")">일반 (파란색)</MudSelectItem> Class="mb-0" />
<MudSelectItem Value="@("banner")">배너 (주황색) — 중요 이벤트</MudSelectItem>
<MudSelectItem Value="@("urgent")">긴급 (빨간색) — 마감 임박</MudSelectItem>
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
@@ -112,18 +112,14 @@
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)) a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>(); .OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) if (AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadAsync();
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
} }
} }
} }
@@ -1,11 +1,9 @@
@page "/admin/blog/create" @page "/admin/blog/create"
@attribute [Authorize] @attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Web.Components.Admin.Pages.Blog
@using TaxBaik.Domain.Interfaces @inject IBlogBrowserClient BlogClient
@inject BlogService BlogService @inject ICategoryBrowserClient CategoryClient
@inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -21,62 +19,16 @@
</section> </section>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form"> <BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" OnCancel="GoBack" />
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
</div>
</MudForm>
</MudPaper> </MudPaper>
@code { @code {
private MudForm? form; private IReadOnlyList<Domain.Entities.Category> categories = [];
private List<Domain.Entities.Category> categories = []; private BlogForm.BlogFormModel model = new();
private CreatePostModel model = new();
private EasyMDE.Editor? editor;
[Inject]
private IJSRuntime JS { get; set; } = null!;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
categories = (await CategoryRepository.GetAllAsync()).ToList(); categories = await CategoryClient.GetAllAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
} }
private void GoBack() private void GoBack()
@@ -86,25 +38,9 @@
private async Task SavePost() private async Task SavePost()
{ {
if (form == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
return;
try try
{ {
await BlogService.CreateAsync(new CreateBlogPostDto var result = await BlogClient.CreateAsync(new CreateBlogPostDto
{ {
Title = model.Title, Title = model.Title,
Content = model.Content, Content = model.Content,
@@ -115,6 +51,12 @@
IsPublished = model.IsPublished IsPublished = model.IsPublished
}); });
if (result == null)
{
Snackbar.Add("포스트 저장에 실패했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success); Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog"); Navigation.NavigateTo("/taxbaik/admin/blog");
} }
@@ -123,45 +65,4 @@
Snackbar.Add(ex.Message, Severity.Error); Snackbar.Add(ex.Message, Severity.Error);
} }
} }
private class CreatePostModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
} }
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -1,11 +1,9 @@
@page "/admin/blog/{id:int}/edit" @page "/admin/blog/{id:int}/edit"
@attribute [Authorize] @attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services @using TaxBaik.Web.Components.Admin.Pages.Blog
@using TaxBaik.Domain.Interfaces @inject IBlogBrowserClient BlogClient
@inject BlogService BlogService @inject ICategoryBrowserClient CategoryClient
@inject ICategoryRepository CategoryRepository
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@@ -32,42 +30,10 @@ else if (post == null)
else else
{ {
<MudPaper Class="pa-4 mt-4" Elevation="1"> <MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form"> <BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" />
<MudTextField @bind-Value="model.Title" Label="제목 *" <div class="mt-4">
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" /> <MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeletePost">삭제</MudButton>
</div>
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error"
@onclick="DeletePost">삭제</MudButton>
</div>
</MudForm>
</MudPaper> </MudPaper>
} }
@@ -75,23 +41,19 @@ else
[Parameter] [Parameter]
public int Id { get; set; } public int Id { get; set; }
[Inject] private TaxBaik.Application.DTOs.BlogPostResponseDto? post;
private IJSRuntime JS { get; set; } = null!; private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
private MudForm? form;
private Domain.Entities.BlogPost? post;
private List<Domain.Entities.Category> categories = [];
private EditPostModel model = new();
private bool isLoading = true; private bool isLoading = true;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
{ {
post = await BlogService.GetByIdAsync(Id); post = await BlogClient.GetByIdAsync(Id);
if (post != null) if (post != null)
{ {
categories = (await CategoryRepository.GetAllAsync()).ToList(); categories = await CategoryClient.GetAllAsync();
MapPostToModel(post); MapPostToModel(post);
} }
} }
@@ -105,15 +67,7 @@ else
} }
} }
protected override async Task OnAfterRenderAsync(bool firstRender) private void MapPostToModel(TaxBaik.Application.DTOs.BlogPostResponseDto post)
{
if (firstRender && post != null)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
}
private void MapPostToModel(Domain.Entities.BlogPost post)
{ {
model.Title = post.Title; model.Title = post.Title;
model.Content = post.Content; model.Content = post.Content;
@@ -131,25 +85,12 @@ else
private async Task SavePost() private async Task SavePost()
{ {
if (form == null || post == null) if (post == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
return; return;
try try
{ {
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto var result = await BlogClient.UpdateAsync(post.Id, new CreateBlogPostDto
{ {
Title = model.Title, Title = model.Title,
Content = model.Content, Content = model.Content,
@@ -160,6 +101,12 @@ else
IsPublished = model.IsPublished IsPublished = model.IsPublished
}); });
if (result == null)
{
Snackbar.Add("저장 실패: 포스트를 저장하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success); Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog"); Navigation.NavigateTo("/taxbaik/admin/blog");
} }
@@ -188,7 +135,12 @@ else
try try
{ {
await BlogService.DeleteAsync(post.Id); var deleted = await BlogClient.DeleteAsync(post.Id);
if (!deleted)
{
Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog"); Navigation.NavigateTo("/taxbaik/admin/blog");
} }
@@ -197,45 +149,4 @@ else
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
} }
} }
private class EditPostModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
} }
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -0,0 +1,80 @@
@using TaxBaik.Application.DTOs
@using TaxBaik.Domain.Entities
<MudForm @ref="form">
<MudTextField @bind-Value="Model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in Categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
Class="mb-4" />
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="Model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">@SubmitText</MudButton>
@if (OnCancel.HasDelegate)
{
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
}
</div>
</MudForm>
@code {
[Parameter, EditorRequired]
public BlogFormModel Model { get; set; } = new();
[Parameter]
public IReadOnlyList<Category> Categories { get; set; } = [];
[Parameter]
public string SubmitText { get; set; } = "저장";
[Parameter]
public EventCallback OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
private MudForm? form;
private async Task HandleSubmit()
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync();
}
public class BlogFormModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
@@ -7,6 +7,14 @@
<AdminPageHeader Title="블로그 관리" Eyebrow="Content" Subtitle="검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다."> <AdminPageHeader Title="블로그 관리" Eyebrow="Content" Subtitle="검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.">
<ChildContent> <ChildContent>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore"
OnClick="ToggleArchiveView">
@(showArchived ? "전체 글 보기" : "숨김 글 보기")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Refresh"
OnClick="Reload">
새로고침
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote" <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote"
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton> Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
</ChildContent> </ChildContent>
@@ -39,8 +47,16 @@
<CellTemplate Context="cell"> <CellTemplate Context="cell">
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary" <MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton> Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error" @if (showArchived)
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton> {
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Success"
@onclick="@(async () => await RestorePost(cell.Item.Id))">복원</MudButton>
}
else
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
}
</CellTemplate> </CellTemplate>
</TemplateColumn> </TemplateColumn>
</Columns> </Columns>
@@ -61,6 +77,7 @@
private int currentPage = 1; private int currentPage = 1;
private int totalPages = 1; private int totalPages = 1;
private int totalPosts = 0; private int totalPosts = 0;
private bool showArchived;
private const int PageSize = 20; private const int PageSize = 20;
private IEnumerable<TaxBaik.Application.DTOs.BlogPostResponseDto> FilteredPosts => posts private IEnumerable<TaxBaik.Application.DTOs.BlogPostResponseDto> FilteredPosts => posts
@@ -85,7 +102,9 @@
isLoading = true; isLoading = true;
try try
{ {
var result = await BlogClient.GetAdminPagedAsync(currentPage, PageSize); var result = showArchived
? await BlogClient.GetArchivedPagedAsync(currentPage, PageSize)
: await BlogClient.GetAdminPagedAsync(currentPage, PageSize);
posts = result.Items.ToList(); posts = result.Items.ToList();
totalPosts = result.Total; totalPosts = result.Total;
totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize)); totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize));
@@ -155,4 +174,26 @@
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
await LoadPosts(); await LoadPosts();
} }
private async Task RestorePost(int postId)
{
var restored = await BlogClient.RestoreAsync(postId);
if (!restored)
{
Snackbar.Add("포스트 복원에 실패했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 복원되었습니다.", Severity.Success);
await LoadPosts();
}
private async Task ToggleArchiveView()
{
showArchived = !showArchived;
currentPage = 1;
await LoadPosts();
}
private async Task Reload() => await LoadPosts();
} }
@@ -1,8 +1,10 @@
@page "/admin/clients/{ClientId:int}" @page "/admin/clients/{ClientId:int}"
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.Services @using TaxBaik.Web.Services
@inject ClientService ClientService @using TaxBaik.Web.Services.AdminClients
@inject ConsultationService ConsultationService @using TaxBaik.Web.Components.Admin.Shared
@inject IClientBrowserClient ClientClient
@inject IConsultingActivityBrowserClient ConsultingClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -102,12 +104,7 @@
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" /> <MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야"> <CommonCodeSelect @bind-Value="newServiceType" Group="CONSULTING_ACTIVITY_TYPE" Label="서비스 분야" Placeholder="선택" Clearable="true" />
@foreach (var t in ClientService.ServiceTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *" <MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
@@ -116,7 +113,7 @@
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newResult" Label="결과"> <MudSelect T="string" @bind-Value="newResult" Label="결과">
<MudSelectItem Value="@("")">-</MudSelectItem> <MudSelectItem Value="@("")">-</MudSelectItem>
@foreach (var r in ConsultationService.Results) @foreach (var r in results)
{ {
<MudSelectItem Value="@r">@r</MudSelectItem> <MudSelectItem Value="@r">@r</MudSelectItem>
} }
@@ -182,6 +179,7 @@
private Domain.Entities.Client? client; private Domain.Entities.Client? client;
private List<Domain.Entities.Consultation> consultations = []; private List<Domain.Entities.Consultation> consultations = [];
private static readonly string[] results = ["", "상담완료", "추가자료 요청", "견적발송", "계약전환", "보류"];
private bool showAddForm; private bool showAddForm;
private DateTime? newDate = DateTime.Today; private DateTime? newDate = DateTime.Today;
@@ -197,8 +195,19 @@
private async Task LoadAll() private async Task LoadAll()
{ {
client = await ClientService.GetByIdAsync(ClientId); client = await ClientClient.GetByIdAsync(ClientId);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); consultations = (await ConsultingClient.GetByClientIdAsync(ClientId))
.Select(c => new Domain.Entities.Consultation
{
Id = c.Id,
ClientId = c.ClientId,
ConsultationDate = c.ActivityDate,
ServiceType = c.ActivityType,
Summary = c.Description,
Result = null,
Fee = null
})
.ToList();
} }
private void OpenAddConsultation() private void OpenAddConsultation()
@@ -215,30 +224,35 @@
{ {
try try
{ {
var c = new Domain.Entities.Consultation var newId = await ConsultingClient.CreateAsync(
{ ClientId,
ClientId = ClientId, string.IsNullOrWhiteSpace(newServiceType) ? "기타" : newServiceType,
ConsultationDate = newDate?.ToUniversalTime() ?? DateTime.UtcNow, newDate?.ToUniversalTime() ?? DateTime.UtcNow,
ServiceType = string.IsNullOrWhiteSpace(newServiceType) ? null : newServiceType, newSummary,
Summary = newSummary, null,
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult, null);
Fee = newFee
}; if (newId <= 0)
await ConsultationService.CreateAsync(c); throw new Exception("상담 생성 실패");
showAddForm = false; showAddForm = false;
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); await LoadAll();
Snackbar.Add("상담이 추가되었습니다.", Severity.Success); Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
Snackbar.Add(ex.Message, Severity.Error); Snackbar.Add(ex.Message, Severity.Error);
} }
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
} }
private async Task DeleteConsultation(int id) private async Task DeleteConsultation(int id)
{ {
await ConsultationService.DeleteAsync(id); await ConsultingClient.DeleteAsync(id);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); await LoadAll();
Snackbar.Add("삭제되었습니다.", Severity.Info); Snackbar.Add("삭제되었습니다.", Severity.Info);
} }
} }
@@ -4,6 +4,7 @@
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -54,20 +55,10 @@
<MudDivider /> <MudDivider />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true"> <CommonCodeSelect @bind-Value="dto.ServiceType" Group="CLIENT_SERVICE_TYPE" Label="서비스 유형" Clearable="true" />
@foreach (var t in ClientService.ServiceTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true"> <CommonCodeSelect @bind-Value="dto.TaxType" Group="CLIENT_TAX_TYPE" Label="세금 유형" Clearable="true" />
@foreach (var t in ClientService.TaxTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
</MudItem> </MudItem>
@* 관리 정보 *@ @* 관리 정보 *@
@@ -76,18 +67,10 @@
<MudDivider /> <MudDivider />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true"> <CommonCodeSelect @bind-Value="dto.Status" Group="CLIENT_STATUS" Label="상태 *" Required="true" />
<MudSelectItem Value="@("active")">활성</MudSelectItem>
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true"> <CommonCodeSelect @bind-Value="dto.Source" Group="CLIENT_SOURCE" Label="유입 경로" Clearable="true" />
@foreach (var s in ClientService.Sources)
{
<MudSelectItem Value="@s">@s</MudSelectItem>
}
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudTextField @bind-Value="dto.Memo" Label="메모" <MudTextField @bind-Value="dto.Memo" Label="메모"
@@ -119,7 +102,6 @@
private bool isValid; private bool isValid;
private bool isLoading = true; private bool isLoading = true;
private bool isSaving; private bool isSaving;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (Id.HasValue) if (Id.HasValue)
@@ -9,18 +9,15 @@
<PageTitle>고객 관리</PageTitle> <PageTitle>고객 관리</PageTitle>
<section class="admin-page-hero"> <AdminPageHeader Title="고객 관리" Eyebrow="CRM" Subtitle="고객 카드를 등록하고 상담 이력을 관리합니다.">
<div> <ChildContent>
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText> <MudButton Variant="Variant.Filled" Color="Color.Primary"
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText> StartIcon="@Icons.Material.Filled.PersonAdd"
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText> Href="/taxbaik/admin/clients/create">
</div> 고객 등록
<MudButton Variant="Variant.Filled" Color="Color.Primary" </MudButton>
StartIcon="@Icons.Material.Filled.PersonAdd" </ChildContent>
Href="/taxbaik/admin/clients/create"> </AdminPageHeader>
고객 등록
</MudButton>
</section>
@* 검색/필터 바 *@ @* 검색/필터 바 *@
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0"> <MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
@@ -31,11 +28,7 @@
Immediate="false" OnKeyUp="@OnSearchKeyUp" /> Immediate="false" OnKeyUp="@OnSearchKeyUp" />
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="3">
<MudSelect @bind-Value="statusFilter" Label="상태" T="string"> <CommonCodeSelect @bind-Value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
<MudSelectItem Value="@("")">전체</MudSelectItem>
<MudSelectItem Value="@("active")">활성</MudSelectItem>
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="2" Class="d-flex align-center"> <MudItem xs="12" md="2" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton> <MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
@@ -53,10 +46,7 @@
} }
else if (!clients.Any()) else if (!clients.Any())
{ {
<div class="pa-6 text-center"> <AdminEmptyState Icon="@Icons.Material.Filled.PeopleAlt" Message="등록된 고객이 없습니다." />
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
</div>
} }
else else
{ {
@@ -140,18 +130,14 @@
private int totalPages; private int totalPages;
private const int PageSize = 20; private const int PageSize = 20;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) if (AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadAsync();
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
} }
} }
} }
@@ -1,5 +1,6 @@
@page "/admin/consulting-activities" @page "/admin/consulting-activities"
@using TaxBaik.Web.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared
@inject IConsultingActivityBrowserClient ActivityClient @inject IConsultingActivityBrowserClient ActivityClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -103,14 +104,7 @@
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem> <MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true"> <CommonCodeSelect @bind-Value="activityForm.ActivityType" Group="CONSULTING_ACTIVITY_TYPE" Label="활동 유형" Class="mb-4" Required="true" />
<MudSelectItem Value="@("방문 상담")">방문 상담</MudSelectItem>
<MudSelectItem Value="@("전화 상담")">전화 상담</MudSelectItem>
<MudSelectItem Value="@("세무조사 대응 미팅")">세무조사 대응 미팅</MudSelectItem>
<MudSelectItem Value="@("카카오톡 상담")">카카오톡 상담</MudSelectItem>
<MudSelectItem Value="@("이메일 자료 접수")">이메일 자료 접수</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" /> <MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" /> <MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
@@ -134,18 +128,14 @@
private ConsultingActivity? editingActivity; private ConsultingActivity? editingActivity;
private ConsultingActivityForm activityForm = new(); private ConsultingActivityForm activityForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) if (AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
} }
} }
} }
@@ -43,12 +43,7 @@
Placeholder="방문자에게 보여질 답변을 입력하세요." /> Placeholder="방문자에게 보여질 답변을 입력하세요." />
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudSelect @bind-Value="faq.Category" Label="카테고리" T="string" Clearable="true"> <CommonCodeSelect @bind-Value="faq.Category" Group="FAQ_CATEGORY" Label="카테고리" Clearable="true" Placeholder="전체" />
@foreach (var cat in FaqService.Categories)
{
<MudSelectItem Value="@cat">@cat</MudSelectItem>
}
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="3">
<MudNumericField @bind-Value="faq.SortOrder" <MudNumericField @bind-Value="faq.SortOrder"
@@ -116,18 +116,14 @@
(f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))) (f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)))
.OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>(); .OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) if (AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadAsync();
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadAsync();
StateHasChanged();
}
} }
} }
} }
@@ -1,9 +1,8 @@
@page "/admin/inquiries/create" @page "/admin/inquiries/create"
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService @inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -32,13 +31,21 @@
{ {
try try
{ {
await InquiryService.SubmitAsync( var result = await InquiryClient.CreateAsync(new SubmitInquiryDto
model.Name, {
model.Phone, Name = model.Name,
model.ServiceType, Phone = model.Phone,
model.Message, Email = model.Email,
model.Email, ServiceType = model.ServiceType,
ipAddress: "admin-registered"); Message = model.Message,
SuppressNotification = true
});
if (result == null)
{
Snackbar.Add("문의가 등록되지 않았습니다.", Severity.Error);
return;
}
Snackbar.Add("문의가 등록되었습니다.", Severity.Success); Snackbar.Add("문의가 등록되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries"); Navigation.NavigateTo("/taxbaik/admin/inquiries");
@@ -1,9 +1,8 @@
@page "/admin/inquiries/{id:int}/edit" @page "/admin/inquiries/{id:int}/edit"
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.DTOs @using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Forms @using TaxBaik.Web.Components.Admin.Forms
@inject InquiryService InquiryService @inject IInquiryBrowserClient InquiryClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@@ -52,7 +51,7 @@ else
{ {
try try
{ {
inquiry = await InquiryService.GetByIdAsync(Id); inquiry = await InquiryClient.GetByIdAsync(Id);
if (inquiry != null) if (inquiry != null)
{ {
formModel = new InquiryForm.InquiryFormModel formModel = new InquiryForm.InquiryFormModel
@@ -89,19 +88,34 @@ else
try try
{ {
inquiry.Name = model.Name; var updated = await InquiryClient.UpdateAsync(inquiry.Id, new UpdateInquiryDto
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); Name = model.Name,
Phone = model.Phone,
Email = model.Email,
ServiceType = model.ServiceType,
Message = model.Message,
Status = model.Status,
AdminMemo = model.AdminMemo
});
if (updated == null)
{
Snackbar.Add("문의 수정에 실패했습니다.", Severity.Error);
return;
} }
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, model.AdminMemo); inquiry = updated;
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
};
Snackbar.Add("문의가 수정되었습니다.", Severity.Success); Snackbar.Add("문의가 수정되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries"); Navigation.NavigateTo("/taxbaik/admin/inquiries");
@@ -131,7 +145,12 @@ else
try try
{ {
await InquiryService.DeleteAsync(inquiry.Id); var deleted = await InquiryClient.DeleteAsync(inquiry.Id);
if (!deleted)
{
Snackbar.Add("문의 삭제에 실패했습니다.", Severity.Error);
return;
}
Snackbar.Add("문의가 삭제되었습니다.", Severity.Success); Snackbar.Add("문의가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/inquiries"); Navigation.NavigateTo("/taxbaik/admin/inquiries");
} }
@@ -1,5 +1,6 @@
@page "/admin/revenue-trackings" @page "/admin/revenue-trackings"
@using TaxBaik.Web.Services.AdminClients @using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Web.Components.Admin.Shared
@inject IRevenueTrackingBrowserClient RevenueClient @inject IRevenueTrackingBrowserClient RevenueClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -102,13 +103,7 @@
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" /> <MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4"> <CommonCodeSelect @bind-Value="revenueForm.ServiceType" Group="REVENUE_SERVICE_TYPE" Label="서비스 유형" Class="mb-4" />
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem>
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem>
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem>
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem>
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" /> <MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm> </MudForm>
</DialogContent> </DialogContent>
@@ -129,18 +124,14 @@
private bool isDialogOpen; private bool isDialogOpen;
private RevenueForm revenueForm = new(); private RevenueForm revenueForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) if (AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
StateHasChanged();
}
} }
} }
} }
@@ -35,7 +35,7 @@
<MudTextField @bind-Value="email" Label="이메일" <MudTextField @bind-Value="email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="kakaoUrl" Label="카카오 채널 URL" <MudTextField @bind-Value="kakaoUrl" Label="카카오채널 URL"
Variant="Variant.Outlined" Class="mb-4" /> Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="instagramUrl" Label="인스타그램" <MudTextField @bind-Value="instagramUrl" Label="인스타그램"
@@ -178,19 +178,15 @@ else
private TaxFilingSchedule? selectedSchedule; private TaxFilingSchedule? selectedSchedule;
private TaxFilingScheduleForm scheduleForm = new(); private TaxFilingScheduleForm scheduleForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnInitializedAsync()
{ {
if (firstRender) if (AuthStateTask != null)
{ {
if (AuthStateTask != null) var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{ {
var authState = await AuthStateTask; await LoadData();
if (authState.User.Identity?.IsAuthenticated == true) PrepareCreate();
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
} }
} }
} }
@@ -1,5 +1,6 @@
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared
@inject ITaxFilingBrowserClient FilingClient @inject ITaxFilingBrowserClient FilingClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -21,10 +22,10 @@ else
<RowTemplate> <RowTemplate>
<MudTd>@context.ClientName</MudTd> <MudTd>@context.ClientName</MudTd>
<MudTd>@context.FilingType</MudTd> <MudTd>@context.FilingType</MudTd>
<MudTd>@context.DueDate.ToString("yyyy-MM-dd")</MudTd> <MudTd>@BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(context.DueDate)).ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd")</MudTd>
<MudTd> <MudTd>
@{ @{
var dday = (context.DueDate.Date - DateTime.Today).Days; var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(context.DueDate));
} }
@if (dday < 0) @if (dday < 0)
{ {
@@ -2,6 +2,7 @@
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Web.Components.Admin.Shared
@inject ITaxFilingBrowserClient FilingClient @inject ITaxFilingBrowserClient FilingClient
@inject IClientBrowserClient ClientClient @inject IClientBrowserClient ClientClient
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -34,12 +35,7 @@
Variant="Variant.Outlined" /> Variant="Variant.Outlined" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined"> <CommonCodeSelect @bind-Value="newFilingType" Group="FILING_TYPE" Label="신고 유형 *" />
@foreach (var t in TaxFilingService.FilingTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" /> <MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
@@ -82,6 +78,10 @@
protected override async Task OnInitializedAsync() => await Reload(); protected override async Task OnInitializedAsync() => await Reload();
protected override async Task OnParametersSetAsync()
{
}
private async Task Reload() private async Task Reload()
{ {
try try
@@ -0,0 +1,12 @@
<div class="pa-6 text-center">
<MudIcon Icon="@Icon" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">@Message</MudText>
</div>
@code {
[Parameter, EditorRequired]
public string Icon { get; set; } = Icons.Material.Filled.Info;
[Parameter, EditorRequired]
public string Message { get; set; } = "";
}
@@ -0,0 +1,31 @@
<section class="admin-page-hero">
<div>
@if (!string.IsNullOrWhiteSpace(Eyebrow))
{
<MudText Typo="Typo.caption" Class="admin-eyebrow">@Eyebrow</MudText>
}
<MudText Typo="Typo.h4" Class="admin-page-title">@Title</MudText>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<MudText Typo="Typo.body2" Class="admin-page-subtitle">@Subtitle</MudText>
}
</div>
@if (ChildContent is not null)
{
<div>@ChildContent</div>
}
</section>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = "";
[Parameter]
public string? Eyebrow { get; set; }
[Parameter]
public string? Subtitle { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -24,7 +24,18 @@ public static class BusinessDayCalculator
new(new DateOnly(2026, 9, 24), new DateOnly(2026, 9, 26)), new(new DateOnly(2026, 9, 24), new DateOnly(2026, 9, 26)),
new(new DateOnly(2026, 10, 3), new DateOnly(2026, 10, 5)), new(new DateOnly(2026, 10, 3), new DateOnly(2026, 10, 5)),
new(new DateOnly(2026, 10, 9), new DateOnly(2026, 10, 9)), new(new DateOnly(2026, 10, 9), new DateOnly(2026, 10, 9)),
new(new DateOnly(2026, 12, 25), new DateOnly(2026, 12, 25)) new(new DateOnly(2026, 12, 25), new DateOnly(2026, 12, 25)),
new(new DateOnly(2027, 1, 1), new DateOnly(2027, 1, 1)),
new(new DateOnly(2027, 2, 6), new DateOnly(2027, 2, 9)),
new(new DateOnly(2027, 3, 1), new DateOnly(2027, 3, 2)),
new(new DateOnly(2027, 5, 5), new DateOnly(2027, 5, 5)),
new(new DateOnly(2027, 5, 13), new DateOnly(2027, 5, 13)),
new(new DateOnly(2027, 6, 6), new DateOnly(2027, 6, 6)),
new(new DateOnly(2027, 8, 15), new DateOnly(2027, 8, 16)),
new(new DateOnly(2027, 9, 14), new DateOnly(2027, 9, 16)),
new(new DateOnly(2027, 10, 3), new DateOnly(2027, 10, 4)),
new(new DateOnly(2027, 10, 9), new DateOnly(2027, 10, 11)),
new(new DateOnly(2027, 12, 25), new DateOnly(2027, 12, 26))
}; };
private static readonly HashSet<DateOnly> HolidayDates = BuildHolidayDates(); private static readonly HashSet<DateOnly> HolidayDates = BuildHolidayDates();
@@ -63,26 +74,6 @@ public static class BusinessDayCalculator
} }
} }
// 주말과 연속 공휴일 뒤에 붙는 대체휴일을 다음 영업일로 자동 확장한다.
foreach (var window in HolidayWindows)
{
foreach (var date in window.Dates())
{
if (date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday)
{
continue;
}
var substitute = date.AddDays(1);
while (substitute.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(substitute))
{
substitute = substitute.AddDays(1);
}
holidays.Add(substitute);
}
}
return holidays; return holidays;
} }
} }
@@ -8,6 +8,9 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor @using MudBlazor
@using TaxBaik.Application.DTOs
@using TaxBaik.Web.Services @using TaxBaik.Web.Services
@using TaxBaik.Web.Services.AdminClients
@using TaxBaik.Domain.Entities @using TaxBaik.Domain.Entities
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@using TaxBaik.Web.Components.Admin.Shared
+27 -1
View File
@@ -32,6 +32,16 @@ public class BlogController : ControllerBase
return Ok(post); return Ok(post);
} }
[HttpGet("admin/{id:int}")]
[Authorize]
public async Task<IActionResult> GetById(int id)
{
var post = await _blogService.GetByIdAsync(id);
if (post == null)
return NotFound(new ProblemDetails { Title = "포스트를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(post);
}
[HttpGet("admin/all")] [HttpGet("admin/all")]
[Authorize] [Authorize]
public async Task<IActionResult> GetAll() public async Task<IActionResult> GetAll()
@@ -48,6 +58,14 @@ public class BlogController : ControllerBase
return Ok(new { data = items, total, page, pageSize }); return Ok(new { data = items, total, page, pageSize });
} }
[HttpGet("admin/archived")]
[Authorize]
public async Task<IActionResult> GetArchivedPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var (items, total) = await _blogService.GetArchivedPagedAsync(page, pageSize);
return Ok(new { data = items, total, page, pageSize });
}
[HttpPost] [HttpPost]
[Authorize] [Authorize]
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto) public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
@@ -84,7 +102,15 @@ public class BlogController : ControllerBase
[Authorize] [Authorize]
public async Task<IActionResult> Delete(int id) public async Task<IActionResult> Delete(int id)
{ {
await _blogService.DeleteAsync(id); await _blogService.ArchiveAsync(id);
return NoContent();
}
[HttpPost("{id}/restore")]
[Authorize]
public async Task<IActionResult> Restore(int id)
{
await _blogService.RestoreAsync(id);
return NoContent(); return NoContent();
} }
} }
+19 -11
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Security.Claims; using System.Security.Claims;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers; namespace TaxBaik.Web.Controllers;
@@ -19,7 +20,7 @@ public class InquiryController : ControllerBase
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Submit([FromBody] SubmitInquiryRequest request) public async Task<IActionResult> Submit([FromBody] SubmitInquiryDto request)
{ {
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone)) if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone))
return BadRequest(new ProblemDetails { Title = "이름과 전화번호를 입력하세요.", Status = StatusCodes.Status400BadRequest }); return BadRequest(new ProblemDetails { Title = "이름과 전화번호를 입력하세요.", Status = StatusCodes.Status400BadRequest });
@@ -99,6 +100,23 @@ public class InquiryController : ControllerBase
} }
} }
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] UpdateInquiryDto request)
{
try
{
var result = await _inquiryService.UpdateAsync(id, request);
if (result == null)
return NotFound(new ProblemDetails { Title = "문의를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(result);
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
}
[HttpPost("{id}/convert-to-client")] [HttpPost("{id}/convert-to-client")]
[Authorize] [Authorize]
public async Task<IActionResult> ConvertToClient(int id, [FromBody] ConvertToClientRequest request) public async Task<IActionResult> ConvertToClient(int id, [FromBody] ConvertToClientRequest request)
@@ -129,16 +147,6 @@ public class InquiryController : ControllerBase
} }
} }
public class SubmitInquiryRequest
{
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string? Email { get; set; }
public string ServiceType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public bool SuppressNotification { get; set; }
}
public class UpdateStatusRequest public class UpdateStatusRequest
{ {
public string Status { get; set; } = string.Empty; public string Status { get; set; } = string.Empty;
+1 -1
View File
@@ -142,7 +142,7 @@
<p class="text-muted mb-5">무료 상담으로 현재 상황을 진단하고 맞춤형 절세 전략을 받아보세요.</p> <p class="text-muted mb-5">무료 상담으로 현재 상황을 진단하고 맞춤형 절세 전략을 받아보세요.</p>
<div class="d-flex gap-3 justify-content-center flex-wrap"> <div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a> <a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 신청하기</a>
<a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-warning btn-lg">카카오로 문의</a> <a href="http://pf.kakao.com/_xoxchTX" target="_blank" class="btn btn-warning btn-lg">카카오채널로 문의</a>
</div> </div>
</section> </section>
+2 -2
View File
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities; using TaxBaik.Domain.Entities;
using Markdig; using System.Net;
namespace TaxBaik.Web.Pages.Blog; namespace TaxBaik.Web.Pages.Blog;
@@ -22,7 +22,7 @@ public class BlogPostModel : PageModel
Post = await _blogService.GetBySlugAsync(slug); Post = await _blogService.GetBySlugAsync(slug);
if (Post != null) if (Post != null)
{ {
HtmlContent = Markdown.ToHtml(Post.Content ?? ""); HtmlContent = WebUtility.HtmlEncode(Post.Content ?? "").Replace("\r\n", "<br />").Replace("\n", "<br />");
_ = _blogService.IncrementViewCountAsync(Post.Id); _ = _blogService.IncrementViewCountAsync(Post.Id);
} }
} }
+1 -1
View File
@@ -47,7 +47,7 @@
<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> </select>
</div> </div>
+23 -23
View File
@@ -4,8 +4,8 @@
var season = Model.CurrentSeason; var season = Model.CurrentSeason;
ViewData["Title"] = season != null ViewData["Title"] = season != null
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요" ? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담"; : "백원숙 세무회계 | 사업자·부동산·증여상속 세무 상담";
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여, 종합소득세 전문 상담. 온라인 맞춤 상담 제공."; ViewData["Description"] = "사업자 기장, 부동산 양도세·증여상속, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
} }
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@ @* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
@@ -50,7 +50,7 @@
</a> </a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg" <a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;"> onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의 💬 카카오채널 문의
</a> </a>
</div> </div>
@if (season.DaysUntilDeadline <= 7) @if (season.DaysUntilDeadline <= 7)
@@ -91,7 +91,7 @@ else
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a> <a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg" <a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
onclick="openKakao()" style="border-color: white; color: white;"> onclick="openKakao()" style="border-color: white; color: white;">
💬 카카오 채널 문의 💬 카카오채널 문의
</a> </a>
</div> </div>
</div> </div>
@@ -176,7 +176,7 @@ else
<div class="service-icon">👨‍👩‍👧‍👦</div> <div class="service-icon">👨‍👩‍👧‍👦</div>
<div class="card-body pt-0"> <div class="card-body pt-0">
<h3 class="card-title">가족자산 관리</h3> <h3 class="card-title">가족자산 관리</h3>
<p class="text-muted small">증여·상속 사전 계획부터 대표자 리스크 관리까지 가족 자산을 지키는 전략.</p> <p class="text-muted small">증여상속 사전 계획부터 대표자 리스크 관리까지 - 가족 자산을 지키는 전략.</p>
<a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a> <a href="/taxbaik/services#family-asset" class="btn btn-sm btn-outline-primary mt-3">자세히 보기</a>
</div> </div>
</div> </div>
@@ -263,6 +263,15 @@ else
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a> <a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
</div> </div>
} }
else
{
<div class="text-center py-5">
<div class="mb-3" style="font-size: 3rem;">📝</div>
<h3 class="h5 mb-2">현재 표시할 블로그 글이 없습니다.</h3>
<p class="text-muted mb-4">최신 세무 정보는 블로그에서 확인할 수 있습니다.</p>
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">블로그 바로가기</a>
</div>
}
</div> </div>
</section> </section>
@@ -321,24 +330,15 @@ else
<p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p> <p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p>
</div> </div>
<div class="accordion faq-accordion" id="faqAccordion"> <div class="faq-accordion">
@for (int i = 0; i < Model.ActiveFaqs.Count; i++) @foreach (var faqItem in Model.ActiveFaqs)
{ {
var faqItem = Model.ActiveFaqs[i]; <details class="faq-item">
var collapseId = $"faq-{faqItem.Id}"; <summary class="faq-question">@faqItem.Question</summary>
<div class="accordion-item faq-item"> <div class="faq-answer">
<h3 class="accordion-header"> @faqItem.Answer
<button class="accordion-button collapsed faq-question" type="button"
data-bs-toggle="collapse" data-bs-target="#@collapseId">
@faqItem.Question
</button>
</h3>
<div id="@collapseId" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body faq-answer">
@faqItem.Answer
</div>
</div> </div>
</div> </details>
} }
</div> </div>
@@ -362,7 +362,7 @@ else
</p> </p>
<div class="d-flex gap-3 justify-content-center flex-wrap"> <div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a> <a href="/taxbaik/contact" class="btn btn-warning btn-lg">⏰ @season.CtaText</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a> <a href="https://pf.kakao.com/_xoxchTX" target="_blank" rel="noopener noreferrer" class="btn btn-light btn-lg">카카오채널로 문의</a>
</div> </div>
} }
else else
@@ -374,7 +374,7 @@ else
</p> </p>
<div class="d-flex gap-3 justify-content-center flex-wrap"> <div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a> <a href="/taxbaik/contact" class="btn btn-warning btn-lg">상담 신청하기</a>
<a href="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오로 문의</a> <a href="https://pf.kakao.com/_xoxchTX" target="_blank" rel="noopener noreferrer" class="btn btn-light btn-lg">카카오채널로 문의</a>
</div> </div>
} }
</div> </div>
+8 -7
View File
@@ -682,21 +682,22 @@ img {
border-radius: var(--radius-md) !important; border-radius: var(--radius-md) !important;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
overflow: hidden; overflow: hidden;
background: white;
} }
.faq-question { .faq-question {
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text);
background: white;
font-size: 1rem; font-size: 1rem;
padding: 1.1rem 1.5rem; padding: 1.1rem 1.5rem;
cursor: pointer;
list-style: none;
} }
.faq-question:not(.collapsed) { .faq-question::-webkit-details-marker {
display: none;
}
.faq-item[open] .faq-question {
color: var(--color-secondary); color: var(--color-secondary);
background: white; border-bottom: 1px solid var(--color-border);
box-shadow: none;
}
.faq-question::after {
filter: none;
} }
.faq-question:focus { .faq-question:focus {
box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.2); box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.2);
+5 -48
View File
@@ -21,51 +21,9 @@ window.taxbaikAdminSession = {
}, },
showLoading: function () { showLoading: function () {
if (document.documentElement.classList.contains('admin-login-route')) { // Route transitions are handled by Blazor; avoid full-screen overlays
window.taxbaikAdminSession.hideLoading(); // that block drawer interaction and make the app feel frozen.
return; window.taxbaikAdminSession.hideLoading();
}
const overlay = document.getElementById('blazor-loading');
if (!overlay) return;
// Show overlay immediately
overlay.classList.add('show');
// Check if page is already ready (cached state on fast nav)
const pageReady =
document.querySelector('.admin-page-hero') !== null ||
document.querySelector('.admin-login-page') !== null;
if (pageReady) {
// Page already rendered, hide immediately
window.taxbaikAdminSession.hideLoading();
return;
}
// Start observer to catch future mutations
if (window._taxbaikLoadingObserver) {
window._taxbaikLoadingObserver.disconnect();
}
window._taxbaikLoadingObserver = new MutationObserver(function () {
const pageReady =
document.querySelector('.admin-page-hero') !== null ||
document.querySelector('.admin-login-page') !== null;
if (pageReady) {
window.taxbaikAdminSession.hideLoading();
}
});
window._taxbaikLoadingObserver.observe(document.body, {
childList: true,
subtree: true
});
// Safety fallback: hide after 3 seconds regardless.
if (window._taxbaikLoadingTimeout) {
clearTimeout(window._taxbaikLoadingTimeout);
}
window._taxbaikLoadingTimeout = setTimeout(function () {
window.taxbaikAdminSession.hideLoading();
}, 3000);
}, },
hideLoading: function () { hideLoading: function () {
@@ -93,9 +51,8 @@ window.taxbaikAdminSession = {
window.taxbaikAdminSession.hideLoading(); window.taxbaikAdminSession.hideLoading();
} }
// Show loading on initial page load — overlay has 'show' from HTML, // Keep the initial overlay hidden unless explicitly enabled elsewhere.
// but we still need to set up the observer to detect when to hide it. window.taxbaikAdminSession.hideLoading();
window.taxbaikAdminSession.showLoading();
const modal = document.getElementById('components-reconnect-modal'); const modal = document.getElementById('components-reconnect-modal');
if (!modal) return; if (!modal) return;
+2 -2
View File
@@ -65,9 +65,9 @@
보통 <strong>1~2분</strong> 이내에 완료됩니다. 보통 <strong>1~2분</strong> 이내에 완료됩니다.
</p> </p>
<hr class="divider" /> <hr class="divider" />
<p>급하신 세무 문의는 카카오 채널로 연락해 주세요.</p> <p>급하신 세무 문의는 카카오채널로 연락해 주세요.</p>
<a class="kakao-btn" href="http://pf.kakao.com/_xoxchTX" target="_blank"> <a class="kakao-btn" href="http://pf.kakao.com/_xoxchTX" target="_blank">
💬 카카오 채널 상담 💬 카카오채널 상담
</a> </a>
<p class="timer">이 페이지는 15초 후 자동으로 새로고침됩니다.</p> <p class="timer">이 페이지는 15초 후 자동으로 새로고침됩니다.</p>
<p class="footer">© 2026 백원숙 세무회계</p> <p class="footer">© 2026 백원숙 세무회계</p>
+3 -3
View File
@@ -5,10 +5,10 @@ CREATE TABLE IF NOT EXISTS clients (
company_name VARCHAR(200), company_name VARCHAR(200),
phone VARCHAR(30), phone VARCHAR(30),
email VARCHAR(200), email VARCHAR(200),
service_type VARCHAR(50), -- 기장, 부동산, 증여·상속, 종합소득세, 기타 service_type VARCHAR(50), -- 기장, 부동산, 증여상속, 종합소득세, 기타
tax_type VARCHAR(30), -- 개인, 법인, 면세사업자 tax_type VARCHAR(30), -- 개인사업자, 법인사업자, 면세사업자
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, inactive status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, inactive
source VARCHAR(50), -- 홈페이지문의, 소개, 직접방문, 기타 source VARCHAR(50), -- 홈페이지문의, 소개, 직접방문, 카카오채널, 블로그, 기타
memo TEXT, memo TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+6 -6
View File
@@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS faqs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
question VARCHAR(300) NOT NULL, question VARCHAR(300) NOT NULL,
answer TEXT NOT NULL, answer TEXT NOT NULL,
category VARCHAR(50), -- 기장·세금신고, 부동산, 증여·상속, 기타 category VARCHAR(50), -- 기장세금신고, 부동산, 증여상속, 기타
sort_order INT NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -17,20 +17,20 @@ INSERT INTO faqs (question, answer, category, sort_order, is_active) VALUES
( (
'기장료가 얼마인지 미리 알 수 있나요?', '기장료가 얼마인지 미리 알 수 있나요?',
'업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다. 일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다. 먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.', '업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다. 일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다. 먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.',
'기장·세금신고', 10, TRUE '기장세금신고', 10, TRUE
), ),
( (
'양도세 상담은 어떻게 진행되나요?', '양도세 상담은 어떻게 진행되나요?',
'등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오 채널 또는 문의폼으로 전달해 주시면 예상 세액과 절세 방법을 검토해 드립니다. 매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다.', '등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오채널 또는 문의폼으로 전달해 주시면 예상 세액과 절세 방법을 검토해 드립니다. 매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다.',
'부동산', 20, TRUE '부동산', 20, TRUE
), ),
( (
'무료 상담도 가능한가요?', '무료 상담도 가능한가요?',
'네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다. 카카오 채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다. 실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다.', '네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다. 카카오채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다. 실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다.',
'기타', 30, TRUE '기타', 30, TRUE
), ),
( (
'처음 상담 시 어떤 자료를 준비해야 하나요?', '처음 상담 시 어떤 자료를 준비해야 하나요?',
'상담 목적에 따라 다르지만 아래 자료가 있으면 더 정확한 안내가 가능합니다. 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료 / 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료 / 증여·상속: 재산 목록, 증여 예정 자산 내역. 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.', '상담 목적에 따라 다르지만 아래 자료가 있으면 더 정확한 안내가 가능합니다. 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료 / 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료 / 증여상속: 재산 목록, 증여 예정 자산 내역. 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.',
'기타', 40, TRUE '증여상속', 40, TRUE
); );
+3 -3
View File
@@ -35,13 +35,13 @@ INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('FILING_TYPE', '법인세', '법인세', 30), ('FILING_TYPE', '법인세', '법인세', 30),
('FILING_TYPE', '원천세', '원천세', 40), ('FILING_TYPE', '원천세', '원천세', 40),
('FILING_TYPE', '양도소득세', '양도소득세', 50), ('FILING_TYPE', '양도소득세', '양도소득세', 50),
('FILING_TYPE', '상속/증여세', '상속/증여세', 60) ('FILING_TYPE', '상속증여세', '상속·증여세', 60)
ON CONFLICT (code_group, code_value) DO NOTHING; ON CONFLICT (code_group, code_value) DO NOTHING;
-- Seed data for SERVICE_TYPE -- Seed data for SERVICE_TYPE
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('SERVICE_TYPE', '개인 기장대리', '개인 기장대리', 10), ('SERVICE_TYPE', '개인기장대리', '개인 기장대리', 10),
('SERVICE_TYPE', '법인 기장대리', '법인 기장대리', 20), ('SERVICE_TYPE', '법인기장대리', '법인 기장대리', 20),
('SERVICE_TYPE', '세무조정', '세무조정', 30), ('SERVICE_TYPE', '세무조정', '세무조정', 30),
('SERVICE_TYPE', '세무컨설팅', '세무컨설팅', 40), ('SERVICE_TYPE', '세무컨설팅', '세무컨설팅', 40),
('SERVICE_TYPE', '불복청구', '불복청구', 50) ('SERVICE_TYPE', '불복청구', '불복청구', 50)
@@ -1,9 +1,6 @@
-- V019: Fix blog posts migration (V018 had quote escaping issues) -- V019: Fix blog posts migration (V018 had quote escaping issues)
-- Complete rewrite using $$ quote style to avoid escaping problems -- Complete rewrite using $$ quote style to avoid escaping problems
-- Delete posts 6-12 added in V018 (if they exist)
DELETE FROM blog_posts WHERE id >= 6;
-- Re-insert all 12 posts with proper formatting -- Re-insert all 12 posts with proper formatting
-- 6. 스마트스토어 판매자를 위한 첫 세무 기장 -- 6. 스마트스토어 판매자를 위한 첫 세무 기장
@@ -3,8 +3,6 @@
-- Layer 2: Details + Tax law changes (impossible to track alone) -- Layer 2: Details + Tax law changes (impossible to track alone)
-- Layer 3: Professional value (tax accountants needed) -- Layer 3: Professional value (tax accountants needed)
DELETE FROM blog_posts WHERE id >= 1;
-- 1. 사업자 기장 시 자주 하는 실수 5가지 -- 1. 사업자 기장 시 자주 하는 실수 5가지
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
VALUES ( VALUES (
@@ -2,8 +2,6 @@
-- Remove absolute claims, replace with past-tense examples -- Remove absolute claims, replace with past-tense examples
-- Replace guarantee language with possibility statements -- Replace guarantee language with possibility statements
DELETE FROM blog_posts WHERE id >= 1;
-- 1. 사업자 기장 시 자주 하는 실수 5가지 -- 1. 사업자 기장 시 자주 하는 실수 5가지
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
VALUES ( VALUES (
@@ -199,7 +197,8 @@ $$,
1, 1,
true, true,
NOW() NOW()
); )
ON CONFLICT (slug) DO NOTHING;
-- 2. 이번달 부가가치세 신고 -- 2. 이번달 부가가치세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -401,7 +400,8 @@ $$,
1, 1,
true, true,
NOW() NOW()
); )
ON CONFLICT (slug) DO NOTHING;
-- 3. 프리랜서를 위한 종합소득세 신고 -- 3. 프리랜서를 위한 종합소득세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -638,3 +638,4 @@ $$,
true, true,
NOW() NOW()
); );
@@ -2,8 +2,6 @@
-- Add tax law citations, 2025 standards, data sources -- Add tax law citations, 2025 standards, data sources
-- Remove speculation, assumptions, opinions -- Remove speculation, assumptions, opinions
DELETE FROM blog_posts WHERE id >= 1;
-- 1. 사업자 기장 시 자주 하는 실수 5가지 -- 1. 사업자 기장 시 자주 하는 실수 5가지
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
VALUES ( VALUES (
@@ -209,7 +207,8 @@ $$,
1, 1,
true, true,
NOW() NOW()
); )
ON CONFLICT (slug) DO NOTHING;
-- 2. 이번달 부가가치세 신고 -- 2. 이번달 부가가치세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -416,7 +415,8 @@ $$,
1, 1,
true, true,
NOW() NOW()
); )
ON CONFLICT (slug) DO NOTHING;
-- 3. 프리랜서를 위한 종합소득세 신고 -- 3. 프리랜서를 위한 종합소득세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -674,4 +674,5 @@ $$,
1, 1,
true, true,
NOW() NOW()
); )
ON CONFLICT (slug) DO NOTHING;
@@ -2,8 +2,6 @@
-- Remove internal jargon (Layer 1-3, "3층 구조", etc.) -- Remove internal jargon (Layer 1-3, "3층 구조", etc.)
-- Replace with customer perspective: "할 수 있어요" → "복잡하네" → "세무사가 필요하네" -- Replace with customer perspective: "할 수 있어요" → "복잡하네" → "세무사가 필요하네"
DELETE FROM blog_posts WHERE id >= 1;
-- 1. 사업자 기장 시 자주 하는 실수 5가지 -- 1. 사업자 기장 시 자주 하는 실수 5가지
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
VALUES ( VALUES (
@@ -146,7 +144,7 @@ $$,
1, 1,
true, true,
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 2. 이번달 부가가치세 신고 -- 2. 이번달 부가가치세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -282,7 +280,7 @@ $$,
1, 1,
true, true,
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 3. 프리랜서를 위한 종합소득세 신고 -- 3. 프리랜서를 위한 종합소득세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -458,4 +456,5 @@ $$,
1, 1,
true, true,
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
@@ -3,8 +3,6 @@
-- Simplify emojis (remove section headers like 📊, 🧮) -- Simplify emojis (remove section headers like 📊, 🧮)
-- Keep customer-friendly language (1️⃣ 2️⃣ 3️⃣) -- Keep customer-friendly language (1️⃣ 2️⃣ 3️⃣)
DELETE FROM blog_posts WHERE id >= 1;
-- 1. 사업자 기장 시 자주 하는 실수 5가지 -- 1. 사업자 기장 시 자주 하는 실수 5가지
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
VALUES ( VALUES (
@@ -158,7 +156,7 @@ $$,
1, 1,
true, true,
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 2. 이번달 부가가치세 신고 -- 2. 이번달 부가가치세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -313,7 +311,7 @@ $$,
1, 1,
true, true,
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 3. 프리랜서를 위한 종합소득세 신고 -- 3. 프리랜서를 위한 종합소득세 신고
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
@@ -464,4 +462,5 @@ $$,
1, 1,
true, true,
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-2
View File
@@ -1,8 +1,6 @@
-- V025: Add 9 new blog posts with correct SQL structure -- V025: Add 9 new blog posts with correct SQL structure
-- All posts follow BLOG_TEMPLATE.md guidelines: 3-step structure, accuracy principle, list format -- All posts follow BLOG_TEMPLATE.md guidelines: 3-step structure, accuracy principle, list format
DELETE FROM blog_posts WHERE id >= 4;
INSERT INTO blog_posts (title, content, slug, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) VALUES INSERT INTO blog_posts (title, content, slug, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) VALUES
-- 1. 프리랜서가 놓친 경비 5가지 -- 1. 프리랜서가 놓친 경비 5가지
+10 -11
View File
@@ -2,8 +2,6 @@
-- Each post: 1,500-2,500 words, law citations, 3-step structure -- Each post: 1,500-2,500 words, law citations, 3-step structure
-- 2025 tax year basis, accuracy principle -- 2025 tax year basis, accuracy principle
DELETE FROM blog_posts WHERE id >= 1;
-- 1. 프리랜서가 놓친 경비 5가지 -- 1. 프리랜서가 놓친 경비 5가지
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
VALUES ( VALUES (
@@ -196,7 +194,7 @@ $$,
'프리랜서,경비,소득세,절세,디자이너,유튜버,강사', '프리랜서,경비,소득세,절세,디자이너,유튜버,강사',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 2. 월세 신고하는 방법 -- 2. 월세 신고하는 방법
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -360,7 +358,7 @@ $$,
'월세,임대소득,부동산세,신고,절세,집주인', '월세,임대소득,부동산세,신고,절세,집주인',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 3. 자녀 증여세 계산하기 -- 3. 자녀 증여세 계산하기
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -530,7 +528,7 @@ $$,
'증여세,자녀,상속세,절세,기초공제,특별공제', '증여세,자녀,상속세,절세,기초공제,특별공제',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 4. 사업자 등록 타이밍 -- 4. 사업자 등록 타이밍
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -717,7 +715,7 @@ $$,
'사업자등록,부가가치세,창업,타이밍,간이과세', '사업자등록,부가가치세,창업,타이밍,간이과세',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 5. 소상공인 간단 기장 -- 5. 소상공인 간단 기장
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -940,7 +938,7 @@ $$,
'기장,소상공인,카페,편의점,부가가치세,소득세', '기장,소상공인,카페,편의점,부가가치세,소득세',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 6. 스마트스토어 판매자 세무 -- 6. 스마트스토어 판매자 세무
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -1138,7 +1136,7 @@ $$,
'스마트스토어,쿠팡,네이버,판매자,부가가치세,소득세', '스마트스토어,쿠팡,네이버,판매자,부가가치세,소득세',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 7. 부가가치세 신고 기한 -- 7. 부가가치세 신고 기한
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -1318,7 +1316,7 @@ $$,
'부가가치세,신고,기한,25일,가산세,납부', '부가가치세,신고,기한,25일,가산세,납부',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 8. 종합소득세 신고 완벽 가이드 -- 8. 종합소득세 신고 완벽 가이드
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -1505,7 +1503,7 @@ $$,
'종합소득세,신고,기한,5월,프리랜서,사업자', '종합소득세,신고,기한,5월,프리랜서,사업자',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
-- 9. 연말정산 환급 최대화 -- 9. 연말정산 환급 최대화
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at)
@@ -1709,4 +1707,5 @@ $$,
'연말정산,환급,공제,절세,회사원,기본공제', '연말정산,환급,공제,절세,회사원,기본공제',
NOW(), NOW(),
NOW() NOW()
); ) ON CONFLICT (slug) DO NOTHING;
@@ -6,8 +6,6 @@
-- cat 4 (부가가치세): 부가세 신고, 부가세 기한, 사업자 등록 -- cat 4 (부가가치세): 부가세 신고, 부가세 기한, 사업자 등록
-- cat 5 (가족자산): 연말정산 환급 -- cat 5 (가족자산): 연말정산 환급
DELETE FROM blog_posts WHERE id >= 1;
INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) VALUES INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) VALUES
-- 기초 3개 포스트 (V022, V024) -- 기초 3개 포스트 (V022, V024)
@@ -296,4 +294,6 @@ INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_tit
- -
- 2 - 2
.$$, 5, true, 'SEO Title', 'SEO Description', '연말정산,환급', NOW(), NOW()); .$$, 5, true, 'SEO Title', 'SEO Description', '연말정산,환급', NOW(), NOW())
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,21 @@
ALTER TABLE blog_posts
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
DROP INDEX IF EXISTS idx_blog_slug;
DROP INDEX IF EXISTS blog_posts_slug_key;
CREATE UNIQUE INDEX IF NOT EXISTS ux_blog_posts_slug_active
ON blog_posts (slug)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_blog_slug_active
ON blog_posts (slug)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_blog_published_active
ON blog_posts (is_published, published_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_blog_category_active
ON blog_posts (category_id)
WHERE deleted_at IS NULL;
@@ -0,0 +1,131 @@
-- Seed and normalize admin common codes.
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('INQUIRY_SERVICE_TYPE', '사업자세무', '사업자세무', 10),
('INQUIRY_SERVICE_TYPE', '부동산세금', '부동산세금', 20),
('INQUIRY_SERVICE_TYPE', '가족자산', '가족자산', 30),
('INQUIRY_SERVICE_TYPE', '기타', '기타', 40),
('INQUIRY_STATUS', 'new', '신규', 10),
('INQUIRY_STATUS', 'consulting', '상담중', 20),
('INQUIRY_STATUS', 'contracted', '계약완료', 30),
('INQUIRY_STATUS', 'rejected', '거절', 40),
('INQUIRY_STATUS', 'closed', '종결', 50),
('CLIENT_STATUS', 'active', '활성', 10),
('CLIENT_STATUS', 'inactive', '비활성', 20),
('CLIENT_SERVICE_TYPE', '기장', '기장', 10),
('CLIENT_SERVICE_TYPE', '부동산', '부동산', 20),
('CLIENT_SERVICE_TYPE', '증여상속', '증여·상속', 30),
('CLIENT_SERVICE_TYPE', '종합소득세', '종합소득세', 40),
('CLIENT_SERVICE_TYPE', '법인세', '법인세', 50),
('CLIENT_SERVICE_TYPE', '부가가치세', '부가가치세', 60),
('CLIENT_SERVICE_TYPE', '기타', '기타', 70),
('CLIENT_TAX_TYPE', '개인사업자', '개인사업자', 10),
('CLIENT_TAX_TYPE', '법인사업자', '법인사업자', 20),
('CLIENT_TAX_TYPE', '면세사업자', '면세사업자', 30),
('CLIENT_TAX_TYPE', '근로소득자', '근로소득자', 40),
('CLIENT_TAX_TYPE', '기타', '기타', 50),
('CLIENT_SOURCE', '홈페이지문의', '홈페이지 문의', 10),
('CLIENT_SOURCE', '소개', '소개', 20),
('CLIENT_SOURCE', '직접방문', '직접 방문', 30),
('CLIENT_SOURCE', '카카오채널', '카카오 채널', 40),
('CLIENT_SOURCE', '블로그', '블로그', 50),
('CLIENT_SOURCE', '기타', '기타', 60),
('CONTRACT_SERVICE_TYPE', '개인기장대리', '개인 기장대리', 10),
('CONTRACT_SERVICE_TYPE', '법인기장대리', '법인 기장대리', 20),
('CONTRACT_SERVICE_TYPE', '세무조정', '세무조정', 30),
('CONTRACT_SERVICE_TYPE', '세무컨설팅', '세무컨설팅', 40),
('CONTRACT_SERVICE_TYPE', '불복청구', '불복청구', 50),
('REVENUE_SERVICE_TYPE', '기장수수료', '기장 수수료', 10),
('REVENUE_SERVICE_TYPE', '세무조정료', '세무조정료', 20),
('REVENUE_SERVICE_TYPE', '세무상담료', '세무상담료', 30),
('REVENUE_SERVICE_TYPE', '신고대행료', '신고 대행료', 40),
('REVENUE_SERVICE_TYPE', '자문수수료', '자문 수수료', 50),
('FILING_TYPE', '종합소득세', '종합소득세', 10),
('FILING_TYPE', '부가가치세', '부가가치세', 20),
('FILING_TYPE', '법인세', '법인세', 30),
('FILING_TYPE', '원천세', '원천세', 40),
('FILING_TYPE', '양도소득세', '양도소득세', 50),
('FILING_TYPE', '상속증여세', '상속·증여세', 60),
('FILING_TYPE', '세무조정', '세무조정', 70),
('TAX_RISK_LEVEL', 'low', '낮음', 10),
('TAX_RISK_LEVEL', 'normal', '보통', 20),
('TAX_RISK_LEVEL', 'high', '높음', 30)
ON CONFLICT (code_group, code_value) DO UPDATE
SET code_name = EXCLUDED.code_name,
sort_order = EXCLUDED.sort_order,
is_active = TRUE;
-- Normalize storage keys and migrate existing rows.
UPDATE common_codes
SET code_value = CASE
WHEN code_group = 'CLIENT_SERVICE_TYPE' AND code_value = '증여·상속' THEN '증여상속'
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '홈페이지 문의' THEN '홈페이지문의'
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '직접 방문' THEN '직접방문'
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '카카오 채널' THEN '카카오채널'
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '개인 기장대리' THEN '개인기장대리'
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '법인 기장대리' THEN '법인기장대리'
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '기장 수수료' THEN '기장수수료'
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '신고 대행료' THEN '신고대행료'
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '자문 수수료' THEN '자문수수료'
WHEN code_group = 'FILING_TYPE' AND code_value = '상속·증여세' THEN '상속증여세'
ELSE code_value
END,
code_name = CASE
WHEN code_group = 'CLIENT_SERVICE_TYPE' AND code_value = '증여·상속' THEN '증여·상속'
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '홈페이지 문의' THEN '홈페이지 문의'
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '직접 방문' THEN '직접 방문'
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '카카오 채널' THEN '카카오 채널'
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '개인 기장대리' THEN '개인 기장대리'
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '법인 기장대리' THEN '법인 기장대리'
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '기장 수수료' THEN '기장 수수료'
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '신고 대행료' THEN '신고 대행료'
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '자문 수수료' THEN '자문 수수료'
WHEN code_group = 'FILING_TYPE' AND code_value = '상속·증여세' THEN '상속·증여세'
ELSE code_name
END
WHERE (code_group, code_value) IN (
('CLIENT_SERVICE_TYPE', '증여·상속'),
('CLIENT_SOURCE', '홈페이지 문의'),
('CLIENT_SOURCE', '직접 방문'),
('CLIENT_SOURCE', '카카오 채널'),
('CONTRACT_SERVICE_TYPE', '개인 기장대리'),
('CONTRACT_SERVICE_TYPE', '법인 기장대리'),
('REVENUE_SERVICE_TYPE', '기장 수수료'),
('REVENUE_SERVICE_TYPE', '신고 대행료'),
('REVENUE_SERVICE_TYPE', '자문 수수료'),
('FILING_TYPE', '상속·증여세')
);
UPDATE clients
SET
service_type = CASE WHEN service_type = '증여·상속' THEN '증여상속' ELSE service_type END,
source = CASE
WHEN source = '홈페이지 문의' THEN '홈페이지문의'
WHEN source = '직접 방문' THEN '직접방문'
WHEN source = '카카오 채널' THEN '카카오채널'
ELSE source
END;
UPDATE contracts
SET service_type = REPLACE(REPLACE(service_type, ' ', ''), '·', '')
WHERE service_type IS NOT NULL;
UPDATE revenue_tracking
SET service_type = REPLACE(REPLACE(service_type, ' ', ''), '·', '')
WHERE service_type IS NOT NULL;
UPDATE tax_filings
SET filing_type = '상속증여세'
WHERE filing_type = '상속·증여세';
UPDATE tax_filing_schedules
SET filing_type = '상속증여세'
WHERE filing_type = '상속·증여세';
@@ -0,0 +1,22 @@
-- Allow Korean code values and future growth without truncation risk.
ALTER TABLE common_codes
ALTER COLUMN code_group TYPE VARCHAR(80),
ALTER COLUMN code_value TYPE VARCHAR(120),
ALTER COLUMN code_name TYPE VARCHAR(200);
ALTER TABLE clients
ALTER COLUMN service_type TYPE VARCHAR(100),
ALTER COLUMN tax_type TYPE VARCHAR(60),
ALTER COLUMN source TYPE VARCHAR(100);
ALTER TABLE contracts
ALTER COLUMN service_type TYPE VARCHAR(120);
ALTER TABLE revenue_tracking
ALTER COLUMN service_type TYPE VARCHAR(120);
ALTER TABLE tax_filings
ALTER COLUMN filing_type TYPE VARCHAR(120);
ALTER TABLE tax_filing_schedules
ALTER COLUMN filing_type TYPE VARCHAR(120);
@@ -0,0 +1,16 @@
-- Additional common codes for admin combo policy normalization.
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('CONSULTING_ACTIVITY_TYPE', '방문상담', '방문 상담', 10),
('CONSULTING_ACTIVITY_TYPE', '전화상담', '전화 상담', 20),
('CONSULTING_ACTIVITY_TYPE', '세무조사대응미팅', '세무조사 대응 미팅', 30),
('CONSULTING_ACTIVITY_TYPE', '카카오톡상담', '카카오톡 상담', 40),
('CONSULTING_ACTIVITY_TYPE', '이메일자료접수', '이메일 자료 접수', 50),
('CONSULTING_ACTIVITY_TYPE', '기타', '기타', 60),
('ANNOUNCEMENT_DISPLAY_TYPE', 'info', '일반', 10),
('ANNOUNCEMENT_DISPLAY_TYPE', 'banner', '배너', 20),
('ANNOUNCEMENT_DISPLAY_TYPE', 'urgent', '긴급', 30)
ON CONFLICT (code_group, code_value) DO UPDATE
SET code_name = EXCLUDED.code_name,
sort_order = EXCLUDED.sort_order,
is_active = TRUE;
@@ -0,0 +1,6 @@
ALTER TABLE blog_posts
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_blog_posts_deleted_at
ON blog_posts (deleted_at)
WHERE deleted_at IS NOT NULL;
@@ -0,0 +1,15 @@
INSERT INTO common_codes (code_group, code_value, code_name, sort_order)
SELECT v.code_group, v.code_value, v.code_name, v.sort_order
FROM (
VALUES
('FAQ_CATEGORY', '기장세금신고', '기장세금신고', 10),
('FAQ_CATEGORY', '부동산', '부동산', 20),
('FAQ_CATEGORY', '증여상속', '증여상속', 30),
('FAQ_CATEGORY', '기타', '기타', 40)
) AS v(code_group, code_value, code_name, sort_order)
WHERE NOT EXISTS (
SELECT 1
FROM common_codes cc
WHERE cc.code_group = v.code_group
AND cc.code_value = v.code_value
);
+97
View File
@@ -0,0 +1,97 @@
# Admin Pattern Critique And WBS
대상은 어드민 Blog, 문의사항, 등록/수정 페이지 전반이다. 이 문서는 비판, 개선 방향, 정량 완료 기준을 한 곳에 둔다.
## Brutal Critique
| 영역 | 현재 문제 | 왜 위험한가 | 개선 기준 |
| --- | --- | --- | --- |
| API-first 위반 | 어드민 Razor 컴포넌트가 `BlogService`, `InquiryService`, repository를 직접 주입 | 어드민을 클라이언트 사이드 Blazor WebAssembly로 운용할 때 구조가 깨지고 API 계약 테스트가 우회된다 | 모든 어드민 화면은 BrowserClient를 통해 `/api/*` 호출 |
| Blog 등록/수정 중복 | `BlogCreate.razor``BlogEdit.razor`가 필드, JS 편집기, 저장 로직을 반복 | 한쪽만 수정되는 파편화가 생긴다 | `BlogForm.razor` + `BlogEditorJsModule` 패턴 |
| JS 과다/전역 상태 | `window.easyMDEInstance` 단일 전역 인스턴스 사용 | 페이지 이동/다중 편집/재렌더에서 내용 섞임 위험, Blazor 책임 경계가 흐려진다 | JS 제거 우선 검토, 불가피하면 JS module + element별 instance map + dispose |
| 문의 수정 착시 | `InquiryEdit`가 이름/전화/이메일/내용 수정 UI를 보여주지만 실제 저장은 상태/메모 중심 | 운영자가 저장 성공을 믿어도 핵심 데이터가 DB에 반영되지 않을 수 있다 | 전체 수정 API를 만들거나 해당 필드를 read-only 처리 |
| 문자열 상태 난립 | 문의 상태, 서비스 유형이 UI 문자열/API 문자열/DB 값으로 분산 | 오타 하나가 통계와 필터를 깨뜨린다 | enum/공통코드/상태 mapper 단일화 |
| 삭제 위험 | Blog/Inquiry 삭제가 즉시 hard delete | 운영 감사, 상담 이력, SEO URL 보존에 취약 | soft delete 또는 archive 정책 |
| 정합성 부족 | Blog slug 생성이 전체 목록 조회 기반 | 동시 생성 충돌에 약하고 데이터가 늘면 느려진다 | DB unique index + 충돌 재시도 |
| 템플릿 부재 | CRUD 페이지마다 버튼, 오류, 로딩, 페이징 패턴이 다름 | 바이브코딩식 흔들림이 반복된다 | List/Form/Detail/PageState 템플릿화 |
| 배포 완료 착시 | 문서상 완료 항목과 운영 검증 항목이 섞임 | 체크박스가 실제 성공을 대체한다 | WBS는 수치, 로그, CI URL로만 완료 |
## Target Admin Pattern
```text
Razor Page/Form
-> BrowserClient with JWT
-> Controller DTO
-> Application Service
-> Repository
-> DB constraints/indexes
```
어드민은 클라이언트 사이드 Blazor WebAssembly 기준이다. 예외는 명시해야 한다. 서버 전용 컴포넌트가 Application Service를 직접 호출해야 한다면 `ENGINEERING_HARNESS.md`의 API-first 기준에 대한 사유와 제거 예정 WBS를 남긴다.
## Quantitative Success Metrics
| 지표 | 기준값 | 측정 방법 |
| --- | --- | --- |
| Admin direct service injection | 0건 | `rg "@inject .*Service|@inject I.*Repository" TaxBaik.Web/Components/Admin` |
| Blog create/edit duplicate fields | 0개 중복 폼 | `BlogForm.razor` 단일 사용 여부 |
| Admin JavaScript surface | 필수 module만 허용 | `window.*` 전역 admin JS 0건, JS interop 사유 문서화 |
| Inquiry visible-but-unsaved fields | 0개 | E2E로 수정 후 API 재조회 |
| Protected admin API anonymous access | 0개 | API smoke에서 401/403 확인 |
| CI required gates | 6/6 통과 | build, unit, publish, deploy, browser e2e, api smoke |
| Playwright admin flows | 8개 이상 통과 | login, blog CRUD, inquiry CRUD/status, responsive, password, smoke |
| DB integrity constraints | 핵심 테이블 100% | PK, FK, unique/check/index 리뷰 |
| WBS evidence coverage | 100% | 각 완료 항목에 command/log/test 파일 기재 |
## Roadmap
| Phase | 목적 | 종료 조건 |
| --- | --- | --- |
| P0 Harness | 기준 고정과 문서 최소화 | 이 문서와 Engineering Harness가 README에서 참조됨 |
| P1 Stabilize | Blog/Inquiry 착시와 중복 제거 | API-first 전환, 공통 폼, 정합성 테스트 통과 |
| P2 Harden | DB 제약, 충돌 방지, 삭제 정책 | migration + 회귀 테스트 + E2E 통과 |
| P3 Standardize | CRUD 템플릿화와 반복 패턴 제거 | 신규 CRUD 생성 시 템플릿만 사용 |
| P4 Integrate | 더존 UX 정신 내재화 | 고밀도 화면, 표준 동선, 빠른 입력, 상태 가시성, 회귀 최소화 검증 |
| P5 Operate | CI/CD와 운영 지표 고도화 | 배포본 기준 smoke/E2E/로그 알림 안정화 |
## Detailed WBS
| ID | 작업 | 산출물 | 정량 완료 기준 |
| --- | --- | --- | --- |
| P0-01 | 문서 기준점 정리 | `docs/INDEX.md`, `ENGINEERING_HARNESS.md` | canonical 문서 3개 이하, README 링크 1곳 |
| P0-02 | 기존 장문 문서 역할 축소 | README 문서 섹션 정리 | `CLAUDE.md`를 보조자료로 표시 |
| P1-01 | Blog API client 도입 | `IBlogBrowserClient`, `BlogBrowserClient` | Blog admin page direct service/repository injection 0건 |
| P1-02 | Blog 공통 폼 도입 | `BlogForm.razor` | create/edit 필드 중복 0건, 저장 E2E 2개 통과 |
| P1-03 | Markdown editor JS 최소화/격리 | Blazor 대체 또는 JS module | 전역 `window.easyMDEInstance` 사용 0건, JS interop 사유 명시 |
| P1-04 | Inquiry 수정 계약 확정 | `UpdateInquiryRequest` 또는 read-only UI | 화면 표시 editable 필드와 저장 필드 불일치 0건 |
| P1-05 | Inquiry API client 도입 | `IInquiryBrowserClient` 정비 | Inquiry admin direct service injection 0건 |
| P1-06 | 상태/서비스 유형 단일화 | enum/common code/mapper | 상태 문자열 하드코딩 UI 위치 0건 또는 공통 상수 참조 |
| P2-01 | Blog slug 충돌 방지 | unique index + retry | 동시 생성 테스트 1개 통과 |
| P2-02 | 삭제 정책 정리 | soft delete migration 또는 archive 정책 | hard delete 운영 엔티티 0건 또는 예외 문서화 |
| P2-03 | DB index 점검 | migration | 목록/검색/상태 필터 explain 기준 seq scan 위험 제거 |
| P2-04 | 낙관적 충돌 방지 | `updatedAt` 조건부 update | stale update API 테스트 1개 이상 통과 |
| P3-01 | CRUD 템플릿 작성 | page/form/client/test skeleton | 신규 admin CRUD 생성 시간 30% 감소 |
| P3-02 | 공통 PageState/Error 처리 | reusable component/service | admin page 중복 try/catch/snackbar 패턴 50% 감소 |
| P3-03 | 메뉴/라우팅 표준화 | route registry 또는 constants | admin route 문자열 중복 50% 감소 |
| P4-01 | 더존 UX 패턴 캡슐화 | 고밀도 grid/form/template 규칙 | 신규 어드민 화면이 템플릿을 따르지 않는 경우 0건 |
| P4-02 | UX 회귀 검증 | responsive, keyboard flow, density, state visibility test | 핵심 CRUD 화면 E2E 100% 통과 |
| P5-01 | CI gate 명문화 | workflow 체크 목록 | 6개 gate 모두 required |
| P5-02 | 배포본 API smoke 확장 | workflow curl 추가 | Blog/Inquiry create-read-update test 2xx |
| P5-03 | 운영 회귀 대시보드 | test report/version endpoint | 배포 커밋과 E2E 결과 추적 가능 |
## Immediate Refactor Order
1. `InquiryEdit` 착시 제거: 전체 수정 API를 추가하거나 저장 안 되는 필드를 read-only로 바꾼다.
2. `BlogForm.razor`를 만들고 create/edit 중복을 제거한다.
3. Blog/Inquiry 어드민 페이지를 BrowserClient 경유로 바꾼다.
4. 상태/서비스 유형 문자열을 단일 source로 모은다.
5. DB 제약과 삭제 정책을 migration으로 고정한다.
## Completion Rule
WBS 항목은 다음 네 가지가 모두 있어야 완료다.
- 관련 코드 또는 문서 diff
- 로컬 검증 명령과 결과
- CI/CD workflow 성공
- 배포본 기준 API 또는 Browser E2E 증거
+72
View File
@@ -0,0 +1,72 @@
# Combo Policy
이 문서는 TaxBaik 어드민의 콤보 정책을 정한다. 여기서 콤보는 `MudSelect`, `MudAutocomplete`, `MudChip`, 상태 필터, 코드 선택 입력을 포함한다.
## Policy
- 닫힌 집합은 `MudSelect`를 쓴다.
- 열린 집합 또는 검색이 필요한 집합은 `MudAutocomplete`를 쓴다.
- 상태/유형/등급처럼 값이 고정된 항목은 문자열 직접 입력을 금지한다.
- 선택한 값은 저장 값과 표시 값을 분리한다.
- 표시 값은 사람이 읽는 라벨, 저장 값은 코드값이어야 한다.
- `null` 허용 여부는 UI에서 명시한다.
- `전체`, `선택 안 함`, `기타`는 서로 다른 의미로 취급한다.
- 다중 선택이 필요하면 단일 선택 콤보를 억지로 재사용하지 않는다.
## Closed Set
다음 경우 `MudSelect`를 기본으로 사용한다.
- 상태
- 세금 유형
- 신고 유형
- 위험도
- 고정 서비스 유형
- 공지 유형
규칙:
- 값은 상수, enum, 공통코드 중 하나에서만 가져온다.
- `MudSelectItem`의 라벨과 값은 일치하는 쌍으로 관리한다.
- 운영자가 값의 의미를 추측해야 하는 항목은 콤보로 두지 않는다.
## Search Set
다음 경우 `MudAutocomplete`를 기본으로 사용한다.
- 고객 선택
- 회사 선택
- 데이터가 많아 스크롤 선택이 비효율적인 경우
규칙:
- 검색어 입력 후 서버 또는 클라이언트 필터 결과를 보여준다.
- 결과가 적을 때는 `MudSelect`보다 `MudAutocomplete`를 우선하지 않는다.
- 선택 후 보여주는 텍스트와 저장되는 id를 분리한다.
## Display Rules
- 목록에서는 상태를 칩으로 보여준다.
- 폼에서는 텍스트보다 구조화된 값으로 저장한다.
- 필터에서는 현재 선택값이 명확히 보이게 한다.
- `Clearable`은 의미가 명확한 경우에만 켠다.
## Standard Sources
- 상태 값은 `InquiryStatusMapper` 또는 전용 enum을 사용한다.
- 공지/신고/세무 정보는 각 도메인별 공통코드 소스를 둔다.
- 고객/회사 선택은 검색형 콤보로 통일한다.
## Anti-Patterns
- 같은 화면에 `MudSelect`와 자유 텍스트 입력을 섞어 같은 의미를 표현
- 코드값과 표시값을 뒤섞어서 저장
- 콤보 옵션을 화면마다 하드코딩
- `기타`를 예외 처리처럼 쓰고 실제 저장 값은 제각각 두는 것
- `전체`를 저장 값으로 사용
## Acceptance Criteria
- 신규 어드민 화면은 이 문서의 `Closed Set`/`Search Set` 중 하나를 명시해야 한다.
- 상태/유형/등급 입력이 있는 화면은 콤보 정책 위반이 없어야 한다.
- 고객/회사처럼 데이터가 많은 항목은 검색형 선택으로 통일한다.
+58
View File
@@ -0,0 +1,58 @@
# Common Code Policy
이 문서는 어드민 콤보, 상태, 유형, 출처 값의 단일 기준이다. 값은 DB `common_codes`를 우선 사용하고, 화면은 표시명만 바꾼다.
## Canonical Rules
- `code_value`는 저장 키다.
- `code_name`은 화면 표시값이다.
- `code_value`는 공백을 넣지 않는다.
- 새 콤보를 추가할 때는 먼저 `common_codes`에 그룹을 추가한다.
- 화면 하드코딩 배열은 금지한다. 불가피하면 임시 폴백으로만 두고 제거 계획을 함께 적는다.
- 같은 의미의 값이 테이블마다 다르면 저장값을 먼저 통일하고 마이그레이션으로 이관한다.
## Grouping Rules
- 상태값: `*_STATUS`
- 유형값: `*_TYPE`
- 출처값: `*_SOURCE`
- 위험도/스코어: `*_LEVEL`
## Standard Groups
- `INQUIRY_SERVICE_TYPE`
- `INQUIRY_STATUS`
- `CONSULTING_ACTIVITY_TYPE`
- `ANNOUNCEMENT_DISPLAY_TYPE`
- `CLIENT_STATUS`
- `CLIENT_SERVICE_TYPE`
- `CLIENT_TAX_TYPE`
- `CLIENT_SOURCE`
- `CONTRACT_SERVICE_TYPE`
- `REVENUE_SERVICE_TYPE`
- `FILING_TYPE`
- `TAX_RISK_LEVEL`
- `BUSINESS_TYPE`
- `FAQ_CATEGORY`
## Data Rules
- DB seed와 운영 데이터의 저장값이 다르면 UI를 먼저 맞추지 말고 저장값을 먼저 정규화한다.
- 한글 코드값을 사용하더라도 컬럼 길이를 먼저 검토하고, 업무 테이블과 마스터 테이블을 함께 조정한다.
- 표시용 문구가 길면 `code_name`에 둔다.
## UI Rules
- `MudSelect``code_value`를 바인딩하고 `code_name`을 보여준다.
- 검색형이면 `MudAutocomplete`를 쓰고, 선택형이면 `MudSelect`를 쓴다.
- 자유 입력을 허용하지 않을 값은 텍스트 필드로 만들지 않는다.
## Acceptance Criteria
- 신규 콤보 추가 시 DB 마이그레이션이 먼저 존재해야 한다.
- 화면에 하드코딩된 선택값이 없어야 한다.
- 기존 저장값과 신규 저장값의 불일치가 없어야 한다.
## Audit
- 점검 SQL은 [docs/ops/COMMON_CODE_AUDIT.sql](./ops/COMMON_CODE_AUDIT.sql)를 사용한다.
+104
View File
@@ -0,0 +1,104 @@
# DOUZONE UX Guide
이 문서는 TaxBaik 어드민 UX의 기준선이다. 목표는 더존 세무회계프로그램류의 고밀도 운영 화면을 구현하되, TaxBaik의 도메인과 검증 규칙을 유지하는 것이다.
## UX Principles
- 고밀도 우선: 한 화면에서 상태, 입력, 결과, 작업을 함께 본다.
- 표준 동선 우선: 목록 -> 상세 -> 수정 -> 저장 흐름을 기본으로 둔다.
- 빠른 입력 우선: 마우스 최소, 키보드/단축 동선 최대, 기본값 명확화.
- 상태 가시성 우선: 진행중/성공/실패/비활성/삭제됨을 즉시 구분 가능하게 한다.
- 회귀 최소화 우선: 같은 화면 패턴은 같은 컴포넌트와 같은 구조를 사용한다.
- 추측 금지: 의미가 불명확한 텍스트, 상태, 버튼, 색상은 새로 만들지 않는다.
## Layout Template
어드민 화면은 기본적으로 아래 구조를 따른다.
```text
PageHeader
FilterBar or ActionBar
ContentSurface
-> DenseGrid or DetailPanel
-> EmptyState when empty
-> Paging/Footer when needed
```
권장 규칙:
- 페이지 제목은 1개만 둔다.
- 보조 설명은 1줄만 둔다.
- 주요 액션은 우측 상단 또는 헤더 우측에 둔다.
- 목록은 `Dense`를 기본으로 한다.
- 상세/수정은 좌우 2열 또는 상단 요약 + 하단 폼 패턴을 우선한다.
## Component Template
### Page Header
- 구성: `Eyebrow`, `Title`, `Subtitle`, `Primary Action`
- 역할: 화면 맥락 고정, 다음 행동 제시
- 금지: 동일 화면에 헤더가 2개 이상 존재
### Dense Grid
- 행 간격은 좁게 유지한다.
- 컬럼은 우선순위 순으로 배치한다.
- 상태는 텍스트 대신 칩/색상/아이콘으로 함께 보여준다.
- 작업 버튼은 `보기`, `수정`, `삭제`처럼 짧고 일관되게 둔다.
### Form
- 기본값은 채워진 상태로 시작한다.
- 저장 전 필수 검증은 화면에서 즉시 보인다.
- 저장되지 않는 필드는 read-only로 바꾼다.
- 입력이 많은 폼은 섹션으로 나누되, 섹션 수는 최소화한다.
### Empty State
- 데이터 없음, 필터 결과 없음, 로드 실패를 구분한다.
- 단순 문구보다 다음 행동 버튼을 함께 둔다.
### Status Chip
- 상태는 문자열 그대로 노출하지 말고 칩으로 시각화한다.
- 색상은 의미를 유지한다.
- 동일 상태는 동일 색을 사용한다.
## Text And Labels
- 라벨은 짧게 쓴다.
- 같은 개념은 같은 단어를 쓴다.
- 약어는 화면 전체에서 통일한다.
- 운영자가 오해할 수 있는 추상적인 표현은 금지한다.
## Serving Rules
- 공개 사이트는 SSR, 어드민은 Blazor WebAssembly 기준으로 본다.
- 어드민 화면은 API-first 경유를 기본으로 한다.
- JS는 불가피할 때만 사용하고, 모듈로 격리한다.
- 상태/메뉴/라우트/버튼은 문자열 흩뿌리기를 금지하고 공통 상수 또는 템플릿으로 묶는다.
## Reference Rules
- 이 문서를 어드민 UX의 1차 기준으로 사용한다.
- 세부 코드 규칙은 [ENGINEERING_HARNESS.md](./ENGINEERING_HARNESS.md)를 따른다.
- 콤보/선택/검색 규칙은 [COMBO_POLICY.md](./COMBO_POLICY.md)를 따른다.
- 공통코드/저장값 규칙은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)를 따른다.
- 패턴 비판과 WBS는 [ADMIN_PATTERN_CRITIQUE_WBS.md](./ADMIN_PATTERN_CRITIQUE_WBS.md)를 따른다.
- 문서 인덱스는 [INDEX.md](./INDEX.md)를 따른다.
## Prohibited Patterns
- 목록마다 서로 다른 헤더 구조
- 버튼 색과 의미의 중복/충돌
- 저장 안 되는 필드를 편집 가능한 척 보여주기
- 전역 JS 상태에 의존하는 편집기
- 같은 CRUD 화면의 개별 구현체마다 다른 DOM/행 높이/행동 패턴
- 불필요한 중첩 컴포넌트와 과한 추상화
## Acceptance Criteria
- 신규 어드민 화면은 이 문서의 레이아웃/컴포넌트 규칙 중 최소 80%를 따른다.
- 기존 화면은 새로 건드릴 때 이 문서로 수렴한다.
- 화면 추가 시 `PageHeader`, `EmptyState`, `DenseGrid`, `Form` 패턴 중 하나 이상을 재사용한다.
+105
View File
@@ -0,0 +1,105 @@
# Engineering Harness
이 문서는 TaxBaik 코드가 매번 흔들리지 않도록 막는 최소 하네스다. 여기에 없는 내용은 추측하지 않고 코드, 테스트, 운영 로그, DB 스키마 중 하나로 확인한다.
## Non-Negotiables
| 항목 | 기준 | 실패 판정 |
| --- | --- | --- |
| Runtime | ASP.NET Core `net10.0` 기준 유지 | 프로젝트별 TargetFramework 불일치 |
| Public UI | 홈페이지/공개 페이지는 서버 사이드 렌더링 기준 | 공개 페이지가 불필요하게 WASM 번들에 의존 |
| Admin UI | 어드민은 클라이언트 사이드 Blazor WebAssembly + MudBlazor + API-first | 어드민 컴포넌트가 Application/Repository를 직접 주입 |
| API | 모든 운영 기능은 `/api/*` DTO 경유 | UI 전용 서비스 호출만 존재 |
| Auth | JWT 인증, 관리자 API는 `[Authorize]` | 익명으로 관리자 데이터 접근 가능 |
| Deploy | Gitea Actions CI/CD만 배포 경로 | 수동 SSH/복사로 운영 반영 |
| Evidence | 빌드, 테스트, E2E, API smoke 로그 | "확인함", "될 것" 같은 진술 |
## Architecture Guardrails
- Domain은 엔티티, enum, repository interface만 가진다.
- Application은 use case와 검증 규칙을 가진다. HTTP, JS, MudBlazor, DB 연결 세부를 모른다.
- Infrastructure는 Dapper SQL과 외부 시스템 구현을 가진다.
- Web은 Controller, 공개 Razor Pages SSR, Blazor host, 인증/서빙 설정을 가진다.
- Web.Client/Admin UI는 클라이언트 사이드 Blazor WebAssembly로 본다. 서버 DI 서비스에 의존하지 않고 API client만 호출한다.
- 관리자 호스트가 prerender를 사용하더라도 데이터 접근 원칙은 WASM + API-first다. prerender는 초기 마크업용이며 비즈니스 로직의 근거가 아니다.
- JavaScript는 최소화한다. 브라우저 API, 인증 토큰 저장, 서드파티 편집기처럼 Blazor/MudBlazor만으로 해결하기 부적절한 경우에만 JS module로 격리한다.
- 상속은 프레임워크 요구 또는 명확한 다형성 모델에만 사용한다. 폼/테이블/CRUD 재사용은 기본적으로 컴포넌트 합성과 작은 service/client로 처리한다.
## Code Quality Harness
| 원칙 | 적용 방식 |
| --- | --- |
| SOLID | 페이지는 orchestration만, 검증은 Application, 저장은 Repository, HTTP 계약은 DTO |
| 유지보수 | Blog/Inquiry 같은 CRUD는 `List`, `Form`, `Client`, `Dto`, `Validator` 패턴으로 고정 |
| 리팩토링 | 동작 보존 테스트를 먼저 추가하고 작은 단위로 이동 |
| 일관성 | 오류 응답은 ProblemDetails, 페이징은 `{ data, total, page, pageSize }` |
| 파편화 방지 | 같은 필드/상태/서비스유형 문자열은 enum/상수/공통 코드 중 하나로 단일화 |
| 과유불급 | 추상화는 2개 이상 실제 사용처가 생긴 뒤 도입 |
| 정규화 | 고객, 문의, 상담, 계약, 세금신고는 원천 테이블을 분리 |
| 역정규화 | 대시보드/검색/운영 요약용 스냅샷만 허용하고 원천 id와 갱신 시점을 저장 |
| 충돌방지 | 수정 API는 가능하면 `updatedAt` 또는 row version 기반 충돌 감지를 둔다 |
| 더존 UX 정신 | 더존 세무회계프로그램처럼 고밀도, 표준 동선, 빠른 입력, 상태 가시성, 회귀 최소화를 기본 UX 원칙으로 삼는다 |
| 추측금지 | 세법, 세율, 더존 필드, 운영 계정, 배포 결과는 공식 자료/코드/DB/로그 없이는 단정하지 않는다 |
| JS 최소화 | Blazor/MudBlazor 우선, 불가피한 JS는 module + dispose + 테스트 가능한 얇은 wrapper |
| 공통코드 | 상태/유형/출처/위험도는 `common_codes`를 우선 소스로 사용하고 화면 하드코딩을 금지 |
## Data Integrity Harness
- DB 제약 조건이 1차 방어선이다: NOT NULL, UNIQUE, FK, CHECK, index.
- Application validation은 사용자 메시지와 use case 규칙을 담당한다.
- UI validation은 빠른 피드백일 뿐이며 유일한 검증으로 보지 않는다.
- 관리자 수정 화면에 노출한 필드는 실제 저장되어야 한다. 저장하지 않는 필드는 read-only로 표시한다.
- 상태 전이는 허용 목록을 둔다. 임의 문자열 저장을 금지한다.
- 삭제는 운영 데이터 손실 위험이 있으면 soft delete 또는 archive를 우선 검토한다.
- 콤보 값은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)를 1차 기준으로 삼는다.
## API-First Admin Pattern
새 어드민 기능은 클라이언트 사이드 Blazor WebAssembly를 기준으로 아래 구조를 기본 템플릿으로 따른다.
| Layer | Naming | 책임 |
| --- | --- | --- |
| DTO | `CreateXRequest`, `UpdateXRequest`, `XResponse` | HTTP 계약 |
| Controller | `XController` | 인증, 라우팅, status code, ProblemDetails |
| Client | `IXBrowserClient`, `XBrowserClient` | JWT 포함 HTTP 호출 |
| Page | `XList.razor`, `XCreate.razor`, `XEdit.razor` | 화면 상태와 navigation |
| Form | `XForm.razor` | 입력 컴포넌트와 UI validation |
| Tests | unit + Playwright/API smoke | 회귀 방지 |
## Rendering Boundary
| 영역 | 렌더링 | 데이터 접근 |
| --- | --- | --- |
| Public Home/Blog/Contact | 서버 사이드 렌더링 | 서버 Application Service 직접 사용 가능 |
| Admin | 클라이언트 사이드 Blazor WebAssembly | JWT 포함 HTTP API만 사용 |
| Shared DTO | 서버/클라이언트 공유 가능 | UI 전용 상태와 DB 엔티티를 섞지 않음 |
공개 페이지의 SEO와 초기 로딩은 SSR로 최적화한다. 어드민은 앱처럼 동작해야 하므로 WebAssembly와 API 계약을 기준으로 설계한다.
## CI Harness
완료는 로컬 성공이 아니라 CI와 배포본 성공이다.
| Gate | Command/Check | Target |
| --- | --- | --- |
| Build | `dotnet build TaxBaik.sln -c Release --no-restore` | error 0 |
| Unit | `dotnet test TaxBaik.sln -c Release --no-build` | failed 0 |
| Browser | `npx playwright test --project="Desktop Chrome"` | failed 0 |
| API Smoke | login + protected admin API curl | HTTP 2xx |
| Deploy | `.gitea/workflows/deploy.yml` | success |
| Post Deploy | `.gitea/workflows/browser-e2e.yml` | success |
### Gitea Auth Harness
- Gitea API와 workflow dispatch에는 `GITEA_TOKEN_TAXBAIK`만 사용한다.
- `GITEA_TOKEN`은 쓰지 않는다.
- 인증 헤더는 `Authorization: token <GITEA_TOKEN_TAXBAIK>`를 기본으로 한다.
- 토큰 검증은 먼저 `GET /api/v1/user`로 확인하고, 그 다음 `workflow_dispatch`를 실행한다.
- `401 invalid username, password or token`이 나오면 토큰 이름, 공백, 환경 변수 scope를 먼저 확인한다.
## Stop Conditions
- 동일 개념이 3곳 이상 다른 이름/계약으로 구현되면 기능 추가를 중단하고 정리한다.
- UI가 저장한다고 보이는 필드를 API/Application이 저장하지 않으면 릴리스하지 않는다.
- 운영 배포 검증이 CI 밖에서만 가능하면 완료로 보지 않는다.
- 데이터 모델을 추측해서 세무 규칙이나 더존 UX 관습을 왜곡해 구현하지 않는다.
+32
View File
@@ -0,0 +1,32 @@
# TaxBaik Engineering Index
이 디렉터리의 문서만 현재 개발 기준의 기준점으로 사용한다. 다른 문서는 보조 자료로만 본다.
## Canonical Documents
| 문서 | 용도 | 변경 조건 |
| --- | --- | --- |
| [ENGINEERING_HARNESS.md](./ENGINEERING_HARNESS.md) | 아키텍처, 코드 품질, 배포, 데이터 정합성 하네스 | 방향성 변경 또는 반복 위반 발견 |
| [DOUZONE_UX_GUIDE.md](./DOUZONE_UX_GUIDE.md) | 더존식 어드민 UX 원칙, 템플릿, 컴포넌트, 서빙 규칙 | 화면 패턴 변경 또는 신규 템플릿 추가 |
| [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md) | 공통코드, 저장값, 컬럼 길이, 하드코딩 금지 규칙 | 공통코드 또는 콤보 추가/수정 |
| [COMBO_POLICY.md](./COMBO_POLICY.md) | 콤보/선택/검색 입력 정책과 저장값 규칙 | 상태/유형/선택 입력 정책 변경 |
| [ADMIN_PATTERN_CRITIQUE_WBS.md](./ADMIN_PATTERN_CRITIQUE_WBS.md) | 어드민 Blog/문의 등록 패턴 비판, 개선 로드맵, 정량 WBS | WBS 상태 또는 성공 지표 변경 |
## Route And Serving Map
| 영역 | 라우트/파일 | 기준 |
| --- | --- | --- |
| Public Home/Blog/Contact | `/taxbaik/`, `/taxbaik/blog`, `/taxbaik/contact` | 서버 사이드 렌더링, SEO 우선, WASM 의존 금지 |
| Admin Blog | `/taxbaik/admin/blog`, `/taxbaik/admin/blog/create`, `/taxbaik/admin/blog/{id}/edit` | 클라이언트 사이드 Blazor WebAssembly, API-first 클라이언트 경유, JS 최소화 |
| Admin Inquiry | `/taxbaik/admin/inquiries`, `/taxbaik/admin/inquiries/create`, `/taxbaik/admin/inquiries/{id}/edit` | 클라이언트 사이드 Blazor WebAssembly, 공개 접수/관리자 등록/상태 변경 분리 |
| Public API | `/taxbaik/api/*` | JWT 인증, ProblemDetails 오류, DTO 입출력 |
| CI/CD | `.gitea/workflows/deploy.yml`, `.gitea/workflows/browser-e2e.yml` | 수동 배포 금지, 배포본 E2E 통과 후 완료 |
## Document Rules
- 문서는 짧게 유지한다. 새 문서를 만들기 전에 이 인덱스에 추가할 가치가 있는지 판단한다.
- 동일한 기준을 여러 문서에 중복 작성하지 않는다.
- 아키텍처/UX/콤보/공통코드 기준은 `ENGINEERING_HARNESS.md`, `DOUZONE_UX_GUIDE.md`, `COMBO_POLICY.md`, `COMMON_CODE_POLICY.md`만 본다.
- WBS 완료 여부는 체크박스가 아니라 수치와 실행 로그로 판단한다.
- 코드 변경 시 관련 WBS ID를 커밋/PR 설명 또는 작업 메모에 남긴다.
- 공통코드 관련 규칙은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)만 1차 기준으로 사용한다.
+17
View File
@@ -0,0 +1,17 @@
-- Common code audit checks
SELECT code_group, code_value
FROM common_codes
WHERE code_value LIKE '% %';
SELECT code_group, COUNT(*)
FROM common_codes
GROUP BY code_group
ORDER BY code_group;
SELECT DISTINCT c.service_type
FROM clients c
LEFT JOIN common_codes cc
ON cc.code_group = 'CLIENT_SERVICE_TYPE'
AND cc.code_value = c.service_type
WHERE c.service_type IS NOT NULL
AND cc.code_value IS NULL;
+7
View File
@@ -0,0 +1,7 @@
-- Restore archived blog posts that were hidden by soft delete.
-- Use only when the goal is to bring back previously archived posts.
UPDATE blog_posts
SET deleted_at = NULL,
updated_at = NOW()
WHERE deleted_at IS NOT NULL;