KIM-JINO5 4 mesiacov pred
rodič
commit
ace9407bcf
100 zmenil súbory, kde vykonal 3300 pridanie a 67 odobranie
  1. 0 1
      Admin/Admin.csproj
  2. 3 3
      Admin/Pages/Config/Basic/Images.cshtml
  3. 1 1
      Admin/Pages/Config/Basic/Images.cshtml.cs
  4. 3 3
      Admin/Pages/Config/Basic/Index.cshtml
  5. 2 2
      Admin/Pages/Config/Basic/Index.cshtml.cs
  6. 8 0
      Admin/Pages/Config/Basic/_navTabs.cshtml
  7. 1 1
      Admin/Pages/Config/Company.cshtml
  8. 1 1
      Admin/Pages/Config/External.cshtml
  9. 1 1
      Admin/Pages/Config/Meta.cshtml
  10. 1 1
      Admin/Pages/Config/Register.cshtml
  11. 1 1
      Admin/Pages/Config/Template/Email.cshtml
  12. 1 1
      Admin/Pages/Config/Test/Email.cshtml
  13. 1 5
      Admin/Pages/Director/Role/Index.cshtml
  14. 8 1
      Admin/Pages/Director/Role/Index.cshtml.cs
  15. 0 5
      Admin/Pages/Director/Role/Permission.cshtml
  16. 1 2
      Admin/Pages/Director/User/Edit.cshtml
  17. 1 1
      Admin/Pages/Director/User/Index.cshtml
  18. 1 2
      Admin/Pages/Director/User/Roles.cshtml
  19. 82 0
      Admin/Pages/Document/Edit.cshtml
  20. 97 0
      Admin/Pages/Document/Edit.cshtml.cs
  21. 122 0
      Admin/Pages/Document/Index.cshtml
  22. 89 0
      Admin/Pages/Document/Index.cshtml.cs
  23. 62 0
      Admin/Pages/Document/Write.cshtml
  24. 79 0
      Admin/Pages/Document/Write.cshtml.cs
  25. 211 0
      Admin/Pages/Faq/Category.cshtml
  26. 114 0
      Admin/Pages/Faq/Category.cshtml.cs
  27. 91 0
      Admin/Pages/Faq/List/Edit.cshtml
  28. 117 0
      Admin/Pages/Faq/List/Edit.cshtml.cs
  29. 141 0
      Admin/Pages/Faq/List/Index.cshtml
  30. 99 0
      Admin/Pages/Faq/List/Index.cshtml.cs
  31. 70 0
      Admin/Pages/Faq/List/Write.cshtml
  32. 93 0
      Admin/Pages/Faq/List/Write.cshtml.cs
  33. 8 0
      Admin/Pages/Faq/_navTabs.cshtml
  34. 111 0
      Admin/Pages/Popup/Edit.cshtml
  35. 112 0
      Admin/Pages/Popup/Edit.cshtml.cs
  36. 130 0
      Admin/Pages/Popup/Index.cshtml
  37. 88 0
      Admin/Pages/Popup/Index.cshtml.cs
  38. 89 0
      Admin/Pages/Popup/Write.cshtml
  39. 89 0
      Admin/Pages/Popup/Write.cshtml.cs
  40. 0 8
      Admin/Pages/Shared/Config/_navTabs.cshtml
  41. 1 2
      Admin/Pages/Shared/_Layout.cshtml
  42. 51 0
      Admin/Pages/Shared/_Pagination.cshtml
  43. 1 1
      Admin/Pages/Shared/_StatusMessage.cshtml
  44. 2 1
      Admin/Pages/Shared/_ValidationScriptsPartial.cshtml
  45. 3 0
      Admin/Program.cs
  46. 18 0
      Admin/SharedKernel/Extensions/ModelStateDictionaryExtensions.cs
  47. 27 1
      Admin/using.cs
  48. 2 2
      Admin/wwwroot/js/func.js
  49. 27 15
      Admin/wwwroot/js/site.js
  50. 10 1
      Application/Abstractions/Data/IAppDbContext.cs
  51. 20 0
      Application/Features/Director/Role/Get/Handler.cs
  52. 6 0
      Application/Features/Director/Role/Get/Query.cs
  53. 9 0
      Application/Features/Director/Role/Get/Response.cs
  54. 1 1
      Application/Features/Director/User/GetRoles/Handler.cs
  55. 11 0
      Application/Features/Document/Create/Command.cs
  56. 40 0
      Application/Features/Document/Create/Handler.cs
  57. 6 0
      Application/Features/Document/Delete/Command.cs
  58. 14 0
      Application/Features/Document/Delete/Handler.cs
  59. 28 0
      Application/Features/Document/Get/Handler.cs
  60. 6 0
      Application/Features/Document/Get/Query.cs
  61. 13 0
      Application/Features/Document/Get/Response.cs
  62. 36 0
      Application/Features/Document/Search/Handler.cs
  63. 6 0
      Application/Features/Document/Search/Query.cs
  64. 22 0
      Application/Features/Document/Search/Response.cs
  65. 12 0
      Application/Features/Document/Update/Command.cs
  66. 39 0
      Application/Features/Document/Update/Handler.cs
  67. 48 0
      Application/Features/Faq/Category/Get/Handler.cs
  68. 6 0
      Application/Features/Faq/Category/Get/Query.cs
  69. 18 0
      Application/Features/Faq/Category/Get/Response.cs
  70. 15 0
      Application/Features/Faq/Category/Save/Command.cs
  71. 82 0
      Application/Features/Faq/Category/Save/Handler.cs
  72. 4 0
      Application/Features/Faq/Category/Save/Response.cs
  73. 12 0
      Application/Features/Faq/Item/Create/Command.cs
  74. 41 0
      Application/Features/Faq/Item/Create/Handler.cs
  75. 6 0
      Application/Features/Faq/Item/Delete/Command.cs
  76. 14 0
      Application/Features/Faq/Item/Delete/Handler.cs
  77. 49 0
      Application/Features/Faq/Item/Get/Handler.cs
  78. 6 0
      Application/Features/Faq/Item/Get/Query.cs
  79. 15 0
      Application/Features/Faq/Item/Get/Response.cs
  80. 72 0
      Application/Features/Faq/Item/Search/Handler.cs
  81. 6 0
      Application/Features/Faq/Item/Search/Query.cs
  82. 23 0
      Application/Features/Faq/Item/Search/Response.cs
  83. 13 0
      Application/Features/Faq/Item/Update/Command.cs
  84. 31 0
      Application/Features/Faq/Item/Update/Handler.cs
  85. 14 0
      Application/Features/Popup/Create/Command.cs
  86. 37 0
      Application/Features/Popup/Create/Handler.cs
  87. 6 0
      Application/Features/Popup/Delete/Command.cs
  88. 25 0
      Application/Features/Popup/Delete/Handler.cs
  89. 29 0
      Application/Features/Popup/Get/Handler.cs
  90. 6 0
      Application/Features/Popup/Get/Query.cs
  91. 15 0
      Application/Features/Popup/Get/Response.cs
  92. 56 0
      Application/Features/Popup/Search/Handler.cs
  93. 6 0
      Application/Features/Popup/Search/Query.cs
  94. 22 0
      Application/Features/Popup/Search/Response.cs
  95. 15 0
      Application/Features/Popup/Update/Command.cs
  96. 34 0
      Application/Features/Popup/Update/Handler.cs
  97. 8 1
      Domain/Entities/Page/Document.cs
  98. 7 1
      Domain/Entities/Page/Faq/Category.cs
  99. 13 1
      Domain/Entities/Page/Faq/Item.cs
  100. 6 0
      Domain/Entities/Page/Popup.cs

+ 0 - 1
Admin/Admin.csproj

@@ -33,7 +33,6 @@
 
   <ItemGroup>
     <Folder Include="Middleware\" />
-    <Folder Include="Pages\Document\" />
     <Folder Include="wwwroot\uploads\basic\" />
   </ItemGroup>
 

+ 3 - 3
Admin/Pages/Config/Images.cshtml → Admin/Pages/Config/Basic/Images.cshtml

@@ -1,5 +1,5 @@
 @page
-@model Admin.Pages.Config.ImagesModel
+@model Admin.Pages.Config.Basic.ImagesModel
 @{
     ViewData["Title"] = "À̹ÌÁö ¼³Á¤";
 }
@@ -7,7 +7,7 @@
 <div class="container">
     <h3>@ViewData["Title"]</h3>
     <hr />
-    <partial name="Config/_navTabs" />
+    <partial name="_navTabs" />
 
     <div asp-validation-summary="ModelOnly" class="text-danger"></div>
     <partial name="_StatusMessage" />
@@ -209,5 +209,5 @@
 </div>
 
 @section Scripts {
-    <partial name="_ValidationScriptsPartial" />
+    @* <partial name="_ValidationScriptsPartial" /> *@
 }

+ 1 - 1
Admin/Pages/Config/Images.cshtml.cs → Admin/Pages/Config/Basic/Images.cshtml.cs

