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:
@@ -16,6 +16,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<AnnouncementService>();
|
||||
services.AddSingleton<SeasonalMarketingService>();
|
||||
services.AddScoped<ClientService>();
|
||||
services.AddScoped<FaqService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default) =>
|
||||
await repository.GetActiveAsync(ct);
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllAsync(ct);
|
||||
|
||||
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<int> 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("답변을 입력하세요.");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface IFaqRepository
|
||||
{
|
||||
Task<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default);
|
||||
Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<int> CreateAsync(Faq faq, CancellationToken ct = default);
|
||||
Task UpdateAsync(Faq faq, CancellationToken ct = default);
|
||||
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ISiteSettingRepository, SiteSettingRepository>();
|
||||
services.AddScoped<IAnnouncementRepository, AnnouncementRepository>();
|
||||
services.AddScoped<IClientRepository, ClientRepository>();
|
||||
services.AddScoped<IFaqRepository, FaqRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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<IEnumerable<Faq>> GetActiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Faq>(
|
||||
$"SELECT {SelectColumns} FROM faqs WHERE is_active = TRUE ORDER BY sort_order, id");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Faq>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Faq>(
|
||||
$"SELECT {SelectColumns} FROM faqs ORDER BY sort_order, id");
|
||||
}
|
||||
|
||||
public async Task<Faq?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Faq>(
|
||||
$"SELECT {SelectColumns} FROM faqs WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Faq faq, CancellationToken ct = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
@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="#faq1">
|
||||
기장료가 얼마인지 미리 알 수 있나요?
|
||||
data-bs-toggle="collapse" data-bs-target="#@collapseId">
|
||||
@faqItem.Question
|
||||
</button>
|
||||
</h3>
|
||||
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div id="@collapseId" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body faq-answer">
|
||||
업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다.
|
||||
일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다.
|
||||
<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="#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>
|
||||
@faqItem.Answer
|
||||
</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;">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user