Standardize admin pages and blog editor
TaxBaik CI/CD / build-and-deploy (push) Failing after 2m39s

This commit is contained in:
2026-07-09 11:33:17 +09:00
parent 9cf6675dc9
commit 55933649f6
27 changed files with 825 additions and 68 deletions
@@ -1,4 +1,34 @@
@page
@model TaxBaik.Web.Pages.Admin.Announcements.IndexModel
@{ ViewData["Title"] = "공지사항"; }
<section class="container py-5"><h1 class="h2 fw-bold">공지사항 관리</h1></section>
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Content", "공지사항 관리", "공지 목록을 서버 렌더링으로 표시합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>제목</th><th>유형</th><th>활성</th><th>정렬</th></tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="4"><partial name="_EmptyState" model="공지사항이 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{
<tr>
<td>@item.Title</td>
<td>@item.DisplayType</td>
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td>
<td>@item.SortOrder</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</section>
@@ -1,7 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Announcements;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Announcements;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel(AnnouncementService announcementService) : PageModel
{
public List<Announcement> Items { get; private set; } = [];
public async Task OnGetAsync(CancellationToken ct = default)
=> Items = (await announcementService.GetAllAsync(ct)).ToList();
}
+81 -2
View File
@@ -1,4 +1,83 @@
@page
@model TaxBaik.Web.Pages.Admin.Blog.CreateModel
@{ ViewData["Title"] = "블로그 등록"; }
<section class="container py-5"><h1 class="h2 fw-bold">블로그 등록</h1></section>
@{
ViewData["Title"] = "블로그 등록";
}
<section class="container-fluid py-4" data-blog-editor-root>
<div class="row g-3">
<div class="col-12">
<div class="page-header">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">Content</div>
<h2 class="page-title">블로그 등록</h2>
</div>
</div>
</div>
</div>
<div class="col-lg-8">
<form method="post" class="card">
<div class="card-body">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<input type="hidden" asp-for="Input.Content" data-blog-editor-value />
<div class="mb-3">
<label asp-for="Input.Title" class="form-label"></label>
<input asp-for="Input.Title" class="form-control" />
<span asp-validation-for="Input.Title" class="text-danger small"></span>
</div>
<div class="mb-3">
<label class="form-label">본문</label>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="bold">Bold</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="italic">Italic</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="insertUnorderedList">UL</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="formatBlock" data-blog-value="h2">H2</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="formatBlock" data-blog-value="blockquote">Quote</button>
</div>
<div class="form-control" data-blog-editor contenteditable="true" style="min-height: 320px; overflow:auto;"></div>
<div class="form-hint text-muted small mt-2">HTML 태그는 그대로 저장되고 홈페이지에서 렌더링됩니다.</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="Input.CategoryId" class="form-label"></label>
<input asp-for="Input.CategoryId" class="form-control" />
</div>
<div class="col-md-4">
<label asp-for="Input.IsPublished" class="form-label d-block"></label>
<input asp-for="Input.IsPublished" class="form-check-input" type="checkbox" />
</div>
<div class="col-md-4">
<label asp-for="Input.ThumbnailUrl" class="form-label"></label>
<input asp-for="Input.ThumbnailUrl" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="Input.SeoTitle" class="form-label"></label>
<input asp-for="Input.SeoTitle" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="Input.SeoDescription" class="form-label"></label>
<input asp-for="Input.SeoDescription" class="form-control" />
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<a class="btn btn-outline-secondary" href="/admin/blog">취소</a>
<button class="btn btn-primary" type="submit">저장</button>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">미리보기</h3></div>
<div class="card-body">
<article class="markdown-body" data-blog-preview></article>
</div>
</div>
</div>
</div>
</section>
@section Scripts {
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
}
@@ -1,7 +1,30 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel(BlogService blogService) : PageModel
{
[BindProperty]
public CreateBlogPostDto Input { get; set; } = new()
{
Content = string.Empty,
Title = string.Empty
};
public IActionResult OnGet() => Page();
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
await blogService.CreateAsync(Input, ct);
return RedirectToPage("/Admin/Blog/Index");
}
}
+83 -2
View File
@@ -1,4 +1,85 @@
@page "{id:int}"
@model TaxBaik.Web.Pages.Admin.Blog.EditModel
@{ ViewData["Title"] = "블로그 수정"; }
<section class="container py-5"><h1 class="h2 fw-bold">블로그 수정</h1></section>
@{
ViewData["Title"] = "블로그 수정";
}
<section class="container-fluid py-4" data-blog-editor-root>
<div class="row g-3">
<div class="col-12">
<div class="page-header">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">Content</div>
<h2 class="page-title">블로그 수정</h2>
</div>
</div>
</div>
</div>
<div class="col-lg-8">
<form method="post" class="card">
<div class="card-body">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="Input.Content" data-blog-editor-value />
<div class="mb-3">
<label asp-for="Input.Title" class="form-label"></label>
<input asp-for="Input.Title" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">본문</label>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="bold">Bold</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="italic">Italic</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="insertUnorderedList">UL</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="formatBlock" data-blog-value="h2">H2</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-blog-command="formatBlock" data-blog-value="blockquote">Quote</button>
</div>
<div class="form-control" data-blog-editor contenteditable="true" style="min-height: 320px; overflow:auto;">@Html.Raw(Model.Input.Content)</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="Input.CategoryId" class="form-label"></label>
<input asp-for="Input.CategoryId" class="form-control" />
</div>
<div class="col-md-4">
<label asp-for="Input.IsPublished" class="form-label d-block"></label>
<input asp-for="Input.IsPublished" class="form-check-input" type="checkbox" />
</div>
<div class="col-md-4">
<label asp-for="Input.ThumbnailUrl" class="form-label"></label>
<input asp-for="Input.ThumbnailUrl" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="Input.SeoTitle" class="form-label"></label>
<input asp-for="Input.SeoTitle" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="Input.SeoDescription" class="form-label"></label>
<input asp-for="Input.SeoDescription" class="form-control" />
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<button class="btn btn-outline-danger" type="submit" formaction="?handler=Delete" formmethod="post" onclick="return confirm('삭제하시겠습니까?');">삭제</button>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="/admin/blog">취소</a>
<button class="btn btn-primary" type="submit">저장</button>
</div>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">미리보기</h3></div>
<div class="card-body">
<article class="markdown-body" data-blog-preview>@Html.Raw(Model.Input.Content)</article>
</div>
</div>
</div>
</div>
</section>
@section Scripts {
<script src="~/js/admin-blog-editor.js" asp-append-version="true"></script>
}
@@ -1,7 +1,62 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel(BlogService blogService) : PageModel
{
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
[BindProperty]
public CreateBlogPostDto Input { get; set; } = new()
{
Content = string.Empty,
Title = string.Empty
};
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
var post = await blogService.GetByIdAsync(Id, ct);
if (post is null)
return NotFound();
Input = new CreateBlogPostDto
{
Title = post.Title,
Content = post.Content,
CategoryId = post.CategoryId,
Tags = post.Tags,
SeoTitle = post.SeoTitle,
SeoDescription = post.SeoDescription,
ThumbnailUrl = post.ThumbnailUrl,
IsPublished = post.IsPublished,
AuthorId = post.AuthorId
};
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
return Page();
var updated = await blogService.UpdateAsync(Id, Input, ct);
if (updated is null)
return NotFound();
return RedirectToPage("/Admin/Blog/Index");
}
public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
{
await blogService.DeleteAsync(Id, ct);
return RedirectToPage("/Admin/Blog/Index");
}
}
+74 -2
View File
@@ -1,4 +1,76 @@
@page
@model TaxBaik.Web.Pages.Admin.Blog.IndexModel
@{ ViewData["Title"] = "블로그"; }
<section class="container py-5"><h1 class="h2 fw-bold">블로그 관리</h1></section>
@{
ViewData["Title"] = "블로그";
}
<section class="container-fluid py-4">
<div class="page-header d-print-none mb-4">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">Content</div>
<h2 class="page-title">블로그 관리</h2>
</div>
<div class="col-auto ms-auto">
<a class="btn btn-primary" href="/admin/blog/create">새 글 작성</a>
</div>
</div>
</div>
<form method="get" class="card mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-6">
<label class="form-label" for="SearchQuery">검색</label>
<input class="form-control" id="SearchQuery" name="searchQuery" value="@Model.SearchQuery" placeholder="제목 또는 본문 검색" />
</div>
<div class="col-md-6 d-flex gap-2">
<button class="btn btn-primary" type="submit">검색</button>
<a class="btn btn-outline-secondary" href="/admin/blog">초기화</a>
<a class="btn btn-outline-secondary" href="/admin/blog?showArchived=true">내린 글 보기</a>
</div>
</div>
</div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>제목</th>
<th>발행</th>
<th>조회수</th>
<th>작성일</th>
<th class="text-end">작업</th>
</tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="5"><partial name="_EmptyState" model="블로그 글이 없습니다." /></td></tr>
}
else
{
foreach (var post in Model.Items)
{
<tr>
<td>
<div class="fw-semibold">@post.Title</div>
<div class="text-muted small">@post.Slug</div>
</td>
<td><partial name="_StatusBadge" model="@(post.IsPublished ? "active" : "inactive")" /></td>
<td>@post.ViewCount</td>
<td>@post.CreatedAt.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/admin/blog/@post.Id/edit">수정</a>
<a class="btn btn-sm btn-outline-secondary" href="/blog/@post.Slug" target="_blank">보기</a>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</section>
@@ -1,7 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel { }
public class IndexModel(BlogService blogService) : PageModel
{
public List<BlogPost> Items { get; private set; } = [];
public string? SearchQuery { get; set; }
public async Task OnGetAsync(string? searchQuery, bool showArchived = false, CancellationToken ct = default)
{
SearchQuery = searchQuery;
var posts = showArchived
? await blogService.GetArchivedPagedAsync(1, 100, ct)
: await blogService.GetAdminPagedAsync(1, 100, ct);
Items = posts.Item1
.Where(p => string.IsNullOrWhiteSpace(searchQuery) ||
p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
(p.Content?.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ?? false))
.ToList();
}
}
@@ -1,4 +1,91 @@
@page
@model TaxBaik.Web.Pages.Admin.Clients.IndexModel
@{ ViewData["Title"] = "클라이언트"; }
<section class="container py-5"><h1 class="h2 fw-bold">클라이언트 목록</h1><p class="text-muted">Blazor 관리자 화면을 Razor Pages로 전환하는 자리입니다.</p></section>
@{
ViewData["Title"] = "고객 관리";
}
<section class="container-fluid py-4">
<div class="page-header d-print-none mb-4">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">CRM</div>
<h2 class="page-title">고객 관리</h2>
</div>
<div class="col-auto ms-auto">
<a class="btn btn-primary" href="/admin/customers/create">고객 등록</a>
</div>
</div>
</div>
<form method="get" class="card mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label class="form-label" for="Search">검색</label>
<input class="form-control" id="Search" name="search" value="@Model.Search" placeholder="이름, 전화, 회사명" />
</div>
<div class="col-md-3">
<label class="form-label" for="Status">상태</label>
<select class="form-select" id="Status" name="status">
<option value="" selected="@(string.IsNullOrWhiteSpace(Model.Status))">전체</option>
<option value="active" selected="@(Model.Status == "active")">active</option>
<option value="inactive" selected="@(Model.Status == "inactive")">inactive</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="PageSize">페이지 크기</label>
<select class="form-select" id="PageSize" name="pageSize">
<option value="10" selected="@(Model.PageSize == 10)">10</option>
<option value="20" selected="@(Model.PageSize == 20)">20</option>
<option value="50" selected="@(Model.PageSize == 50)">50</option>
</select>
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-primary" type="submit">검색</button>
</div>
</div>
</div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>고객명</th>
<th>회사명</th>
<th>전화</th>
<th>상태</th>
<th>등록일</th>
<th class="text-end">작업</th>
</tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="6"><partial name="_EmptyState" model="검색 결과가 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{
<tr>
<td class="fw-semibold">@item.Name</td>
<td>@item.CompanyName</td>
<td>@item.Phone</td>
<td><partial name="_StatusBadge" model="@(item.Status)" /></td>
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/admin/customers/@item.Id">상세</a>
<a class="btn btn-sm btn-outline-secondary" href="/admin/customers/@item.Id/edit">수정</a>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
<partial name="_Pagination" model="new TaxBaik.Web.Pages.Shared.PaginationModel(Model.CurrentPage, Model.TotalPages, Model.BuildPageUrl)" />
</section>
@@ -1,7 +1,41 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Clients;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Clients;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel(ClientService clientService) : PageModel
{
public List<TaxBaik.Domain.Entities.Client> Items { get; private set; } = [];
public int CurrentPage { get; private set; } = 1;
public int TotalPages { get; private set; } = 1;
public int TotalCount { get; private set; }
public string? Search { get; set; }
public string? Status { get; set; }
public int PageSize { get; set; } = 10;
public async Task OnGetAsync(string? search, string? status, int page = 1, int pageSize = 10, CancellationToken ct = default)
{
Search = search;
Status = status;
PageSize = pageSize;
CurrentPage = Math.Max(1, page);
var (items, total) = await clientService.GetPagedAsync(CurrentPage, PageSize, status, search, ct);
Items = items.ToList();
TotalCount = total;
TotalPages = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize));
}
public string BuildPageUrl(int page) =>
$"/admin/clients?search={Uri.EscapeDataString(Search ?? string.Empty)}&status={Uri.EscapeDataString(Status ?? string.Empty)}&page={page}&pageSize={PageSize}";
public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
{
await clientService.DeleteAsync(id, ct);
return RedirectToPage();
}
}
@@ -1,4 +1,34 @@
@page
@model TaxBaik.Web.Pages.Admin.Companies.IndexModel
@{ ViewData["Title"] = "회사"; }
<section class="container py-5"><h1 class="h2 fw-bold">회사 관리</h1></section>
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("CRM", "회사 관리", "고객사 회사를 관리합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>코드</th><th>회사명</th><th>담당자</th><th>활성</th></tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="4"><partial name="_EmptyState" model="회사가 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{
<tr>
<td>@item.CompanyCode</td>
<td>@item.CompanyName</td>
<td>@item.ContactPerson</td>
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</section>
@@ -1,7 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Companies;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Companies;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel(CompanyService companyService) : PageModel
{
public List<Company> Items { get; private set; } = [];
public async Task OnGetAsync(CancellationToken ct = default)
{
var (items, _) = await companyService.GetPagedAsync(1, 100, ct);
Items = items.ToList();
}
}
+31 -1
View File
@@ -1,4 +1,34 @@
@page
@model TaxBaik.Web.Pages.Admin.Faqs.IndexModel
@{ ViewData["Title"] = "FAQ"; }
<section class="container py-5"><h1 class="h2 fw-bold">FAQ 관리</h1></section>
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Support", "FAQ 관리", "질문과 답변을 관리합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>질문</th><th>카테고리</th><th>활성</th><th>정렬</th></tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="4"><partial name="_EmptyState" model="FAQ가 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{
<tr>
<td>@item.Question</td>
<td>@item.Category</td>
<td><partial name="_StatusBadge" model="@(item.IsActive ? "active" : "inactive")" /></td>
<td>@item.SortOrder</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</section>
@@ -1,7 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Faqs;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Faqs;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel(FaqService faqService) : PageModel
{
public List<Faq> Items { get; private set; } = [];
public async Task OnGetAsync(CancellationToken ct = default)
=> Items = (await faqService.GetAllAsync(ct)).ToList();
}
@@ -1,4 +1,54 @@
@page
@model TaxBaik.Web.Pages.Admin.Inquiries.IndexModel
@{ ViewData["Title"] = "문의"; }
<section class="container py-5"><h1 class="h2 fw-bold">문의 관리</h1></section>
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Customer Requests", "문의 관리", "상담 요청을 상태별로 확인합니다.", null, null)' />
<form method="get" class="card mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label" for="Status">상태</label>
<input class="form-control" id="Status" name="status" value="@Model.Status" placeholder="new / consulting / contracted ..." />
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-primary" type="submit">필터</button>
</div>
</div>
</div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>이름</th>
<th>전화</th>
<th>서비스</th>
<th>상태</th>
<th>등록일</th>
</tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="5"><partial name="_EmptyState" model="문의 목록이 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{
<tr>
<td>@item.Name</td>
<td>@item.Phone</td>
<td>@item.ServiceType</td>
<td><partial name="_StatusBadge" model="@(item.Status)" /></td>
<td>@item.CreatedAt.ToString("yyyy-MM-dd")</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</section>
@@ -1,7 +1,21 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.Inquiries;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel(InquiryService inquiryService) : PageModel
{
public List<Inquiry> Items { get; private set; } = [];
public string? Status { get; set; }
public async Task OnGetAsync(string? status = null, CancellationToken ct = default)
{
Status = status;
var (items, _) = await inquiryService.GetPagedAsync(1, 100, status, ct);
Items = items.ToList();
}
}
@@ -1,4 +1,34 @@
@page
@model TaxBaik.Web.Pages.Admin.TaxProfiles.IndexModel
@{ ViewData["Title"] = "세무 프로필"; }
<section class="container py-5"><h1 class="h2 fw-bold">세무 프로필 관리</h1></section>
<section class="container-fluid py-4">
<partial name="_AdminPageHeader" model='("Tax", "세무 프로필 관리", "고객별 세무 속성을 관리합니다.", null, null)' />
<div class="card">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr><th>고객ID</th><th>사업유형</th><th>세무위험</th><th>다음신고일</th></tr>
</thead>
<tbody>
@if (Model.Items.Count == 0)
{
<tr><td colspan="4"><partial name="_EmptyState" model="세무 프로필이 없습니다." /></td></tr>
}
else
{
foreach (var item in Model.Items)
{
<tr>
<td>@item.ClientId</td>
<td>@item.BusinessType</td>
<td><partial name="_StatusBadge" model="@(item.TaxRiskLevel ?? "normal")" /></td>
<td>@item.NextFilingDueDate?.ToString("yyyy-MM-dd")</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</section>
@@ -1,7 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.TaxProfiles;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel { }
namespace TaxBaik.Web.Pages.Admin.TaxProfiles;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel(TaxProfileService taxProfileService) : PageModel
{
public List<TaxProfile> Items { get; private set; } = [];
public async Task OnGetAsync(CancellationToken ct = default)
=> Items = (await taxProfileService.GetAllAsync(ct)).ToList();
}
+24 -12
View File
@@ -6,23 +6,35 @@
<title>@(ViewData["Title"] ?? "TaxBaik Admin")</title>
<meta name="theme-color" content="#111827" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/css/tabler.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
</head>
<body class="admin-app">
<partial name="_AdminTopbar" />
<div class="admin-shell container-fluid">
<div class="row g-0">
<aside class="col-lg-2 admin-sidebar">
<partial name="_AdminSidebar" />
</aside>
<main class="col-lg-10 admin-content">
@RenderBody()
</main>
<div class="page">
<header class="navbar navbar-expand-md d-print-none">
<div class="container-xl">
<div class="navbar-brand navbar-brand-autodark">TaxBaik Admin</div>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
<a class="btn btn-outline-secondary btn-sm" href="/admin/logout">로그아웃</a>
</div>
</div>
</div>
</header>
<div class="page-wrapper">
<div class="container-xl">
<div class="row g-3">
<aside class="col-12 col-lg-2">
<partial name="_AdminSidebar" />
</aside>
<main class="col-12 col-lg-10">
@RenderBody()
</main>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js" defer></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
+1 -2
View File
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using System.Net;
namespace TaxBaik.Web.Pages.Blog;
@@ -22,7 +21,7 @@ public class BlogPostModel : PageModel
Post = await _blogService.GetBySlugAsync(slug);
if (Post != null)
{
HtmlContent = WebUtility.HtmlEncode(Post.Content ?? "").Replace("\r\n", "<br />").Replace("\n", "<br />");
HtmlContent = Post.Content ?? string.Empty;
_ = _blogService.IncrementViewCountAsync(Post.Id);
}
}
@@ -0,0 +1,4 @@
namespace TaxBaik.Web.Pages.Shared;
public sealed record PaginationModel(int Page, int TotalPages, Func<int, string> BuildPageUrl);
@@ -0,0 +1,20 @@
@model (string Eyebrow, string Title, string Subtitle, string? ActionHref, string? ActionText)
<div class="page-header d-print-none mb-4">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">@Model.Eyebrow</div>
<h2 class="page-title">@Model.Title</h2>
@if (!string.IsNullOrWhiteSpace(Model.Subtitle))
{
<div class="text-muted">@Model.Subtitle</div>
}
</div>
@if (!string.IsNullOrWhiteSpace(Model.ActionHref) && !string.IsNullOrWhiteSpace(Model.ActionText))
{
<div class="col-auto ms-auto">
<a class="btn btn-primary" href="@Model.ActionHref">@Model.ActionText</a>
</div>
}
</div>
</div>
@@ -1,8 +1,13 @@
<nav class="p-3">
<div class="list-group list-group-flush">
<div class="card">
<div class="card-header">
<h3 class="card-title">관리 메뉴</h3>
</div>
<div class="list-group list-group-flush list-group-transparent">
<a class="list-group-item list-group-item-action" href="/admin/dashboard">대시보드</a>
<a class="list-group-item list-group-item-action" href="/admin/customers">고객 관리</a>
<a class="list-group-item list-group-item-action" href="/admin/login">로그인</a>
<a class="list-group-item list-group-item-action" href="/admin/clients">기존 고객</a>
<a class="list-group-item list-group-item-action" href="/admin/blog">블로그</a>
<a class="list-group-item list-group-item-action" href="/admin/inquiries">문의</a>
<a class="list-group-item list-group-item-action" href="/admin/announcements">공지사항</a>
</div>
</nav>
</div>
@@ -1,5 +1,5 @@
@model string
<div class="text-center text-muted py-5">
<div class="fw-semibold mb-1">@(string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model)</div>
<div class="empty">
<div class="empty-img"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-database-off" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6c0 1.657 4.03 3 9 3s9-1.343 9-3-4.03-3-9-3-9 1.343-9 3" /><path d="M4 6v6c0 1.657 4.03 3 9 3s9-1.343 9-3V6" /><path d="M4 12v6c0 1.657 4.03 3 9 3s9-1.343 9-3v-6" /><path d="M3 3l18 18" /></svg></div>
<p class="empty-title">@(string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model)</p>
</div>
@@ -1,8 +1,8 @@
@model dynamic
@model TaxBaik.Web.Pages.Shared.PaginationModel
@if (Model.TotalPages > 1)
{
<nav class="mt-4">
<ul class="pagination justify-content-center">
<nav class="pagination-wrapper mt-4">
<ul class="pagination">
@for (var i = 1; i <= Model.TotalPages; i++)
{
<li class="page-item @(i == Model.Page ? "active" : "")">
@@ -12,4 +12,3 @@
</ul>
</nav>
}
@@ -2,10 +2,14 @@
@{
var badgeClass = Model?.ToLowerInvariant() switch
{
"active" => "text-bg-success",
"inactive" => "text-bg-secondary",
_ => "text-bg-light"
"active" => "bg-green text-green-fg",
"inactive" => "bg-gray text-gray-fg",
"new" => "bg-blue text-blue-fg",
"consulting" => "bg-yellow text-yellow-fg",
"contracted" => "bg-green text-green-fg",
"rejected" => "bg-red text-red-fg",
"closed" => "bg-secondary text-secondary-fg",
_ => "bg-secondary text-secondary-fg"
};
}
<span class="badge @badgeClass">@Model</span>
@@ -0,0 +1,42 @@
(function () {
function syncEditor(editor, hiddenInput) {
const html = editor.innerHTML.trim();
hiddenInput.value = html;
}
function setupEditor(root) {
const editor = root.querySelector('[data-blog-editor]');
const hiddenInput = root.querySelector('[data-blog-editor-value]');
const preview = root.querySelector('[data-blog-preview]');
const buttons = root.querySelectorAll('[data-blog-command]');
if (!editor || !hiddenInput) return;
const renderPreview = () => {
if (preview) preview.innerHTML = editor.innerHTML;
};
editor.addEventListener('input', () => {
syncEditor(editor, hiddenInput);
renderPreview();
});
buttons.forEach(button => {
button.addEventListener('click', () => {
const command = button.getAttribute('data-blog-command');
const value = button.getAttribute('data-blog-value');
editor.focus();
document.execCommand(command, false, value);
syncEditor(editor, hiddenInput);
renderPreview();
});
});
syncEditor(editor, hiddenInput);
renderPreview();
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-blog-editor-root]').forEach(setupEditor);
});
})();