@@ -2,7 +2,7 @@ using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 
-namespace Admin.Pages.Config;
+namespace Admin.Pages.Config.Basic;
 
 public sealed class ImagesModel(IMediator mediator) : PageModel
 {

+ 3 - 3
Admin/Pages/Config/Basic.cshtml → Admin/Pages/Config/Basic/Index.cshtml

@@ -1,5 +1,5 @@
 @page
-@model Admin.Pages.Config.BasicModel
+@model Admin.Pages.Config.Basic.IndexModel
 
 @{
     ViewData["Title"] = "기본 설정";
@@ -8,7 +8,7 @@
 <div class="container">
     <h3>@ViewData["Title"]</h3>
     <hr />
-    <partial name="Config/_navTabs" />
+    <partial name="_navTabs" />
 
     <div asp-validation-summary="ModelOnly" class="text-danger"></div>
     <partial name="_StatusMessage" />
@@ -177,6 +177,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 2 - 2
Admin/Pages/Config/Basic.cshtml.cs → Admin/Pages/Config/Basic/Index.cshtml.cs

@@ -2,9 +2,9 @@
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 
-namespace Admin.Pages.Config
+namespace Admin.Pages.Config.Basic
 {
-    public class BasicModel(IMediator mediator) : PageModel
+    public class IndexModel(IMediator mediator) : PageModel
     {
         [BindProperty]
         public InputModel Input { get; set; } = new();

+ 8 - 0
Admin/Pages/Config/Basic/_navTabs.cshtml

@@ -0,0 +1,8 @@
+<ul class="nav nav-tabs">
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Config/Basic/Index")" asp-page="/Config/Basic/Index">기본</a>
+    </li>
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Config/Basic/Images")" asp-page="/Config/Basic/Images">이미지</a>
+    </li>
+</ul>

+ 1 - 1
Admin/Pages/Config/Company.cshtml

@@ -156,6 +156,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 1
Admin/Pages/Config/External.cshtml

@@ -86,6 +86,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 1
Admin/Pages/Config/Meta.cshtml

@@ -92,6 +92,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 1
Admin/Pages/Config/Register.cshtml

@@ -258,6 +258,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 1
Admin/Pages/Config/Template/Email.cshtml

@@ -124,6 +124,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 1
Admin/Pages/Config/Test/Email.cshtml

@@ -65,6 +65,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 5
Admin/Pages/Director/Role/Index.cshtml

@@ -58,7 +58,7 @@
                             <td>@role.Name</td>
                             <td>@role.ClaimsCount</td>
                             <td>
-                                <button type="button" class="btn btn-sm btn-danger btn-row-delete" data-id="@role.ID" data-name="@role.Name"> 삭제 </button>
+                                <button type="button" class="btn btn-sm btn-danger btn-row-delete" data-id="@role.ID" data-name="@role.Name">삭제</button>
                                 <a class="btn btn-sm btn-primary" asp-page="/Director/Role/Permission" asp-route-id="@role.ID">권한 관리</a>
                             </td>
                         </tr>
@@ -70,10 +70,6 @@
 </div>
 
 @section Scripts {
-    @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
-    }
-
     <script>
         document.addEventListener("click", function(e) {
             const btn = e.target.closest('.btn-row-delete');

+ 8 - 1
Admin/Pages/Director/Role/Index.cshtml.cs

@@ -54,8 +54,15 @@ namespace Admin.Pages.Director.Role
         {
             try
             {
+                var role = await mediator.Send(new GetRole.Query(id), ct);
+                if (role == null)
+                {
+                    throw new Exception("존재하지 않는 역할(Role)입니다.");
+                }
+
                 await mediator.Send(new DeleteRole.Command(id), ct);
-                TempData["SuccessMessage"] = "정상적으로 삭제되었습니다.";
+
+                TempData["SuccessMessage"] = $"{role.Name} 역할이 정상적으로 삭제되었습니다.";
                 return RedirectToPage();
             }
             catch (Exception e)

+ 0 - 5
Admin/Pages/Director/Role/Permission.cshtml

@@ -5,7 +5,6 @@
 }
 
 <div class="container">
-    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
     <partial name="_StatusMessage" />
 
     <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
@@ -90,10 +89,6 @@
 </div>
 
 @section Scripts {
-    @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
-    }
-
     <script>
         document.querySelectorAll('.group-toggle').forEach(groupCheckbox => {
              if (groupCheckbox.dataset.indeterminate === "true") {

+ 1 - 2
Admin/Pages/Director/User/Edit.cshtml

@@ -8,7 +8,6 @@
     <h3>@ViewData["Title"]</h3>
     <hr />
 
-    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
     <partial name="_StatusMessage" />
     <small>관리자 가입 회원들입니다. 메뉴 접근 권한을 관리할 수 있습니다.</small>
 
@@ -93,6 +92,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 1
Admin/Pages/Director/User/Index.cshtml

@@ -86,6 +86,6 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 }

+ 1 - 2
Admin/Pages/Director/User/Roles.cshtml

@@ -8,7 +8,6 @@
     <h3>@ViewData["Title"]</h3>
     <hr />
 
-    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
     <partial name="_StatusMessage" />
     <small>사용자에게 권한이 지정된 역할을 부여합니다.</small>
 
@@ -60,7 +59,7 @@
 
 @section Scripts {
     @{
-        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+
     }
 
     <script>

+ 82 - 0
Admin/Pages/Document/Edit.cshtml

@@ -0,0 +1,82 @@
+@page "{id:int}"
+@model Admin.Pages.Document.EditModel
+@{
+    ViewData["Title"] = "문서 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form id="fAdminWrite" class="mt-3" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="QueryString" />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Code" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 주소</label>
+            <div class="col-sm-10">
+                @Model.SiteUrl/docs/<input type="text" asp-for="Input.Code" class="form-control d-inline w-auto" required />
+                <span asp-validation-for="Input.Code" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span>*</span> 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Subject" class="form-control" required />
+                <span asp-validation-for="Input.Subject" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Content" class="col-sm-2 col-form-label">내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Content" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.Content" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">
+                        사용합니다.
+                    </label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        @if (Model.Input.UpdatedAt is not null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">수정일시</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.UpdatedAt" class="form-control-plaintext" type="text" readonly />
+                </div>
+            </div>
+        }
+        @if (Model.Input.CreatedAt != null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">등록일시</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.CreatedAt" class="form-control-plaintext" type="text" readonly />
+                </div>
+            </div>
+        }
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a href="/Document?@Model.QueryString" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+
+    }
+}

+ 97 - 0
Admin/Pages/Document/Edit.cshtml.cs

@@ -0,0 +1,97 @@
+using SharedKernel;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Options;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Document
+{
+    public class EditModel(IMediator mediator, IOptions<AppSettings> settings) : PageModel
+    {
+        [BindProperty]
+        public string? QueryString { get; set; }
+        public string SiteUrl { get; private set; } = settings.Value.App.FrontURL;
+
+        [BindProperty]
+        public InputMdeol Input { get; set; } = new();
+
+        public sealed class InputMdeol
+        {
+            [DisplayName("ID")]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            public int ID { get; set; }
+
+            [DisplayName("주소")]
+            [DataType(DataType.Text)]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            [StringLength(30, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+            [RegularExpression(@"^[a-zA-Z0-9]+$", ErrorMessage = "주소는 영문 및 숫자로만 구성되어야 합니다.")]
+            public string Code { get; set; } = default!;
+
+            [DisplayName("제목")]
+            [DataType(DataType.Text)]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            [StringLength(120, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+            public string Subject { get; set; } = default!;
+
+            [DisplayName("내용")]
+            [DataType(DataType.Html)]
+            public string? Content { get; set; }
+
+            [DisplayName("사용 여부")]
+            public bool IsActive { get; set; } = false;
+
+            public string? UpdatedAt { get; set; }
+            public string CreatedAt { get; set; } = default!;
+        }
+
+        public async Task OnGetAsync(int id, CancellationToken ct)
+        {
+            QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+            var document =  await mediator.Send(new GetDocument.Query(id), ct);
+            if (document != null)
+            {
+                Input.ID = document.ID;
+                Input.Code = document.Code;
+                Input.Subject = document.Subject;
+                Input.Content = document.Content;
+                Input.IsActive = document.IsActive;
+                Input.UpdatedAt = document.UpdatedAt.GetDateAt();
+                Input.CreatedAt = document.CreatedAt.GetDateAt();
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    return Page();
+                }
+
+                var command = new UpdateDocument.Command(
+                    Input.ID,
+                    Input.Code,
+                    Input.Subject,
+                    Input.Content,
+                    Input.IsActive
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = $"{Input.Subject} 문서가 수정되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return Redirect($"/Document/Edit/{Input.ID}?{QueryString}");
+        }
+    }
+}

+ 122 - 0
Admin/Pages/Document/Index.cshtml

@@ -0,0 +1,122 @@
+@page
+@model Admin.Pages.Document.IndexModel
+@{
+    ViewData["Title"] = "문서 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Document/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col style="width: 25%;" />
+                <col />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
+                <col style="width: 15%;" />
+                <col style="width: 15%;" />
+                <col style="width: 10%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>제목</th>
+                    <th>주소</th>
+                    <th>사용</th>
+                    <th>조회 수</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">@row.Subject</td>
+                            <td class="text-start">
+                                <a href="@row.Link" target="_blank" rel="external">
+                                    <i class="bi bi-box-arrow-up-right"></i> @row.Link
+                                </a>
+                            </td>
+                            <td>@row.IsActive</td>
+                            <td>@row.Views</td>
+                            <td>@row.CreatedAt</td>
+                            <td>@row.UpdatedAt</td>
+                            <td>
+                                <div class="d-xl-flex gap-2 justify-content-center d-grid">
+                                    <a class="btn btn-sm btn-outline-info" href="@row.EditURL">수정</a>
+                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID" data-subject="@row.Subject">삭제</button>
+                                </div>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<!-- 검색을 위한 -->
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<!-- 삭제를 위한 -->
+<form id="fAdminList" method="post" accept-charset="utf-8" asp-page-handler="Delete">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.submit();
+        });
+    </script>
+}

+ 89 - 0
Admin/Pages/Document/Index.cshtml.cs

@@ -0,0 +1,89 @@
+using SharedKernel.Helpers;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Document
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 10;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<(
+            int Num,
+            int ID,
+            string Link,
+            string Code,
+            string Subject,
+            string? Content,
+            char IsActive,
+            string Views,
+            string? UpdatedAt,
+            string CreatedAt,
+            string EditURL,
+            string DeleteURL
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var result = await mediator.Send(new SearchDocuments.Query(Query.PageNum, Query.PerPage), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.Link,
+                c.Code,
+                c.Subject,
+                c.Content,
+                c.IsActive,
+                c.Views,
+                c.UpdatedAt,
+                c.CreatedAt,
+                EditURL: $"/Document/Edit/{c.ID}{Request.QueryString}",
+                DeleteURL: $"/Document/Delete/{c.ID}{Request.QueryString}"
+            ))];
+
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteDocument.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}개 문서가 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Document/Index", Query);
+        }
+    }
+}

+ 62 - 0
Admin/Pages/Document/Write.cshtml

@@ -0,0 +1,62 @@
+@page
+@model Admin.Pages.Document.WriteModel
+@{
+    ViewData["Title"] = "문서 생성";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-3" method="post" accept-charset="utf-8" autocomplete="off">
+        <div class="row mb-2">
+            <label asp-for="Input.Code" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 주소</label>
+            <div class="col-sm-10">
+                @Model.SiteUrl/docs/<input type="text" asp-for="Input.Code" class="form-control d-inline w-auto" required />
+                <span asp-validation-for="Input.Code" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span>*</span> 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Subject" class="form-control" required />
+                <span asp-validation-for="Input.Subject" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Content" class="col-sm-2 col-form-label">내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Content" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.Content" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">
+                        사용합니다.
+                    </label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a href="/Document?@Model.QueryString" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+
+    }
+}

+ 79 - 0
Admin/Pages/Document/Write.cshtml.cs

@@ -0,0 +1,79 @@
+using SharedKernel;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Options;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Document
+{
+    public class WriteModel(IMediator mediator, IOptions<AppSettings> settings) : PageModel
+    {
+        public string QueryString { get; private set; } = "";
+        public string SiteUrl { get; private set; } = settings.Value.App.FrontURL;
+
+        [BindProperty]
+        public InputMdeol Input { get; set; } = new();
+
+        public sealed class InputMdeol
+        {
+            [DisplayName("주소")]
+            [DataType(DataType.Text)]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            [StringLength(30, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+            [RegularExpression(@"^[a-zA-Z0-9]+$", ErrorMessage = "주소는 영문 및 숫자로만 구성되어야 합니다.")]
+            public string Code { get; set; } = default!;
+
+            [DisplayName("제목")]
+            [DataType(DataType.Text)]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            [StringLength(120, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+            public string Subject { get; set; } = default!;
+
+            [DisplayName("내용")]
+            [DataType(DataType.Html)]
+            public string? Content { get; set; }
+
+            [DisplayName("사용 여부")]
+            public bool IsActive { get; set; } = false;
+        }
+
+        public Task OnGetAsync(CancellationToken _)
+        {
+            QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+            return Task.CompletedTask;
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception("유효성 검사에 실패하였습니다.");
+                }
+
+                var command = new CreateDocument.Command(
+                    Input.Code,
+                    Input.Subject,
+                    Input.Content,
+                    Input.IsActive
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = $"{Input.Subject} 문서가 등록되었습니다.";
+
+                return RedirectToPage("/Document/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Redirect($"/Document/Write?{QueryString}");
+            }
+        }
+    }
+}

+ 211 - 0
Admin/Pages/Faq/Category.cshtml

@@ -0,0 +1,211 @@
+@page
+@model Admin.Pages.Faq.CategoryModel
+@{
+    ViewData["Title"] = "FAQ 분류";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+    <partial name="_navTabs" />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end mt-2">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <button type="button" id="btnAdd" class="btn btn-primary" form="fAdminWrite">추가</button>
+            <button type="submit" id="btnSave" class="btn btn-success" form="fAdminWrite">저장</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off"></form>
+
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <caption>
+                FAQ 분류에 등록된 FAQ 가 있다면 삭제가 불가합니다.<br />
+                FAQ 분류를 삭제하려면 해당 FAQ 를 먼저 삭제해주세요.
+            </caption>
+            <colgroup>
+                <col width="5%" />
+                <col width="*" />
+                <col width="*" />
+                <col width="*" />
+                <col width="*" />
+                <col width="*" />
+                <col width="*" />
+                <col width="*" />
+                <col width="*" />
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>ID</th>
+                    <th>Code</th>
+                    <th>분류 명</th>
+                    <th>FAQ 수</th>
+                    <th>순서</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody id="categories">
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        var index = row.Index;
+
+                        <tr>
+                            <td>
+                                <input type="text" readonly class="form-control-plaintext text-center @(row.FaqItemRows > 0 ? "text-white bg-danger" : "")" value="@row.Num" />
+                                <input type="hidden" name="request[@index].ID" readonly class="form-control-plaintext text-center" required form="fAdminWrite" value="@row.ID" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Code" class="form-control" maxlength="30" required form="fAdminWrite" value="@row.Code" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Subject" class="form-control" maxlength="255" required form="fAdminWrite" value="@row.Subject" />
+                            </td>
+                            <td>@row.FaqItemRows</td>
+                            <td>
+                                <input type="number" name="request[@index].Order" class="form-control" min="-999" max="999" required form="fAdminWrite" value="@row.Order" />
+                            </td>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input class="form-check-input" type="checkbox" id="request_@(index)_IsActive" name="request[@index].IsActive" checked="@Model.Data[index].IsActive" form="fAdminWrite" value="true" />
+                                    <label class="form-check-label" for="request_@(index)_IsActive">
+                                        사용
+                                    </label>
+                                </div>
+                            </td>
+                            <td><input type="text" name="request[@index].CreatedAt" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.CreatedAt" /></td>
+                            <td><input type="text" name="request[@index].UpdatedAt" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.UpdatedAt" /></td>
+                            <td>
+                                <button type="button" class="btn btn-sm btn-danger btn-delete">삭제</button>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        $(function() {
+            let $categories = $("#categories");
+            let total = Number(@Model.Total);
+
+            // 추가
+            $(document).on("click", "#btnAdd", function() {
+                if (total <= 0) {
+                    $categories.empty();
+                }
+
+                let tableRow = `
+                    <tr>
+                        <td>-</td>
+                        <td>
+                            <input type="text" name="request[${total}].Code" class="form-control" maxlength="30" required form="fAdminWrite" />
+                        </td>
+                        <td>
+                            <input type="text" name="request[${total}].Subject" class="form-control" maxlength="255" required form="fAdminWrite" />
+                        </td>
+                        <td>-</td>
+                        <td>
+                            <input type="number" name="request[${total}].Order" class="form-control" min="-999" max="999" required form="fAdminWrite" />
+                        </td>
+                        <td>
+                            <div class="form-check-inline">
+                                <input class="form-check-input" type="checkbox" id="request_${total}_IsActive" name="request[${total}].IsActive" checked form="fAdminWrite" value="true" />
+                                <label class="form-check-label" for="request_${total}_IsActive">
+                                    사용
+                                </label>
+                            </div>
+                        </td>
+                        <td>-</td>
+                        <td>-</td>
+                        <td>
+                            <button type="button" class="btn btn-danger btn-sm btn-delete">삭제</button>
+                        </td>
+                    </tr>
+                `;
+
+                $categories.append(tableRow);
+                total++;
+                recalculateIndices();
+            });
+
+            // 삭제
+            $(document).on("click", "button.btn-delete", function(e) {
+                e.target.closest("tr").remove();
+                total--;
+
+                if (total <= 0) {
+                    $categories.append(
+                        `<tr><td colspan="9">No Data.</td></tr>`
+                    );
+                    total = 0;
+                 } else {
+                    recalculateIndices();
+                 }
+            });
+
+            // 저장
+            $(document).on("click", "#btnSave", function() {
+                 if (confirm("저장 하시겠습니까?")) {
+                    let form = document.getElementById("fAdminWrite");
+                      if (form.checkValidity()) { // HTML5 폼 검증 수행
+                        form.submit();
+                    } else {
+                        form.reportValidity();
+                    }
+                }
+                return false;
+            });
+
+            // 인덱스 재계산 함수
+            function recalculateIndices() {
+                $categories.find("tr").each(function(index, tr) {
+                    $(tr)
+                        .find("input, label")
+                        .each(function() {
+                            let name = $(this).attr("name");
+                            let id = $(this).attr("id");
+
+                            if (name) {
+                                $(this).attr("name", name.replace(/\[\d+\]/, `[${index}]`));
+                            }
+
+                            if (id) {
+                                $(this).attr("id", id.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+
+                    // 인덱스 기반으로 라벨의 `for` 속성도 수정
+                    $(tr)
+                        .find("label")
+                        .each(function() {
+                            let labelFor = $(this).attr("for");
+                            if (labelFor) {
+                                $(this).attr("for", labelFor.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+                });
+            }
+        });
+    </script>
+}

+ 114 - 0
Admin/Pages/Faq/Category.cshtml.cs

@@ -0,0 +1,114 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Faq
+{
+    public class CategoryModel(IMediator mediator) : PageModel
+    {
+        public int Total { get; private set; } = 0;
+        public List<(
+            int Num,
+            int ID,
+            int Index,
+            string Code,
+            string Subject,
+            short Order,
+            char IsActive,
+            int FaqItemRows,
+            string? UpdatedAt,
+            string CreatedAt,
+            string EditURL,
+            string DeleteURL
+        )> List { get; set; } = [];
+
+        [BindProperty(Name = "request")]
+        public List<InputModel> Input { get; private set; } = [];
+        public List<InputModel> Data { get; private set; } = [];
+
+        public sealed class InputModel
+        {
+            public int? ID { get; set; }
+
+            [Required]
+            [StringLength(30)]
+            public required string Code { get; set; }
+
+            [Required]
+            [StringLength(255)]
+            public required string Subject { get; set; }
+
+            [Range(-9999, 9999)]
+            public short Order { get; set; }
+
+            public bool IsActive { get; set; } = false;
+        }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var result = await mediator.Send(new GetFaqCategories.Query(), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.Index,
+                c.Code,
+                c.Subject,
+                c.Order,
+                c.IsActive ? 'Y' : 'N',
+                c.FaqItemRows,
+                c.UpdatedAt,
+                c.CreatedAt,
+                EditURL : $"/Faq/Category/Edit/{c.ID}",
+                DeleteURL : $"/Faq/Category/Delete/{c.ID}"
+            ))];
+
+            Data = [..result.List
+               .Select(x => new InputModel{
+                   ID = x.ID,
+                   Code = x.Code,
+                   Subject = x.Subject,
+                   Order = x.Order,
+                   IsActive = x.IsActive
+               })];
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception();
+                }
+
+                var cmd = new SaveFaqCategories.Command(
+                   [..Input.Select(x => new SaveFaqCategories.Command.Row(
+                       x.ID,
+                       x.Code,
+                       x.Subject,
+                       x.Order,
+                       x.IsActive
+                   ))]
+               );
+
+                var response = await mediator.Send(cmd, ct);
+
+                TempData["SuccessMessage"] = $"저장 완료 (추가: {response.Inserted}, 수정: {response.Updated}, 삭제: {response.Deleted})";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Faq/Category");
+        }
+    }
+}

+ 91 - 0
Admin/Pages/Faq/List/Edit.cshtml

@@ -0,0 +1,91 @@
+@page "{id:int}"
+@model Admin.Pages.Faq.List.EditModel
+@{
+    ViewData["Title"] = "FAQ 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="QueryString" />
+
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label"><span>*</span> PK</label>
+            <div class="col-sm-10">
+                <input type="text" readonly class="form-control-plaintext" value="@Model.Input.ID" />
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.CategoryID" class="col-sm-2 col-form-label"><span>*</span> 분류</label>
+            <div class="col-sm-10">
+                <select asp-for="Input.CategoryID" asp-items="Model.Categories" class="form-select w-auto" required></select>
+                <span asp-validation-for="Input.CategoryID" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Question" class="col-sm-2 col-form-label"><span>*</span> 질문</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Question" class="form-control required" />
+                <span asp-validation-for="Input.Question" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Answer" class="col-sm-2 col-form-label">답변</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Answer" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.Answer" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span>*</span> 순서</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="number" asp-for="Input.Order" class="form-control" min="-9999" max="9999" required />
+                    <span asp-validation-for="Input.Order" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">
+                        사용합니다.
+                    </label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        @if (Model.Input.UpdatedAt is not null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">수정일시</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.UpdatedAt" class="form-control-plaintext" type="text" readonly />
+                </div>
+            </div>
+        }
+        @if (Model.Input.CreatedAt != null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">등록일시</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.CreatedAt" class="form-control-plaintext" type="text" readonly />
+                </div>
+            </div>
+        }
+        <hr/>
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a href="/Faq/List?@Model.QueryString" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+        <br/>
+    </form>
+</div>

+ 117 - 0
Admin/Pages/Faq/List/Edit.cshtml.cs

@@ -0,0 +1,117 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Faq.List
+{
+    public class EditModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public string? QueryString { get; set; }
+        public List<SelectListItem> Categories { get; private set; } = [];
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public sealed class InputModel
+        {
+            [DisplayName("ID")]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            public int ID { get; set; }
+
+            [DisplayName("FAQ 분류")]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            public int CategoryID { get; set; }
+
+            [DisplayName("질문")]
+            [DataType(DataType.Text)]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+            public string Question { get; set; } = default!;
+
+            [DisplayName("답변")]
+            [DataType(DataType.Html)]
+            public string? Answer { get; set; }
+
+            [DisplayName("순서")]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            [Range(-9999, 9999, ErrorMessage = "{0}은 {1}에서 {2} 사이의 값이어야 합니다.")]
+            public short Order { get; set; } = 0;
+
+            [DisplayName("사용 여부")]
+            public bool IsActive { get; set; } = false;
+
+            public string? UpdatedAt { get; set; }
+            public string CreatedAt { get; set; } = default!;
+        }
+
+        private async Task<List<SelectListItem>> GetCategories(CancellationToken ct)
+        {
+            return [..(await mediator.Send(new GetFaqCategories.Query(), ct)).List
+                   .Select(c => new SelectListItem
+                   {
+                       Value = c.ID.ToString(),
+                       Text = $"[{c.Code}] {c.Subject}"
+                   })];
+        }
+
+        public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
+        {
+            QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+            Categories = await GetCategories(ct);
+
+            var item = await mediator.Send(new GetFaqItem.Query(id), ct);
+            if (item is null)
+            {
+                TempData["ErrorMessages"] = "해당 FAQ를 찾을 수 없습니다.";
+                return RedirectToPage("/Faq/List/Index");
+            }
+
+            Input.ID = item.ID;
+            Input.CategoryID = item.CategoryID;
+            Input.Question = item.Question;
+            Input.Answer = item.Answer;
+            Input.Order = item.Order;
+            Input.IsActive = item.IsActive;
+            Input.UpdatedAt = item.UpdatedAt.GetDateAt();
+            Input.CreatedAt = item.CreatedAt.GetDateAt();
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception(ModelState.GetErrorMessages());
+                }
+
+                var command = new UpdateFaqItem.Command(
+                    Input.CategoryID,
+                    Input.ID,
+                    Input.Question,
+                    Input.Answer,
+                    Input.Order,
+                    Input.IsActive
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = $"{Input.Question} FAQ가 수정되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return Redirect($"/Faq/List/Edit/{Input.ID}?{QueryString}");
+        }
+    }
+}

+ 141 - 0
Admin/Pages/Faq/List/Index.cshtml

@@ -0,0 +1,141 @@
+@page
+@model Admin.Pages.Faq.List.IndexModel
+@{
+    ViewData["Title"] = "FAQ 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_navTabs" />
+
+    <div class="row g-2 mb-2 mb-sm-0 mt-2">
+        <div class="col-auto">
+            <select name="categoryID" id="categoryID" class="form-select w-auto" form="fAdminSearch">
+                <option value="">선택하세요.</option>
+                @foreach ((int ID, string Code, string Subject, int FaqItemRows) row in Model.Categories)
+                {
+                    <option value="@row.ID" selected="@(row.ID == Model.Query.CategoryID)">[@row.Code] @row.Subject (@row.FaqItemRows)</option>
+                }
+            </select>
+        </div>
+        <div class="col col-sm-auto">
+            <input type="search" name="keyword" class="form-control" value="@Model.Query.Keyword" placeholder="질문" form="fAdminSearch"/>
+        </div>
+        <div class="col-auto col-sm">
+            <button type="submit" class="btn btn-success" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Faq/List/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col />
+                <col style="width: 10%" />
+                <col style="width: 30%" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>분류</th>
+                    <th>질문</th>
+                    <th>순서</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td>@row.CategorySubject</td>
+                            <td>@row.Question</td>
+                            <td>@row.Order</td>
+                            <td>@row.IsActive</td>
+                            <td>@row.CreatedAt</td>
+                            <td>@row.UpdatedAt</td>
+                            <td>
+                                <div class="d-grid gap-2 d-block d-xxl-inline">
+                                    <a class="btn btn-sm btn-outline-info" href="@row.EditURL">수정</a>
+                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
+                                </div>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<!-- 검색을 위한 -->
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<!-- 삭제를 위한 -->
+<form id="fAdminList" method="post" accept-charset="utf-8" asp-page-handler="Delete">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="categoryID" value="@Model.Query.CategoryID" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#categoryID, #perPage", function () {
+           searchForm.submit();
+        });
+    </script>
+}

+ 99 - 0
Admin/Pages/Faq/List/Index.cshtml.cs

@@ -0,0 +1,99 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Faq.List
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+        public List<(int ID, string Code, string Subject, int FaqItemRows)> Categories { get; set; } = [];
+
+        public sealed class QueryParams
+        {
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 10;
+
+            [DisplayName("FAQ 분류")]
+            public int? CategoryID { get; set; }
+
+            [DisplayName("검색어")]
+            public string? Keyword { get; set; }
+        }
+
+        public int Total { get; set; } = 0;
+        public List<(
+            int Num,
+            int ID,
+            string CategoryCode,
+            string CategorySubject,
+            string Question,
+            string? Answer,
+            short Order,
+            char IsActive,
+            string? UpdatedAt,
+            string CreatedAt,
+            string EditURL,
+            string DeleteURL
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            Categories = [..(await mediator.Send(new GetFaqCategories.Query(), ct)).List.Select(c => (c.ID, c.Code, c.Subject, c.FaqItemRows))];
+
+            var result = await mediator.Send(new SearchFaqItems.Query(Query.CategoryID, Query.Keyword, Query.PageNum, Query.PerPage), ct);
+            var queryString = Query.ToQueryString();
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.CategoryCode,
+                c.CategorySubject,
+                c.Question,
+                c.Answer,
+                c.Order,
+                c.IsActive ? 'Y' : 'N',
+                c.UpdatedAt,
+                c.CreatedAt,
+                EditURL: $"/Faq/List/Edit/{c.ID}{Request.QueryString}",
+                DeleteURL: $"/Faq/List/Delete/{c.ID}{Request.QueryString}"
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteFaqItem.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}개 FAQ가 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Faq/List/Index", Query);
+        }
+    }
+}

+ 70 - 0
Admin/Pages/Faq/List/Write.cshtml

@@ -0,0 +1,70 @@
+@page
+@model Admin.Pages.Faq.List.WriteModel
+@{
+    ViewData["Title"] = "FAQ 등록";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+     <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <div class="row mb-2">
+            <label asp-for="Input.CategoryID" class="col-sm-2 col-form-label"><span>*</span> 분류</label>
+            <div class="col-sm-10">
+                <select asp-for="Input.CategoryID" class="form-select w-auto" required>
+                    <option value="">선택하세요.</option>
+                    @foreach (var row in Model.Categories)
+                    {
+                        <option value="@row.Value" selected="@row.Selected">@row.Text</option>
+                    }
+                </select>
+                <span asp-validation-for="Input.CategoryID" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Question" class="col-sm-2 col-form-label"><span>*</span> 질문</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Question" class="form-control" required />
+                <span asp-validation-for="Input.Question" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Answer" class="col-sm-2 col-form-label">답변</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Answer" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.Answer" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span>*</span> 순서</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="number" asp-for="Input.Order" class="form-control" min="-9999" max="9999" required />
+                    <span asp-validation-for="Input.Order" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">
+                        사용합니다.
+                    </label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <hr/>
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a href="/Faq/List?@Model.QueryString" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+        <br/>
+    </form>
+</div>

+ 93 - 0
Admin/Pages/Faq/List/Write.cshtml.cs

@@ -0,0 +1,93 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using SharedKernel.Extensions;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Faq.List
+{
+    public class WriteModel(IMediator mediator) : PageModel
+    {
+        public string? QueryString { get; set; }
+        public List<SelectListItem> Categories { get; set; } = [];
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public sealed class InputModel
+        {
+            [DisplayName("FAQ 분류")]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            public int CategoryID { get; set; }
+
+            [DisplayName("질문")]
+            [DataType(DataType.Text)]
+            [Required(ErrorMessage = "{0}는 필수입니다.")]
+            [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+            public string Question { get; set; } = default!;
+
+            [DisplayName("답변")]
+            [DataType(DataType.Html)]
+            public string? Answer { get; set; }
+
+            [DisplayName("순서")]
+            [Range(-9999, 9999, ErrorMessage = "{0}은 {1}에서 {2} 사이의 값이어야 합니다.")]
+            public short Order { get; set; } = 0;
+
+            [DisplayName("사용 여부")]
+            public bool IsActive { get; set; } = false;
+        }
+
+        public async Task OnGetAsync(int? categoryID, CancellationToken ct)
+        {
+            QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+            Categories = (await mediator.Send(new GetFaqCategories.Query(), ct)).List
+                        .Select(c => new SelectListItem
+                        {
+                            Value = c.ID.ToString(),
+                            Text = $"[{c.Code}] {c.Subject}",
+                            Selected = categoryID.HasValue && c.ID == categoryID.Value
+                        })
+                        .ToList();
+
+            if (categoryID.HasValue)
+            {
+                Input.CategoryID = categoryID.Value;
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception(ModelState.GetErrorMessages());
+                }
+
+                var command = new CreateFaqItem.Command(
+                    Input.CategoryID,
+                    Input.Question,
+                    Input.Answer,
+                    Input.Order,
+                    Input.IsActive
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = $"{Input.Question} FAQ가 등록되었습니다.";
+
+                return RedirectToPage("/Faq/List/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Redirect($"/Faq/List/Write?{QueryString}");
+            }
+        }
+    }
+}

+ 8 - 0
Admin/Pages/Faq/_navTabs.cshtml

@@ -0,0 +1,8 @@
+<ul class="nav nav-tabs">
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Faq/List/Index")" asp-page="/Faq/List/Index">FAQ 목록</a>
+    </li>
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Faq/Category")" asp-page="/Faq/Category">FAQ 분류</a>
+    </li>
+</ul>

+ 111 - 0
Admin/Pages/Popup/Edit.cshtml

@@ -0,0 +1,111 @@
+@page "{id:int}"
+@model Admin.Pages.Popup.EditModel
+@{
+    ViewData["Title"] = "팝업 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="QueryString" />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Subject" class="form-control" required />
+                <span asp-validation-for="Input.Subject" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Content" class="col-sm-2 col-form-label">내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Content" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.Content" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Link" class="col-sm-2 col-form-label">주소</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Link" class="form-control" />
+                <span asp-validation-for="Input.Link" class="text-danger"></span>
+                <span class="text-muted form-text">
+                    팝업 클릭 시 페이지 이동 주소를 지정합니다.
+                </span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 순서</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Order" class="form-control d-inline w-auto" type="number" min="-9999" max="9999" required />
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">사용 기간</label>
+            <div class="col-sm-10">
+                <div class="row g-2">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.StartAt" class="form-control" type="datetime-local" />
+                        <span asp-validation-for="Input.StartAt" class="text-danger"></span>
+                    </div>
+                    <div class="col-auto d-none d-md-block align-self-center">~</div>
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.EndAt" class="form-control" type="datetime-local" />
+                        <span asp-validation-for="Input.EndAt" class="text-danger"></span>
+                    </div>
+                </div>
+                <span class="text-muted form-text">
+                    사용 기간을 설정하지 않으면 무제한으로 사용됩니다.
+                </span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+
+        @if (Model.Input.UpdatedAt is not null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">수정일시</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.UpdatedAt" class="form-control-plaintext" type="text" readonly />
+                </div>
+            </div>
+        }
+        @if (Model.Input.CreatedAt is not null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">등록일시</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.CreatedAt" class="form-control-plaintext" type="text" readonly />
+                </div>
+            </div>
+        }
+
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a href="/Popup?@Model.QueryString" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+
+    }
+}

+ 112 - 0
Admin/Pages/Popup/Edit.cshtml.cs

@@ -0,0 +1,112 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using SharedKernel.Extensions;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Popup;
+
+public class EditModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("ID")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        public int ID { get; set; }
+
+        [DisplayName("제목")]
+        [DataType(DataType.Text)]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string Subject { get; set; } = default!;
+
+        [DisplayName("내용")]
+        [DataType(DataType.Html)]
+        [StringLength(4000, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Content { get; set; }
+
+        [DisplayName("주소")]
+        [DataType(DataType.Url)]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Link { get; set; }
+
+        [DisplayName("시작 일시")]
+        [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
+        public DateTime? StartAt { get; set; }
+
+        [DisplayName("종료 일시")]
+        [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
+        public DateTime? EndAt { get; set; }
+
+        [DisplayName("순서")]
+        [Range(-9999, 9999)]
+        public short Order { get; set; }
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; }
+
+        public string? UpdatedAt { get; set; }
+        public string CreatedAt { get; set; } = default!;
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+        var popup = await mediator.Send(new GetPopup.Query(id), ct);
+        if (popup is null)
+        {
+            return;
+        }
+
+        Input.ID = popup.ID;
+        Input.Subject = popup.Subject;
+        Input.Content = popup.Content;
+        Input.Link = popup.Link;
+        Input.StartAt = popup.StartAt;
+        Input.EndAt = popup.EndAt;
+        Input.Order = popup.Order;
+        Input.IsActive = popup.IsActive;
+        Input.UpdatedAt = popup.UpdatedAt.GetDateAt();
+        Input.CreatedAt = popup.CreatedAt.GetDateAt();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            var command = new UpdatePopup.Command(
+                Input.ID,
+                Input.Subject,
+                Input.Content,
+                Input.Link,
+                Input.StartAt,
+                Input.EndAt,
+                Input.Order,
+                Input.IsActive
+            );
+
+            await mediator.Send(command, ct);
+
+            TempData["SuccessMessage"] = $"{Input.Subject} 팝업이 수정되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Popup/Edit/{Input.ID}?{QueryString}");
+    }
+}

+ 130 - 0
Admin/Pages/Popup/Index.cshtml

@@ -0,0 +1,130 @@
+@page
+@model Admin.Pages.Popup.IndexModel
+@{
+    ViewData["Title"] = "팝업 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Popup/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col style="width: 25%;" />
+                <col />
+                <col style="width: 12%;" />
+                <col style="width: 12%;" />
+                <col style="width: 6%;" />
+                <col style="width: 6%;" />
+                <col style="width: 12%;" />
+                <col style="width: 10%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>제목</th>
+                    <th>주소</th>
+                    <th>시작</th>
+                    <th>종료</th>
+                    <th>순서</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">@row.Subject</td>
+                            <td>
+                                @if (!string.IsNullOrWhiteSpace(row.Link) && row.Link != "-")
+                                {
+                                    <a href="@row.Link" target="_blank" rel="external">
+                                        <i class="bi bi-box-arrow-up-right"></i> @row.Link
+                                    </a>
+                                }
+                                else
+                                {
+                                    <text>-</text>
+                                }
+                            </td>
+                            <td>@row.StartAt</td>
+                            <td>@row.EndAt</td>
+                            <td>@row.Order</td>
+                            <td>@(row.IsActive ? "Y" : "N")</td>
+                            <td>@row.CreatedAt</td>
+                            <td>
+                                <div class="d-xl-flex gap-2 justify-content-center d-grid">
+                                    <a class="btn btn-sm btn-outline-info" href="@row.EditURL">수정</a>
+                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID" data-subject="@row.Subject">삭제</button>
+                                </div>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8" asp-page-handler="Delete">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+           searchForm.submit();
+        });
+    </script>
+}

+ 88 - 0
Admin/Pages/Popup/Index.cshtml.cs

@@ -0,0 +1,88 @@
+using SharedKernel.Helpers;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Popup;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        [DisplayName("페이지 번호")]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        [DisplayName("페이지 목록 수")]
+        public ushort PerPage { get; set; } = 10;
+    }
+
+    public int Total { get; set; } = 0;
+
+    public List<(
+        int Num,
+        int ID,
+        string Subject,
+        string? Link,
+        string? StartAt,
+        string? EndAt,
+        short Order,
+        bool IsActive,
+        string? UpdatedAt,
+        string CreatedAt,
+        string EditURL,
+        string DeleteURL
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var result = await mediator.Send(new SearchPopups.Query(Query.PageNum, Query.PerPage), ct);
+
+        Total = result.Total;
+        List = [.. result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.Subject,
+            c.Link ?? "-",
+            c.StartAt ?? "-",
+            c.EndAt ?? "-",
+            c.Order,
+            c.IsActive,
+            c.UpdatedAt,
+            c.CreatedAt,
+            EditURL: $"/Popup/Edit/{c.ID}{Request.QueryString}",
+            DeleteURL: $"/Popup/Delete/{c.ID}{Request.QueryString}"
+        ))];
+
+        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+    }
+
+    public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+    {
+        try
+        {
+            await mediator.Send(new DeletePopup.Command(ids), ct);
+
+            TempData["SuccessMessage"] = $"{ids.Length}개 팝업이 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Popup/Index", Query);
+    }
+}

+ 89 - 0
Admin/Pages/Popup/Write.cshtml

@@ -0,0 +1,89 @@
+@page
+@model Admin.Pages.Popup.WriteModel
+@{
+    ViewData["Title"] = "팝업 등록";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Subject" class="form-control" required />
+                <span asp-validation-for="Input.Subject" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Content" class="col-sm-2 col-form-label">내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Content" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.Content" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Link" class="col-sm-2 col-form-label">주소</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Link" class="form-control" />
+                <span asp-validation-for="Input.Link" class="text-danger"></span>
+                <span class="text-muted form-text">
+                    팝업 클릭 시 페이지 이동 주소를 지정합니다.
+                </span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 순서</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Order" class="form-control d-inline w-auto" type="number" min="-9999" max="9999" required />
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">사용 기간</label>
+            <div class="col-sm-10">
+                <div class="row g-2">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.StartAt" class="form-control" />
+                        <span asp-validation-for="Input.StartAt" class="text-danger"></span>
+                    </div>
+                    <div class="col-auto d-none d-md-block align-self-center">~</div>
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.EndAt" class="form-control" />
+                        <span asp-validation-for="Input.EndAt" class="text-danger"></span>
+                    </div>
+                </div>
+                <span class="text-muted form-text">
+                    사용 기간을 설정하지 않으면 무제한으로 사용됩니다.
+                </span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a href="/Popup?@Model.QueryString" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+
+    }
+}

+ 89 - 0
Admin/Pages/Popup/Write.cshtml.cs

@@ -0,0 +1,89 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Popup;
+
+public class WriteModel(IMediator mediator) : PageModel
+{
+    public string QueryString { get; private set; } = "";
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("제목")]
+        [DataType(DataType.Text)]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string Subject { get; set; } = default!;
+
+        [DisplayName("내용")]
+        [DataType(DataType.Html)]
+        [StringLength(4000, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Content { get; set; }
+
+        [DisplayName("주소")]
+        [DataType(DataType.Url)]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Link { get; set; }
+
+        [DisplayName("시작 일시")]
+        [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
+        public DateTime? StartAt { get; set; }
+
+        [DisplayName("종료 일시")]
+        [DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}", ApplyFormatInEditMode = true)]
+        public DateTime? EndAt { get; set; }
+
+        [DisplayName("순서")]
+        [Range(-9999, 9999)]
+        public short Order { get; set; }
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+    }
+
+    public Task OnGetAsync(CancellationToken _)
+    {
+        QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+        return Task.CompletedTask;
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception("유효성 검사에 실패했습니다.");
+            }
+
+            var command = new CreatePopup.Command(
+                Input.Subject,
+                Input.Content,
+                Input.Link,
+                Input.StartAt,
+                Input.EndAt,
+                Input.Order,
+                Input.IsActive
+            );
+
+            await mediator.Send(command, ct);
+
+            TempData["SuccessMessage"] = $"{Input.Subject} 팝업이 등록되었습니다.";
+
+            return RedirectToPage("/Popup/Index");
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+            return Redirect($"/Popup/Write?{QueryString}");
+        }
+    }
+}

+ 0 - 8
Admin/Pages/Shared/Config/_navTabs.cshtml

@@ -1,8 +0,0 @@
-<ul class="nav nav-tabs">
-    <li class="nav-item">
-        <a class="nav-link @Html.IsActive("/Config/Basic")" asp-page="/Config/Basic">기본</a>
-    </li>
-    <li class="nav-item">
-        <a class="nav-link @Html.IsActive("/Config/Images")" asp-page="/Config/Images">이미지</a>
-    </li>
-</ul>

+ 1 - 2
Admin/Pages/Shared/_Layout.cshtml

@@ -86,8 +86,7 @@
 	</main>
 
 	<script src="~/lib/jquery/dist/jquery.min.js"></script>
-	<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
-	<script src="~/lib/jquery-validation/dist/additional-methods.min.js"></script>
+	@await Html.PartialAsync("_ValidationScriptsPartial")
 	<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
 	<script src="~/js/func.js" asp-append-version="true"></script>
 	<script src="~/js/site.js" asp-append-version="true"></script>

+ 51 - 0
Admin/Pages/Shared/_Pagination.cshtml

@@ -0,0 +1,51 @@
+@model SharedKernel.Helpers.Pagination
+@if (Model is not null && Model.TotalRows > 0)
+{
+<nav id="pagination" aria-label="Page navigation">
+    <ul class="pagination justify-content-center">
+
+        @if (Model.HasPreviousPage)
+        {
+            <li class="page-item">
+                <a class="page-link" href="?pageNum=@Model.PrevGroupPage&perPage=@Model.PerPage@Model.BuildQueryString()">이전</a>
+            </li>
+        }
+        else
+        {
+            <li class="page-item disabled">
+                <span class="page-link">이전</span>
+            </li>
+        }
+
+        <!-- 페이지 번호 표시 -->
+        @for (int i = Model.StartPage; i <= Model.EndPage; i++)
+        {
+            if (i == Model.Page)
+            {
+                <li class="page-item active">
+                    <span class="page-link">@i</span>
+                </li>
+            }
+            else
+            {
+                <li class="page-item">
+                    <a class="page-link" href="?pageNum=@i&perPage=@Model.PerPage@Model.BuildQueryString()">@i</a>
+                </li>
+            }
+        }
+
+        @if (Model.HasNextPage && Model.NextGroupPage <= Model.TotalPage)
+        {
+            <li class="page-item">
+                    <a class="page-link" href="?pageNum=@Model.NextGroupPage&perPage=@Model.PerPage@Model.BuildQueryString()">다음</a>
+            </li>
+        }
+        else
+        {
+            <li class="page-item disabled">
+                <span class="page-link">다음</span>
+            </li>
+        }
+    </ul>
+</nav>
+}

+ 1 - 1
Admin/Pages/Shared/_StatusMessage.cshtml

@@ -3,7 +3,7 @@
     var errorMessages = TempData["ErrorMessages"] as string;
 }
 
-<div class="pt-2">
+<div>
     <!-- 성공 메시지 표시 -->
     @if (!string.IsNullOrEmpty(successMessage))
     {

+ 2 - 1
Admin/Pages/Shared/_ValidationScriptsPartial.cshtml

@@ -1,2 +1,3 @@
 <script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
-<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>
+<script src="~/lib/jquery-validation/dist/additional-methods.min.js"></script>
+<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>

+ 3 - 0
Admin/Program.cs

@@ -11,6 +11,9 @@ var settings = builder.Configuration.Get<AppSettings>()!;
 
 Console.Title = settings.App.Name;
 Console.WriteLine($"ENV={builder.Environment.EnvironmentName}");
+Console.WriteLine(TimeZoneInfo.Local.Id);
+Console.WriteLine(DateTime.Now);
+Console.WriteLine(DateTime.UtcNow);
 
 // Add services to the container.
 builder.Services.AddRazorPages(options =>

+ 18 - 0
Admin/SharedKernel/Extensions/ModelStateDictionaryExtensions.cs

@@ -0,0 +1,18 @@
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace SharedKernel.Extensions
+{
+    public static class ModelStateDictionaryExtensions
+    {
+        public static string GetErrorMessages(this ModelStateDictionary modelState, string separator = "\n")
+        {
+            var errors = modelState
+                .Where(kvp => kvp.Value?.Errors.Count > 0)
+                .SelectMany(kvp => kvp.Value!.Errors)
+                .Select(e => string.IsNullOrWhiteSpace(e.ErrorMessage) ? "Invalid value." : e.ErrorMessage)
+                .ToArray();
+
+            return string.Join(separator, errors);
+        }
+    }
+}

+ 27 - 1
Admin/using.cs

@@ -6,7 +6,33 @@ global using UpdateUser = Application.Features.Director.User.Update;
 global using UpdateUserRoles = Application.Features.Director.User.UpdateRoles;
 global using GetUsers = Application.Features.Director.Users.Get;
 global using GetRoles = Application.Features.Director.Roles.Get;
+global using GetRole = Application.Features.Director.Role.Get;
 global using CreateRole = Application.Features.Director.Role.Create;
 global using DeleteRole = Application.Features.Director.Role.Delete;
 global using GetRolePermissions = Application.Features.Director.Role.Permissions.Get;
-global using UpdateRolePermissions = Application.Features.Director.Role.Permissions.Update;
+global using UpdateRolePermissions = Application.Features.Director.Role.Permissions.Update;
+
+// 문서
+global using SearchDocuments = Application.Features.Document.Search;
+global using GetDocument = Application.Features.Document.Get;
+global using CreateDocument = Application.Features.Document.Create;
+global using UpdateDocument = Application.Features.Document.Update;
+global using DeleteDocument = Application.Features.Document.Delete;
+
+// 팝업
+global using SearchPopups = Application.Features.Popup.Search;
+global using GetPopup = Application.Features.Popup.Get;
+global using CreatePopup = Application.Features.Popup.Create;
+global using UpdatePopup = Application.Features.Popup.Update;
+global using DeletePopup = Application.Features.Popup.Delete;
+
+// FAQ 분류
+global using GetFaqCategories = Application.Features.Faq.Category.Get;
+global using SaveFaqCategories = Application.Features.Faq.Category.Save;
+
+// FAQ 목록
+global using SearchFaqItems = Application.Features.Faq.Item.Search;
+global using GetFaqItem = Application.Features.Faq.Item.Get;
+global using CreateFaqItem = Application.Features.Faq.Item.Create;
+global using UpdateFaqItem = Application.Features.Faq.Item.Update;
+global using DeleteFaqItem = Application.Features.Faq.Item.Delete;

+ 2 - 2
Admin/wwwroot/js/func.js

@@ -75,10 +75,10 @@ $.validator.setDefaults({ // Bootstrap Required.
     ignore: [], // hidden
     debug: false,
     invalidHandler: function (event, validator) {
-        
+
     },
     submitHandler: function (form) {
-        
+        form.submit();
     },
     showErrors: function (errorMap, errorList) {
         $.each(this.successList, function (index, value) {

+ 27 - 15
Admin/wwwroot/js/site.js

@@ -110,6 +110,10 @@ document.querySelectorAll(".collapse").forEach(e => {
 // 드롭박스 적용
 const dropdownList = [...document.querySelectorAll('.dropdown-toggle')].map(e => new bootstrap.Dropdown(e));
 
+function GetCheckedBoxIDs() {
+    return Array.from(document.querySelectorAll('input[type="checkbox"].list-check-box:checked')).map(c => c.value);
+}
+
 // 목록 버튼상자
 class ActionButtons {
     constructor() {
@@ -125,14 +129,7 @@ class ActionButtons {
         return true;
     }
 
-    checkout(e) {
-        const action = (e.target.dataset.action || e.target.closest("a").href);
-        if (!action) {
-            alert("처리 주소를 확인하세요.");
-            return false;
-        }
-
-        this.form.action = action + window.location.search;
+    checkout() {
         this.form.submit();
     }
 
@@ -147,13 +144,26 @@ class ActionButtons {
     }
 
     Delete(e) {
-        if (!this.validate()) {
-            return false;
-        }
+        let ids = GetCheckedBoxIDs();
 
-        if (confirm("선택한 항목을 정말 삭제 하시겠습니까?")) {
-            this.checkout(e);
+        if (ids.length > 1) {
+            if (confirm(`${ids.length}건의 항목을 정말 삭제 하시겠습니까?`)) {
+                if (!this.validate()) {
+                    return false;
+                }
+            }
+        } else {
+            if (!confirm("선택한 항목을 정말 삭제 하시겠습니까?")) {
+                return false;
+            }
+
+            let id = e.target.dataset.id;
+            if (id) {
+                this.form.elements[`ids_${id}`].checked = true;
+            }
         }
+
+        setTimeout(() => this.checkout(e), 100);
     }
 
     Recover(e) {
@@ -193,17 +203,19 @@ class ActionButtons {
     toggleDisabled() {
         let checked = $("input:checkbox.list-check-box:checked");
         if (checked.length > 0) {
-            $("[data-action]").prop("disabled", false);
+            $('button[form="fAdminList"]').prop("disabled", false);
         } else {
-            $("[data-action]").prop("disabled", true);
+            $('button[form="fAdminList"]').prop("disabled", true);
         }
     }
 }
 
 const actionButtons = new ActionButtons();
 $(document).on("click", "#btnListUpdate", (e) => actionButtons.Update(e));
+$(document).on("click", "#btnListDelete", (e) => actionButtons.Delete(e));
 $(document).on("click", "#btnListRecover", (e) => actionButtons.Recover(e));
 $(document).on("click", "#btnListExecute", (e) => actionButtons.Execute(e));
+$(document).on("click", ".btn-row-delete", (e) => actionButtons.Delete(e));
 $(document).on("click", ".btn-row-execute", () => confirm("정말 처리하시겠습니까?"));
 $(document).on("click", "#checkedAll", (e) => actionButtons.checkedAll(e));
 $(document).on("change", "input.list-check-box", actionButtons.toggleDisabled);

+ 10 - 1
Application/Abstractions/Data/IAppDbContext.cs

@@ -1,12 +1,21 @@
 using Microsoft.EntityFrameworkCore;
 using Domain.Entities.Common;
+using Domain.Entities.Page;
+using Domain.Entities.Page.Faq;
+using Domain.Entities.Page.Banner;
 
 namespace Application.Abstractions.Data
 {
     public interface IAppDbContext
     {
         DbSet<Config> Config { get; set;  }
+        DbSet<Document> Document { get; set;  }
+        DbSet<Popup> Popup { get; set;  }
+        DbSet<FaqCategory> FaqCategory { get; set;  }
+        DbSet<FaqItem> FaqItem { get; set;  }
+        DbSet<BannerPosition> BannerPosition { get; set;  }
+        DbSet<BannerItem> BannerItem { get; set;  }
 
-        Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
+        Task<int> SaveChangesAsync(CancellationToken ct = default);
     }
 }

+ 20 - 0
Application/Features/Director/Role/Get/Handler.cs

@@ -0,0 +1,20 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.Role.Get
+{
+    public sealed class Handler(IIdentityRoleReader roleReader) : IRequestHandler<Query, Response>
+    {
+        public async Task<Response> Handle(Query request, CancellationToken ct)
+        {
+            var role = await roleReader.GetRoleAsync(request.ID, ct);
+
+            return new Response
+            {
+                ID = role.ID,
+                Name = role.Name,
+                ClaimsCount = role.Claims.Count
+            };
+        }
+    }
+}

+ 6 - 0
Application/Features/Director/Role/Get/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Director.Role.Get
+{
+    public sealed record Query(string ID) : IRequest<Response>;
+}

+ 9 - 0
Application/Features/Director/Role/Get/Response.cs

@@ -0,0 +1,9 @@
+namespace Application.Features.Director.Role.Get
+{
+    public class Response
+    {
+        public required string ID { get; set; }
+        public string? Name { get; set; }
+        public int ClaimsCount { get; set; } = 0;
+    }
+}

+ 1 - 1
Application/Features/Director/User/GetRoles/Handler.cs

@@ -3,7 +3,7 @@ using MediatR;
 
 namespace Application.Features.Director.User.GetRoles
 {
-    public sealed class Handler(IIdentityRoleReader roleReader, IIdentityUserReader userReader) : IRequestHandler<Query, Response?>
+    public sealed class Handler(IIdentityRoleReader roleReader, IIdentityUserReader userReader) : IRequestHandler<Query, Response>
     {
         public async Task<Response> Handle(Query request, CancellationToken ct)
         {

+ 11 - 0
Application/Features/Document/Create/Command.cs

@@ -0,0 +1,11 @@
+using MediatR;
+
+namespace Application.Features.Document.Create
+{
+    public sealed record Command(
+        string Code,
+        string Subject,
+        string? Content,
+        bool IsActive
+    ) : IRequest;
+}

+ 40 - 0
Application/Features/Document/Create/Handler.cs

@@ -0,0 +1,40 @@
+using Application.Abstractions.Data;
+using SharedKernel.Storage;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Document.Create
+{
+    public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            if (await db.Document.AnyAsync(c => c.Code == request.Code))
+            {
+                throw new Exception("이미 등록된 문서입니다.");
+            }
+
+            var document = Domain.Entities.Page.Document.Create(
+                request.Code,
+                request.Subject,
+                null,
+                request.IsActive
+            );
+
+            await db.Document.AddAsync(document, ct);
+
+            int affectedRows = await db.SaveChangesAsync();
+            if (affectedRows <= 0)
+            {
+                throw new Exception("문서 등록 중 오류가 발생했습니다.");
+            }
+
+            var path = new FileStoragePath(UploadTarget.Editor, UploadFolder.Document, document.ID);
+            var html = await editorImage.UploadAsync(request.Content, path, ct);
+
+            document.SetContent(html);
+
+            await db.SaveChangesAsync();
+        }
+    }
+}

+ 6 - 0
Application/Features/Document/Delete/Command.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Document.Delete
+{
+    public sealed record Command(int[] IDs) : IRequest;
+}

+ 14 - 0
Application/Features/Document/Delete/Handler.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Document.Delete
+{
+    public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            await db.Document.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
+        }
+    }
+}

+ 28 - 0
Application/Features/Document/Get/Handler.cs

@@ -0,0 +1,28 @@
+using Application.Abstractions.Data;
+using MediatR;
+
+namespace Application.Features.Document.Get
+{
+    public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response?>
+    {
+        public async Task<Response?> Handle(Query request, CancellationToken ct)
+        {
+            var document = await db.Document.FindAsync(request.ID, ct);
+            if (document is null)
+            {
+                return null;
+            }
+
+            return new Response
+            {
+                ID = document.ID,
+                Code = document.Code,
+                Subject = document.Subject,
+                Content = document.Content,
+                IsActive = document.IsActive,
+                UpdatedAt = document.UpdatedAt,
+                CreatedAt = document.CreatedAt
+            };
+        }
+    }
+}

+ 6 - 0
Application/Features/Document/Get/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Document.Get
+{
+    public sealed record Query(int ID) : IRequest<Response?>;
+}

+ 13 - 0
Application/Features/Document/Get/Response.cs

@@ -0,0 +1,13 @@
+namespace Application.Features.Document.Get
+{
+    public class Response
+    {
+        public required int ID { get; set; }
+        public required string Code { get; set; }
+        public required string Subject { get; set; }
+        public string? Content { get; set; }
+        public bool IsActive { get; set; } = false;
+        public DateTime? UpdatedAt { get; set; }
+        public required DateTime CreatedAt { get; set; }
+    }
+}

+ 36 - 0
Application/Features/Document/Search/Handler.cs

@@ -0,0 +1,36 @@
+using Application.Abstractions.Data;
+using SharedKernel;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace Application.Features.Document.Search
+{
+    public sealed class Handler(IAppDbContext db, IOptions<AppSettings> settings) : IRequestHandler<Query, Response>
+    {
+        public async Task<Response> Handle(Query request, CancellationToken ct)
+        {
+            var total = await db.Document.CountAsync(ct);
+            var list = await db.Document.AsNoTracking().OrderByDescending(c => c.ID).Skip((request.Page - 1) * request.PerPage).Take(request.PerPage).ToListAsync(ct);
+
+            return new Response
+            {
+                Total = total,
+                List = [..list.Select((c, i) => new Response.Item
+                {
+                    Num = total - ((request.Page - 1) * request.PerPage) - i,
+                    ID = c.ID,
+                    Link = $"{settings.Value.App.FrontURL}/docs/{c.Code}",
+                    Code = c.Code,
+                    Subject = c.Subject,
+                    Content = c.Content,
+                    Views = c.Views.ToString("N0"),
+                    IsActive = c.IsActive ? 'Y' : 'N',
+                    UpdatedAt = c.UpdatedAt.GetDateAt() ?? "-",
+                    CreatedAt = c.CreatedAt.GetDateAt()
+                })]
+            };
+        }
+    }
+}

+ 6 - 0
Application/Features/Document/Search/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Document.Search
+{
+    public sealed record Query(int Page, ushort PerPage) : IRequest<Response>;
+}

+ 22 - 0
Application/Features/Document/Search/Response.cs

@@ -0,0 +1,22 @@
+namespace Application.Features.Document.Search
+{
+    public class Response
+    {
+        public int Total { get; set; } = 0;
+        public List<Item> List { get; set; } = [];
+
+        public class Item
+        {
+            public int Num { get; set; }
+            public int ID { get; set; }
+            public required string Link { get; set; }
+            public required string Code { get; set; }
+            public required string Subject { get; set; }
+            public string? Content { get; set; }
+            public required char IsActive { get; set; }
+            public required string Views { get; set; }
+            public string? UpdatedAt { get; set; }
+            public required string CreatedAt { get; set; }
+        }
+    }
+}

+ 12 - 0
Application/Features/Document/Update/Command.cs

@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace Application.Features.Document.Update
+{
+    public sealed record Command(
+        int ID,
+        string Code,
+        string Subject,
+        string? Content,
+        bool IsActive
+    ) : IRequest;
+}

+ 39 - 0
Application/Features/Document/Update/Handler.cs

@@ -0,0 +1,39 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Storage;
+
+namespace Application.Features.Document.Update
+{
+    public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            var document = await db.Document.FirstOrDefaultAsync(c => c.ID == request.ID, ct);
+            if (document is null)
+            {
+                throw new Exception("문서를 찾을 수 없습니다.");
+            }
+
+            if (await db.Document.AnyAsync(c => c.Code == request.Code && c.ID != request.ID))
+            {
+                throw new Exception("이미 등록된 문서입니다.");
+            }
+
+            var path = new FileStoragePath(UploadTarget.Editor, UploadFolder.Document, request.ID);
+
+            document.Update(
+                request.Code,
+                request.Subject,
+                await editorImage.UploadAsync(request.Content, path, ct),
+                request.IsActive
+            );
+
+            int affectedRows = await db.SaveChangesAsync();
+            if (affectedRows <= 0)
+            {
+                throw new Exception("문서 수정 중 오류가 발생했습니다.");
+            }
+        }
+    }
+}

+ 48 - 0
Application/Features/Faq/Category/Get/Handler.cs

@@ -0,0 +1,48 @@
+using Application.Abstractions.Data;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Faq.Category.Get
+{
+    public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+    {
+        public async Task<Response> Handle(Query request, CancellationToken ct)
+        {
+            var items = await db.FaqCategory
+                .AsNoTracking()
+                .Include(c => c.FaqItems)
+                .OrderBy(c => c.Order)
+                .ThenByDescending(c => c.ID)
+                .Select(c => new
+                {
+                    c.ID,
+                    c.Code,
+                    c.Subject,
+                    c.Order,
+                    c.IsActive,
+                    FaqItemCount = c.FaqItems.Count,
+                    c.UpdatedAt,
+                    c.CreatedAt
+                })
+                .ToListAsync(ct);
+
+            int total = items.Count;
+
+            var rows = items.Select((c, index) => new Response.Row(
+                Num: total - index,
+                ID: c.ID,
+                Index: index,
+                Code: c.Code,
+                Subject: c.Subject,
+                Order: c.Order,
+                IsActive: c.IsActive,
+                (ushort)c.FaqItemCount,
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            )).ToList();
+
+            return new Response(total, rows);
+        }
+    }
+}

+ 6 - 0
Application/Features/Faq/Category/Get/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Faq.Category.Get
+{
+    public sealed record Query: IRequest<Response>;
+}

+ 18 - 0
Application/Features/Faq/Category/Get/Response.cs

@@ -0,0 +1,18 @@
+namespace Application.Features.Faq.Category.Get
+{
+    public sealed record Response(int Total, IReadOnlyList<Response.Row> List)
+    {
+        public sealed record Row(
+            int Num,
+            int ID,
+            int Index,
+            string Code,
+            string Subject,
+            short Order,
+            bool IsActive,
+            ushort FaqItemRows,
+            string? UpdatedAt,
+            string CreatedAt
+        );
+    }
+}

+ 15 - 0
Application/Features/Faq/Category/Save/Command.cs

@@ -0,0 +1,15 @@
+using MediatR;
+
+namespace Application.Features.Faq.Category.Save
+{
+    public sealed record Command(IReadOnlyList<Command.Row> Items) : IRequest<Response>
+    {
+        public sealed record Row(
+            int? ID,
+            string Code,
+            string Subject,
+            short Order,
+            bool IsActive
+        );
+    }
+}

+ 82 - 0
Application/Features/Faq/Category/Save/Handler.cs

@@ -0,0 +1,82 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Domain.Entities.Page.Faq;
+
+namespace Application.Features.Faq.Category.Save
+{
+    public sealed class Handler(IAppDbContext db) : IRequestHandler<Command, Response>
+    {
+        public async Task<Response> Handle(Command request, CancellationToken ct)
+        {
+            var items = request.Items;
+
+            var dbRows = await db.FaqCategory.ToListAsync(ct);
+            var dbByID = dbRows.ToDictionary(c => c.ID);
+
+            // 값이 있는 것만 추출
+            var requestIDs = items.Where(c => c.ID.HasValue && c.ID.Value > 0).Select(c => c.ID!.Value).ToHashSet();
+
+            // 삭제 대상 조회
+            var deleteTargets = dbRows.Where(x => !requestIDs.Contains(x.ID)).ToList();
+
+            if (deleteTargets.Count > 0)
+            {
+                var deleteIDs = deleteTargets.Select(c => c.ID).ToList();
+                var hasItems = await db.FaqItem.AsNoTracking().AnyAsync(c => deleteIDs.Contains(c.CategoryID), ct);
+
+                if (hasItems)
+                {
+                    throw new InvalidOperationException("FAQ가 등록된 분류는 삭제할 수 없습니다. 먼저 해당 FAQ를 삭제하세요.");
+                }
+
+                db.FaqCategory.RemoveRange(deleteTargets);
+            }
+
+            ushort inserted = 0;
+            ushort updated = 0;
+            ushort deleted = 0;
+
+            foreach(var row in items)
+            {
+                // 신규 추가
+                if (!row.ID.HasValue || row.ID.Value <= 0)
+                {
+                    // Code 중복 검사
+                    if (await db.FaqCategory.AnyAsync(c => c.Code == row.Code && c.ID != row.ID, ct))
+                    {
+                        throw new InvalidOperationException($"`{row.Code}`는 이미 등록되었습니다.");
+                    }
+
+                    db.FaqCategory.Add(
+                        FaqCategory.Create(row.Code, row.Subject, row.Order, row.IsActive)
+                    );
+
+                    inserted++;
+                    continue;
+                }
+
+                // 수정
+                if (!dbByID.TryGetValue(row.ID.Value, out var existing))
+                {
+                    throw new InvalidOperationException($"존재하지 않는 ID: {row.ID.Value}");
+                }
+
+                // Code가 변경된 경우에만 중복 검사
+                if (existing.Code != row.Code && await db.FaqCategory.AnyAsync(c => c.Code == row.Code, ct))
+                {
+                    throw new InvalidOperationException($"`{row.Code}`는 이미 등록되었습니다.");
+                }
+
+                existing.Update(row.Code, row.Subject, row.Order, row.IsActive);
+                updated++;
+            }
+
+            deleted = (ushort)deleteTargets.Count;
+
+            await db.SaveChangesAsync(ct);
+
+            return new Response(inserted, updated, deleted);
+        }
+    }
+}

+ 4 - 0
Application/Features/Faq/Category/Save/Response.cs

@@ -0,0 +1,4 @@
+namespace Application.Features.Faq.Category.Save
+{
+    public sealed record Response(ushort Inserted = 0, ushort Updated = 0, ushort Deleted = 0);
+}

+ 12 - 0
Application/Features/Faq/Item/Create/Command.cs

@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace Application.Features.Faq.Item.Create
+{
+    public sealed record Command(
+        int CategoryID,
+        string Question,
+        string? Answer,
+        short Order,
+        bool IsActive
+    ) : IRequest;
+}

+ 41 - 0
Application/Features/Faq/Item/Create/Handler.cs

@@ -0,0 +1,41 @@
+using Application.Abstractions.Data;
+using SharedKernel.Storage;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Faq.Item.Create
+{
+    public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            if (await db.FaqItem.AnyAsync(c => c.Question == request.Question))
+            {
+                throw new Exception("이미 등록된 FAQ입니다.");
+            }
+
+            var faq = Domain.Entities.Page.Faq.FaqItem.Create(
+                request.CategoryID,
+                request.Question,
+                null,
+                request.Order,
+                request.IsActive
+            );
+
+            await db.FaqItem.AddAsync(faq, ct);
+
+            int affectedRows = await db.SaveChangesAsync();
+            if (affectedRows <= 0)
+            {
+                throw new Exception("FAQ 등록 중 오류가 발생했습니다.");
+            }
+
+            var path = new FileStoragePath(UploadTarget.Editor, UploadFolder.Faq, faq.ID);
+            var html = await editorImage.UploadAsync(request.Answer, path, ct);
+
+            faq.SetContent(html);
+
+            await db.SaveChangesAsync();
+        }
+    }
+}

+ 6 - 0
Application/Features/Faq/Item/Delete/Command.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Faq.Item.Delete
+{
+    public sealed record Command(int[] IDs) : IRequest;
+}

+ 14 - 0
Application/Features/Faq/Item/Delete/Handler.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Faq.Item.Delete
+{
+    public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            await db.FaqItem.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
+        }
+    }
+}

+ 49 - 0
Application/Features/Faq/Item/Get/Handler.cs

@@ -0,0 +1,49 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Faq.Item.Get
+{
+    public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response?>
+    {
+        public async Task<Response?> Handle(Query request, CancellationToken ct)
+        {
+            var item = await db.FaqItem
+                .AsNoTracking()
+                .Include(i => i.FaqCategory)
+                .Where(i => i.ID == request.ID)
+                .Select(i => new
+                {
+                    i.ID,
+                    i.CategoryID,
+                    CategoryCode = i.FaqCategory.Code,
+                    CategorySubject = i.FaqCategory.Subject,
+                    i.Question,
+                    i.Answer,
+                    i.Order,
+                    i.IsActive,
+                    i.UpdatedAt,
+                    i.CreatedAt
+                })
+                .FirstOrDefaultAsync(ct);
+
+            if (item is null)
+            {
+                return null;
+            }
+
+            return new Response(
+                item.ID,
+                item.CategoryID,
+                item.CategoryCode,
+                item.CategorySubject,
+                item.Question,
+                item.Answer,
+                item.Order,
+                item.IsActive,
+                item.UpdatedAt,
+                item.CreatedAt
+            );
+        }
+    }
+}

+ 6 - 0
Application/Features/Faq/Item/Get/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Faq.Item.Get
+{
+    public sealed record Query(int ID) : IRequest<Response?>;
+}

+ 15 - 0
Application/Features/Faq/Item/Get/Response.cs

@@ -0,0 +1,15 @@
+namespace Application.Features.Faq.Item.Get
+{
+    public sealed record Response(
+       int ID,
+       int CategoryID,
+       string CategoryCode,
+       string CategorySubject,
+       string Question,
+       string? Answer,
+       short Order,
+       bool IsActive,
+       DateTime? UpdatedAt,
+       DateTime CreatedAt
+   );
+}

+ 72 - 0
Application/Features/Faq/Item/Search/Handler.cs

@@ -0,0 +1,72 @@
+using Application.Abstractions.Data;
+using SharedKernel;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace Application.Features.Faq.Item.Search
+{
+    public sealed class Handler(IAppDbContext db, IOptions<AppSettings> settings) : IRequestHandler<Query, Response>
+    {
+        public async Task<Response> Handle(Query request, CancellationToken ct)
+        {
+            var query = db.FaqItem.AsNoTracking().Include(i => i.FaqCategory).AsQueryable();
+
+            if (request.CategoryID.HasValue)
+            {
+                query = query.Where(i => i.CategoryID == request.CategoryID.Value);
+            }
+
+            if (!string.IsNullOrWhiteSpace(request.Keyword))
+            {
+                var keyword = request.Keyword!.Trim();
+                query = query.Where(i =>
+                    EF.Functions.Like(i.Question, $"%{keyword}%") ||
+                    EF.Functions.Like(i.Answer, $"%{keyword}%"));
+            }
+
+            var total = await query.CountAsync(ct);
+
+            var list = await query
+                .OrderBy(i => i.Order)
+                .ThenByDescending(i => i.ID)
+                .Skip((request.Page - 1) * request.PerPage)
+                .Take(request.PerPage)
+                .Select(c => new
+                {
+                    c.ID,
+                    c.CategoryID,
+                    CategoryCode = c.FaqCategory.Code,
+                    CategorySubject = c.FaqCategory.Subject,
+                    c.Question,
+                    c.Answer,
+                    c.Order,
+                    c.IsActive,
+                    c.UpdatedAt,
+                    c.CreatedAt
+                })
+                .OrderByDescending(c => c.ID)
+                .ToListAsync(ct);
+
+            return new Response
+            {
+                Total = total,
+                List = [..list.Select((c, i) => new Response.Item
+                {
+                    Num = total - ((request.Page - 1) * request.PerPage) - i,
+                    ID = c.ID,
+                    CategoryID = c.CategoryID,
+                    CategoryCode = c.CategoryCode,
+                    CategorySubject = c.CategorySubject,
+                    Question = c.Question,
+                    Answer = c.Answer,
+                    Order = c.Order,
+                    IsActive = c.IsActive,
+                    UpdatedAt = c.UpdatedAt.GetDateAt(),
+                    CreatedAt = c.CreatedAt.GetDateAt()
+                })]
+            };
+        }
+    }
+}

+ 6 - 0
Application/Features/Faq/Item/Search/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Faq.Item.Search
+{
+    public sealed record Query(int? CategoryID, string? Keyword, int Page, ushort PerPage) : IRequest<Response>;
+}

+ 23 - 0
Application/Features/Faq/Item/Search/Response.cs

@@ -0,0 +1,23 @@
+namespace Application.Features.Faq.Item.Search
+{
+    public class Response
+    {
+        public int Total { get; set; } = 0;
+        public List<Item> List { get; set; } = [];
+
+        public class Item
+        {
+            public int Num { get; set; }
+            public int ID { get; set; }
+            public int CategoryID { get; set; }
+            public required string CategoryCode { get; set; }
+            public required string CategorySubject { get; set; }
+            public required string Question { get; set; }
+            public string? Answer { get; set; }
+            public required short Order { get; set; }
+            public required bool IsActive { get; set; }
+            public string? UpdatedAt { get; set; }
+            public required string CreatedAt { get; set; }
+        }
+    }
+}

+ 13 - 0
Application/Features/Faq/Item/Update/Command.cs

@@ -0,0 +1,13 @@
+using MediatR;
+
+namespace Application.Features.Faq.Item.Update
+{
+    public sealed record Command(
+        int CategoryID,
+        int ID,
+        string Question,
+        string? Answer,
+        short Order,
+        bool IsActive
+    ) : IRequest;
+}

+ 31 - 0
Application/Features/Faq/Item/Update/Handler.cs

@@ -0,0 +1,31 @@
+using SharedKernel.Storage;
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Faq.Item.Update
+{
+    public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command>
+    {
+        public async Task Handle(Command request, CancellationToken ct)
+        {
+            var item = await db.FaqItem.FirstOrDefaultAsync(c => c.ID == request.ID, ct);
+            if (item is null)
+            {
+                throw new Exception("ÇØ´ç FAQ¸¦ ãÀ» ¼ö ¾ø½À´Ï´Ù.");
+            }
+
+            var path = new FileStoragePath(UploadTarget.Editor, UploadFolder.Faq, request.ID);
+
+            item.Update(
+                request.CategoryID,
+                request.Question,
+                await editorImage.UploadAsync(request.Answer, path, ct),
+                request.Order,
+                request.IsActive
+            );
+
+            await db.SaveChangesAsync(ct);
+        }
+    }
+}

+ 14 - 0
Application/Features/Popup/Create/Command.cs

@@ -0,0 +1,14 @@
+using MediatR;
+
+namespace Application.Features.Popup.Create
+{
+    public sealed record Command(
+       string Subject,
+       string? Content,
+       string? Link,
+       DateTime? StartAt,
+       DateTime? EndAt,
+       short Order,
+       bool IsActive
+   ) : IRequest<int>;
+}

+ 37 - 0
Application/Features/Popup/Create/Handler.cs

@@ -0,0 +1,37 @@
+using Application.Abstractions.Data;
+using MediatR;
+using SharedKernel.Storage;
+
+namespace Application.Features.Popup.Create;
+
+public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command, int>
+{
+    public async Task<int> Handle(Command request, CancellationToken ct)
+    {
+        if (request.StartAt > request.EndAt)
+        {
+            throw new Exception("팝업의 종료일은 시작일보다 이전일 수 없습니다.");
+        }
+
+        var popup = Domain.Entities.Page.Popup.Create(
+            request.Subject,
+            null,
+            request.Link,
+            request.StartAt,
+            request.EndAt,
+            request.Order,
+            request.IsActive
+        );
+
+        await db.Popup.AddAsync(popup);
+        await db.SaveChangesAsync(ct);
+
+        var path = new FileStoragePath(UploadTarget.Editor, UploadFolder.Popup, popup.ID);
+        var html = await editorImage.UploadAsync(request.Content, path, ct);
+
+        popup.SetContent(html);
+        await db.SaveChangesAsync(ct);
+
+        return popup.ID;
+    }
+}

+ 6 - 0
Application/Features/Popup/Delete/Command.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Popup.Delete
+{
+    public sealed record Command(int[] IDs) : IRequest;
+}

+ 25 - 0
Application/Features/Popup/Delete/Handler.cs

@@ -0,0 +1,25 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Popup.Delete;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        if (request.IDs is null || request.IDs.Length < 1)
+        {
+            return;
+        }
+
+        var list = await db.Popup.Where(x => request.IDs.Contains(x.ID)).ToListAsync(ct);
+        if (list.Count < 1)
+        {
+            return;
+        }
+
+        db.Popup.RemoveRange(list);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 29 - 0
Application/Features/Popup/Get/Handler.cs

@@ -0,0 +1,29 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Popup.Get;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response?>
+{
+    public async Task<Response?> Handle(Query request, CancellationToken ct)
+    {
+        return await db.Popup
+            .AsNoTracking()
+            .Where(x => x.ID == request.ID)
+            .Select(x => new Response
+            {
+                ID = x.ID,
+                Subject = x.Subject,
+                Content = x.Content,
+                Link = x.Link,
+                StartAt = x.StartAt,
+                EndAt = x.EndAt,
+                Order = x.Order,
+                IsActive = x.IsActive,
+                UpdatedAt = x.UpdatedAt,
+                CreatedAt = x.CreatedAt
+            })
+            .FirstOrDefaultAsync(ct);
+    }
+}

+ 6 - 0
Application/Features/Popup/Get/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Popup.Get
+{
+    public sealed record Query(int ID) : IRequest<Response?>;
+}

+ 15 - 0
Application/Features/Popup/Get/Response.cs

@@ -0,0 +1,15 @@
+namespace Application.Features.Popup.Get;
+
+public sealed class Response
+{
+    public int ID { get; init; }
+    public required string Subject { get; init; }
+    public string? Content { get; init; }
+    public string? Link { get; init; }
+    public DateTime? StartAt { get; init; }
+    public DateTime? EndAt { get; init; }
+    public short Order { get; init; }
+    public bool IsActive { get; init; }
+    public DateTime? UpdatedAt { get; init; }
+    public DateTime CreatedAt { get; init; }
+}

+ 56 - 0
Application/Features/Popup/Search/Handler.cs

@@ -0,0 +1,56 @@
+using SharedKernel.Extensions;
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Popup.Search;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.Popup.AsNoTracking();
+        var total = await query.CountAsync(ct);
+        var skip = (request.PageNum - 1) * request.PerPage;
+
+        var list = await query
+            .OrderByDescending(x => x.ID)
+            .Skip(skip)
+            .Take(request.PerPage)
+            .Select(x => new
+            {
+                x.ID,
+                x.Subject,
+                x.Link,
+                x.StartAt,
+                x.EndAt,
+                x.Order,
+                x.IsActive,
+                x.UpdatedAt,
+                x.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        var rows = list
+            .Select((x, idx) => new Response.Row
+            {
+                Num = total - skip - idx,
+                ID = x.ID,
+                Subject = x.Subject,
+                Link = x.Link,
+                StartAt = x.StartAt?.ToString("yyyy-MM-dd HH:mm"),
+                EndAt = x.EndAt?.ToString("yyyy-MM-dd HH:mm"),
+                Order = x.Order,
+                IsActive = x.IsActive,
+                UpdatedAt = x.UpdatedAt.GetDateAt(),
+                CreatedAt = x.CreatedAt.GetDateAt()
+            })
+            .ToList();
+
+        return new Response
+        {
+            Total = total,
+            List = rows
+        };
+    }
+}

+ 6 - 0
Application/Features/Popup/Search/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Popup.Search
+{
+    public sealed record Query(int PageNum, ushort PerPage) : IRequest<Response>;
+}

+ 22 - 0
Application/Features/Popup/Search/Response.cs

@@ -0,0 +1,22 @@
+namespace Application.Features.Popup.Search;
+
+public sealed class Response
+{
+    public int Total { get; init; }
+
+    public required IReadOnlyList<Row> List { get; init; }
+
+    public sealed class Row
+    {
+        public int Num { get; init; }
+        public int ID { get; init; }
+        public required string Subject { get; init; }
+        public string? Link { get; init; }
+        public string? StartAt { get; init; }
+        public string? EndAt { get; init; }
+        public short Order { get; init; }
+        public bool IsActive { get; init; }
+        public string? UpdatedAt { get; init; }
+        public required string CreatedAt { get; init; }
+    }
+}

+ 15 - 0
Application/Features/Popup/Update/Command.cs

@@ -0,0 +1,15 @@
+using MediatR;
+
+namespace Application.Features.Popup.Update
+{
+    public sealed record Command(
+        int ID,
+        string Subject,
+        string? Content,
+        string? Link,
+        DateTime? StartAt,
+        DateTime? EndAt,
+        short Order,
+        bool IsActive
+    ) : IRequest;
+}

+ 34 - 0
Application/Features/Popup/Update/Handler.cs

@@ -0,0 +1,34 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Popup.Update;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        if (request.StartAt > request.EndAt)
+        {
+            throw new Exception("팝업의 종료일은 시작일보다 이전일 수 없습니다.");
+        }
+
+        var popup = await db.Popup.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        if (popup is null)
+        {
+            throw new Exception("팝업을 찾을 수 없습니다.");
+        }
+
+        popup.Update(
+            request.Subject,
+            request.Content,
+            request.Link,
+            request.StartAt,
+            request.EndAt,
+            request.Order,
+            request.IsActive
+        );
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 8 - 1
Domain/Entities/Page/Document.cs

@@ -53,7 +53,7 @@ namespace Domain.Entities.Page
             return new(code, subject, content, isActive);
         }
 
-        public void Update(string subject, string? content, bool isActive)
+        public void Update(string code, string subject, string? content, bool isActive)
         {
             if (string.IsNullOrWhiteSpace(subject))
             {
@@ -65,12 +65,19 @@ namespace Domain.Entities.Page
                 throw new ArgumentOutOfRangeException(nameof(subject));
             }
 
+            Code = code;
             Subject = subject;
             Content = content;
             IsActive = isActive;
             UpdatedAt = DateTime.UtcNow;
         }
 
+        public void SetContent(string? content)
+        {
+            Content = content;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
         public void IncreaseViews()
         {
             Views++;

+ 7 - 1
Domain/Entities/Page/Faq/Category.cs

@@ -58,8 +58,13 @@ namespace Domain.Entities.Page.Faq
             return new(code, subject, order, isActive);
         }
 
-        public void Update(string subject, short order, bool isActive)
+        public void Update(string code, string subject, short order, bool isActive)
         {
+            if (string.IsNullOrWhiteSpace(code))
+            {
+                throw new ArgumentException("Code is required.", nameof(code));
+            }
+
             if (string.IsNullOrWhiteSpace(subject))
             {
                 throw new ArgumentException("Subject is required.", nameof(subject));
@@ -75,6 +80,7 @@ namespace Domain.Entities.Page.Faq
                 throw new ArgumentOutOfRangeException(nameof(order));
             }
 
+            Code = code;
             Subject = subject;
             Order = order;
             IsActive = isActive;

+ 13 - 1
Domain/Entities/Page/Faq/Item.cs

@@ -56,8 +56,13 @@ namespace Domain.Entities.Page.Faq
             return new(categoryID, question, answer, order, isActive);
         }
 
-        public void Update(string question, string? answer, short order, bool isActive)
+        public void Update(int categoryID, string question, string? answer, short order, bool isActive)
         {
+            if (categoryID <= 0)
+            {
+                throw new ArgumentOutOfRangeException(nameof(categoryID));
+            }
+
             if (string.IsNullOrWhiteSpace(question))
             {
                 throw new ArgumentException("Question is required.", nameof(question));
@@ -73,11 +78,18 @@ namespace Domain.Entities.Page.Faq
                 throw new ArgumentOutOfRangeException(nameof(order));
             }
 
+            CategoryID = categoryID;
             Question = question;
             Answer = answer;
             Order = order;
             IsActive = isActive;
             UpdatedAt = DateTime.UtcNow;
         }
+
+        public void SetContent(string? answer)
+        {
+            Answer = answer;
+            UpdatedAt = DateTime.UtcNow;
+        }
     }
 }

+ 6 - 0
Domain/Entities/Page/Popup.cs

@@ -81,5 +81,11 @@ namespace Domain.Entities.Page
             IsActive = isActive;
             UpdatedAt = DateTime.UtcNow;
         }
+
+        public void SetContent(string? content)
+        {
+            Content = content;
+            UpdatedAt = DateTime.UtcNow;
+        }
     }
 }

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov