feat(admin): stabilize blog and admin patterns
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-07-02 10:46:27 +09:00
parent b3cab87539
commit cb47349a25
67 changed files with 1354 additions and 486 deletions
+4 -4
View File
@@ -76,7 +76,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 +119,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 +972,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) - 서버 자동 설치 스크립트
+30 -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)
@@ -83,7 +102,15 @@ 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 IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = 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; }
}
@@ -110,6 +110,12 @@ 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 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);
@@ -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);
+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; }
@@ -15,5 +15,6 @@ public interface IBlogPostRepository
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 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);
} }
@@ -15,7 +15,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.id = @Id", WHERE bp.id = @Id AND bp.deleted_at IS NULL",
new { Id = id }); new { Id = id });
} }
@@ -28,7 +28,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.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 });
} }
@@ -44,12 +44,12 @@ 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.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();
@@ -67,7 +67,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.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");
} }
@@ -97,10 +98,11 @@ 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
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();
@@ -130,19 +132,26 @@ 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 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,88 @@
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<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> 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<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> 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()
@@ -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>
<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> </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; }
}
}
@@ -1,8 +1,9 @@
@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 @inject IClientBrowserClient ClientClient
@inject IConsultingActivityBrowserClient ConsultingClient
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -103,7 +104,7 @@
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야"> <MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
@foreach (var t in ClientService.ServiceTypes) @foreach (var t in serviceTypes)
{ {
<MudSelectItem Value="@t">@t</MudSelectItem> <MudSelectItem Value="@t">@t</MudSelectItem>
} }
@@ -116,7 +117,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 +183,8 @@
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[] serviceTypes = ["기장대리", "세무조정", "양도세", "증여세", "상속세", "부가세", "종소세", "기타"];
private static readonly string[] results = ["", "상담완료", "추가자료 요청", "견적발송", "계약전환", "보류"];
private bool showAddForm; private bool showAddForm;
private DateTime? newDate = DateTime.Today; private DateTime? newDate = DateTime.Today;
@@ -197,8 +200,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 +229,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>
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" <MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PersonAdd" StartIcon="@Icons.Material.Filled.PersonAdd"
Href="/taxbaik/admin/clients/create"> Href="/taxbaik/admin/clients/create">
고객 등록 고객 등록
</MudButton> </MudButton>
</section> </ChildContent>
</AdminPageHeader>
@* 검색/필터 바 *@ @* 검색/필터 바 *@
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0"> <MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
@@ -53,10 +50,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
{ {
@@ -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>
@@ -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; }
}
+11 -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()
@@ -84,7 +94,7 @@ 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(); 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>
+5 -5
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"] = "사업자 기장, 부동산 양도세·증여상속, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
} }
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@ @* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
@@ -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>
@@ -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="javascript:void(0);" onclick="openKakao()" 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="javascript:void(0);" onclick="openKakao()" class="btn btn-light btn-lg">카카오채널로 문의</a>
</div> </div>
} }
</div> </div>
+4 -47
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
// that block drawer interaction and make the app feel frozen.
window.taxbaikAdminSession.hideLoading(); window.taxbaikAdminSession.hideLoading();
return;
}
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;
+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()
+4 -4
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,7 +17,7 @@ INSERT INTO faqs (question, answer, category, sort_order, is_active) VALUES
( (
'기장료가 얼마인지 미리 알 수 있나요?', '기장료가 얼마인지 미리 알 수 있나요?',
'업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다. 일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다. 먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.', '업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다. 일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다. 먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.',
'기장·세금신고', 10, TRUE '기장세금신고', 10, TRUE
), ),
( (
'양도세 상담은 어떻게 진행되나요?', '양도세 상담은 어떻게 진행되나요?',
@@ -31,6 +31,6 @@ INSERT INTO faqs (question, answer, category, sort_order, is_active) VALUES
), ),
( (
'처음 상담 시 어떤 자료를 준비해야 하나요?', '처음 상담 시 어떤 자료를 준비해야 하나요?',
'상담 목적에 따라 다르지만 아래 자료가 있으면 더 정확한 안내가 가능합니다. 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료 / 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료 / 증여·상속: 재산 목록, 증여 예정 자산 내역. 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.', '상담 목적에 따라 다르지만 아래 자료가 있으면 더 정확한 안내가 가능합니다. 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료 / 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료 / 증여상속: 재산 목록, 증여 예정 자산 내역. 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.',
'기타', 40, TRUE '증여상속', 40, TRUE
); );
+1 -1
View File
@@ -35,7 +35,7 @@ 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
@@ -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 (
@@ -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 (
@@ -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 (
@@ -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 (
-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가지
@@ -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 (
@@ -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)
@@ -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);
+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` 중 하나를 명시해야 한다.
- 상태/유형/등급 입력이 있는 화면은 콤보 정책 위반이 없어야 한다.
- 고객/회사처럼 데이터가 많은 항목은 검색형 선택으로 통일한다.
+55
View File
@@ -0,0 +1,55 @@
# 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`
- `CLIENT_STATUS`
- `CLIENT_SERVICE_TYPE`
- `CLIENT_TAX_TYPE`
- `CLIENT_SOURCE`
- `CONTRACT_SERVICE_TYPE`
- `REVENUE_SERVICE_TYPE`
- `FILING_TYPE`
- `TAX_RISK_LEVEL`
- `BUSINESS_TYPE`
## 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` 패턴 중 하나 이상을 재사용한다.
+96
View File
@@ -0,0 +1,96 @@
# 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만 호출한다.
- 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 |
## Stop Conditions
- 동일 개념이 3곳 이상 다른 이름/계약으로 구현되면 기능 추가를 중단하고 정리한다.
- UI가 저장한다고 보이는 필드를 API/Application이 저장하지 않으면 릴리스하지 않는다.
- 운영 배포 검증이 CI 밖에서만 가능하면 완료로 보지 않는다.
- 데이터 모델을 추측해서 세무 규칙이나 더존 UX 관습을 왜곡해 구현하지 않는다.
+31
View File
@@ -0,0 +1,31 @@
# 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
- 문서는 짧게 유지한다. 새 문서를 만들기 전에 이 인덱스에 추가할 가치가 있는지 판단한다.
- 동일한 기준을 여러 문서에 중복 작성하지 않는다.
- 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;