Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6642f3d6f1 | |||
| 67f2f4b5d6 | |||
| faf4273e6d | |||
| 15c261a49d | |||
| b06c0f99fb | |||
| ad55bd1884 | |||
| e0b8d4e370 | |||
| e65f01b196 | |||
| 124b3b4dfc | |||
| 3785bc7a70 | |||
| bd44ec7c5f | |||
| cb47349a25 | |||
| b3cab87539 | |||
| 1fc3b6c0a4 |
@@ -81,7 +81,7 @@ jobs:
|
||||
- name: Generate build info
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
||||
BUILD_TIME=$(TZ=Asia/Seoul date +'%Y-%m-%d %H:%M:%S KST')
|
||||
mkdir -p ./publish/wwwroot
|
||||
printf '{\n "version": "%s",\n "built": "%s"\n}\n' "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
|
||||
echo "✓ Build: $COMMIT_HASH @ $BUILD_TIME"
|
||||
|
||||
@@ -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개 컬럼 (시각적 혼잡 제거)
|
||||
|
||||
@@ -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) - 서버 자동 설치 스크립트
|
||||
|
||||
|
||||
@@ -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<BlogPost> Posts { get; init; } = [];
|
||||
|
||||
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) =>
|
||||
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(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default)
|
||||
@@ -74,6 +93,13 @@ public class BlogServiceTests
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Posts.Where(x => x.DeletedAt != null).ToList();
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||
{
|
||||
post.Id = Posts.Count + 1;
|
||||
@@ -83,7 +109,23 @@ 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 RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var post = Posts.FirstOrDefault(x => x.Id == id);
|
||||
if (post != null)
|
||||
post.DeletedAt = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ public class BusinessDayCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2026, 2, 14, 2026, 2, 19)]
|
||||
[InlineData(2026, 8, 15, 2026, 8, 20)]
|
||||
[InlineData(2026, 9, 24, 2026, 9, 29)]
|
||||
[InlineData(2026, 10, 3, 2026, 10, 8)]
|
||||
[InlineData(2026, 8, 15, 2026, 8, 18)]
|
||||
[InlineData(2026, 9, 24, 2026, 9, 28)]
|
||||
[InlineData(2026, 10, 3, 2026, 10, 6)]
|
||||
[InlineData(2027, 2, 6, 2027, 2, 10)]
|
||||
public void GetEffectiveDueDate_SkipsWeekendHolidayAndSubstituteHoliday(
|
||||
int dueYear, int dueMonth, int dueDay,
|
||||
int expectedYear, int expectedMonth, int expectedDay)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace TaxBaik.Application.Tests;
|
||||
|
||||
using TaxBaik.Application.Services;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
public class CommonCodeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAsync_TrimsAndRejectsWhitespaceInCodeValue()
|
||||
{
|
||||
var repository = new FakeCommonCodeRepository();
|
||||
var service = new CommonCodeService(repository);
|
||||
|
||||
await Assert.ThrowsAsync<ValidationException>(() => service.UpsertAsync(new CommonCode
|
||||
{
|
||||
CodeGroup = " CLIENT_STATUS ",
|
||||
CodeValue = "active code",
|
||||
CodeName = " 활성 "
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_TrimsAndPersistsNormalizedValues()
|
||||
{
|
||||
var repository = new FakeCommonCodeRepository();
|
||||
var service = new CommonCodeService(repository);
|
||||
|
||||
await service.UpsertAsync(new CommonCode
|
||||
{
|
||||
CodeGroup = " CLIENT_STATUS ",
|
||||
CodeValue = "active",
|
||||
CodeName = " 활성 ",
|
||||
SortOrder = 10
|
||||
});
|
||||
|
||||
var saved = Assert.Single(repository.SavedCodes);
|
||||
Assert.Equal("CLIENT_STATUS", saved.CodeGroup);
|
||||
Assert.Equal("active", saved.CodeValue);
|
||||
Assert.Equal("활성", saved.CodeName);
|
||||
}
|
||||
|
||||
private sealed class FakeCommonCodeRepository : ICommonCodeRepository
|
||||
{
|
||||
public List<CommonCode> SavedCodes { get; } = [];
|
||||
|
||||
public Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<string>>([]);
|
||||
|
||||
public Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||
|
||||
public Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IEnumerable<CommonCode>>([]);
|
||||
|
||||
public Task<CommonCode?> GetAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||
Task.FromResult<CommonCode?>(null);
|
||||
|
||||
public Task UpsertAsync(CommonCode code, CancellationToken ct = default)
|
||||
{
|
||||
SavedCodes.Add(new CommonCode
|
||||
{
|
||||
CodeGroup = code.CodeGroup,
|
||||
CodeValue = code.CodeValue,
|
||||
CodeName = code.CodeName,
|
||||
SortOrder = code.SortOrder,
|
||||
IsActive = code.IsActive
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,22 @@ public class InquiryServiceTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TaxBaik.Application.DTOs;
|
||||
|
||||
public class SubmitInquiryDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
public string ServiceType { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public bool SuppressNotification { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TaxBaik.Application.DTOs;
|
||||
|
||||
public class UpdateInquiryDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
public string ServiceType { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? AdminMemo { get; set; }
|
||||
}
|
||||
@@ -42,6 +42,10 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
||||
int page, int pageSize, CancellationToken ct = default) =>
|
||||
await repository.GetAdminPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||
|
||||
public async Task<(IEnumerable<BlogPost>, int)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken ct = default) =>
|
||||
await repository.GetArchivedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||
|
||||
public async Task<int> CreateAsync(BlogPost post, CancellationToken ct = default)
|
||||
{
|
||||
ValidatePost(post);
|
||||
@@ -110,6 +114,18 @@ 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 RestoreAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
await repository.RestoreAsync(id, ct);
|
||||
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||
}
|
||||
|
||||
public async Task IncrementViewCountAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.IncrementViewCountAsync(id, ct);
|
||||
|
||||
|
||||
@@ -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<Client> 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);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ namespace TaxBaik.Application.Services;
|
||||
|
||||
public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
|
||||
{
|
||||
private const int MaxCodeGroupLength = 80;
|
||||
private const int MaxCodeValueLength = 120;
|
||||
private const int MaxCodeNameLength = 200;
|
||||
|
||||
public async Task<IEnumerable<string>> GetAllGroupsAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await commonCodeRepository.GetAllGroupsAsync(ct);
|
||||
@@ -36,13 +40,28 @@ public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
|
||||
|
||||
public async Task DeleteAsync(string codeGroup, string codeValue, CancellationToken ct = default)
|
||||
{
|
||||
await commonCodeRepository.DeleteAsync(codeGroup.Trim(), codeValue.Trim(), ct);
|
||||
await commonCodeRepository.DeleteAsync(NormalizeToken(codeGroup, nameof(codeGroup), MaxCodeGroupLength), NormalizeToken(codeValue, nameof(codeValue), MaxCodeValueLength), ct);
|
||||
}
|
||||
|
||||
private static void Normalize(CommonCode code)
|
||||
{
|
||||
code.CodeGroup = code.CodeGroup.Trim();
|
||||
code.CodeValue = code.CodeValue.Trim();
|
||||
code.CodeName = code.CodeName.Trim();
|
||||
code.CodeGroup = NormalizeToken(code.CodeGroup, nameof(code.CodeGroup), MaxCodeGroupLength);
|
||||
code.CodeValue = NormalizeToken(code.CodeValue, nameof(code.CodeValue), MaxCodeValueLength, disallowWhitespace: true);
|
||||
code.CodeName = NormalizeToken(code.CodeName, nameof(code.CodeName), MaxCodeNameLength);
|
||||
}
|
||||
|
||||
private static string NormalizeToken(string value, string fieldName, int maxLength, bool disallowWhitespace = false)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
throw new ValidationException($"{fieldName}은(는) 필수입니다.");
|
||||
|
||||
if (normalized.Length > maxLength)
|
||||
throw new ValidationException($"{fieldName}은(는) 최대 {maxLength}자까지 입력할 수 있습니다.");
|
||||
|
||||
if (disallowWhitespace && normalized.Any(char.IsWhiteSpace))
|
||||
throw new ValidationException($"{fieldName}에는 공백을 사용할 수 없습니다.");
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using TaxBaik.Domain.Interfaces;
|
||||
public class FaqService(IFaqRepository repository)
|
||||
{
|
||||
public static readonly string[] Categories =
|
||||
["기장·세금신고", "부동산", "증여·상속", "기타"];
|
||||
["기장세금신고", "부동산", "증여상속", "기타"];
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
||||
await repository.GetActiveAsync(ct);
|
||||
|
||||
@@ -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<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) =>
|
||||
await repository.LinkClientAsync(inquiryId, clientId, ct);
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -12,8 +12,12 @@ public interface IBlogPostRepository
|
||||
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(BlogPost post, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task ArchiveAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task RestoreAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public interface IInquiryRepository
|
||||
Task<int> 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);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
return await conn.QueryFirstOrDefaultAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -41,15 +41,15 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
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<BlogPost>()).ToList();
|
||||
@@ -64,10 +64,10 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
return await conn.QueryAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
|
||||
bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -94,13 +95,14 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
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<BlogPost>()).ToList();
|
||||
@@ -109,6 +111,30 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPost> Items, int Total)> GetArchivedPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT bp.id, bp.title, bp.content, bp.slug, bp.category_id, bp.tags, bp.author_id,
|
||||
bp.published_at, bp.view_count, bp.seo_title, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, bp.deleted_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.deleted_at IS NOT NULL
|
||||
ORDER BY bp.deleted_at DESC
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM blog_posts WHERE deleted_at IS NOT NULL;",
|
||||
new { PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<BlogPost>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(BlogPost post, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
@@ -130,19 +156,34 @@ 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 RestoreAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE blog_posts SET deleted_at = NULL, updated_at = NOW() WHERE id = @Id AND deleted_at IS NOT NULL",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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 });
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -29,6 +29,8 @@ builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
|
||||
|
||||
// 각 Browser API Client 등록
|
||||
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<IClientBrowserClient, ClientBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client => client.BaseAddress = new Uri(apiBaseUrl)).AddHttpMessageHandler<TokenRefreshHandler>();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Application.DTOs;
|
||||
|
||||
public interface IBlogBrowserClient
|
||||
{
|
||||
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
|
||||
Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
|
||||
Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default);
|
||||
Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||
Task<bool> RestoreAsync(int id, CancellationToken ct = default);
|
||||
Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class BlogBrowserClient : IBlogBrowserClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<BlogBrowserClient> _logger;
|
||||
private readonly ITokenStore _tokenStore;
|
||||
|
||||
public BlogBrowserClient(HttpClient http, ILogger<BlogBrowserClient> logger, ITokenStore tokenStore)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
private void EnsureAuthHeader()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _tokenStore.AccessToken);
|
||||
else
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetAdminPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin?page={page}&pageSize={pageSize}", ct);
|
||||
return result != null ? (result.Data, result.Total) : ([], 0);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPostResponseDto> Items, int Total)> GetArchivedPagedAsync(int page = 1, int pageSize = 20, CancellationToken ct = default)
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var result = await _http.GetFromJsonAsync<PagedResponse>($"blog/admin/archived?page={page}&pageSize={pageSize}", ct);
|
||||
return result != null ? (result.Data, result.Total) : ([], 0);
|
||||
}
|
||||
|
||||
public async Task<BlogPostResponseDto?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
return await _http.GetFromJsonAsync<BlogPostResponseDto>($"blog/{id}", ct);
|
||||
}
|
||||
|
||||
public async Task<BlogPostResponseDto?> CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default)
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsJsonAsync("blog", dto, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
|
||||
public async Task<BlogPostResponseDto?> UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PutAsJsonAsync($"blog/{id}", dto, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<BlogPostResponseDto>(content, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.DeleteAsync($"blog/{id}", ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> RestoreAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
EnsureAuthHeader();
|
||||
var response = await _http.PostAsync($"blog/{id}/restore", null, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> TogglePublishAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var result = await UpdateAsync(id, dto, ct);
|
||||
return result != null;
|
||||
}
|
||||
|
||||
private sealed class PagedResponse
|
||||
{
|
||||
public List<BlogPostResponseDto> Data { get; set; } = [];
|
||||
public int Total { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ICategoryBrowserClient
|
||||
{
|
||||
Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class CategoryBrowserClient : ICategoryBrowserClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<CategoryBrowserClient> _logger;
|
||||
|
||||
public CategoryBrowserClient(HttpClient http, ILogger<CategoryBrowserClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Category>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _http.GetFromJsonAsync<List<Category>>("category", cancellationToken: ct);
|
||||
return result ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch categories");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ namespace TaxBaik.Web.Services;
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using TaxBaik.Application.DTOs;
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,7 +16,10 @@ public interface IInquiryBrowserClient
|
||||
Task<Inquiry?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<bool> UpdateStatusAsync(int id, string status, 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<Inquiry?> CreateAsync(SubmitInquiryDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
public List<Inquiry> Data { get; set; } = [];
|
||||
|
||||
@@ -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/icon?family=Material+Icons" 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>
|
||||
document.documentElement.classList.toggle(
|
||||
'admin-login-route',
|
||||
@@ -32,7 +27,7 @@
|
||||
<span style="font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.8;">자동으로 페이지를 새로고침합니다. 잠시만 기다려주세요.</span>
|
||||
</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="spinner"></div>
|
||||
<p>로드 중...</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Name" Label="이름"
|
||||
@@ -11,25 +12,12 @@
|
||||
<MudTextField @bind-Value="model.Email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
|
||||
|
||||
<MudSelect @bind-Value="model.ServiceType" Label="문의 유형"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
<MudSelectItem Value="@("사업자세무")">사업자세무</MudSelectItem>
|
||||
<MudSelectItem Value="@("부동산세금")">부동산세금</MudSelectItem>
|
||||
<MudSelectItem Value="@("가족자산")">가족자산</MudSelectItem>
|
||||
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="model.ServiceType" Group="INQUIRY_SERVICE_TYPE" Label="문의 유형" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="model.Message" Label="문의 내용"
|
||||
Variant="Variant.Outlined" Lines="5" Class="mb-4" Required="true" />
|
||||
|
||||
<MudSelect @bind-Value="model.Status" Label="상태"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
<MudSelectItem Value="@("new")">신규</MudSelectItem>
|
||||
<MudSelectItem Value="@("consulting")">상담중</MudSelectItem>
|
||||
<MudSelectItem Value="@("contracted")">계약완료</MudSelectItem>
|
||||
<MudSelectItem Value="@("rejected")">거절</MudSelectItem>
|
||||
<MudSelectItem Value="@("closed")">종결</MudSelectItem>
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="model.Status" Group="INQUIRY_STATUS" Label="상태" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="model.AdminMemo" Label="관리 메모"
|
||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||
@@ -57,8 +45,7 @@
|
||||
|
||||
private MudForm? form;
|
||||
private InquiryFormModel model = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (InitialData != null)
|
||||
{
|
||||
@@ -73,6 +60,7 @@
|
||||
AdminMemo = InitialData.AdminMemo
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
|
||||
@@ -116,16 +116,6 @@
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
var viewportWidth = await JS.InvokeAsync<int>("taxbaikAdminSession.getViewportWidth");
|
||||
drawerOpen = viewportWidth >= 960;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||
{
|
||||
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.hideLoading"));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@inject IAnnouncementBrowserClient AnnouncementClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -37,13 +38,10 @@
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect @bind-Value="model.DisplayType"
|
||||
Label="유형"
|
||||
Variant="Variant.Outlined">
|
||||
<MudSelectItem Value="@("info")">일반 (파란색)</MudSelectItem>
|
||||
<MudSelectItem Value="@("banner")">배너 (주황색) — 중요 이벤트</MudSelectItem>
|
||||
<MudSelectItem Value="@("urgent")">긴급 (빨간색) — 마감 임박</MudSelectItem>
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="model.DisplayType"
|
||||
Group="ANNOUNCEMENT_DISPLAY_TYPE"
|
||||
Label="유형"
|
||||
Class="mb-0" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6">
|
||||
|
||||
@@ -112,18 +112,14 @@
|
||||
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (firstRender)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
@page "/admin/blog/create"
|
||||
@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
|
||||
|
||||
@@ -21,62 +19,16 @@
|
||||
</section>
|
||||
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" 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>
|
||||
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" OnCancel="GoBack" />
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private MudForm? form;
|
||||
private List<Domain.Entities.Category> categories = [];
|
||||
private CreatePostModel model = new();
|
||||
private EasyMDE.Editor? editor;
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JS { get; set; } = null!;
|
||||
private IReadOnlyList<Domain.Entities.Category> categories = [];
|
||||
private BlogForm.BlogFormModel model = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
categories = (await CategoryRepository.GetAllAsync()).ToList();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
|
||||
}
|
||||
categories = await CategoryClient.GetAllAsync();
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
@@ -86,25 +38,9 @@
|
||||
|
||||
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
|
||||
{
|
||||
await BlogService.CreateAsync(new CreateBlogPostDto
|
||||
var result = await BlogClient.CreateAsync(new CreateBlogPostDto
|
||||
{
|
||||
Title = model.Title,
|
||||
Content = model.Content,
|
||||
@@ -115,6 +51,12 @@
|
||||
IsPublished = model.IsPublished
|
||||
});
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
Snackbar.Add("포스트 저장에 실패했습니다.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
|
||||
Navigation.NavigateTo("/taxbaik/admin/blog");
|
||||
}
|
||||
@@ -123,45 +65,4 @@
|
||||
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"
|
||||
@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
|
||||
{
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" 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>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error"
|
||||
@onclick="DeletePost">삭제</MudButton>
|
||||
</div>
|
||||
</MudForm>
|
||||
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" />
|
||||
<div class="mt-4">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeletePost">삭제</MudButton>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@@ -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<Domain.Entities.Category> categories = [];
|
||||
private EditPostModel model = new();
|
||||
private TaxBaik.Application.DTOs.BlogPostResponseDto? post;
|
||||
private IReadOnlyList<Domain.Entities.Category> 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<string>("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; }
|
||||
}
|
||||
}
|
||||
|
||||
<!-- EasyMDE 초기화 스크립트 -->
|
||||
<script>
|
||||
window.initMarkdownEditor = function(editorId, initialContent) {
|
||||
if (!window.easyMDEInstance) {
|
||||
window.easyMDEInstance = new EasyMDE({
|
||||
element: document.getElementById(editorId),
|
||||
spellChecker: false,
|
||||
autoDownloadFontAwesome: false,
|
||||
initialValue: initialContent || "",
|
||||
toolbar: [
|
||||
"bold", "italic", "strikethrough", "|",
|
||||
"heading", "code", "|",
|
||||
"unordered-list", "ordered-list", "|",
|
||||
"link", "image", "table", "|",
|
||||
"quote", "horizontal-rule", "|",
|
||||
"preview", "side-by-side", "fullscreen", "|",
|
||||
"guide"
|
||||
],
|
||||
previewRender: function(plainText) {
|
||||
return marked.parse(plainText);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.getMarkdownContent = function() {
|
||||
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Domain.Entities
|
||||
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="Model.Title" Label="제목 *"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
|
||||
|
||||
<MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@foreach (var category in Categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
|
||||
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
|
||||
Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
|
||||
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
|
||||
|
||||
<MudCheckBox @bind-Checked="Model.IsPublished" Label="즉시 발행" Class="mb-4" />
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">@SubmitText</MudButton>
|
||||
@if (OnCancel.HasDelegate)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
|
||||
}
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public BlogFormModel Model { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<Category> Categories { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string SubmitText { get; set; } = "저장";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
private MudForm? form;
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
if (form == null)
|
||||
return;
|
||||
|
||||
await form.Validate();
|
||||
if (!form.IsValid)
|
||||
return;
|
||||
|
||||
await OnSubmit.InvokeAsync();
|
||||
}
|
||||
|
||||
public class BlogFormModel
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string Content { get; set; } = "";
|
||||
public int? CategoryId { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public string? SeoTitle { get; set; }
|
||||
public string? SeoDescription { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,14 @@
|
||||
|
||||
<AdminPageHeader Title="블로그 관리" Eyebrow="Content" Subtitle="검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다.">
|
||||
<ChildContent>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore"
|
||||
OnClick="ToggleArchiveView">
|
||||
@(showArchived ? "전체 글 보기" : "숨김 글 보기")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="Reload">
|
||||
새로고침
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote"
|
||||
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
||||
</ChildContent>
|
||||
@@ -39,8 +47,16 @@
|
||||
<CellTemplate Context="cell">
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
|
||||
Href="@($"/taxbaik/admin/blog/{cell.Item.Id}/edit")">수정하기</MudButton>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
|
||||
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
|
||||
@if (showArchived)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Success"
|
||||
@onclick="@(async () => await RestorePost(cell.Item.Id))">복원</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error"
|
||||
@onclick="@(async () => await DeletePost(cell.Item.Id))">삭제</MudButton>
|
||||
}
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
@@ -61,6 +77,7 @@
|
||||
private int currentPage = 1;
|
||||
private int totalPages = 1;
|
||||
private int totalPosts = 0;
|
||||
private bool showArchived;
|
||||
private const int PageSize = 20;
|
||||
|
||||
private IEnumerable<TaxBaik.Application.DTOs.BlogPostResponseDto> FilteredPosts => posts
|
||||
@@ -85,7 +102,9 @@
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
var result = await BlogClient.GetAdminPagedAsync(currentPage, PageSize);
|
||||
var result = showArchived
|
||||
? await BlogClient.GetArchivedPagedAsync(currentPage, PageSize)
|
||||
: await BlogClient.GetAdminPagedAsync(currentPage, PageSize);
|
||||
posts = result.Items.ToList();
|
||||
totalPosts = result.Total;
|
||||
totalPages = Math.Max(1, (int)Math.Ceiling(totalPosts / (double)PageSize));
|
||||
@@ -155,4 +174,26 @@
|
||||
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
private async Task RestorePost(int postId)
|
||||
{
|
||||
var restored = await BlogClient.RestoreAsync(postId);
|
||||
if (!restored)
|
||||
{
|
||||
Snackbar.Add("포스트 복원에 실패했습니다.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
Snackbar.Add("포스트가 복원되었습니다.", Severity.Success);
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
private async Task ToggleArchiveView()
|
||||
{
|
||||
showArchived = !showArchived;
|
||||
currentPage = 1;
|
||||
await LoadPosts();
|
||||
}
|
||||
|
||||
private async Task Reload() => await LoadPosts();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/admin/clients/{ClientId:int}"
|
||||
@attribute [Authorize]
|
||||
@using TaxBaik.Application.Services
|
||||
@inject ClientService ClientService
|
||||
@inject ConsultationService ConsultationService
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject IConsultingActivityBrowserClient ConsultingClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@@ -102,12 +104,7 @@
|
||||
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
|
||||
@foreach (var t in ClientService.ServiceTypes)
|
||||
{
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="newServiceType" Group="CONSULTING_ACTIVITY_TYPE" Label="서비스 분야" Placeholder="선택" Clearable="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
|
||||
@@ -116,7 +113,7 @@
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="string" @bind-Value="newResult" Label="결과">
|
||||
<MudSelectItem Value="@("")">-</MudSelectItem>
|
||||
@foreach (var r in ConsultationService.Results)
|
||||
@foreach (var r in results)
|
||||
{
|
||||
<MudSelectItem Value="@r">@r</MudSelectItem>
|
||||
}
|
||||
@@ -182,6 +179,7 @@
|
||||
|
||||
private Domain.Entities.Client? client;
|
||||
private List<Domain.Entities.Consultation> consultations = [];
|
||||
private static readonly string[] results = ["", "상담완료", "추가자료 요청", "견적발송", "계약전환", "보류"];
|
||||
|
||||
private bool showAddForm;
|
||||
private DateTime? newDate = DateTime.Today;
|
||||
@@ -197,8 +195,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 +224,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<MudDivider />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true">
|
||||
@foreach (var t in ClientService.ServiceTypes)
|
||||
{
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="dto.ServiceType" Group="CLIENT_SERVICE_TYPE" Label="서비스 유형" Clearable="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true">
|
||||
@foreach (var t in ClientService.TaxTypes)
|
||||
{
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="dto.TaxType" Group="CLIENT_TAX_TYPE" Label="세금 유형" Clearable="true" />
|
||||
</MudItem>
|
||||
|
||||
@* 관리 정보 *@
|
||||
@@ -76,18 +67,10 @@
|
||||
<MudDivider />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true">
|
||||
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
||||
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="dto.Status" Group="CLIENT_STATUS" Label="상태 *" Required="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true">
|
||||
@foreach (var s in ClientService.Sources)
|
||||
{
|
||||
<MudSelectItem Value="@s">@s</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="dto.Source" Group="CLIENT_SOURCE" Label="유입 경로" Clearable="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField @bind-Value="dto.Memo" Label="메모"
|
||||
@@ -119,7 +102,6 @@
|
||||
private bool isValid;
|
||||
private bool isLoading = true;
|
||||
private bool isSaving;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Id.HasValue)
|
||||
|
||||
@@ -9,18 +9,15 @@
|
||||
|
||||
<PageTitle>고객 관리</PageTitle>
|
||||
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">고객 관리</MudText>
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객 카드를 등록하고 상담 이력을 관리합니다.</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Href="/taxbaik/admin/clients/create">
|
||||
고객 등록
|
||||
</MudButton>
|
||||
</section>
|
||||
<AdminPageHeader Title="고객 관리" Eyebrow="CRM" Subtitle="고객 카드를 등록하고 상담 이력을 관리합니다.">
|
||||
<ChildContent>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Href="/taxbaik/admin/clients/create">
|
||||
고객 등록
|
||||
</MudButton>
|
||||
</ChildContent>
|
||||
</AdminPageHeader>
|
||||
|
||||
@* 검색/필터 바 *@
|
||||
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
|
||||
@@ -31,11 +28,7 @@
|
||||
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudSelect @bind-Value="statusFilter" Label="상태" T="string">
|
||||
<MudSelectItem Value="@("")">전체</MudSelectItem>
|
||||
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
||||
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="statusFilter" Group="CLIENT_STATUS" Label="상태" Placeholder="전체" Clearable="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
|
||||
@@ -53,10 +46,7 @@
|
||||
}
|
||||
else if (!clients.Any())
|
||||
{
|
||||
<div class="pa-6 text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
|
||||
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
|
||||
</div>
|
||||
<AdminEmptyState Icon="@Icons.Material.Filled.PeopleAlt" Message="등록된 고객이 없습니다." />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -140,18 +130,14 @@
|
||||
private int totalPages;
|
||||
private const int PageSize = 20;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (firstRender)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/admin/consulting-activities"
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@inject IConsultingActivityBrowserClient ActivityClient
|
||||
@inject IClientBrowserClient ClientClient
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -103,14 +104,7 @@
|
||||
<MudSelectItem Value="@client.Id">@GetClientDisplayName(client)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="activityForm.ActivityType" Label="활동 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
|
||||
<MudSelectItem Value="@("방문 상담")">방문 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("전화 상담")">전화 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조사 대응 미팅")">세무조사 대응 미팅</MudSelectItem>
|
||||
<MudSelectItem Value="@("카카오톡 상담")">카카오톡 상담</MudSelectItem>
|
||||
<MudSelectItem Value="@("이메일 자료 접수")">이메일 자료 접수</MudSelectItem>
|
||||
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="activityForm.ActivityType" Group="CONSULTING_ACTIVITY_TYPE" Label="활동 유형" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="activityForm.ActivityDate" Label="활동일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudTextField T="string" @bind-Value="activityForm.Description" Label="설명" Variant="Variant.Outlined" FullWidth="true" Lines="3" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="activityForm.NextFollowupDate" Label="다음 팔로업일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
@@ -134,18 +128,14 @@
|
||||
private ConsultingActivity? editingActivity;
|
||||
private ConsultingActivityForm activityForm = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (firstRender)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,7 @@
|
||||
Placeholder="방문자에게 보여질 답변을 입력하세요." />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="faq.Category" Label="카테고리" T="string" Clearable="true">
|
||||
@foreach (var cat in FaqService.Categories)
|
||||
{
|
||||
<MudSelectItem Value="@cat">@cat</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="faq.Category" Group="FAQ_CATEGORY" Label="카테고리" Clearable="true" Placeholder="전체" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudNumericField @bind-Value="faq.SortOrder"
|
||||
|
||||
@@ -116,18 +116,14 @@
|
||||
(f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (firstRender)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<MudTextField T="string" @bind-Value="revenueForm.InvoiceNumber" Label="청구번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudDatePicker @bind-Date="revenueForm.InvoiceDate" Label="청구일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudNumericField T="decimal" @bind-Value="revenueForm.Amount" Label="청구액" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
|
||||
<MudSelect T="string" @bind-Value="revenueForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
|
||||
<MudSelectItem Value="@("기장 수수료")">기장 수수료</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무조정료")">세무조정료</MudSelectItem>
|
||||
<MudSelectItem Value="@("세무상담료")">세무상담료</MudSelectItem>
|
||||
<MudSelectItem Value="@("신고 대행료")">신고 대행료</MudSelectItem>
|
||||
<MudSelectItem Value="@("자문 수수료")">자문 수수료</MudSelectItem>
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="revenueForm.ServiceType" Group="REVENUE_SERVICE_TYPE" Label="서비스 유형" Class="mb-4" />
|
||||
<MudDatePicker @bind-Date="revenueForm.DueDate" Label="납부예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
@@ -129,18 +124,14 @@
|
||||
private bool isDialogOpen;
|
||||
private RevenueForm revenueForm = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (firstRender)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
StateHasChanged();
|
||||
}
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<MudTextField @bind-Value="email" Label="이메일"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="kakaoUrl" Label="카카오 채널 URL"
|
||||
<MudTextField @bind-Value="kakaoUrl" Label="카카오채널 URL"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
<MudTextField @bind-Value="instagramUrl" Label="인스타그램"
|
||||
|
||||
@@ -178,19 +178,15 @@ else
|
||||
private TaxFilingSchedule? selectedSchedule;
|
||||
private TaxFilingScheduleForm scheduleForm = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (firstRender)
|
||||
if (AuthStateTask != null)
|
||||
{
|
||||
if (AuthStateTask != null)
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var authState = await AuthStateTask;
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
StateHasChanged();
|
||||
}
|
||||
await LoadData();
|
||||
PrepareCreate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
@inject ITaxFilingBrowserClient FilingClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@@ -21,10 +22,10 @@ else
|
||||
<RowTemplate>
|
||||
<MudTd>@context.ClientName</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>
|
||||
@{
|
||||
var dday = (context.DueDate.Date - DateTime.Today).Days;
|
||||
var dday = BusinessDayCalculator.GetDday(DateOnly.FromDateTime(context.DueDate));
|
||||
}
|
||||
@if (dday < 0)
|
||||
{
|
||||
|
||||
@@ -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" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined">
|
||||
@foreach (var t in TaxFilingService.FilingTypes)
|
||||
{
|
||||
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<CommonCodeSelect @bind-Value="newFilingType" Group="FILING_TYPE" Label="신고 유형 *" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
|
||||
@@ -82,6 +78,10 @@
|
||||
|
||||
protected override async Task OnInitializedAsync() => await Reload();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
}
|
||||
|
||||
private async Task Reload()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="pa-6 text-center">
|
||||
<MudIcon Icon="@Icon" Style="font-size:3rem; opacity:.3;" />
|
||||
<MudText Class="mt-2 text-muted">@Message</MudText>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string Icon { get; set; } = Icons.Material.Filled.Info;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<section class="admin-page-hero">
|
||||
<div>
|
||||
@if (!string.IsNullOrWhiteSpace(Eyebrow))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="admin-eyebrow">@Eyebrow</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.h4" Class="admin-page-title">@Title</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(Subtitle))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="admin-page-subtitle">@Subtitle</MudText>
|
||||
}
|
||||
</div>
|
||||
@if (ChildContent is not null)
|
||||
{
|
||||
<div>@ChildContent</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public string? Eyebrow { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? Subtitle { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -24,7 +24,18 @@ public static class BusinessDayCalculator
|
||||
new(new DateOnly(2026, 9, 24), new DateOnly(2026, 9, 26)),
|
||||
new(new DateOnly(2026, 10, 3), new DateOnly(2026, 10, 5)),
|
||||
new(new DateOnly(2026, 10, 9), new DateOnly(2026, 10, 9)),
|
||||
new(new DateOnly(2026, 12, 25), new DateOnly(2026, 12, 25))
|
||||
new(new DateOnly(2026, 12, 25), new DateOnly(2026, 12, 25)),
|
||||
new(new DateOnly(2027, 1, 1), new DateOnly(2027, 1, 1)),
|
||||
new(new DateOnly(2027, 2, 6), new DateOnly(2027, 2, 9)),
|
||||
new(new DateOnly(2027, 3, 1), new DateOnly(2027, 3, 2)),
|
||||
new(new DateOnly(2027, 5, 5), new DateOnly(2027, 5, 5)),
|
||||
new(new DateOnly(2027, 5, 13), new DateOnly(2027, 5, 13)),
|
||||
new(new DateOnly(2027, 6, 6), new DateOnly(2027, 6, 6)),
|
||||
new(new DateOnly(2027, 8, 15), new DateOnly(2027, 8, 16)),
|
||||
new(new DateOnly(2027, 9, 14), new DateOnly(2027, 9, 16)),
|
||||
new(new DateOnly(2027, 10, 3), new DateOnly(2027, 10, 4)),
|
||||
new(new DateOnly(2027, 10, 9), new DateOnly(2027, 10, 11)),
|
||||
new(new DateOnly(2027, 12, 25), new DateOnly(2027, 12, 26))
|
||||
};
|
||||
|
||||
private static readonly HashSet<DateOnly> HolidayDates = BuildHolidayDates();
|
||||
@@ -63,26 +74,6 @@ public static class BusinessDayCalculator
|
||||
}
|
||||
}
|
||||
|
||||
// 주말과 연속 공휴일 뒤에 붙는 대체휴일을 다음 영업일로 자동 확장한다.
|
||||
foreach (var window in HolidayWindows)
|
||||
{
|
||||
foreach (var date in window.Dates())
|
||||
{
|
||||
if (date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var substitute = date.AddDays(1);
|
||||
while (substitute.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(substitute))
|
||||
{
|
||||
substitute = substitute.AddDays(1);
|
||||
}
|
||||
|
||||
holidays.Add(substitute);
|
||||
}
|
||||
}
|
||||
|
||||
return holidays;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using TaxBaik.Application.DTOs
|
||||
@using TaxBaik.Web.Services
|
||||
@using TaxBaik.Web.Services.AdminClients
|
||||
@using TaxBaik.Domain.Entities
|
||||
@using TaxBaik.Application.Services
|
||||
@using TaxBaik.Web.Components.Admin.Shared
|
||||
|
||||
@@ -32,6 +32,16 @@ public class BlogController : ControllerBase
|
||||
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")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetAll()
|
||||
@@ -48,6 +58,14 @@ public class BlogController : ControllerBase
|
||||
return Ok(new { data = items, total, page, pageSize });
|
||||
}
|
||||
|
||||
[HttpGet("admin/archived")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetArchivedPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var (items, total) = await _blogService.GetArchivedPagedAsync(page, pageSize);
|
||||
return Ok(new { data = items, total, page, pageSize });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Create([FromBody] CreateBlogPostDto dto)
|
||||
@@ -84,7 +102,15 @@ public class BlogController : ControllerBase
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _blogService.DeleteAsync(id);
|
||||
await _blogService.ArchiveAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{id}/restore")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Restore(int id)
|
||||
{
|
||||
await _blogService.RestoreAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> Submit([FromBody] SubmitInquiryRequest request)
|
||||
public async Task<IActionResult> 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<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")]
|
||||
[Authorize]
|
||||
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 string Status { get; set; } = string.Empty;
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<p class="text-muted mb-5">무료 상담으로 현재 상황을 진단하고 맞춤형 절세 전략을 받아보세요.</p>
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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", "<br />").Replace("\n", "<br />");
|
||||
_ = _blogService.IncrementViewCountAsync(Post.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<option value="기장">사업자 기장</option>
|
||||
<option value="양도세">부동산 양도세</option>
|
||||
<option value="종소세">종합소득세</option>
|
||||
<option value="증여상속">증여·상속세</option>
|
||||
<option value="증여상속">증여상속세</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
var season = Model.CurrentSeason;
|
||||
ViewData["Title"] = season != null
|
||||
? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요"
|
||||
: "백원숙 세무회계 | 사업자·부동산·증여 세무 상담";
|
||||
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
|
||||
: "백원숙 세무회계 | 사업자·부동산·증여상속 세무 상담";
|
||||
ViewData["Description"] = "사업자 기장, 부동산 양도세·증여상속, 종합소득세 전문 상담. 온라인 맞춤 상담 제공.";
|
||||
}
|
||||
|
||||
@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@
|
||||
@@ -50,7 +50,7 @@
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
|
||||
onclick="openKakao()" style="border-color: white; color: white;">
|
||||
💬 카카오 채널 문의
|
||||
💬 카카오채널 문의
|
||||
</a>
|
||||
</div>
|
||||
@if (season.DaysUntilDeadline <= 7)
|
||||
@@ -91,7 +91,7 @@ else
|
||||
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">무료 상담 신청</a>
|
||||
<a href="javascript:void(0);" class="btn btn-outline-primary btn-lg"
|
||||
onclick="openKakao()" style="border-color: white; color: white;">
|
||||
💬 카카오 채널 문의
|
||||
💬 카카오채널 문의
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,7 +176,7 @@ else
|
||||
<div class="service-icon">👨👩👧👦</div>
|
||||
<div class="card-body pt-0">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,6 +263,15 @@ else
|
||||
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-3" style="font-size: 3rem;">📝</div>
|
||||
<h3 class="h5 mb-2">현재 표시할 블로그 글이 없습니다.</h3>
|
||||
<p class="text-muted mb-4">최신 세무 정보는 블로그에서 확인할 수 있습니다.</p>
|
||||
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">블로그 바로가기</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -321,24 +330,15 @@ else
|
||||
<p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p>
|
||||
</div>
|
||||
|
||||
<div class="accordion faq-accordion" id="faqAccordion">
|
||||
@for (int i = 0; i < Model.ActiveFaqs.Count; i++)
|
||||
<div class="faq-accordion">
|
||||
@foreach (var faqItem in Model.ActiveFaqs)
|
||||
{
|
||||
var faqItem = Model.ActiveFaqs[i];
|
||||
var collapseId = $"faq-{faqItem.Id}";
|
||||
<div class="accordion-item faq-item">
|
||||
<h3 class="accordion-header">
|
||||
<button class="accordion-button collapsed faq-question" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#@collapseId">
|
||||
@faqItem.Question
|
||||
</button>
|
||||
</h3>
|
||||
<div id="@collapseId" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body faq-answer">
|
||||
@faqItem.Answer
|
||||
</div>
|
||||
<details class="faq-item">
|
||||
<summary class="faq-question">@faqItem.Question</summary>
|
||||
<div class="faq-answer">
|
||||
@faqItem.Answer
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -362,7 +362,7 @@ else
|
||||
</p>
|
||||
<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="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>
|
||||
}
|
||||
else
|
||||
@@ -374,7 +374,7 @@ else
|
||||
</p>
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||
<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>
|
||||
|
||||
@@ -682,21 +682,22 @@ img {
|
||||
border-radius: var(--radius-md) !important;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
.faq-question {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
background: white;
|
||||
font-size: 1rem;
|
||||
padding: 1.1rem 1.5rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.faq-question:not(.collapsed) {
|
||||
.faq-question::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.faq-item[open] .faq-question {
|
||||
color: var(--color-secondary);
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
}
|
||||
.faq-question::after {
|
||||
filter: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.faq-question:focus {
|
||||
box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.2);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,9 +65,9 @@
|
||||
보통 <strong>1~2분</strong> 이내에 완료됩니다.
|
||||
</p>
|
||||
<hr class="divider" />
|
||||
<p>급하신 세무 문의는 카카오 채널로 연락해 주세요.</p>
|
||||
<p>급하신 세무 문의는 카카오채널로 연락해 주세요.</p>
|
||||
<a class="kakao-btn" href="http://pf.kakao.com/_xoxchTX" target="_blank">
|
||||
💬 카카오 채널 상담
|
||||
💬 카카오채널 상담
|
||||
</a>
|
||||
<p class="timer">이 페이지는 15초 후 자동으로 새로고침됩니다.</p>
|
||||
<p class="footer">© 2026 백원숙 세무회계</p>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. 스마트스토어 판매자를 위한 첫 세무 기장
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
@@ -199,7 +197,8 @@ $$,
|
||||
1,
|
||||
true,
|
||||
NOW()
|
||||
);
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- 2. 이번달 부가가치세 신고
|
||||
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
|
||||
@@ -401,7 +400,8 @@ $$,
|
||||
1,
|
||||
true,
|
||||
NOW()
|
||||
);
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- 3. 프리랜서를 위한 종합소득세 신고
|
||||
INSERT INTO blog_posts (title, slug, content, category_id, is_published, created_at)
|
||||
@@ -638,3 +638,4 @@ $$,
|
||||
true,
|
||||
NOW()
|
||||
);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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가지
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE blog_posts
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
DROP INDEX IF EXISTS idx_blog_slug;
|
||||
DROP INDEX IF EXISTS blog_posts_slug_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_blog_posts_slug_active
|
||||
ON blog_posts (slug)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_slug_active
|
||||
ON blog_posts (slug)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_published_active
|
||||
ON blog_posts (is_published, published_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_category_active
|
||||
ON blog_posts (category_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,131 @@
|
||||
-- Seed and normalize admin common codes.
|
||||
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
|
||||
('INQUIRY_SERVICE_TYPE', '사업자세무', '사업자세무', 10),
|
||||
('INQUIRY_SERVICE_TYPE', '부동산세금', '부동산세금', 20),
|
||||
('INQUIRY_SERVICE_TYPE', '가족자산', '가족자산', 30),
|
||||
('INQUIRY_SERVICE_TYPE', '기타', '기타', 40),
|
||||
|
||||
('INQUIRY_STATUS', 'new', '신규', 10),
|
||||
('INQUIRY_STATUS', 'consulting', '상담중', 20),
|
||||
('INQUIRY_STATUS', 'contracted', '계약완료', 30),
|
||||
('INQUIRY_STATUS', 'rejected', '거절', 40),
|
||||
('INQUIRY_STATUS', 'closed', '종결', 50),
|
||||
|
||||
('CLIENT_STATUS', 'active', '활성', 10),
|
||||
('CLIENT_STATUS', 'inactive', '비활성', 20),
|
||||
|
||||
('CLIENT_SERVICE_TYPE', '기장', '기장', 10),
|
||||
('CLIENT_SERVICE_TYPE', '부동산', '부동산', 20),
|
||||
('CLIENT_SERVICE_TYPE', '증여상속', '증여·상속', 30),
|
||||
('CLIENT_SERVICE_TYPE', '종합소득세', '종합소득세', 40),
|
||||
('CLIENT_SERVICE_TYPE', '법인세', '법인세', 50),
|
||||
('CLIENT_SERVICE_TYPE', '부가가치세', '부가가치세', 60),
|
||||
('CLIENT_SERVICE_TYPE', '기타', '기타', 70),
|
||||
|
||||
('CLIENT_TAX_TYPE', '개인사업자', '개인사업자', 10),
|
||||
('CLIENT_TAX_TYPE', '법인사업자', '법인사업자', 20),
|
||||
('CLIENT_TAX_TYPE', '면세사업자', '면세사업자', 30),
|
||||
('CLIENT_TAX_TYPE', '근로소득자', '근로소득자', 40),
|
||||
('CLIENT_TAX_TYPE', '기타', '기타', 50),
|
||||
|
||||
('CLIENT_SOURCE', '홈페이지문의', '홈페이지 문의', 10),
|
||||
('CLIENT_SOURCE', '소개', '소개', 20),
|
||||
('CLIENT_SOURCE', '직접방문', '직접 방문', 30),
|
||||
('CLIENT_SOURCE', '카카오채널', '카카오 채널', 40),
|
||||
('CLIENT_SOURCE', '블로그', '블로그', 50),
|
||||
('CLIENT_SOURCE', '기타', '기타', 60),
|
||||
|
||||
('CONTRACT_SERVICE_TYPE', '개인기장대리', '개인 기장대리', 10),
|
||||
('CONTRACT_SERVICE_TYPE', '법인기장대리', '법인 기장대리', 20),
|
||||
('CONTRACT_SERVICE_TYPE', '세무조정', '세무조정', 30),
|
||||
('CONTRACT_SERVICE_TYPE', '세무컨설팅', '세무컨설팅', 40),
|
||||
('CONTRACT_SERVICE_TYPE', '불복청구', '불복청구', 50),
|
||||
|
||||
('REVENUE_SERVICE_TYPE', '기장수수료', '기장 수수료', 10),
|
||||
('REVENUE_SERVICE_TYPE', '세무조정료', '세무조정료', 20),
|
||||
('REVENUE_SERVICE_TYPE', '세무상담료', '세무상담료', 30),
|
||||
('REVENUE_SERVICE_TYPE', '신고대행료', '신고 대행료', 40),
|
||||
('REVENUE_SERVICE_TYPE', '자문수수료', '자문 수수료', 50),
|
||||
|
||||
('FILING_TYPE', '종합소득세', '종합소득세', 10),
|
||||
('FILING_TYPE', '부가가치세', '부가가치세', 20),
|
||||
('FILING_TYPE', '법인세', '법인세', 30),
|
||||
('FILING_TYPE', '원천세', '원천세', 40),
|
||||
('FILING_TYPE', '양도소득세', '양도소득세', 50),
|
||||
('FILING_TYPE', '상속증여세', '상속·증여세', 60),
|
||||
('FILING_TYPE', '세무조정', '세무조정', 70),
|
||||
|
||||
('TAX_RISK_LEVEL', 'low', '낮음', 10),
|
||||
('TAX_RISK_LEVEL', 'normal', '보통', 20),
|
||||
('TAX_RISK_LEVEL', 'high', '높음', 30)
|
||||
ON CONFLICT (code_group, code_value) DO UPDATE
|
||||
SET code_name = EXCLUDED.code_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_active = TRUE;
|
||||
|
||||
-- Normalize storage keys and migrate existing rows.
|
||||
UPDATE common_codes
|
||||
SET code_value = CASE
|
||||
WHEN code_group = 'CLIENT_SERVICE_TYPE' AND code_value = '증여·상속' THEN '증여상속'
|
||||
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '홈페이지 문의' THEN '홈페이지문의'
|
||||
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '직접 방문' THEN '직접방문'
|
||||
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '카카오 채널' THEN '카카오채널'
|
||||
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '개인 기장대리' THEN '개인기장대리'
|
||||
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '법인 기장대리' THEN '법인기장대리'
|
||||
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '기장 수수료' THEN '기장수수료'
|
||||
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '신고 대행료' THEN '신고대행료'
|
||||
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '자문 수수료' THEN '자문수수료'
|
||||
WHEN code_group = 'FILING_TYPE' AND code_value = '상속·증여세' THEN '상속증여세'
|
||||
ELSE code_value
|
||||
END,
|
||||
code_name = CASE
|
||||
WHEN code_group = 'CLIENT_SERVICE_TYPE' AND code_value = '증여·상속' THEN '증여·상속'
|
||||
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '홈페이지 문의' THEN '홈페이지 문의'
|
||||
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '직접 방문' THEN '직접 방문'
|
||||
WHEN code_group = 'CLIENT_SOURCE' AND code_value = '카카오 채널' THEN '카카오 채널'
|
||||
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '개인 기장대리' THEN '개인 기장대리'
|
||||
WHEN code_group = 'CONTRACT_SERVICE_TYPE' AND code_value = '법인 기장대리' THEN '법인 기장대리'
|
||||
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '기장 수수료' THEN '기장 수수료'
|
||||
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '신고 대행료' THEN '신고 대행료'
|
||||
WHEN code_group = 'REVENUE_SERVICE_TYPE' AND code_value = '자문 수수료' THEN '자문 수수료'
|
||||
WHEN code_group = 'FILING_TYPE' AND code_value = '상속·증여세' THEN '상속·증여세'
|
||||
ELSE code_name
|
||||
END
|
||||
WHERE (code_group, code_value) IN (
|
||||
('CLIENT_SERVICE_TYPE', '증여·상속'),
|
||||
('CLIENT_SOURCE', '홈페이지 문의'),
|
||||
('CLIENT_SOURCE', '직접 방문'),
|
||||
('CLIENT_SOURCE', '카카오 채널'),
|
||||
('CONTRACT_SERVICE_TYPE', '개인 기장대리'),
|
||||
('CONTRACT_SERVICE_TYPE', '법인 기장대리'),
|
||||
('REVENUE_SERVICE_TYPE', '기장 수수료'),
|
||||
('REVENUE_SERVICE_TYPE', '신고 대행료'),
|
||||
('REVENUE_SERVICE_TYPE', '자문 수수료'),
|
||||
('FILING_TYPE', '상속·증여세')
|
||||
);
|
||||
|
||||
UPDATE clients
|
||||
SET
|
||||
service_type = CASE WHEN service_type = '증여·상속' THEN '증여상속' ELSE service_type END,
|
||||
source = CASE
|
||||
WHEN source = '홈페이지 문의' THEN '홈페이지문의'
|
||||
WHEN source = '직접 방문' THEN '직접방문'
|
||||
WHEN source = '카카오 채널' THEN '카카오채널'
|
||||
ELSE source
|
||||
END;
|
||||
|
||||
UPDATE contracts
|
||||
SET service_type = REPLACE(REPLACE(service_type, ' ', ''), '·', '')
|
||||
WHERE service_type IS NOT NULL;
|
||||
|
||||
UPDATE revenue_tracking
|
||||
SET service_type = REPLACE(REPLACE(service_type, ' ', ''), '·', '')
|
||||
WHERE service_type IS NOT NULL;
|
||||
|
||||
UPDATE tax_filings
|
||||
SET filing_type = '상속증여세'
|
||||
WHERE filing_type = '상속·증여세';
|
||||
|
||||
UPDATE tax_filing_schedules
|
||||
SET filing_type = '상속증여세'
|
||||
WHERE filing_type = '상속·증여세';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Allow Korean code values and future growth without truncation risk.
|
||||
ALTER TABLE common_codes
|
||||
ALTER COLUMN code_group TYPE VARCHAR(80),
|
||||
ALTER COLUMN code_value TYPE VARCHAR(120),
|
||||
ALTER COLUMN code_name TYPE VARCHAR(200);
|
||||
|
||||
ALTER TABLE clients
|
||||
ALTER COLUMN service_type TYPE VARCHAR(100),
|
||||
ALTER COLUMN tax_type TYPE VARCHAR(60),
|
||||
ALTER COLUMN source TYPE VARCHAR(100);
|
||||
|
||||
ALTER TABLE contracts
|
||||
ALTER COLUMN service_type TYPE VARCHAR(120);
|
||||
|
||||
ALTER TABLE revenue_tracking
|
||||
ALTER COLUMN service_type TYPE VARCHAR(120);
|
||||
|
||||
ALTER TABLE tax_filings
|
||||
ALTER COLUMN filing_type TYPE VARCHAR(120);
|
||||
|
||||
ALTER TABLE tax_filing_schedules
|
||||
ALTER COLUMN filing_type TYPE VARCHAR(120);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Additional common codes for admin combo policy normalization.
|
||||
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
|
||||
('CONSULTING_ACTIVITY_TYPE', '방문상담', '방문 상담', 10),
|
||||
('CONSULTING_ACTIVITY_TYPE', '전화상담', '전화 상담', 20),
|
||||
('CONSULTING_ACTIVITY_TYPE', '세무조사대응미팅', '세무조사 대응 미팅', 30),
|
||||
('CONSULTING_ACTIVITY_TYPE', '카카오톡상담', '카카오톡 상담', 40),
|
||||
('CONSULTING_ACTIVITY_TYPE', '이메일자료접수', '이메일 자료 접수', 50),
|
||||
('CONSULTING_ACTIVITY_TYPE', '기타', '기타', 60),
|
||||
|
||||
('ANNOUNCEMENT_DISPLAY_TYPE', 'info', '일반', 10),
|
||||
('ANNOUNCEMENT_DISPLAY_TYPE', 'banner', '배너', 20),
|
||||
('ANNOUNCEMENT_DISPLAY_TYPE', 'urgent', '긴급', 30)
|
||||
ON CONFLICT (code_group, code_value) DO UPDATE
|
||||
SET code_name = EXCLUDED.code_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_active = TRUE;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE blog_posts
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_posts_deleted_at
|
||||
ON blog_posts (deleted_at)
|
||||
WHERE deleted_at IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
INSERT INTO common_codes (code_group, code_value, code_name, sort_order)
|
||||
SELECT v.code_group, v.code_value, v.code_name, v.sort_order
|
||||
FROM (
|
||||
VALUES
|
||||
('FAQ_CATEGORY', '기장세금신고', '기장세금신고', 10),
|
||||
('FAQ_CATEGORY', '부동산', '부동산', 20),
|
||||
('FAQ_CATEGORY', '증여상속', '증여상속', 30),
|
||||
('FAQ_CATEGORY', '기타', '기타', 40)
|
||||
) AS v(code_group, code_value, code_name, sort_order)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM common_codes cc
|
||||
WHERE cc.code_group = v.code_group
|
||||
AND cc.code_value = v.code_value
|
||||
);
|
||||
@@ -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 증거
|
||||
@@ -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` 중 하나를 명시해야 한다.
|
||||
- 상태/유형/등급 입력이 있는 화면은 콤보 정책 위반이 없어야 한다.
|
||||
- 고객/회사처럼 데이터가 많은 항목은 검색형 선택으로 통일한다.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Common Code Policy
|
||||
|
||||
이 문서는 어드민 콤보, 상태, 유형, 출처 값의 단일 기준이다. 값은 DB `common_codes`를 우선 사용하고, 화면은 표시명만 바꾼다.
|
||||
|
||||
## Canonical Rules
|
||||
|
||||
- `code_value`는 저장 키다.
|
||||
- `code_name`은 화면 표시값이다.
|
||||
- `code_value`는 공백을 넣지 않는다.
|
||||
- 새 콤보를 추가할 때는 먼저 `common_codes`에 그룹을 추가한다.
|
||||
- 화면 하드코딩 배열은 금지한다. 불가피하면 임시 폴백으로만 두고 제거 계획을 함께 적는다.
|
||||
- 같은 의미의 값이 테이블마다 다르면 저장값을 먼저 통일하고 마이그레이션으로 이관한다.
|
||||
|
||||
## Grouping Rules
|
||||
|
||||
- 상태값: `*_STATUS`
|
||||
- 유형값: `*_TYPE`
|
||||
- 출처값: `*_SOURCE`
|
||||
- 위험도/스코어: `*_LEVEL`
|
||||
|
||||
## Standard Groups
|
||||
|
||||
- `INQUIRY_SERVICE_TYPE`
|
||||
- `INQUIRY_STATUS`
|
||||
- `CONSULTING_ACTIVITY_TYPE`
|
||||
- `ANNOUNCEMENT_DISPLAY_TYPE`
|
||||
- `CLIENT_STATUS`
|
||||
- `CLIENT_SERVICE_TYPE`
|
||||
- `CLIENT_TAX_TYPE`
|
||||
- `CLIENT_SOURCE`
|
||||
- `CONTRACT_SERVICE_TYPE`
|
||||
- `REVENUE_SERVICE_TYPE`
|
||||
- `FILING_TYPE`
|
||||
- `TAX_RISK_LEVEL`
|
||||
- `BUSINESS_TYPE`
|
||||
- `FAQ_CATEGORY`
|
||||
|
||||
## Data Rules
|
||||
|
||||
- DB seed와 운영 데이터의 저장값이 다르면 UI를 먼저 맞추지 말고 저장값을 먼저 정규화한다.
|
||||
- 한글 코드값을 사용하더라도 컬럼 길이를 먼저 검토하고, 업무 테이블과 마스터 테이블을 함께 조정한다.
|
||||
- 표시용 문구가 길면 `code_name`에 둔다.
|
||||
|
||||
## UI Rules
|
||||
|
||||
- `MudSelect`는 `code_value`를 바인딩하고 `code_name`을 보여준다.
|
||||
- 검색형이면 `MudAutocomplete`를 쓰고, 선택형이면 `MudSelect`를 쓴다.
|
||||
- 자유 입력을 허용하지 않을 값은 텍스트 필드로 만들지 않는다.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- 신규 콤보 추가 시 DB 마이그레이션이 먼저 존재해야 한다.
|
||||
- 화면에 하드코딩된 선택값이 없어야 한다.
|
||||
- 기존 저장값과 신규 저장값의 불일치가 없어야 한다.
|
||||
|
||||
## Audit
|
||||
|
||||
- 점검 SQL은 [docs/ops/COMMON_CODE_AUDIT.sql](./ops/COMMON_CODE_AUDIT.sql)를 사용한다.
|
||||
@@ -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` 패턴 중 하나 이상을 재사용한다.
|
||||
@@ -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 관습을 왜곡해 구현하지 않는다.
|
||||
@@ -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차 기준으로 사용한다.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Restore archived blog posts that were hidden by soft delete.
|
||||
-- Use only when the goal is to bring back previously archived posts.
|
||||
|
||||
UPDATE blog_posts
|
||||
SET deleted_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE deleted_at IS NOT NULL;
|
||||
Reference in New Issue
Block a user