diff --git a/CLAUDE.md b/CLAUDE.md index b99a467..7acddcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ _refreshTokenExpirationMinutes = 10080; - 모든 API 엔드포인트 구현됨 - 모든 Browser Client 구현됨 - 16개 Blazor 페이지 API-First 마이그레이션 완료 -- MudDataGrid Douzone ERP 수준 UX 적용 +- MudDataGrid 더존 세무회계프로그램 UX 수준 적용 - MudDialog 모달 패턴 (흰 화면 플래시 제거) - ConfirmDialog 삭제 확인 컴포넌트 @@ -119,7 +119,7 @@ _refreshTokenExpirationMinutes = 10080; - 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking) - 5개 Browser Client (API-First 패턴) - 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog) -- Douzone ERP 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화) +- 더존 세무회계프로그램 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화) | 페이지 | API | Client | Blazor | 핵심 기능 | |------|---|---|---|---------| @@ -972,9 +972,9 @@ Admin 로그인 페이지만 [AllowAnonymous]: - 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기 - 업데이트는 `StateHasChanged()` 호출 -### 8.6 어드민 그리드 UX (Dorsum ERP 수준) +### 8.6 어드민 그리드 UX (더존 세무회계프로그램 수준) -**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + ERP 수준의 상호작용성 +**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + 더존식 상호작용성 #### 그리드 기본 원칙 - **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거) diff --git a/README.md b/README.md index 27b7ece..d426db6 100644 --- a/README.md +++ b/README.md @@ -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) - 배포 완전 가이드 - [SERVER_SETUP.sh](./SERVER_SETUP.sh) - 서버 자동 설치 스크립트 diff --git a/TaxBaik.Application.Tests/BlogServiceTests.cs b/TaxBaik.Application.Tests/BlogServiceTests.cs index 70ce0a0..42fbc49 100644 --- a/TaxBaik.Application.Tests/BlogServiceTests.cs +++ b/TaxBaik.Application.Tests/BlogServiceTests.cs @@ -44,15 +44,34 @@ public class BlogServiceTests 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 { public List Posts { get; init; } = []; public Task 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 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 Items, int Total)> GetPublishedPagedAsync( 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 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; } diff --git a/TaxBaik.Application.Tests/InquiryServiceTests.cs b/TaxBaik.Application.Tests/InquiryServiceTests.cs index 59374f7..59fb625 100644 --- a/TaxBaik.Application.Tests/InquiryServiceTests.cs +++ b/TaxBaik.Application.Tests/InquiryServiceTests.cs @@ -80,6 +80,22 @@ public class InquiryServiceTests 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) { var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId); diff --git a/TaxBaik.Application/DTOs/CreateBlogPostDto.cs b/TaxBaik.Application/DTOs/CreateBlogPostDto.cs index 0e4d2bc..2a5923a 100644 --- a/TaxBaik.Application/DTOs/CreateBlogPostDto.cs +++ b/TaxBaik.Application/DTOs/CreateBlogPostDto.cs @@ -12,3 +12,21 @@ public class CreateBlogPostDto public bool IsPublished { 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; } +} diff --git a/TaxBaik.Application/DTOs/SubmitInquiryDto.cs b/TaxBaik.Application/DTOs/SubmitInquiryDto.cs new file mode 100644 index 0000000..6fb4a59 --- /dev/null +++ b/TaxBaik.Application/DTOs/SubmitInquiryDto.cs @@ -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; } +} diff --git a/TaxBaik.Application/DTOs/UpdateInquiryDto.cs b/TaxBaik.Application/DTOs/UpdateInquiryDto.cs new file mode 100644 index 0000000..47dbe32 --- /dev/null +++ b/TaxBaik.Application/DTOs/UpdateInquiryDto.cs @@ -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; } +} diff --git a/TaxBaik.Application/Services/BlogService.cs b/TaxBaik.Application/Services/BlogService.cs index de43148..c7829a0 100644 --- a/TaxBaik.Application/Services/BlogService.cs +++ b/TaxBaik.Application/Services/BlogService.cs @@ -110,6 +110,12 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach 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) => await repository.IncrementViewCountAsync(id, ct); diff --git a/TaxBaik.Application/Services/ClientService.cs b/TaxBaik.Application/Services/ClientService.cs index a46a6f7..c41679a 100644 --- a/TaxBaik.Application/Services/ClientService.cs +++ b/TaxBaik.Application/Services/ClientService.cs @@ -6,15 +6,6 @@ using TaxBaik.Domain.Interfaces; public class ClientService(IClientRepository repository) { - public static readonly string[] ServiceTypes = - ["기장", "부동산", "증여·상속", "종합소득세", "법인세", "부가가치세", "기타"]; - - public static readonly string[] TaxTypes = - ["개인사업자", "법인사업자", "면세사업자", "근로소득자", "기타"]; - - public static readonly string[] Sources = - ["홈페이지 문의", "소개", "직접 방문", "카카오 채널", "블로그", "기타"]; - public async Task<(IEnumerable Items, int Total)> GetPagedAsync( 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); @@ -81,7 +72,7 @@ public class ClientService(IClientRepository repository) Phone = phone?.Trim(), ServiceType = serviceType, Status = "active", - Source = "홈페이지 문의" + Source = "홈페이지문의" }; return await repository.CreateAsync(client, ct); } diff --git a/TaxBaik.Application/Services/FaqService.cs b/TaxBaik.Application/Services/FaqService.cs index 03e02d0..c071bbd 100644 --- a/TaxBaik.Application/Services/FaqService.cs +++ b/TaxBaik.Application/Services/FaqService.cs @@ -6,7 +6,7 @@ using TaxBaik.Domain.Interfaces; public class FaqService(IFaqRepository repository) { public static readonly string[] Categories = - ["기장·세금신고", "부동산", "증여·상속", "기타"]; + ["기장세금신고", "부동산", "증여상속", "기타"]; public async Task> GetActiveAsync(CancellationToken ct = default) => await repository.GetActiveAsync(ct); diff --git a/TaxBaik.Application/Services/InquiryService.cs b/TaxBaik.Application/Services/InquiryService.cs index d6d7c66..4c3bd96 100644 --- a/TaxBaik.Application/Services/InquiryService.cs +++ b/TaxBaik.Application/Services/InquiryService.cs @@ -2,6 +2,7 @@ namespace TaxBaik.Application.Services; using System.Text.RegularExpressions; using Microsoft.Extensions.Caching.Memory; +using TaxBaik.Application.DTOs; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Enums; using TaxBaik.Domain.Interfaces; @@ -72,6 +73,37 @@ public class InquiryService( public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) => await repository.UpdateAdminMemoAsync(id, adminMemo, ct); + public async Task 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) => await repository.LinkClientAsync(inquiryId, clientId, ct); diff --git a/TaxBaik.Application/Services/TaxFilingService.cs b/TaxBaik.Application/Services/TaxFilingService.cs index cbf25a1..f50563c 100644 --- a/TaxBaik.Application/Services/TaxFilingService.cs +++ b/TaxBaik.Application/Services/TaxFilingService.cs @@ -5,9 +5,6 @@ using TaxBaik.Domain.Interfaces; public class TaxFilingService(ITaxFilingRepository repository) { - public static readonly string[] FilingTypes = - ["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"]; - public static readonly string[] Statuses = ["pending", "filed", "overdue"]; diff --git a/TaxBaik.Domain/Entities/BlogPost.cs b/TaxBaik.Domain/Entities/BlogPost.cs index 71a542c..58cff6c 100644 --- a/TaxBaik.Domain/Entities/BlogPost.cs +++ b/TaxBaik.Domain/Entities/BlogPost.cs @@ -17,6 +17,7 @@ public class BlogPost public bool IsPublished { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + public DateTime? DeletedAt { get; set; } // Navigation property (populated via LEFT JOIN, not stored in DB) public string? CategoryName { get; set; } diff --git a/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs index 7b43091..bcc5526 100644 --- a/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs +++ b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs @@ -15,5 +15,6 @@ public interface IBlogPostRepository Task CreateAsync(BlogPost post, CancellationToken cancellationToken = default); Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default); Task DeleteAsync(int id, CancellationToken cancellationToken = default); + Task ArchiveAsync(int id, CancellationToken cancellationToken = default); Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default); } diff --git a/TaxBaik.Domain/Interfaces/IInquiryRepository.cs b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs index 1b3c092..ed5a301 100644 --- a/TaxBaik.Domain/Interfaces/IInquiryRepository.cs +++ b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs @@ -15,6 +15,7 @@ public interface IInquiryRepository Task CountByStatusAndDateAsync(string status, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); Task UpdateStatusAsync(int id, string status, 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 DeleteAsync(int id, CancellationToken cancellationToken = default); } diff --git a/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs index 5e183b5..dce059a 100644 --- a/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs @@ -15,7 +15,7 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name FROM blog_posts bp 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 }); } @@ -28,7 +28,7 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name FROM blog_posts bp 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 }); } @@ -44,12 +44,12 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name FROM blog_posts bp 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 LIMIT @PageSize OFFSET @Offset; 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 }); var items = (await reader.ReadAsync()).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 FROM blog_posts bp 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 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 FROM blog_posts bp LEFT JOIN categories c ON bp.category_id = c.id + WHERE bp.deleted_at IS NULL 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 FROM blog_posts bp LEFT JOIN categories c ON bp.category_id = c.id + WHERE bp.deleted_at IS NULL ORDER BY bp.created_at DESC LIMIT @PageSize OFFSET @Offset; - SELECT COUNT(*) FROM blog_posts;", + SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NULL;", new { PageSize = pageSize, Offset = offset }); var items = (await reader.ReadAsync()).ToList(); @@ -130,19 +132,26 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe tags = @Tags, author_id = @AuthorId, published_at = @PublishedAt, seo_title = @SeoTitle, seo_description = @SeoDescription, thumbnail_url = @ThumbnailUrl, is_published = @IsPublished, updated_at = NOW() - WHERE id = @Id", + WHERE id = @Id AND deleted_at IS NULL", post); } 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(); - 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) { 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 }); } } diff --git a/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs index 3238ffa..038fd0b 100644 --- a/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs @@ -112,6 +112,23 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep 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) { using var conn = Conn(); diff --git a/TaxBaik.Web.Client/Program.cs b/TaxBaik.Web.Client/Program.cs index 1bd5678..99a3e52 100644 --- a/TaxBaik.Web.Client/Program.cs +++ b/TaxBaik.Web.Client/Program.cs @@ -29,6 +29,8 @@ builder.Services.AddHttpClient(client => // 각 Browser API Client 등록 builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler(); +builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler(); +builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(apiBaseUrl)); builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler(); builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler(); diff --git a/TaxBaik.Web.Client/Services/BlogBrowserClient.cs b/TaxBaik.Web.Client/Services/BlogBrowserClient.cs new file mode 100644 index 0000000..0a24f03 --- /dev/null +++ b/TaxBaik.Web.Client/Services/BlogBrowserClient.cs @@ -0,0 +1,88 @@ +namespace TaxBaik.Web.Services; + +using System.Net.Http.Json; +using TaxBaik.Application.DTOs; + +public interface IBlogBrowserClient +{ + Task<(IEnumerable Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default); + Task UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); + Task TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default); +} + +public class BlogBrowserClient : IBlogBrowserClient +{ + private readonly HttpClient _http; + private readonly ILogger _logger; + private readonly ITokenStore _tokenStore; + + public BlogBrowserClient(HttpClient http, ILogger 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 Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default) + { + EnsureAuthHeader(); + var result = await _http.GetFromJsonAsync($"blog/admin?page={page}&pageSize={pageSize}", ct); + return result != null ? (result.Data, result.Total) : ([], 0); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + EnsureAuthHeader(); + return await _http.GetFromJsonAsync($"blog/{id}", ct); + } + + public async Task 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(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + + public async Task 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(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) + { + EnsureAuthHeader(); + var response = await _http.DeleteAsync($"blog/{id}", ct); + return response.IsSuccessStatusCode; + } + + public async Task TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default) + { + var result = await UpdateAsync(id, dto, ct); + return result != null; + } + + private sealed class PagedResponse + { + public List Data { get; set; } = []; + public int Total { get; set; } + } +} diff --git a/TaxBaik.Web.Client/Services/CategoryBrowserClient.cs b/TaxBaik.Web.Client/Services/CategoryBrowserClient.cs new file mode 100644 index 0000000..97203b5 --- /dev/null +++ b/TaxBaik.Web.Client/Services/CategoryBrowserClient.cs @@ -0,0 +1,35 @@ +namespace TaxBaik.Web.Services; + +using System.Net.Http.Json; +using TaxBaik.Domain.Entities; + +public interface ICategoryBrowserClient +{ + Task> GetAllAsync(CancellationToken ct = default); +} + +public class CategoryBrowserClient : ICategoryBrowserClient +{ + private readonly HttpClient _http; + private readonly ILogger _logger; + + public CategoryBrowserClient(HttpClient http, ILogger logger) + { + _http = http; + _logger = logger; + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + try + { + var result = await _http.GetFromJsonAsync>("category", cancellationToken: ct); + return result ?? []; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to fetch categories"); + throw; + } + } +} diff --git a/TaxBaik.Web.Client/Services/InquiryBrowserClient.cs b/TaxBaik.Web.Client/Services/InquiryBrowserClient.cs index 0f97dc7..2ebdb00 100644 --- a/TaxBaik.Web.Client/Services/InquiryBrowserClient.cs +++ b/TaxBaik.Web.Client/Services/InquiryBrowserClient.cs @@ -2,6 +2,7 @@ namespace TaxBaik.Web.Services; using System.Net.Http; using System.Net.Http.Json; +using TaxBaik.Application.DTOs; using TaxBaik.Domain.Entities; /// @@ -15,7 +16,10 @@ public interface IInquiryBrowserClient Task GetByIdAsync(int id, CancellationToken ct = default); Task UpdateStatusAsync(int id, string status, CancellationToken ct = default); Task UpdateAdminMemoAsync(int id, string adminMemo, CancellationToken ct = default); + Task UpdateAsync(int id, UpdateInquiryDto dto, CancellationToken ct = default); Task ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default); + Task CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); } public class InquiryBrowserClient : IInquiryBrowserClient @@ -116,6 +120,27 @@ public class InquiryBrowserClient : IInquiryBrowserClient } } + public async Task 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( + content, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to update inquiry {InquiryId}", id); + throw; + } + } + public async Task ConvertToClientAsync(int id, string name, string phone, string serviceType, CancellationToken ct = default) { try @@ -143,6 +168,42 @@ public class InquiryBrowserClient : IInquiryBrowserClient } } + public async Task 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( + content, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to create inquiry"); + throw; + } + } + + public async Task 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 { public List Data { get; set; } = []; diff --git a/TaxBaik.Web/Components/Admin/App.razor b/TaxBaik.Web/Components/Admin/App.razor index 5b7c006..021af67 100644 --- a/TaxBaik.Web/Components/Admin/App.razor +++ b/TaxBaik.Web/Components/Admin/App.razor @@ -11,11 +11,6 @@ - - - - - diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor index e55bb7a..2c0aa94 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogEdit.razor @@ -1,11 +1,9 @@ @page "/admin/blog/{id:int}/edit" @attribute [Authorize] -@rendermode @(new InteractiveServerRenderMode(prerender: false)) @using TaxBaik.Application.DTOs -@using TaxBaik.Application.Services -@using TaxBaik.Domain.Interfaces -@inject BlogService BlogService -@inject ICategoryRepository CategoryRepository +@using TaxBaik.Web.Components.Admin.Pages.Blog +@inject IBlogBrowserClient BlogClient +@inject ICategoryBrowserClient CategoryClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -32,42 +30,10 @@ else if (post == null) else { - - - - - @foreach (var category in categories) - { - @category.Name - } - - -
- - -
-
- - - - - - - - - -
- 저장 - 삭제 -
-
+ +
+ 삭제 +
} @@ -75,23 +41,19 @@ else [Parameter] public int Id { get; set; } - [Inject] - private IJSRuntime JS { get; set; } = null!; - - private MudForm? form; - private Domain.Entities.BlogPost? post; - private List categories = []; - private EditPostModel model = new(); + private TaxBaik.Application.DTOs.BlogPostResponseDto? post; + private IReadOnlyList categories = []; + private BlogForm.BlogFormModel model = new(); private bool isLoading = true; protected override async Task OnInitializedAsync() { try { - post = await BlogService.GetByIdAsync(Id); + post = await BlogClient.GetByIdAsync(Id); if (post != null) { - categories = (await CategoryRepository.GetAllAsync()).ToList(); + categories = await CategoryClient.GetAllAsync(); MapPostToModel(post); } } @@ -105,15 +67,7 @@ else } } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender && post != null) - { - await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? ""); - } - } - - private void MapPostToModel(Domain.Entities.BlogPost post) + private void MapPostToModel(TaxBaik.Application.DTOs.BlogPostResponseDto post) { model.Title = post.Title; model.Content = post.Content; @@ -131,25 +85,12 @@ else private async Task SavePost() { - if (form == null || post == null) - return; - - // 에디터에서 최신 내용 가져오기 - model.Content = await JS.InvokeAsync("window.getMarkdownContent"); - - if (string.IsNullOrWhiteSpace(model.Content)) - { - Snackbar.Add("본문 내용을 입력하세요.", Severity.Error); - return; - } - - await form.Validate(); - if (!form.IsValid) + if (post == null) return; try { - await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto + var result = await BlogClient.UpdateAsync(post.Id, new CreateBlogPostDto { Title = model.Title, Content = model.Content, @@ -160,6 +101,12 @@ else IsPublished = model.IsPublished }); + if (result == null) + { + Snackbar.Add("저장 실패: 포스트를 저장하지 못했습니다.", Severity.Error); + return; + } + Snackbar.Add("포스트가 저장되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/blog"); } @@ -188,7 +135,12 @@ else try { - await BlogService.DeleteAsync(post.Id); + var deleted = await BlogClient.DeleteAsync(post.Id); + if (!deleted) + { + Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error); + return; + } Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/blog"); } @@ -197,45 +149,4 @@ else 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; } - } } - - - diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogForm.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogForm.razor new file mode 100644 index 0000000..029752d --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogForm.razor @@ -0,0 +1,80 @@ +@using TaxBaik.Application.DTOs +@using TaxBaik.Domain.Entities + + + + + + @foreach (var category in Categories) + { + @category.Name + } + + + + + + + + + + + + +
+ @SubmitText + @if (OnCancel.HasDelegate) + { + 취소 + } +
+
+ +@code { + [Parameter, EditorRequired] + public BlogFormModel Model { get; set; } = new(); + + [Parameter] + public IReadOnlyList 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; } + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor index 2fe9c75..7b31648 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor @@ -1,8 +1,9 @@ @page "/admin/clients/{ClientId:int}" @attribute [Authorize] -@using TaxBaik.Application.Services -@inject ClientService ClientService -@inject ConsultationService ConsultationService +@using TaxBaik.Web.Services +@using TaxBaik.Web.Services.AdminClients +@inject IClientBrowserClient ClientClient +@inject IConsultingActivityBrowserClient ConsultingClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @@ -102,8 +103,8 @@ - - @foreach (var t in ClientService.ServiceTypes) + + @foreach (var t in serviceTypes) { @t } @@ -116,7 +117,7 @@ - - @foreach (var r in ConsultationService.Results) + @foreach (var r in results) { @r } @@ -182,6 +183,8 @@ private Domain.Entities.Client? client; private List consultations = []; + private static readonly string[] serviceTypes = ["기장대리", "세무조정", "양도세", "증여세", "상속세", "부가세", "종소세", "기타"]; + private static readonly string[] results = ["", "상담완료", "추가자료 요청", "견적발송", "계약전환", "보류"]; private bool showAddForm; private DateTime? newDate = DateTime.Today; @@ -197,8 +200,19 @@ private async Task LoadAll() { - client = await ClientService.GetByIdAsync(ClientId); - consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); + client = await ClientClient.GetByIdAsync(ClientId); + 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() @@ -215,30 +229,35 @@ { try { - var c = new Domain.Entities.Consultation - { - ClientId = ClientId, - ConsultationDate = newDate?.ToUniversalTime() ?? DateTime.UtcNow, - ServiceType = string.IsNullOrWhiteSpace(newServiceType) ? null : newServiceType, - Summary = newSummary, - Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult, - Fee = newFee - }; - await ConsultationService.CreateAsync(c); + var newId = await ConsultingClient.CreateAsync( + ClientId, + string.IsNullOrWhiteSpace(newServiceType) ? "기타" : newServiceType, + newDate?.ToUniversalTime() ?? DateTime.UtcNow, + newSummary, + null, + null); + + if (newId <= 0) + throw new Exception("상담 생성 실패"); + showAddForm = false; - consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); + await LoadAll(); Snackbar.Add("상담이 추가되었습니다.", Severity.Success); } catch (ValidationException ex) { Snackbar.Add(ex.Message, Severity.Error); } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } } private async Task DeleteConsultation(int id) { - await ConsultationService.DeleteAsync(id); - consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList(); + await ConsultingClient.DeleteAsync(id); + await LoadAll(); Snackbar.Add("삭제되었습니다.", Severity.Info); } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor index c31862e..8300b25 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor @@ -4,6 +4,7 @@ @using TaxBaik.Application.DTOs @using TaxBaik.Web.Services @using TaxBaik.Domain.Entities +@using TaxBaik.Web.Components.Admin.Shared @inject IClientBrowserClient ClientClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @@ -54,20 +55,10 @@ - - @foreach (var t in ClientService.ServiceTypes) - { - @t - } - + - - @foreach (var t in ClientService.TaxTypes) - { - @t - } - + @* 관리 정보 *@ @@ -76,18 +67,10 @@ - - 활성 - 비활성 - + - - @foreach (var s in ClientService.Sources) - { - @s - } - + 고객 관리 -
-
- CRM - 고객 관리 - 고객 카드를 등록하고 상담 이력을 관리합니다. -
- - 고객 등록 - -
+ + + + 고객 등록 + + + @* 검색/필터 바 *@ @@ -53,10 +50,7 @@ } else if (!clients.Any()) { -
- - 등록된 고객이 없습니다. -
+ } else { diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor index dbe453e..57690f7 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryCreate.razor @@ -1,9 +1,8 @@ @page "/admin/inquiries/create" @attribute [Authorize] @using TaxBaik.Application.DTOs -@using TaxBaik.Application.Services @using TaxBaik.Web.Components.Admin.Forms -@inject InquiryService InquiryService +@inject IInquiryBrowserClient InquiryClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @@ -32,13 +31,21 @@ { try { - await InquiryService.SubmitAsync( - model.Name, - model.Phone, - model.ServiceType, - model.Message, - model.Email, - ipAddress: "admin-registered"); + var result = await InquiryClient.CreateAsync(new SubmitInquiryDto + { + Name = model.Name, + Phone = model.Phone, + Email = model.Email, + ServiceType = model.ServiceType, + Message = model.Message, + SuppressNotification = true + }); + + if (result == null) + { + Snackbar.Add("문의가 등록되지 않았습니다.", Severity.Error); + return; + } Snackbar.Add("문의가 등록되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/inquiries"); diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor index 5eb11ea..26fd419 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryEdit.razor @@ -1,9 +1,8 @@ @page "/admin/inquiries/{id:int}/edit" @attribute [Authorize] @using TaxBaik.Application.DTOs -@using TaxBaik.Application.Services @using TaxBaik.Web.Components.Admin.Forms -@inject InquiryService InquiryService +@inject IInquiryBrowserClient InquiryClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -52,7 +51,7 @@ else { try { - inquiry = await InquiryService.GetByIdAsync(Id); + inquiry = await InquiryClient.GetByIdAsync(Id); if (inquiry != null) { formModel = new InquiryForm.InquiryFormModel @@ -89,19 +88,34 @@ else try { - inquiry.Name = model.Name; - inquiry.Phone = model.Phone; - inquiry.Email = model.Email; - inquiry.ServiceType = model.ServiceType; - inquiry.Message = model.Message; - inquiry.AdminMemo = model.AdminMemo; - - if (inquiry.Status != model.Status) + var updated = await InquiryClient.UpdateAsync(inquiry.Id, new UpdateInquiryDto { - 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); Navigation.NavigateTo("/taxbaik/admin/inquiries"); @@ -131,7 +145,12 @@ else try { - await InquiryService.DeleteAsync(inquiry.Id); + var deleted = await InquiryClient.DeleteAsync(inquiry.Id); + if (!deleted) + { + Snackbar.Add("문의 삭제에 실패했습니다.", Severity.Error); + return; + } Snackbar.Add("문의가 삭제되었습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/inquiries"); } diff --git a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor index dfa1c3f..fccd373 100644 --- a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor +++ b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor @@ -1,5 +1,6 @@ @page "/admin/revenue-trackings" @using TaxBaik.Web.Services.AdminClients +@using TaxBaik.Web.Components.Admin.Shared @inject IRevenueTrackingBrowserClient RevenueClient @inject IClientBrowserClient ClientClient @inject ISnackbar Snackbar @@ -102,13 +103,7 @@ - - 기장 수수료 - 세무조정료 - 세무상담료 - 신고 대행료 - 자문 수수료 - + diff --git a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor index 0368119..8ee4691 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor @@ -35,7 +35,7 @@ - @context.ClientName @context.FilingType - @context.DueDate.ToString("yyyy-MM-dd") + @BusinessDayCalculator.GetEffectiveDueDate(DateOnly.FromDateTime(context.DueDate)).ToDateTime(TimeOnly.MinValue).ToString("yyyy-MM-dd") @{ - var dday = (context.DueDate.Date - DateTime.Today).Days; + var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(context.DueDate)); } @if (dday < 0) { diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor index 3c58f0e..6018758 100644 --- a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @using TaxBaik.Web.Services @using TaxBaik.Domain.Entities +@using TaxBaik.Web.Components.Admin.Shared @inject ITaxFilingBrowserClient FilingClient @inject IClientBrowserClient ClientClient @inject ISnackbar Snackbar @@ -34,12 +35,7 @@ Variant="Variant.Outlined" />
- - @foreach (var t in TaxFilingService.FilingTypes) - { - @t - } - + @@ -82,6 +78,10 @@ protected override async Task OnInitializedAsync() => await Reload(); + protected override async Task OnParametersSetAsync() + { + } + private async Task Reload() { try diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminEmptyState.razor b/TaxBaik.Web/Components/Admin/Shared/AdminEmptyState.razor new file mode 100644 index 0000000..4e4ea11 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminEmptyState.razor @@ -0,0 +1,12 @@ +
+ + @Message +
+ +@code { + [Parameter, EditorRequired] + public string Icon { get; set; } = Icons.Material.Filled.Info; + + [Parameter, EditorRequired] + public string Message { get; set; } = ""; +} diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminPageHeader.razor b/TaxBaik.Web/Components/Admin/Shared/AdminPageHeader.razor new file mode 100644 index 0000000..becd427 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Shared/AdminPageHeader.razor @@ -0,0 +1,31 @@ +
+
+ @if (!string.IsNullOrWhiteSpace(Eyebrow)) + { + @Eyebrow + } + @Title + @if (!string.IsNullOrWhiteSpace(Subtitle)) + { + @Subtitle + } +
+ @if (ChildContent is not null) + { +
@ChildContent
+ } +
+ +@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; } +} diff --git a/TaxBaik.Web/Controllers/BlogController.cs b/TaxBaik.Web/Controllers/BlogController.cs index 47f6ad1..55b870d 100644 --- a/TaxBaik.Web/Controllers/BlogController.cs +++ b/TaxBaik.Web/Controllers/BlogController.cs @@ -32,6 +32,16 @@ public class BlogController : ControllerBase return Ok(post); } + [HttpGet("admin/{id:int}")] + [Authorize] + public async Task 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")] [Authorize] public async Task GetAll() @@ -84,7 +94,7 @@ public class BlogController : ControllerBase [Authorize] public async Task Delete(int id) { - await _blogService.DeleteAsync(id); + await _blogService.ArchiveAsync(id); return NoContent(); } } diff --git a/TaxBaik.Web/Controllers/InquiryController.cs b/TaxBaik.Web/Controllers/InquiryController.cs index 1a03994..be7e9d3 100644 --- a/TaxBaik.Web/Controllers/InquiryController.cs +++ b/TaxBaik.Web/Controllers/InquiryController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; +using TaxBaik.Application.DTOs; using TaxBaik.Application.Services; namespace TaxBaik.Web.Controllers; @@ -19,7 +20,7 @@ public class InquiryController : ControllerBase } [HttpPost] - public async Task Submit([FromBody] SubmitInquiryRequest request) + public async Task Submit([FromBody] SubmitInquiryDto request) { if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone)) return BadRequest(new ProblemDetails { Title = "이름과 전화번호를 입력하세요.", Status = StatusCodes.Status400BadRequest }); @@ -99,6 +100,23 @@ public class InquiryController : ControllerBase } } + [HttpPut("{id}")] + [Authorize] + public async Task 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")] [Authorize] public async Task 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 string Status { get; set; } = string.Empty; diff --git a/TaxBaik.Web/Pages/About.cshtml b/TaxBaik.Web/Pages/About.cshtml index 847428b..919a12c 100644 --- a/TaxBaik.Web/Pages/About.cshtml +++ b/TaxBaik.Web/Pages/About.cshtml @@ -142,7 +142,7 @@

무료 상담으로 현재 상황을 진단하고 맞춤형 절세 전략을 받아보세요.

diff --git a/TaxBaik.Web/Pages/Blog/Post.cshtml.cs b/TaxBaik.Web/Pages/Blog/Post.cshtml.cs index ddcb64a..5643717 100644 --- a/TaxBaik.Web/Pages/Blog/Post.cshtml.cs +++ b/TaxBaik.Web/Pages/Blog/Post.cshtml.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using TaxBaik.Application.Services; using TaxBaik.Domain.Entities; -using Markdig; +using System.Net; namespace TaxBaik.Web.Pages.Blog; @@ -22,7 +22,7 @@ public class BlogPostModel : PageModel Post = await _blogService.GetBySlugAsync(slug); if (Post != null) { - HtmlContent = Markdown.ToHtml(Post.Content ?? ""); + HtmlContent = WebUtility.HtmlEncode(Post.Content ?? "").Replace("\r\n", "
").Replace("\n", "
"); _ = _blogService.IncrementViewCountAsync(Post.Id); } } diff --git a/TaxBaik.Web/Pages/Contact.cshtml b/TaxBaik.Web/Pages/Contact.cshtml index 3a10e56..aa63541 100644 --- a/TaxBaik.Web/Pages/Contact.cshtml +++ b/TaxBaik.Web/Pages/Contact.cshtml @@ -47,7 +47,7 @@ - + diff --git a/TaxBaik.Web/Pages/Index.cshtml b/TaxBaik.Web/Pages/Index.cshtml index 539a6f2..ac2bdd1 100644 --- a/TaxBaik.Web/Pages/Index.cshtml +++ b/TaxBaik.Web/Pages/Index.cshtml @@ -4,8 +4,8 @@ var season = Model.CurrentSeason; ViewData["Title"] = season != null ? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요" - : "백원숙 세무회계 | 사업자·부동산·증여 세무 상담"; - ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공."; + : "백원숙 세무회계 | 사업자·부동산·증여상속 세무 상담"; + ViewData["Description"] = "사업자 기장, 부동산 양도세·증여상속, 종합소득세 전문 상담. 온라인 맞춤 상담 제공."; } @* ─── 공지사항 배너 (관리자 등록 공지) ─── *@ @@ -50,7 +50,7 @@ - 💬 카카오 채널 문의 + 💬 카카오채널 문의 @if (season.DaysUntilDeadline <= 7) @@ -91,7 +91,7 @@ else 무료 상담 신청 - 💬 카카오 채널 문의 + 💬 카카오채널 문의 @@ -176,7 +176,7 @@ else
👨‍👩‍👧‍👦

가족자산 관리

-

증여·상속 사전 계획부터 대표자 리스크 관리까지 — 가족 자산을 지키는 전략.

+

증여상속 사전 계획부터 대표자 리스크 관리까지 - 가족 자산을 지키는 전략.

자세히 보기
@@ -362,7 +362,7 @@ else

} else @@ -374,7 +374,7 @@ else

} diff --git a/TaxBaik.Web/wwwroot/js/admin-session.js b/TaxBaik.Web/wwwroot/js/admin-session.js index 7e558a0..8ac0352 100644 --- a/TaxBaik.Web/wwwroot/js/admin-session.js +++ b/TaxBaik.Web/wwwroot/js/admin-session.js @@ -21,51 +21,9 @@ window.taxbaikAdminSession = { }, showLoading: function () { - if (document.documentElement.classList.contains('admin-login-route')) { - 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); + // Route transitions are handled by Blazor; avoid full-screen overlays + // that block drawer interaction and make the app feel frozen. + window.taxbaikAdminSession.hideLoading(); }, hideLoading: function () { @@ -93,9 +51,8 @@ window.taxbaikAdminSession = { window.taxbaikAdminSession.hideLoading(); } - // Show loading on initial page load — overlay has 'show' from HTML, - // but we still need to set up the observer to detect when to hide it. - window.taxbaikAdminSession.showLoading(); + // Keep the initial overlay hidden unless explicitly enabled elsewhere. + window.taxbaikAdminSession.hideLoading(); const modal = document.getElementById('components-reconnect-modal'); if (!modal) return; diff --git a/TaxBaik.Web/wwwroot/maintenance.html b/TaxBaik.Web/wwwroot/maintenance.html index 48ed5bd..7119733 100644 --- a/TaxBaik.Web/wwwroot/maintenance.html +++ b/TaxBaik.Web/wwwroot/maintenance.html @@ -65,9 +65,9 @@ 보통 1~2분 이내에 완료됩니다.


-

급하신 세무 문의는 카카오 채널로 연락해 주세요.

+

급하신 세무 문의는 카카오채널로 연락해 주세요.

- 💬 카카오 채널 상담 + 💬 카카오채널 상담

이 페이지는 15초 후 자동으로 새로고침됩니다.

diff --git a/db/migrations/V006__CreateClients.sql b/db/migrations/V006__CreateClients.sql index c33b88b..1c3b7e8 100644 --- a/db/migrations/V006__CreateClients.sql +++ b/db/migrations/V006__CreateClients.sql @@ -5,10 +5,10 @@ CREATE TABLE IF NOT EXISTS clients ( company_name VARCHAR(200), phone VARCHAR(30), email VARCHAR(200), - service_type VARCHAR(50), -- 기장, 부동산, 증여·상속, 종합소득세, 기타 - tax_type VARCHAR(30), -- 개인, 법인, 면세사업자 + service_type VARCHAR(50), -- 기장, 부동산, 증여상속, 종합소득세, 기타 + tax_type VARCHAR(30), -- 개인사업자, 법인사업자, 면세사업자 status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, inactive - source VARCHAR(50), -- 홈페이지문의, 소개, 직접방문, 기타 + source VARCHAR(50), -- 홈페이지문의, 소개, 직접방문, 카카오채널, 블로그, 기타 memo TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() diff --git a/db/migrations/V007__CreateFaqs.sql b/db/migrations/V007__CreateFaqs.sql index 76a008d..14831a2 100644 --- a/db/migrations/V007__CreateFaqs.sql +++ b/db/migrations/V007__CreateFaqs.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS faqs ( id SERIAL PRIMARY KEY, question VARCHAR(300) NOT NULL, answer TEXT NOT NULL, - category VARCHAR(50), -- 기장·세금신고, 부동산, 증여·상속, 기타 + category VARCHAR(50), -- 기장세금신고, 부동산, 증여상속, 기타 sort_order INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -17,20 +17,20 @@ INSERT INTO faqs (question, answer, category, sort_order, is_active) VALUES ( '기장료가 얼마인지 미리 알 수 있나요?', '업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다. 일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다. 먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.', - '기장·세금신고', 10, TRUE + '기장세금신고', 10, TRUE ), ( '양도세 상담은 어떻게 진행되나요?', - '등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오 채널 또는 문의폼으로 전달해 주시면 예상 세액과 절세 방법을 검토해 드립니다. 매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다.', + '등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오채널 또는 문의폼으로 전달해 주시면 예상 세액과 절세 방법을 검토해 드립니다. 매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다.', '부동산', 20, TRUE ), ( '무료 상담도 가능한가요?', - '네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다. 카카오 채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다. 실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다.', + '네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다. 카카오채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다. 실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다.', '기타', 30, TRUE ), ( '처음 상담 시 어떤 자료를 준비해야 하나요?', - '상담 목적에 따라 다르지만 아래 자료가 있으면 더 정확한 안내가 가능합니다. 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료 / 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료 / 증여·상속: 재산 목록, 증여 예정 자산 내역. 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.', - '기타', 40, TRUE + '상담 목적에 따라 다르지만 아래 자료가 있으면 더 정확한 안내가 가능합니다. 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료 / 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료 / 증여상속: 재산 목록, 증여 예정 자산 내역. 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.', + '증여상속', 40, TRUE ); diff --git a/db/migrations/V017__CreateCommonCodes.sql b/db/migrations/V017__CreateCommonCodes.sql index af0cd96..be04dc0 100644 --- a/db/migrations/V017__CreateCommonCodes.sql +++ b/db/migrations/V017__CreateCommonCodes.sql @@ -35,13 +35,13 @@ INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES ('FILING_TYPE', '법인세', '법인세', 30), ('FILING_TYPE', '원천세', '원천세', 40), ('FILING_TYPE', '양도소득세', '양도소득세', 50), -('FILING_TYPE', '상속/증여세', '상속/증여세', 60) +('FILING_TYPE', '상속증여세', '상속·증여세', 60) ON CONFLICT (code_group, code_value) DO NOTHING; -- Seed data for SERVICE_TYPE INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES -('SERVICE_TYPE', '개인 기장대리', '개인 기장대리', 10), -('SERVICE_TYPE', '법인 기장대리', '법인 기장대리', 20), +('SERVICE_TYPE', '개인기장대리', '개인 기장대리', 10), +('SERVICE_TYPE', '법인기장대리', '법인 기장대리', 20), ('SERVICE_TYPE', '세무조정', '세무조정', 30), ('SERVICE_TYPE', '세무컨설팅', '세무컨설팅', 40), ('SERVICE_TYPE', '불복청구', '불복청구', 50) diff --git a/db/migrations/V019__UpdateBlogPostsCleanup.sql b/db/migrations/V019__UpdateBlogPostsCleanup.sql index 3d8aefb..792220d 100644 --- a/db/migrations/V019__UpdateBlogPostsCleanup.sql +++ b/db/migrations/V019__UpdateBlogPostsCleanup.sql @@ -1,9 +1,6 @@ -- V019: Fix blog posts migration (V018 had quote escaping issues) -- 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 -- 6. 스마트스토어 판매자를 위한 첫 세무 기장 diff --git a/db/migrations/V020__ImproveBlogs3LayerTemplate.sql b/db/migrations/V020__ImproveBlogs3LayerTemplate.sql index a5a5763..d5107a3 100644 --- a/db/migrations/V020__ImproveBlogs3LayerTemplate.sql +++ b/db/migrations/V020__ImproveBlogs3LayerTemplate.sql @@ -3,8 +3,6 @@ -- Layer 2: Details + Tax law changes (impossible to track alone) -- Layer 3: Professional value (tax accountants needed) -DELETE FROM blog_posts WHERE id >= 1; - -- 1. 사업자 기장 시 자주 하는 실수 5가지 INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) VALUES ( diff --git a/db/migrations/V021__FixBlogPostsAdvertisingCompliance.sql b/db/migrations/V021__FixBlogPostsAdvertisingCompliance.sql index 03d5c4b..351e051 100644 --- a/db/migrations/V021__FixBlogPostsAdvertisingCompliance.sql +++ b/db/migrations/V021__FixBlogPostsAdvertisingCompliance.sql @@ -2,8 +2,6 @@ -- Remove absolute claims, replace with past-tense examples -- Replace guarantee language with possibility statements -DELETE FROM blog_posts WHERE id >= 1; - -- 1. 사업자 기장 시 자주 하는 실수 5가지 INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) VALUES ( diff --git a/db/migrations/V022__ApplyAccuracyPrincipleToBlogs.sql b/db/migrations/V022__ApplyAccuracyPrincipleToBlogs.sql index 1104f6d..be209d1 100644 --- a/db/migrations/V022__ApplyAccuracyPrincipleToBlogs.sql +++ b/db/migrations/V022__ApplyAccuracyPrincipleToBlogs.sql @@ -2,8 +2,6 @@ -- Add tax law citations, 2025 standards, data sources -- Remove speculation, assumptions, opinions -DELETE FROM blog_posts WHERE id >= 1; - -- 1. 사업자 기장 시 자주 하는 실수 5가지 INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) VALUES ( diff --git a/db/migrations/V023__CustomerFriendlyLanguageUpdate.sql b/db/migrations/V023__CustomerFriendlyLanguageUpdate.sql index 4312057..0b601fd 100644 --- a/db/migrations/V023__CustomerFriendlyLanguageUpdate.sql +++ b/db/migrations/V023__CustomerFriendlyLanguageUpdate.sql @@ -2,8 +2,6 @@ -- Remove internal jargon (Layer 1-3, "3층 구조", etc.) -- Replace with customer perspective: "할 수 있어요" → "복잡하네" → "세무사가 필요하네" -DELETE FROM blog_posts WHERE id >= 1; - -- 1. 사업자 기장 시 자주 하는 실수 5가지 INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) VALUES ( diff --git a/db/migrations/V024__UpdateBlogsWithLatestTemplate.sql b/db/migrations/V024__UpdateBlogsWithLatestTemplate.sql index 10c0164..781f5d6 100644 --- a/db/migrations/V024__UpdateBlogsWithLatestTemplate.sql +++ b/db/migrations/V024__UpdateBlogsWithLatestTemplate.sql @@ -3,8 +3,6 @@ -- Simplify emojis (remove section headers like 📊, 🧮) -- Keep customer-friendly language (1️⃣ 2️⃣ 3️⃣) -DELETE FROM blog_posts WHERE id >= 1; - -- 1. 사업자 기장 시 자주 하는 실수 5가지 INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at) VALUES ( diff --git a/db/migrations/V025__AddNineBlogPosts.sql b/db/migrations/V025__AddNineBlogPosts.sql index 967c2bf..b67e970 100644 --- a/db/migrations/V025__AddNineBlogPosts.sql +++ b/db/migrations/V025__AddNineBlogPosts.sql @@ -1,8 +1,6 @@ -- V025: Add 9 new blog posts with correct SQL structure -- 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 -- 1. 프리랜서가 놓친 경비 5가지 diff --git a/db/migrations/V025__AddNineBlogPostsProper.sql b/db/migrations/V025__AddNineBlogPostsProper.sql index 44005f7..a98de7c 100644 --- a/db/migrations/V025__AddNineBlogPostsProper.sql +++ b/db/migrations/V025__AddNineBlogPostsProper.sql @@ -2,8 +2,6 @@ -- Each post: 1,500-2,500 words, law citations, 3-step structure -- 2025 tax year basis, accuracy principle -DELETE FROM blog_posts WHERE id >= 1; - -- 1. 프리랜서가 놓친 경비 5가지 INSERT INTO blog_posts (title, slug, content, category_id, is_published, seo_title, seo_description, tags, created_at, updated_at) VALUES ( diff --git a/db/migrations/V026__AddBasePostsAndAssignCategories.sql b/db/migrations/V026__AddBasePostsAndAssignCategories.sql index e750455..c439fa2 100644 --- a/db/migrations/V026__AddBasePostsAndAssignCategories.sql +++ b/db/migrations/V026__AddBasePostsAndAssignCategories.sql @@ -6,8 +6,6 @@ -- cat 4 (부가가치세): 부가세 신고, 부가세 기한, 사업자 등록 -- 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 -- 기초 3개 포스트 (V022, V024) diff --git a/db/migrations/V027__BlogSoftDeleteAndSlugIndex.sql b/db/migrations/V027__BlogSoftDeleteAndSlugIndex.sql new file mode 100644 index 0000000..74700fd --- /dev/null +++ b/db/migrations/V027__BlogSoftDeleteAndSlugIndex.sql @@ -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; diff --git a/db/migrations/V028__SeedAdminComboCommonCodes.sql b/db/migrations/V028__SeedAdminComboCommonCodes.sql new file mode 100644 index 0000000..2ac052f --- /dev/null +++ b/db/migrations/V028__SeedAdminComboCommonCodes.sql @@ -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 = '상속·증여세'; diff --git a/db/migrations/V029__WidenCommonCodeAndComboColumns.sql b/db/migrations/V029__WidenCommonCodeAndComboColumns.sql new file mode 100644 index 0000000..79e65cd --- /dev/null +++ b/db/migrations/V029__WidenCommonCodeAndComboColumns.sql @@ -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); diff --git a/docs/ADMIN_PATTERN_CRITIQUE_WBS.md b/docs/ADMIN_PATTERN_CRITIQUE_WBS.md new file mode 100644 index 0000000..7d52cc7 --- /dev/null +++ b/docs/ADMIN_PATTERN_CRITIQUE_WBS.md @@ -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 증거 diff --git a/docs/COMBO_POLICY.md b/docs/COMBO_POLICY.md new file mode 100644 index 0000000..9f8dedc --- /dev/null +++ b/docs/COMBO_POLICY.md @@ -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` 중 하나를 명시해야 한다. +- 상태/유형/등급 입력이 있는 화면은 콤보 정책 위반이 없어야 한다. +- 고객/회사처럼 데이터가 많은 항목은 검색형 선택으로 통일한다. diff --git a/docs/COMMON_CODE_POLICY.md b/docs/COMMON_CODE_POLICY.md new file mode 100644 index 0000000..22619f3 --- /dev/null +++ b/docs/COMMON_CODE_POLICY.md @@ -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)를 사용한다. diff --git a/docs/DOUZONE_UX_GUIDE.md b/docs/DOUZONE_UX_GUIDE.md new file mode 100644 index 0000000..c957363 --- /dev/null +++ b/docs/DOUZONE_UX_GUIDE.md @@ -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` 패턴 중 하나 이상을 재사용한다. diff --git a/docs/ENGINEERING_HARNESS.md b/docs/ENGINEERING_HARNESS.md new file mode 100644 index 0000000..c4ffff2 --- /dev/null +++ b/docs/ENGINEERING_HARNESS.md @@ -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 관습을 왜곡해 구현하지 않는다. diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..2fc89b6 --- /dev/null +++ b/docs/INDEX.md @@ -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차 기준으로 사용한다. diff --git a/docs/ops/COMMON_CODE_AUDIT.sql b/docs/ops/COMMON_CODE_AUDIT.sql new file mode 100644 index 0000000..19bdb39 --- /dev/null +++ b/docs/ops/COMMON_CODE_AUDIT.sql @@ -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;