feat: WBS-UX-03 FAQ 관리 기능 구현 — 어드민 CRUD + 홈페이지 DB 연동

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 23:39:59 +09:00
parent b67002dcf5
commit ccba017e3e
12 changed files with 462 additions and 72 deletions
@@ -47,6 +47,7 @@
</MudNavGroup>
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" Expanded="false">
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
@@ -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
<PageTitle>@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "FAQ 수정" : "FAQ 등록")</MudText>
</div>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs"
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
@if (isLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudForm @ref="form" @bind-IsValid="isValid">
<MudGrid Spacing="3">
<MudItem xs="12">
<MudTextField @bind-Value="faq.Question"
Label="질문 *" Required="true"
RequiredError="질문을 입력하세요."
Counter="300" MaxLength="300"
Lines="2" AutoGrow="true"
Placeholder="예: 기장료가 얼마인지 미리 알 수 있나요?" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="faq.Answer"
Label="답변 *" Required="true"
RequiredError="답변을 입력하세요."
Lines="5" AutoGrow="true"
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>
</MudItem>
<MudItem xs="12" md="3">
<MudNumericField @bind-Value="faq.SortOrder"
Label="정렬 순서"
HelperText="작을수록 위에 노출"
Min="0" Max="9999" />
</MudItem>
<MudItem xs="12" md="3" Class="d-flex align-center">
<MudSwitch T="bool" @bind-Value="faq.IsActive" Color="Color.Success"
Label="@(faq.IsActive ? "노출 중" : "비활성")" />
</MudItem>
<MudItem xs="12" Class="d-flex gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="@SaveAsync" Disabled="@isSaving">
@(isSaving ? "저장 중..." : "저장")
</MudButton>
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/faqs">
취소
</MudButton>
</MudItem>
</MudGrid>
</MudForm>
}
</MudPaper>
@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;
}
}
}
@@ -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
<PageTitle>FAQ 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">홈페이지</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">FAQ 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">홈페이지 자주 묻는 질문을 등록하고 순서를 관리합니다.</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/faqs/create">
FAQ 등록
</MudButton>
</section>
<MudPaper Class="admin-surface" Elevation="0">
@if (faqs is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!faqs.Any())
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
</div>
}
else
{
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th style="width:60px;">순서</th>
<th>질문</th>
<th style="width:130px;">카테고리</th>
<th style="width:90px;">상태</th>
<th style="width:160px;"></th>
</tr>
</thead>
<tbody>
@foreach (var item in faqs)
{
<tr>
<td class="text-center">
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
</td>
<td>
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
@item.Question
</MudText>
</td>
<td>
@if (!string.IsNullOrEmpty(item.Category))
{
<MudChip Size="Size.Small" Color="Color.Default">@item.Category</MudChip>
}
</td>
<td>
@if (item.IsActive)
{
<MudChip Size="Size.Small" Color="Color.Success">노출 중</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
}
</td>
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
삭제
</MudButton>
</MudButtonGroup>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
</MudText>
}
</MudPaper>
@code {
private List<Faq>? 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();
}
}
+20 -63
View File
@@ -352,7 +352,9 @@ else
</div>
</section>
<!-- 자주 묻는 질문 -->
<!-- 자주 묻는 질문 (DB 연동) -->
@if (Model.ActiveFaqs.Count > 0)
{
<section class="py-5" style="background: #F9F7F3;">
<div class="container">
<div class="text-center mb-5">
@@ -361,70 +363,24 @@ else
</div>
<div class="accordion faq-accordion" id="faqAccordion">
<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="#faq1">
기장료가 얼마인지 미리 알 수 있나요?
</button>
</h3>
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body faq-answer">
업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다.
일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다.
<strong>먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.</strong>
@for (int i = 0; i < Model.ActiveFaqs.Count; i++)
{
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>
</div>
</div>
</div>
<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="#faq2">
양도세 상담은 어떻게 진행되나요?
</button>
</h3>
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body faq-answer">
등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오 채널 또는 문의폼으로 전달해 주시면
예상 세액과 절세 방법을 검토해 드립니다.
<strong>매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다.</strong>
</div>
</div>
</div>
<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="#faq3">
무료 상담도 가능한가요?
</button>
</h3>
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body faq-answer">
네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다.
카카오 채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다.
<strong>실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다.</strong>
</div>
</div>
</div>
<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="#faq4">
처음 상담 시 어떤 자료를 준비해야 하나요?
</button>
</h3>
<div id="faq4" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body faq-answer">
상담 목적에 따라 다르지만 일반적으로 아래 자료가 있으면 더 정확한 안내가 가능합니다:
<ul class="mt-2 mb-0">
<li><strong>사업자 세무:</strong> 사업자등록증, 최근 3개월 매출·매입 자료</li>
<li><strong>부동산:</strong> 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료</li>
<li><strong>증여·상속:</strong> 재산 목록, 증여 예정 자산 내역</li>
</ul>
<span class="d-block mt-2"><strong>자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.</strong></span>
</div>
</div>
</div>
}
</div>
<div class="text-center mt-5">
@@ -433,6 +389,7 @@ else
</div>
</div>
</section>
}
<!-- 최종 CTA -->
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
+22 -9
View File
@@ -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<BlogPost> RecentPosts { get; set; } = [];
public List<BlogPost> SeasonalPosts { get; set; } = [];
public CurrentSeasonDto? CurrentSeason { get; set; }
public List<Announcement> ActiveAnnouncements { get; set; } = [];
public List<Faq> 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<IEnumerable<T>?> LoadSafeAsync<T>(Func<Task<IEnumerable<T>>> loader)
{
try { return await loader(); }
catch { return null; }
}
}