From ccba017e3ecaea9900ffa11b6563b5ccb7ba26ce Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 27 Jun 2026 23:39:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20WBS-UX-03=20FAQ=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=E2=80=94=20=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20CRUD=20+=20=ED=99=88=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20DB=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB: - V007__CreateFaqs.sql: faqs 테이블 (question, answer, category, sort_order, is_active) + 기본 FAQ 4개 시드 Domain: - Faq 엔티티 - IFaqRepository (GetActiveAsync, GetAllAsync, CRUD) Infrastructure: - FaqRepository: sort_order 정렬, CRUD Application: - FaqService: Categories 상수, Validate (질문·답변 필수) Admin UI (Blazor): - FaqList.razor: 전체 목록, 활성/비활성 상태 칩, 삭제 확인 - FaqEdit.razor: 질문/답변/카테고리/순서/활성 토글 폼 - MainLayout: 홈페이지 그룹 하위에 FAQ 관리 메뉴 추가 홈페이지: - Index.cshtml 하드코딩 FAQ → ActiveFaqs DB 루프로 교체 - FAQ 없으면 섹션 전체 숨김 (빈 DB에 안전) - IndexModel: FaqService 주입, Task.WhenAll 병렬 로드 Co-Authored-By: Claude Sonnet 4.6 --- TaxBaik.Application/DependencyInjection.cs | 1 + TaxBaik.Application/Services/FaqService.cs | 42 ++++++ TaxBaik.Domain/Entities/Faq.cs | 13 ++ TaxBaik.Domain/Interfaces/IFaqRepository.cs | 13 ++ TaxBaik.Infrastructure/DependencyInjection.cs | 1 + .../Repositories/FaqRepository.cs | 60 ++++++++ .../Components/Admin/Layout/MainLayout.razor | 1 + .../Components/Admin/Pages/Faqs/FaqEdit.razor | 133 ++++++++++++++++++ .../Components/Admin/Pages/Faqs/FaqList.razor | 120 ++++++++++++++++ TaxBaik.Web/Pages/Index.cshtml | 83 +++-------- TaxBaik.Web/Pages/Index.cshtml.cs | 31 ++-- db/migrations/V007__CreateFaqs.sql | 36 +++++ 12 files changed, 462 insertions(+), 72 deletions(-) create mode 100644 TaxBaik.Application/Services/FaqService.cs create mode 100644 TaxBaik.Domain/Entities/Faq.cs create mode 100644 TaxBaik.Domain/Interfaces/IFaqRepository.cs create mode 100644 TaxBaik.Infrastructure/Repositories/FaqRepository.cs create mode 100644 TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor create mode 100644 TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor create mode 100644 db/migrations/V007__CreateFaqs.sql diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index 79e1529..da52111 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -16,6 +16,7 @@ public static class DependencyInjection services.AddScoped(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/TaxBaik.Application/Services/FaqService.cs b/TaxBaik.Application/Services/FaqService.cs new file mode 100644 index 0000000..03e02d0 --- /dev/null +++ b/TaxBaik.Application/Services/FaqService.cs @@ -0,0 +1,42 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class FaqService(IFaqRepository repository) +{ + public static readonly string[] Categories = + ["기장·세금신고", "부동산", "증여·상속", "기타"]; + + public async Task> GetActiveAsync(CancellationToken ct = default) => + await repository.GetActiveAsync(ct); + + public async Task> GetAllAsync(CancellationToken ct = default) => + await repository.GetAllAsync(ct); + + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await repository.GetByIdAsync(id, ct); + + public async Task CreateAsync(Faq faq, CancellationToken ct = default) + { + Validate(faq); + return await repository.CreateAsync(faq, ct); + } + + public async Task UpdateAsync(Faq faq, CancellationToken ct = default) + { + Validate(faq); + await repository.UpdateAsync(faq, ct); + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) => + await repository.DeleteAsync(id, ct); + + private static void Validate(Faq faq) + { + if (string.IsNullOrWhiteSpace(faq.Question)) + throw new ValidationException("질문을 입력하세요."); + if (string.IsNullOrWhiteSpace(faq.Answer)) + throw new ValidationException("답변을 입력하세요."); + } +} diff --git a/TaxBaik.Domain/Entities/Faq.cs b/TaxBaik.Domain/Entities/Faq.cs new file mode 100644 index 0000000..c5ea67d --- /dev/null +++ b/TaxBaik.Domain/Entities/Faq.cs @@ -0,0 +1,13 @@ +namespace TaxBaik.Domain.Entities; + +public class Faq +{ + public int Id { get; set; } + public string Question { get; set; } = null!; + public string Answer { get; set; } = null!; + public string? Category { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/TaxBaik.Domain/Interfaces/IFaqRepository.cs b/TaxBaik.Domain/Interfaces/IFaqRepository.cs new file mode 100644 index 0000000..2630d97 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IFaqRepository.cs @@ -0,0 +1,13 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface IFaqRepository +{ + Task> GetActiveAsync(CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task CreateAsync(Faq faq, CancellationToken ct = default); + Task UpdateAsync(Faq faq, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); +} diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs index c02d85b..60c6231 100644 --- a/TaxBaik.Infrastructure/DependencyInjection.cs +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -17,6 +17,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/TaxBaik.Infrastructure/Repositories/FaqRepository.cs b/TaxBaik.Infrastructure/Repositories/FaqRepository.cs new file mode 100644 index 0000000..beb3d18 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/FaqRepository.cs @@ -0,0 +1,60 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class FaqRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IFaqRepository +{ + private const string SelectColumns = + "id, question, answer, category, sort_order, is_active, created_at, updated_at"; + + public async Task> GetActiveAsync(CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + $"SELECT {SelectColumns} FROM faqs WHERE is_active = TRUE ORDER BY sort_order, id"); + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + $"SELECT {SelectColumns} FROM faqs ORDER BY sort_order, id"); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + $"SELECT {SelectColumns} FROM faqs WHERE id = @Id", + new { Id = id }); + } + + public async Task CreateAsync(Faq faq, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO faqs (question, answer, category, sort_order, is_active, created_at, updated_at) + VALUES (@Question, @Answer, @Category, @SortOrder, @IsActive, NOW(), NOW()) + RETURNING id", + faq); + } + + public async Task UpdateAsync(Faq faq, CancellationToken ct = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE faqs + SET question = @Question, answer = @Answer, category = @Category, + sort_order = @SortOrder, is_active = @IsActive, updated_at = NOW() + WHERE id = @Id", + faq); + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) + { + using var conn = Conn(); + await conn.ExecuteAsync("DELETE FROM faqs WHERE id = @Id", new { Id = id }); + } +} diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index d1ca85d..2e6fd23 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -47,6 +47,7 @@ 공지사항 + FAQ 관리 블로그 관리 문의 관리 diff --git a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor new file mode 100644 index 0000000..caf1ec9 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqEdit.razor @@ -0,0 +1,133 @@ +@page "/admin/faqs/create" +@page "/admin/faqs/{Id:int}/edit" +@attribute [Authorize] +@using TaxBaik.Application.Services +@using TaxBaik.Domain.Entities +@inject FaqService FaqService +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +@(Id.HasValue ? "FAQ 수정" : "FAQ 등록") + +
+
+ 홈페이지 + @(Id.HasValue ? "FAQ 수정" : "FAQ 등록") +
+ 목록으로 +
+ + + @if (isLoading) + { + + } + else + { + + + + + + + + + + + @foreach (var cat in FaqService.Categories) + { + @cat + } + + + + + + + + + + + + @(isSaving ? "저장 중..." : "저장") + + + 취소 + + + + + } + + +@code { + [Parameter] public int? Id { get; set; } + + private MudForm form = null!; + private Faq faq = new() { SortOrder = 10, IsActive = true }; + private bool isValid; + private bool isLoading = true; + private bool isSaving; + + protected override async Task OnInitializedAsync() + { + if (Id.HasValue) + { + var existing = await FaqService.GetByIdAsync(Id.Value); + if (existing is null) + { + Snackbar.Add("FAQ를 찾을 수 없습니다.", Severity.Error); + Navigation.NavigateTo("/taxbaik/admin/faqs"); + return; + } + faq = existing; + } + isLoading = false; + } + + private async Task SaveAsync() + { + await form.Validate(); + if (!isValid) return; + + isSaving = true; + try + { + if (Id.HasValue) + { + await FaqService.UpdateAsync(faq); + Snackbar.Add("FAQ가 수정되었습니다.", Severity.Success); + } + else + { + await FaqService.CreateAsync(faq); + Snackbar.Add("FAQ가 등록되었습니다.", Severity.Success); + } + Navigation.NavigateTo("/taxbaik/admin/faqs"); + } + catch (Exception ex) + { + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); + } + finally + { + isSaving = false; + } + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor new file mode 100644 index 0000000..01151e8 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Faqs/FaqList.razor @@ -0,0 +1,120 @@ +@page "/admin/faqs" +@attribute [Authorize] +@using TaxBaik.Application.Services +@using TaxBaik.Domain.Entities +@inject FaqService FaqService +@inject NavigationManager Navigation +@inject IDialogService DialogService +@inject ISnackbar Snackbar + +FAQ 관리 + +
+
+ 홈페이지 + FAQ 관리 + 홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다. +
+ + FAQ 등록 + +
+ + + @if (faqs is null) + { + + } + else if (!faqs.Any()) + { +
+ + 등록된 FAQ가 없습니다. +
+ } + else + { + + + + 순서 + 질문 + 카테고리 + 상태 + + + + + @foreach (var item in faqs) + { + + + @item.SortOrder + + + + @item.Question + + + + @if (!string.IsNullOrEmpty(item.Category)) + { + @item.Category + } + + + @if (item.IsActive) + { + 노출 중 + } + else + { + 비활성 + } + + + + + 수정 + + + 삭제 + + + + + } + + + + 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개 + + } +
+ +@code { + private List? faqs; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + faqs = (await FaqService.GetAllAsync()).ToList(); + } + + private async Task DeleteAsync(Faq item) + { + var confirmed = await DialogService.ShowMessageBox( + "FAQ 삭제", + $"'{item.Question}' 항목을 삭제하시겠습니까?", + yesText: "삭제", cancelText: "취소"); + + if (confirmed != true) return; + + await FaqService.DeleteAsync(item.Id); + Snackbar.Add("FAQ가 삭제되었습니다.", Severity.Success); + await LoadAsync(); + } +} diff --git a/TaxBaik.Web/Pages/Index.cshtml b/TaxBaik.Web/Pages/Index.cshtml index c4debd9..c17a235 100644 --- a/TaxBaik.Web/Pages/Index.cshtml +++ b/TaxBaik.Web/Pages/Index.cshtml @@ -352,7 +352,9 @@ else - + +@if (Model.ActiveFaqs.Count > 0) +{
@@ -361,70 +363,24 @@ else
-
-

- -

-
-
- 업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다. - 일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다. - 먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다. + @for (int i = 0; i < Model.ActiveFaqs.Count; i++) + { + var faqItem = Model.ActiveFaqs[i]; + var collapseId = $"faq-{faqItem.Id}"; +
+

+ +

+
+
+ @faqItem.Answer +
-
-
-

- -

-
-
- 등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오 채널 또는 문의폼으로 전달해 주시면 - 예상 세액과 절세 방법을 검토해 드립니다. - 매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다. -
-
-
-
-

- -

-
-
- 네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다. - 카카오 채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다. - 실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다. -
-
-
-
-

- -

-
-
- 상담 목적에 따라 다르지만 일반적으로 아래 자료가 있으면 더 정확한 안내가 가능합니다: -
    -
  • 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료
  • -
  • 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료
  • -
  • 증여·상속: 재산 목록, 증여 예정 자산 내역
  • -
- 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요. -
-
-
+ }
@@ -433,6 +389,7 @@ else
+}
diff --git a/TaxBaik.Web/Pages/Index.cshtml.cs b/TaxBaik.Web/Pages/Index.cshtml.cs index 8131281..85b3919 100644 --- a/TaxBaik.Web/Pages/Index.cshtml.cs +++ b/TaxBaik.Web/Pages/Index.cshtml.cs @@ -10,35 +10,42 @@ public class IndexModel : PageModel private readonly BlogService _blogService; private readonly SeasonalMarketingService _seasonalMarketingService; private readonly AnnouncementService _announcementService; + private readonly FaqService _faqService; public List RecentPosts { get; set; } = []; public List SeasonalPosts { get; set; } = []; public CurrentSeasonDto? CurrentSeason { get; set; } public List ActiveAnnouncements { get; set; } = []; + public List ActiveFaqs { get; set; } = []; public IndexModel( BlogService blogService, SeasonalMarketingService seasonalMarketingService, - AnnouncementService announcementService) + AnnouncementService announcementService, + FaqService faqService) { _blogService = blogService; _seasonalMarketingService = seasonalMarketingService; _announcementService = announcementService; + _faqService = faqService; } public async Task OnGetAsync() { CurrentSeason = _seasonalMarketingService.GetCurrentSeason(); - try - { - ActiveAnnouncements = (await _announcementService.GetActiveAsync()).ToList(); - } - catch - { - ActiveAnnouncements = []; - } + var announcementsTask = LoadSafeAsync(() => _announcementService.GetActiveAsync()); + var faqsTask = LoadSafeAsync(() => _faqService.GetActiveAsync()); + var blogTask = LoadBlogAsync(); + await Task.WhenAll(announcementsTask, faqsTask, blogTask); + + ActiveAnnouncements = (await announcementsTask)?.ToList() ?? []; + ActiveFaqs = (await faqsTask)?.ToList() ?? []; + } + + private async Task LoadBlogAsync() + { try { if (CurrentSeason is not null && !string.IsNullOrEmpty(CurrentSeason.RelatedCategorySlug)) @@ -60,4 +67,10 @@ public class IndexModel : PageModel SeasonalPosts = []; } } + + private static async Task?> LoadSafeAsync(Func>> loader) + { + try { return await loader(); } + catch { return null; } + } } diff --git a/db/migrations/V007__CreateFaqs.sql b/db/migrations/V007__CreateFaqs.sql new file mode 100644 index 0000000..76a008d --- /dev/null +++ b/db/migrations/V007__CreateFaqs.sql @@ -0,0 +1,36 @@ +-- FAQ 관리 테이블 +CREATE TABLE IF NOT EXISTS faqs ( + id SERIAL PRIMARY KEY, + question VARCHAR(300) NOT NULL, + answer TEXT NOT NULL, + category VARCHAR(50), -- 기장·세금신고, 부동산, 증여·상속, 기타 + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_faqs_active_order ON faqs (is_active, sort_order); + +-- 기본 FAQ 시드 데이터 (하드코딩 대체) +INSERT INTO faqs (question, answer, category, sort_order, is_active) VALUES +( + '기장료가 얼마인지 미리 알 수 있나요?', + '업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다. 일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다. 먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.', + '기장·세금신고', 10, TRUE +), +( + '양도세 상담은 어떻게 진행되나요?', + '등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오 채널 또는 문의폼으로 전달해 주시면 예상 세액과 절세 방법을 검토해 드립니다. 매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다.', + '부동산', 20, TRUE +), +( + '무료 상담도 가능한가요?', + '네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다. 카카오 채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다. 실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다.', + '기타', 30, TRUE +), +( + '처음 상담 시 어떤 자료를 준비해야 하나요?', + '상담 목적에 따라 다르지만 아래 자료가 있으면 더 정확한 안내가 가능합니다. 사업자 세무: 사업자등록증, 최근 3개월 매출·매입 자료 / 부동산: 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료 / 증여·상속: 재산 목록, 증여 예정 자산 내역. 자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.', + '기타', 40, TRUE +);