KIM-JINO5 4 hónapja
szülő
commit
f8a259c68f
58 módosított fájl, 1799 hozzáadás és 85 törlés
  1. 1 0
      Admin/Admin.csproj
  2. 0 0
      Admin/Application/Features/Banner/Item/Delete/Command.cs
  3. 0 0
      Admin/Application/Features/Banner/Item/Delete/Handler.cs
  4. 0 0
      Admin/Application/Features/Banner/Item/Update/Handler.cs
  5. 0 0
      Admin/Application/Features/Banner/Position/Save/Handler.cs
  6. 139 0
      Admin/Pages/Banner/List/Edit.cshtml
  7. 129 0
      Admin/Pages/Banner/List/Edit.cshtml.cs
  8. 141 0
      Admin/Pages/Banner/List/Index.cshtml
  9. 94 0
      Admin/Pages/Banner/List/Index.cshtml.cs
  10. 117 0
      Admin/Pages/Banner/List/Write.cshtml
  11. 104 0
      Admin/Pages/Banner/List/Write.cshtml.cs
  12. 194 0
      Admin/Pages/Banner/Position.cshtml
  13. 105 0
      Admin/Pages/Banner/Position.cshtml.cs
  14. 8 0
      Admin/Pages/Banner/_navTabs.cshtml
  15. 2 2
      Admin/Pages/Document/Edit.cshtml
  16. 2 2
      Admin/Pages/Document/Write.cshtml
  17. 10 11
      Admin/Pages/Faq/Category.cshtml
  18. 8 10
      Admin/Pages/Faq/Category.cshtml.cs
  19. 2 2
      Admin/Pages/Faq/List/Edit.cshtml
  20. 4 6
      Admin/Pages/Faq/List/Index.cshtml.cs
  21. 2 2
      Admin/Pages/Faq/List/Write.cshtml
  22. 2 2
      Admin/Pages/Popup/Edit.cshtml
  23. 2 2
      Admin/Pages/Popup/Write.cshtml
  24. 13 2
      Admin/using.cs
  25. 11 10
      Admin/wwwroot/js/func.js
  26. BIN
      Admin/wwwroot/uploads/banner/1/97d60f443bd04c70939e9de7ffeaaf1a.png
  27. 17 0
      Application/Features/Banner/Item/Create/Command.cs
  28. 52 0
      Application/Features/Banner/Item/Create/Handler.cs
  29. 6 0
      Application/Features/Banner/Item/Delete/Command.cs
  30. 27 0
      Application/Features/Banner/Item/Delete/Handler.cs
  31. 32 0
      Application/Features/Banner/Item/Get/Handler.cs
  32. 6 0
      Application/Features/Banner/Item/Get/Query.cs
  33. 17 0
      Application/Features/Banner/Item/Get/Response.cs
  34. 72 0
      Application/Features/Banner/Item/Search/Handler.cs
  35. 6 0
      Application/Features/Banner/Item/Search/Query.cs
  36. 23 0
      Application/Features/Banner/Item/Search/Response.cs
  37. 18 0
      Application/Features/Banner/Item/Update/Command.cs
  38. 61 0
      Application/Features/Banner/Item/Update/Handler.cs
  39. 41 0
      Application/Features/Banner/Position/GetAll/Handler.cs
  40. 6 0
      Application/Features/Banner/Position/GetAll/Query.cs
  41. 17 0
      Application/Features/Banner/Position/GetAll/Response.cs
  42. 9 0
      Application/Features/Banner/Position/Save/Command.cs
  43. 70 0
      Application/Features/Banner/Position/Save/Handler.cs
  44. 3 0
      Application/Features/Banner/Position/Save/Response.cs
  45. 14 1
      Application/Features/Document/Delete/Handler.cs
  46. 15 18
      Application/Features/Faq/Category/GetAll/Handler.cs
  47. 1 1
      Application/Features/Faq/Category/GetAll/Query.cs
  48. 3 3
      Application/Features/Faq/Category/GetAll/Response.cs
  49. 14 1
      Application/Features/Faq/Item/Delete/Handler.cs
  50. 3 7
      Application/Features/Faq/Item/Search/Handler.cs
  51. 2 2
      Application/Features/Faq/Item/Search/Response.cs
  52. 10 1
      Application/Features/Popup/Delete/Handler.cs
  53. 12 0
      Domain/Entities/Page/Banner/Item.cs
  54. 2 0
      Infrastructure/Storage/LocalFileStorage.cs
  55. 55 0
      SharedKernel/Attributes/AllowedExtensionsAttribute.cs
  56. 35 0
      SharedKernel/Attributes/MaxFileCountAttribute.cs
  57. 43 0
      SharedKernel/Attributes/MaxFileSizeAttribute.cs
  58. 17 0
      SharedKernel/Attributes/MustBeTrueAttribute.cs

+ 1 - 0
Admin/Admin.csproj

@@ -34,6 +34,7 @@
   <ItemGroup>
     <Folder Include="Middleware\" />
     <Folder Include="wwwroot\uploads\basic\" />
+    <Folder Include="wwwroot\uploads\banner\" />
   </ItemGroup>
 
 </Project>

+ 0 - 0
Admin/Application/Features/Banner/Item/Delete/Command.cs


+ 0 - 0
Admin/Application/Features/Banner/Item/Delete/Handler.cs


+ 0 - 0
Admin/Application/Features/Banner/Item/Update/Handler.cs


+ 0 - 0
Admin/Application/Features/Banner/Position/Save/Handler.cs


+ 139 - 0
Admin/Pages/Banner/List/Edit.cshtml

@@ -0,0 +1,139 @@
+@page "{id:int}"
+@model Admin.Pages.Banner.List.EditModel
+
+@{
+    ViewData["Title"] = "배너 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="QueryString" />
+
+        <div class="row mb-2">
+            <label asp-for="Input.PositionID" class="col-sm-2 col-form-label"><span>*</span> 배너 위치</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.PositionID" class="form-select" asp-items="Model.Positions" required></select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PositionID" 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.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>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">현재 이미지</label>
+            <div class="col-sm-10">
+                <div class="mb-2">
+                    <div class="form-text">
+                        Desktop
+                        @if (Model.CurrentDesktopImage != null)
+                        {
+                            <br/>
+                            <img src="@Model.CurrentDesktopImage" class="img-thumbnail img-fluid rounded" />
+                        } else {
+                            <text>-</text>
+                        }
+                    </div>
+                </div>
+                <div>
+                    <div class="form-text">
+                        Mobile
+                        @if (Model.CurrentMobileImage != null)
+                        {
+                            <br />
+                            <img src="@Model.CurrentMobileImage" class="img-thumbnail img-fluid rounded" />
+                        }
+                        else
+                        {
+                            <text>-</text>
+                        }
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">이미지</label>
+            <div class="col-sm-10">
+                <div class="mb-3">
+                    <label asp-for="Input.DesktopImageFile" class="form-label">Desktop</label>
+                    <input asp-for="Input.DesktopImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.DesktopImageFile" class="text-danger"></span>
+                </div>
+                <div>
+                    <label asp-for="Input.MobileImageFile" class="form-label">Mobile</label>
+                    <input asp-for="Input.MobileImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.MobileImageFile" class="text-danger"></span>
+                </div>
+            </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">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
+                </div>
+                <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="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장</button>
+                <a href="/Banner/List?@Model.QueryString" class="btn btn-secondary">취소</a>
+            </div>
+        </div>
+        <br/>
+    </form>
+</div>

+ 129 - 0
Admin/Pages/Banner/List/Edit.cshtml.cs

@@ -0,0 +1,129 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using SharedKernel.Attributes;
+using SharedKernel.Extensions;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Banner.List;
+
+public class EditModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public string? QueryString { get; set; }
+    public List<SelectListItem> Positions { get; set; } = [];
+
+    public string? CurrentDesktopImage { get; private set; }
+    public string? CurrentMobileImage { 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("배너 위치 ID")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        public int PositionID { get; set; }
+
+        [DisplayName("배너 명")]
+        [DataType(DataType.Text)]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string Subject { get; set; } = null!;
+
+        [DisplayName("첨부 이미지(Desktop)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? DesktopImageFile { get; set; }
+
+        [DisplayName("첨부 이미지(Mobile)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? MobileImageFile { get; set; }
+
+        [DisplayName("주소")]
+        [DataType(DataType.Url)]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Link { get; set; }
+
+        [DisplayName("순서")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [Range(-9999, 9999, ErrorMessage = "{0} 허용 범위는 {2} ~ {1} 입니다.")]
+        public short Order { get; set; } = 0;
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+
+        [DisplayName("사용 기간 - 시작")]
+        [DataType(DataType.DateTime)]
+        public DateTime? StartAt { get; set; }
+
+        [DisplayName("사용 기간 - 종료")]
+        [DataType(DataType.DateTime)]
+        public DateTime? EndAt { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        Positions = [.. (await mediator.Send(new GetBannerPositions.Query(), ct)).List.Select(p => new SelectListItem {
+            Value = p.ID.ToString(),
+            Text = $"[{p.Code}] {p.Subject}"
+        })];
+
+        var result = await mediator.Send(new GetBannerItem.Query(id), ct);
+
+        CurrentDesktopImage = result.DesktopImage;
+        CurrentMobileImage = result.MobileImage;
+
+        Input = new InputModel
+        {
+            ID = result.ID,
+            PositionID = result.PositionID,
+            Subject = result.Subject,
+            Link = result.Link,
+            Order = result.Order,
+            IsActive = result.IsActive,
+            StartAt = result.StartAt,
+            EndAt = result.EndAt
+        };
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBannerItem.Command(
+                Input.ID,
+                Input.PositionID,
+                Input.Subject,
+                Input.DesktopImageFile,
+                Input.MobileImageFile,
+                Input.Link,
+                Input.Order,
+                Input.IsActive,
+                Input.StartAt,
+                Input.EndAt
+            ), ct);
+
+            TempData["SuccessMessage"] = $"{Input.Subject} 배너가 수정되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+        }
+
+        return Redirect($"/Banner/List/Edit/{Input.ID}{Request.QueryString}");
+    }
+}

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

@@ -0,0 +1,141 @@
+@page
+@model Admin.Pages.Banner.List.IndexModel
+@{
+    ViewData["Title"] = "배너 관리";
+}
+
+<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="positionID" id="positionID" class="form-select w-auto" form="fAdminSearch">
+                <option value="">선택하세요.</option>
+                @foreach ((int ID, string Subject, int BannerItemRows) row in Model.Positions)
+                {
+                    <option value="@row.ID" selected="@(row.ID == Model.Query.PositionID)">@row.Subject (@row.BannerItemRows)</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="/Banner/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.PositionSubject</td>
+                            <td>@row.Subject</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="positionID" value="@Model.Query.PositionID" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#positionID, #perPage", function () {
+           searchForm.submit();
+        });
+    </script>
+}

+ 94 - 0
Admin/Pages/Banner/List/Index.cshtml.cs

@@ -0,0 +1,94 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using SharedKernel.Extensions;
+using SharedKernel.Helpers;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Banner.List;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public List<(int ID, string Subject, int BannerItemRows)> Positions { 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("배너 위치")]
+        public int? PositionID { get; set; }
+
+        [DisplayName("검색어")]
+        public string? Keyword { get; set; }
+    }
+
+    public int Total { get; set; }
+
+    public List<(
+        int Num,
+        int ID,
+        string PositionSubject,
+        string Subject,
+        short Order,
+        char IsActive,
+        string? UpdatedAt,
+        string CreatedAt,
+        string EditURL
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        Positions = [.. (await mediator.Send(new GetBannerPositions.Query(), ct)).List.Select(c => (c.ID, c.Subject, c.BannerItemRows))];
+
+        var result = await mediator.Send(new SearchBannerItems.Query(Query.PositionID, Query.Keyword, Query.PageNum, Query.PerPage), ct);
+
+        Total = result.Total;
+        List = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.PositionSubject,
+            c.Subject,
+            c.Order,
+            c.IsActive ? 'Y' : 'N',
+            c.UpdatedAt.GetDateAt() ?? "-",
+            c.CreatedAt.GetDateAt(),
+            EditURL: $"/Banner/List/Edit/{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 DeleteBannerItem.Command(ids), ct);
+
+            TempData["SuccessMessage"] = $"{ids.Length}개 배너가 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Banner/List/Index", Query);
+    }
+}

+ 117 - 0
Admin/Pages/Banner/List/Write.cshtml

@@ -0,0 +1,117 @@
+@page
+@model Admin.Pages.Banner.List.WriteModel
+@{
+    ViewData["Title"] = "배너 등록";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <div class="row mb-2">
+            <label asp-for="Input.PositionID" class="col-sm-2 col-form-label"><span>*</span> 배너 위치</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.PositionID" class="form-select" asp-items="Model.Positions" required></select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PositionID" 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.Link" class="col-sm-2 col-form-label"></label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Link" class="form-control" type="url" />
+                <span asp-validation-for="Input.Link" 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="mb-3">
+                    <label asp-for="Input.DesktopImageFile" class="form-label">Desktop</label>
+                    <div id="DesktopBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Desktop) 미리보기" /><br/>
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
+                    <input asp-for="Input.DesktopImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.DesktopImageFile" class="text-danger"></span>
+                </div>
+                <div>
+                    <label asp-for="Input.MobileImageFile" class="form-label">Mobile</label>
+                    <div id="MobileBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Mobile) 미리보기" /><br/>
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
+                    <input asp-for="Input.MobileImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.MobileImageFile" class="text-danger"></span>
+                </div>
+            </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">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
+                </div>
+                <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="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장</button>
+                <a href="/Banner/List?@Model.QueryString" class="btn btn-secondary">취소</a>
+            </div>
+        </div>
+        <br/>
+    </form>
+</div>
+@section Scripts {
+    <script>
+        setupImagePreview("Input_DesktopImageFile", "DesktopBannerPrev");
+        setupImagePreview("Input_MobileImageFile", "MobileBannerPrev");
+    </script>
+}

+ 104 - 0
Admin/Pages/Banner/List/Write.cshtml.cs

@@ -0,0 +1,104 @@
+using SharedKernel.Attributes;
+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.Banner.List;
+
+public class WriteModel(IMediator mediator) : PageModel
+{
+    public string? QueryString { get; set; }
+    public List<SelectListItem> Positions { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("배너 위치 ID")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        public int PositionID { get; set; }
+
+        [DisplayName("배너 명")]
+        [DataType(DataType.Text)]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string Subject { get; set; } = null!;
+
+        [DisplayName("첨부 이미지(Desktop)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? DesktopImageFile { get; set; }
+
+        [DisplayName("첨부 이미지(Mobile)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? MobileImageFile { get; set; }
+
+        [DisplayName("주소")]
+        [DataType(DataType.Url)]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Link { get; set; }
+
+        [DisplayName("순서")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [Range(-9999, 9999, ErrorMessage = "{0} 허용 범위는 {2} ~ {1} 입니다.")]
+        public short Order { get; set; } = 0;
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+
+        [DisplayName("사용 기간 - 시작")]
+        [DataType(DataType.DateTime)]
+        public DateTime? StartAt { get; set; }
+
+        [DisplayName("사용 기간 - 종료")]
+        [DataType(DataType.DateTime)]
+        public DateTime? EndAt { get; set; }
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        Positions = [.. (await mediator.Send(new GetBannerPositions.Query(), ct)).List.Select(p => new SelectListItem {
+            Value = p.ID.ToString(),
+            Text = $"[{p.Code}] {p.Subject}"
+        })];
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new CreateBannerItem.Command(
+                Input.PositionID,
+                Input.Subject,
+                Input.DesktopImageFile,
+                Input.MobileImageFile,
+                Input.Link,
+                Input.Order,
+                Input.IsActive,
+                Input.StartAt,
+                Input.EndAt
+            ), ct);
+
+            TempData["SuccessMessage"] = $"{Input.Subject} 배너가 등록되었습니다.";
+
+            return RedirectToPage("/Banner/List/Index");
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+            return Redirect($"/Banner/List/Write?{Request.QueryString}");
+        }
+    }
+}

+ 194 - 0
Admin/Pages/Banner/Position.cshtml

@@ -0,0 +1,194 @@
+@page
+@model Admin.Pages.Banner.PositionModel
+@{
+    ViewData["Title"] = "배너 위치";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_navTabs" />
+
+    <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>
+                배너 위치에 등록된 배너가 있다면 삭제가 불가합니다.<br />
+                배너 위치를 삭제하려면 해당 배너를 먼저 삭제해주세요.
+            </caption>
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>ID</th>
+                    <th>Code</th>
+                    <th>위치 명</th>
+                    <th>배너 수</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody id="positions">
+                @if (Model.List is null || Model.List.Count <= 0)
+                {
+                    <tr>
+                        <td colspan="8">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.BannerItemRows > 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.BannerItemRows</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" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.CreatedAt" /></td>
+                            <td><input type="text" 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 $positions = $("#positions");
+            let total = Number(@Model.Total);
+
+            // 추가
+            $(document).on("click", "#btnAdd", function() {
+                if (total <= 0) {
+                    $positions.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>
+                            <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>
+                `;
+
+                $positions.append(tableRow);
+                total++;
+                recalculateIndices();
+            });
+
+            // 삭제
+            $(document).on("click", "button.btn-delete", function(e) {
+                e.target.closest("tr").remove();
+                total--;
+
+                if (total <= 0) {
+                    $positions.append(`<tr><td colspan="8">No Data.</td></tr>`);
+                    total = 0;
+                } else {
+                    recalculateIndices();
+                }
+            });
+
+            // 저장
+            $(document).on("click", "#btnSave", function() {
+                if (confirm("저장 하시겠습니까?")) {
+                    let form = document.getElementById("fAdminWrite");
+                    if (form.checkValidity()) {
+                        form.submit();
+                    } else {
+                        form.reportValidity();
+                    }
+                }
+                return false;
+            });
+
+            function recalculateIndices() {
+                $positions.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}_`));
+                            }
+                        });
+
+                    $(tr)
+                        .find("label")
+                        .each(function() {
+                            let labelFor = $(this).attr("for");
+                            if (labelFor) {
+                                $(this).attr("for", labelFor.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+                });
+            }
+        });
+    </script>
+}

+ 105 - 0
Admin/Pages/Banner/Position.cshtml.cs

@@ -0,0 +1,105 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Banner;
+
+public class PositionModel(IMediator mediator) : PageModel
+{
+    public int Total { get; private set; }
+
+    public List<(
+        int Num,
+        int ID,
+        int Index,
+        string Code,
+        string Subject,
+        char IsActive,
+        int BannerItemRows,
+        string? UpdatedAt,
+        string CreatedAt
+    )> 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; }
+
+        public bool IsActive { get; set; }
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var result = await mediator.Send(new GetBannerPositions.Query(), ct);
+
+        Total = result.Total;
+        List = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.Index,
+            c.Code,
+            c.Subject,
+            c.IsActive ? 'Y' : 'N',
+            c.BannerItemRows,
+            c.UpdatedAt.GetDateAt() ?? "-",
+            c.CreatedAt.GetDateAt()
+        ))];
+
+        Data = [..result.List.Select(x => new InputModel
+        {
+            ID = x.ID,
+            Code = x.Code,
+            Subject = x.Subject,
+            IsActive = x.IsActive
+        })];
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception();
+            }
+
+            var cmd = new SaveBannerPositions.Command(
+                [..Input.Select(x => new SaveBannerPositions.Command.Row(
+                    x.ID,
+                    x.Code,
+                    x.Subject,
+                    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("/Banner/Position");
+    }
+}

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

@@ -0,0 +1,8 @@
+<ul class="nav nav-tabs">
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Banner/List/Index")" asp-page="/Banner/List/Index">배너 목록</a>
+    </li>
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Banner/Position")" asp-page="/Banner/Position">배너 위치</a>
+    </li>
+</ul>

+ 2 - 2
Admin/Pages/Document/Edit.cshtml

@@ -68,8 +68,8 @@
         }
         <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>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Document?@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>

+ 2 - 2
Admin/Pages/Document/Write.cshtml

@@ -48,8 +48,8 @@
         </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>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Document?@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>

+ 10 - 11
Admin/Pages/Faq/Category.cshtml

@@ -7,10 +7,9 @@
 <div class="container-fluid">
     <h3>@ViewData["Title"]</h3>
     <hr />
-    <partial name="_navTabs" />
 
-    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
     <partial name="_StatusMessage" />
+    <partial name="_navTabs" />
 
     <div class="row g-2 align-items-end mt-2">
         <div class="col">
@@ -31,15 +30,15 @@
                 FAQ 분류를 삭제하려면 해당 FAQ 를 먼저 삭제해주세요.
             </caption>
             <colgroup>
-                <col width="5%" />
-                <col width="*" />
-                <col width="*" />
-                <col width="*" />
-                <col width="*" />
-                <col width="*" />
-                <col width="*" />
-                <col width="*" />
-                <col width="*" />
+                <col style="width: 5%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
             </colgroup>
             <thead>
                 <tr class="text-center">

+ 8 - 10
Admin/Pages/Faq/Category.cshtml.cs

@@ -1,4 +1,5 @@
-using MediatR;
+using SharedKernel.Extensions;
+using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 using System.ComponentModel.DataAnnotations;
@@ -8,6 +9,7 @@ namespace Admin.Pages.Faq
     public class CategoryModel(IMediator mediator) : PageModel
     {
         public int Total { get; private set; } = 0;
+
         public List<(
             int Num,
             int ID,
@@ -18,13 +20,12 @@ namespace Admin.Pages.Faq
             char IsActive,
             int FaqItemRows,
             string? UpdatedAt,
-            string CreatedAt,
-            string EditURL,
-            string DeleteURL
+            string CreatedAt
         )> List { get; set; } = [];
 
         [BindProperty(Name = "request")]
         public List<InputModel> Input { get; private set; } = [];
+
         public List<InputModel> Data { get; private set; } = [];
 
         public sealed class InputModel
@@ -64,14 +65,11 @@ namespace Admin.Pages.Faq
                 c.Order,
                 c.IsActive ? 'Y' : 'N',
                 c.FaqItemRows,
-                c.UpdatedAt,
-                c.CreatedAt,
-                EditURL : $"/Faq/Category/Edit/{c.ID}",
-                DeleteURL : $"/Faq/Category/Delete/{c.ID}"
+                c.UpdatedAt.GetDateAt(),
+                c.CreatedAt.GetDateAt()
             ))];
 
-            Data = [..result.List
-               .Select(x => new InputModel{
+            Data = [..result.List.Select(x => new InputModel{
                    ID = x.ID,
                    Code = x.Code,
                    Subject = x.Subject,

+ 2 - 2
Admin/Pages/Faq/List/Edit.cshtml

@@ -83,8 +83,8 @@
         }
         <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>
+            <button type="submit" class="btn btn-success">ÀúÀå</button>
+            <a href="/Faq/List?@Model.QueryString" class="btn btn-secondary">Ãë¼Ò</a>
         </div>
         <br/>
     </form>

+ 4 - 6
Admin/Pages/Faq/List/Index.cshtml.cs

@@ -43,8 +43,7 @@ namespace Admin.Pages.Faq.List
             char IsActive,
             string? UpdatedAt,
             string CreatedAt,
-            string EditURL,
-            string DeleteURL
+            string EditURL
         )> List { get; set; } = [];
 
         public Pagination? Pagination { get; set; }
@@ -71,10 +70,9 @@ namespace Admin.Pages.Faq.List
                 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}"
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt(),
+                EditURL: $"/Faq/List/Edit/{c.ID}{Request.QueryString}"
             ))];
 
             Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);

+ 2 - 2
Admin/Pages/Faq/List/Write.cshtml

@@ -62,8 +62,8 @@
         </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>
+            <button type="submit" class="btn btn-success">ÀúÀå</button>
+            <a href="/Faq/List?@Model.QueryString" class="btn btn-secondary">Ãë¼Ò</a>
         </div>
         <br/>
     </form>

+ 2 - 2
Admin/Pages/Popup/Edit.cshtml

@@ -97,8 +97,8 @@
 
         <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>
+            <button type="submit" class="btn btn-success">ÀúÀå</button>
+            <a href="/Popup?@Model.QueryString" class="btn btn-secondary">Ãë¼Ò</a>
         </div>
         <br />
     </form>

+ 2 - 2
Admin/Pages/Popup/Write.cshtml

@@ -75,8 +75,8 @@
 
         <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>
+            <button type="submit" class="btn btn-success">ÀúÀå</button>
+            <a href="/Popup?@Model.QueryString" class="btn btn-secondary">Ãë¼Ò</a>
         </div>
         <br />
     </form>

+ 13 - 2
Admin/using.cs

@@ -27,7 +27,7 @@ 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 GetFaqCategories = Application.Features.Faq.Category.GetAll;
 global using SaveFaqCategories = Application.Features.Faq.Category.Save;
 
 // FAQ 목록
@@ -35,4 +35,15 @@ 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;
+global using DeleteFaqItem = Application.Features.Faq.Item.Delete;
+
+// 배너 위치
+global using GetBannerPositions = Application.Features.Banner.Position.GetAll;
+global using SaveBannerPositions = Application.Features.Banner.Position.Save;
+
+// 배너 목록
+global using SearchBannerItems = Application.Features.Banner.Item.Search;
+global using GetBannerItem = Application.Features.Banner.Item.Get;
+global using CreateBannerItem = Application.Features.Banner.Item.Create;
+global using UpdateBannerItem = Application.Features.Banner.Item.Update;
+global using DeleteBannerItem = Application.Features.Banner.Item.Delete;

+ 11 - 10
Admin/wwwroot/js/func.js

@@ -59,16 +59,17 @@ function setupImagePreview(fileInputID, previewImageID) {
     });
 
     // 미리보기 삭제
-    document.getElementById("btnRemovePrev")?.addEventListener("click", function () {
-        let prev = document.getElementById(previewImageID);
-        prev.hidden = true;
-
-        let image = prev.querySelector("img");
-        if (image) {
-            image.src = "";
-            document.getElementById(fileInputID).value = "";
-        }
-    });
+    const removeBtn = previewImage.querySelector(".btn-remove-preview");
+    if (removeBtn) {
+        removeBtn.addEventListener("click", function () {
+            const image = previewImage.querySelector("img");
+            if (image) {
+                image.src = "";
+            }
+            previewImage.hidden = true;
+            fileInput.value = "";
+        });
+    }
 }
 
 $.validator.setDefaults({ // Bootstrap Required.

BIN
Admin/wwwroot/uploads/banner/1/97d60f443bd04c70939e9de7ffeaaf1a.png


+ 17 - 0
Application/Features/Banner/Item/Create/Command.cs

@@ -0,0 +1,17 @@
+using MediatR;
+using Microsoft.AspNetCore.Http;
+
+namespace Application.Features.Banner.Item.Create
+{
+    public sealed record Command(
+        int PositionID,
+        string Subject,
+        IFormFile? DesktopImage,
+        IFormFile? MobileImage,
+        string? Link,
+        short Order,
+        bool IsActive,
+        DateTime? StartAt,
+        DateTime? EndAt
+    ) : IRequest;
+}

+ 52 - 0
Application/Features/Banner/Item/Create/Handler.cs

@@ -0,0 +1,52 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Page.Banner;
+using SharedKernel.Storage;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Banner.Item.Create;
+
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IRequestHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        if (!await db.BannerPosition.AnyAsync(x => x.ID == request.PositionID, ct))
+        {
+            throw new KeyNotFoundException("배너 위치를 찾을 수 없습니다.");
+        }
+
+        var bannerItem = BannerItem.Create(
+            request.PositionID,
+            request.Subject,
+            null,
+            null,
+            request.Link,
+            request.Order,
+            request.IsActive,
+            request.StartAt,
+            request.EndAt
+        );
+
+        await db.BannerItem.AddAsync(bannerItem);
+        await db.SaveChangesAsync(ct);
+
+        FileStoragePath uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Banner, bannerItem.ID);
+        string[] allowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
+
+        if (request.DesktopImage is not null)
+        {
+            bannerItem.SetDesktopImage(
+                (await fileStorage.SaveFileAsync(request.DesktopImage, uploadPath, allowedFileExtensions, ct))?.Url
+            );
+        }
+
+        if (request.MobileImage is not null)
+        {
+            bannerItem.SetMobileImage(
+                (await fileStorage.SaveFileAsync(request.MobileImage, uploadPath, allowedFileExtensions, ct))?.Url
+            );
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

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

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

+ 27 - 0
Application/Features/Banner/Item/Delete/Handler.cs

@@ -0,0 +1,27 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Storage;
+
+namespace Application.Features.Banner.Item.Delete;
+
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IRequestHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        if (request.IDs is null || request.IDs.Length == 0)
+        {
+            return;
+        }
+
+        var images = await db.BannerItem.Where(c => request.IDs.Contains(c.ID)).Select(c => new { c.DesktopImage, c.MobileImage }).ToListAsync(ct);
+
+        foreach (var img in images)
+        {
+            fileStorage.DeleteByUrl(img.DesktopImage);
+            fileStorage.DeleteByUrl(img.MobileImage);
+        }
+
+        await db.BannerItem.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
+    }
+}

+ 32 - 0
Application/Features/Banner/Item/Get/Handler.cs

@@ -0,0 +1,32 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Banner.Item.Get;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var item = await db.BannerItem.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        if (item is null)
+        {
+            throw new KeyNotFoundException("¹è³Ê¸¦ ãÀ» ¼ö ¾ø½À´Ï´Ù.");
+        }
+
+        return new Response(
+            item.ID,
+            item.PositionID,
+            item.Subject,
+            item.DesktopImage,
+            item.MobileImage,
+            item.Link,
+            item.Order,
+            item.IsActive,
+            item.StartAt,
+            item.EndAt,
+            item.UpdatedAt,
+            item.CreatedAt
+        );
+    }
+}

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

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

+ 17 - 0
Application/Features/Banner/Item/Get/Response.cs

@@ -0,0 +1,17 @@
+namespace Application.Features.Banner.Item.Get
+{
+    public sealed record Response(
+        int ID,
+        int PositionID,
+        string Subject,
+        string? DesktopImage,
+        string? MobileImage,
+        string? Link,
+        short Order,
+        bool IsActive,
+        DateTime? StartAt,
+        DateTime? EndAt,
+        DateTime? UpdatedAt,
+        DateTime CreatedAt
+    );
+}

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

@@ -0,0 +1,72 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Banner.Item.Search;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.BannerItem.AsNoTracking().Include(c => c.BannerPosition).AsQueryable();
+
+        if (request.PositionID.HasValue)
+        {
+            query = query.Where(c => c.PositionID == request.PositionID.Value);
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.Keyword))
+        {
+            var kw = request.Keyword.Trim();
+            query = query.Where(c => c.Subject.Contains(kw));
+        }
+
+        var total = await query.CountAsync(ct);
+
+        var list = await query
+            .OrderByDescending(c => c.ID)
+            .Skip((request.PageNum - 1) * request.PerPage)
+            .Take(request.PerPage)
+            .Select(c=> new
+            {
+                c.ID,
+                c.PositionID,
+                PositionCode = c.BannerPosition.Code,
+                PositionSubject = c.BannerPosition.Subject,
+                c.Subject,
+                c.DesktopImage,
+                c.MobileImage,
+                c.Link,
+                c.Order,
+                c.IsActive,
+                c.StartAt,
+                c.EndAt,
+                c.UpdatedAt,
+                c.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        var startNum = total - ((request.PageNum - 1) * request.PerPage);
+
+        return new Response(
+            total,
+            [..list.Select((c, i) => new Response.Row(
+                Num: startNum - i,
+                c.ID,
+                c.PositionID,
+                c.PositionCode,
+                c.PositionSubject,
+                c.Subject,
+                c.DesktopImage,
+                c.MobileImage,
+                c.Link,
+                c.Order,
+                c.IsActive,
+                c.StartAt,
+                c.EndAt,
+                c.UpdatedAt,
+                c.CreatedAt
+            ))]
+        );
+    }
+}

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

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

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

@@ -0,0 +1,23 @@
+namespace Application.Features.Banner.Item.Search
+{
+    public sealed record Response(int Total, List<Response.Row> List)
+    {
+        public sealed record Row(
+            int Num,
+            int ID,
+            int PositionID,
+            string PositionCode,
+            string PositionSubject,
+            string Subject,
+            string? DesktopImage,
+            string? MobileImage,
+            string? Link,
+            short Order,
+            bool IsActive,
+            DateTime? StartAt,
+            DateTime? EndAt,
+            DateTime? UpdatedAt,
+            DateTime CreatedAt
+        );
+    }
+}

+ 18 - 0
Application/Features/Banner/Item/Update/Command.cs

@@ -0,0 +1,18 @@
+using MediatR;
+using Microsoft.AspNetCore.Http;
+
+namespace Application.Features.Banner.Item.Update
+{
+    public sealed record Command(
+        int ID,
+        int PositionID,
+        string Subject,
+        IFormFile? DesktopImage,
+        IFormFile? MobileImage,
+        string? Link,
+        short Order,
+        bool IsActive,
+        DateTime? StartAt,
+        DateTime? EndAt
+    ) : IRequest;
+}

+ 61 - 0
Application/Features/Banner/Item/Update/Handler.cs

@@ -0,0 +1,61 @@
+using Application.Abstractions.Data;
+using SharedKernel.Storage;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Banner.Item.Update;
+
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IRequestHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var bannerItem = await db.BannerItem.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        if (bannerItem is null)
+        {
+            throw new KeyNotFoundException("배너를 찾을 수 없습니다.");
+        }
+
+        if (!await db.BannerPosition.AnyAsync(x => x.ID == request.PositionID, ct))
+        {
+            throw new KeyNotFoundException("배너 위치를 찾을 수 없습니다.");
+        }
+
+        FileStoragePath uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Banner, bannerItem.ID);
+        string[] allowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
+        string? desktopImgPath = bannerItem.DesktopImage;
+        string? mobileImgPath = bannerItem.MobileImage;
+
+        if (request.DesktopImage is not null)
+        {
+            if (!string.IsNullOrEmpty(bannerItem.DesktopImage))
+            {
+                fileStorage.DeleteByUrl(bannerItem.DesktopImage);
+            }
+
+            desktopImgPath = (await fileStorage.SaveFileAsync(request.DesktopImage, uploadPath, allowedFileExtensions, ct))?.Url;
+        }
+
+        if (request.MobileImage is not null)
+        {
+            if (!string.IsNullOrEmpty(bannerItem.MobileImage))
+            {
+                fileStorage.DeleteByUrl(bannerItem.MobileImage);
+            }
+
+            mobileImgPath = (await fileStorage.SaveFileAsync(request.MobileImage, uploadPath, allowedFileExtensions, ct))?.Url;
+        }
+
+        bannerItem.Update(
+            request.Subject,
+            desktopImgPath,
+            mobileImgPath,
+            request.Link,
+            request.Order,
+            request.IsActive,
+            request.StartAt,
+            request.EndAt
+        );
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 41 - 0
Application/Features/Banner/Position/GetAll/Handler.cs

@@ -0,0 +1,41 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Banner.Position.GetAll;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var rows = await db.BannerPosition
+            .AsNoTracking()
+            .OrderByDescending(c => c.ID)
+            .Select(c => new
+            {
+                c.ID,
+                c.Code,
+                c.Subject,
+                BannerItemRows = c.BannerItems.Count,
+                c.IsActive,
+                c.UpdatedAt,
+                c.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        return new Response(
+            rows.Count,
+            [..rows.Select((c, i) => new Response.Row(
+                i + 1,
+                c.ID,
+                i,
+                c.Code,
+                c.Subject,
+                c.BannerItemRows,
+                c.IsActive,
+                c.UpdatedAt,
+                c.CreatedAt
+            ))]
+        );
+    }
+}

+ 6 - 0
Application/Features/Banner/Position/GetAll/Query.cs

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

+ 17 - 0
Application/Features/Banner/Position/GetAll/Response.cs

@@ -0,0 +1,17 @@
+namespace Application.Features.Banner.Position.GetAll
+{
+    public sealed record Response(int Total, List<Response.Row> List)
+    {
+        public sealed record Row(
+            int Num,
+            int ID,
+            int Index,
+            string Code,
+            string Subject,
+            int BannerItemRows,
+            bool IsActive,
+            DateTime? UpdatedAt,
+            DateTime CreatedAt
+        );
+    }
+}

+ 9 - 0
Application/Features/Banner/Position/Save/Command.cs

@@ -0,0 +1,9 @@
+using MediatR;
+
+namespace Application.Features.Banner.Position.Save
+{
+    public sealed record Command(List<Command.Row> Items) : IRequest<Response>
+    {
+        public sealed record Row(int? ID, string Code, string Subject, bool IsActive);
+    }
+}

+ 70 - 0
Application/Features/Banner/Position/Save/Handler.cs

@@ -0,0 +1,70 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Page.Banner;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Banner.Position.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.BannerPosition.Include(x => x.BannerItems).ToListAsync(ct);
+
+        var inserted = 0;
+        var updated = 0;
+        var deleted = 0;
+
+        var incomingById = items.Where(x => x.ID.HasValue).ToDictionary(x => x.ID!.Value, x => x);
+
+        foreach (var existing in dbRows)
+        {
+            if (!incomingById.TryGetValue(existing.ID, out var row))
+            {
+                if (existing.BannerItems.Count > 0)
+                {
+                    continue;
+                }
+
+                db.BannerPosition.Remove(existing);
+                deleted++;
+                continue;
+            }
+
+            if (existing.Code != row.Code || existing.Subject != row.Subject || existing.IsActive != row.IsActive)
+            {
+                existing.Update(row.Subject, row.IsActive);
+
+                if (existing.Code != row.Code)
+                {
+                    var prop = typeof(BannerPosition).GetProperty("Code");
+                    prop?.SetValue(existing, row.Code);
+                }
+
+                updated++;
+            }
+        }
+
+        var existingIds = dbRows.Select(x => x.ID).ToHashSet();
+        foreach (var row in items.Where(x => !x.ID.HasValue))
+        {
+            if (dbRows.Any(x => x.Code == row.Code))
+            {
+                continue;
+            }
+
+            var entity = BannerPosition.Create(row.Code, row.Subject, row.IsActive);
+            db.BannerPosition.Add(entity);
+            inserted++;
+        }
+
+        if (inserted + updated + deleted > 0)
+        {
+            await db.SaveChangesAsync(ct);
+        }
+
+        return new Response(inserted, updated, deleted);
+    }
+}

+ 3 - 0
Application/Features/Banner/Position/Save/Response.cs

@@ -0,0 +1,3 @@
+namespace Application.Features.Banner.Position.Save;
+
+public sealed record Response(int Inserted, int Updated, int Deleted);

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

@@ -1,13 +1,26 @@
 using Application.Abstractions.Data;
+using SharedKernel.Storage;
 using MediatR;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Document.Delete
 {
-    public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+    public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command>
     {
         public async Task Handle(Command request, CancellationToken ct)
         {
+            if (request.IDs is null || request.IDs.Length == 0)
+            {
+                return;
+            }
+
+            var contents = await db.Document.Where(c => request.IDs.Contains(c.ID)).Select(c => c.Content).ToListAsync(ct);
+
+            foreach (var content in contents)
+            {
+                await editorImage.CleanupByContentAsync(content, ct);
+            }
+
             await db.Document.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
         }
     }

+ 15 - 18
Application/Features/Faq/Category/Get/Handler.cs → Application/Features/Faq/Category/GetAll/Handler.cs

@@ -1,9 +1,8 @@
 using Application.Abstractions.Data;
-using SharedKernel.Extensions;
 using MediatR;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Faq.Category.Get
+namespace Application.Features.Faq.Category.GetAll
 {
     public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
     {
@@ -27,22 +26,20 @@ namespace Application.Features.Faq.Category.Get
                 })
                 .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);
+            return new Response(
+                items.Count,
+                [..items.Select((c, i) => new Response.Row(
+                    i + i,
+                    c.ID,
+                    i,
+                    c.Code,
+                    c.Subject,
+                    c.Order,
+                    c.IsActive,
+                    (ushort)c.FaqItemCount,
+                    c.UpdatedAt,
+                    c.CreatedAt
+            ))]);
         }
     }
 }

+ 1 - 1
Application/Features/Faq/Category/Get/Query.cs → Application/Features/Faq/Category/GetAll/Query.cs

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

+ 3 - 3
Application/Features/Faq/Category/Get/Response.cs → Application/Features/Faq/Category/GetAll/Response.cs

@@ -1,4 +1,4 @@
-namespace Application.Features.Faq.Category.Get
+namespace Application.Features.Faq.Category.GetAll
 {
     public sealed record Response(int Total, IReadOnlyList<Response.Row> List)
     {
@@ -11,8 +11,8 @@
             short Order,
             bool IsActive,
             ushort FaqItemRows,
-            string? UpdatedAt,
-            string CreatedAt
+            DateTime? UpdatedAt,
+            DateTime CreatedAt
         );
     }
 }

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

@@ -1,13 +1,26 @@
 using Application.Abstractions.Data;
 using MediatR;
 using Microsoft.EntityFrameworkCore;
+using SharedKernel.Storage;
 
 namespace Application.Features.Faq.Item.Delete
 {
-    public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+    public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command>
     {
         public async Task Handle(Command request, CancellationToken ct)
         {
+            if (request.IDs is null || request.IDs.Length == 0)
+            {
+                return;
+            }
+
+            var answers = await db.FaqItem.Where(c => request.IDs.Contains(c.ID)).Select(c => c.Answer).ToListAsync(ct);
+
+            foreach (var answer in answers)
+            {
+                await editorImage.CleanupByContentAsync(answer, ct);
+            }
+
             await db.FaqItem.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
         }
     }

+ 3 - 7
Application/Features/Faq/Item/Search/Handler.cs

@@ -1,13 +1,10 @@
 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 sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
     {
         public async Task<Response> Handle(Query request, CancellationToken ct)
         {
@@ -46,7 +43,6 @@ namespace Application.Features.Faq.Item.Search
                     c.UpdatedAt,
                     c.CreatedAt
                 })
-                .OrderByDescending(c => c.ID)
                 .ToListAsync(ct);
 
             return new Response
@@ -63,8 +59,8 @@ namespace Application.Features.Faq.Item.Search
                     Answer = c.Answer,
                     Order = c.Order,
                     IsActive = c.IsActive,
-                    UpdatedAt = c.UpdatedAt.GetDateAt(),
-                    CreatedAt = c.CreatedAt.GetDateAt()
+                    UpdatedAt = c.UpdatedAt,
+                    CreatedAt = c.CreatedAt
                 })]
             };
         }

+ 2 - 2
Application/Features/Faq/Item/Search/Response.cs

@@ -16,8 +16,8 @@
             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; }
+            public DateTime? UpdatedAt { get; set; }
+            public required DateTime CreatedAt { get; set; }
         }
     }
 }

+ 10 - 1
Application/Features/Popup/Delete/Handler.cs

@@ -1,10 +1,11 @@
 using Application.Abstractions.Data;
+using SharedKernel.Storage;
 using MediatR;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Popup.Delete;
 
-public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+public sealed class Handler(IAppDbContext db, IEditorImageService editorImage) : IRequestHandler<Command>
 {
     public async Task Handle(Command request, CancellationToken ct)
     {
@@ -19,7 +20,15 @@ public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
             return;
         }
 
+        var contents = await db.Popup.Where(c => request.IDs.Contains(c.ID)).Select(c => c.Content).ToListAsync(ct);
+
+        foreach (var content in contents)
+        {
+            await editorImage.CleanupByContentAsync(content, ct);
+        }
+
         db.Popup.RemoveRange(list);
+
         await db.SaveChangesAsync(ct);
     }
 }

+ 12 - 0
Domain/Entities/Page/Banner/Item.cs

@@ -119,5 +119,17 @@ namespace Domain.Entities.Page.Banner
             EndAt = endAt;
             UpdatedAt = DateTime.UtcNow;
         }
+
+        public void SetDesktopImage(string? desktopImage)
+        {
+            DesktopImage = desktopImage;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
+        public void SetMobileImage(string? mobileImage)
+        {
+            MobileImage = mobileImage;
+            UpdatedAt = DateTime.UtcNow;
+        }
     }
 }

+ 2 - 0
Infrastructure/Storage/LocalFileStorage.cs

@@ -32,6 +32,8 @@ namespace Infrastructure.Storage
             var rel = path.ToRelativePath();
             var dir = CombineUnderWebRoot(rel);
 
+            Directory.CreateDirectory(dir); // 경로 생성(없으면)
+
             var fileName = $"{Guid.NewGuid():N}{ext}";
             var fullPath = Path.Combine(dir, fileName);
 

+ 55 - 0
SharedKernel/Attributes/AllowedExtensionsAttribute.cs

@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Http;
+using System.Collections;
+using System.ComponentModel.DataAnnotations;
+
+namespace SharedKernel.Attributes
+{
+    /*
+     * 파일 확장자 제한
+     */
+    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
+    public class AllowedExtensionsAttribute : ValidationAttribute
+    {
+        private readonly string[] _extensions;
+
+        public AllowedExtensionsAttribute(string extensions)
+        {
+            _extensions = extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(x => x.Trim().TrimStart('.').ToLowerInvariant()).Where(x => x.Length > 0).ToArray();
+        }
+
+        public override bool IsValid(object? value)
+        {
+            if (value is IFormFile file)
+            {
+                return _checkExtension(file.FileName);
+            }
+
+            if (value is IEnumerable files)
+            {
+                foreach (var obj in files)
+                {
+                    if (obj is IFormFile f && !_checkExtension(f.FileName))
+                    {
+                        return false;
+                    }
+                }
+            }
+
+            return true;
+        }
+
+        public override string FormatErrorMessage(string name)
+        {
+            var template = string.IsNullOrWhiteSpace(ErrorMessage) ? "{0}에 허용되지 않은 파일 형식이 있습니다.\n\n허용 확장자: [{1}]" : ErrorMessage;
+
+            return string.Format(template, name, string.Join(", ", _extensions));
+        }
+
+        private bool _checkExtension(string fileName)
+        {
+            var ext = Path.GetExtension(fileName)?.TrimStart('.').ToLowerInvariant();
+
+            return !string.IsNullOrEmpty(ext) && _extensions.Contains(ext);
+        }
+    }
+}

+ 35 - 0
SharedKernel/Attributes/MaxFileCountAttribute.cs

@@ -0,0 +1,35 @@
+using System.Collections;
+using System.ComponentModel.DataAnnotations;
+
+namespace SharedKernel.Attributes
+{
+    /*
+     * 파일 개수 제한
+     */
+    [AttributeUsage(AttributeTargets.Property)]
+    public class MaxFileCountAttribute : ValidationAttribute
+    {
+        private readonly int _maxCount;
+        public MaxFileCountAttribute(int maxCount)
+        {
+            _maxCount = maxCount;
+        }
+
+        public override bool IsValid(object? value)
+        {
+            if (value is IEnumerable files)
+            {
+                return files.Cast<object>().Count() <= _maxCount;
+            }
+
+            return true;
+        }
+
+        public override string FormatErrorMessage(string name)
+        {
+            var template = string.IsNullOrWhiteSpace(ErrorMessage) ? "{0}은(는) 최대 {1}개까지 등록 가능합니다." : ErrorMessage;
+
+            return string.Format(template, name, _maxCount);
+        }
+    }
+}

+ 43 - 0
SharedKernel/Attributes/MaxFileSizeAttribute.cs

@@ -0,0 +1,43 @@
+using Microsoft.AspNetCore.Http;
+using System.Collections;
+using System.ComponentModel.DataAnnotations;
+
+namespace SharedKernel.Attributes
+{
+    /*
+     * 파일 크기 제한
+     */
+    [AttributeUsage(AttributeTargets.Property)]
+    public class MaxFileSizeAttribute : ValidationAttribute
+    {
+        private readonly long _maxKBytes;
+
+        public MaxFileSizeAttribute(long maxKBytes)
+        {
+            _maxKBytes = maxKBytes;
+        }
+
+        public override bool IsValid(object? value)
+        {
+            if (value is IEnumerable files)
+            {
+                foreach (var obj in files)
+                {
+                    if (obj is IFormFile file && file.Length > _maxKBytes)
+                    {
+                        return false;
+                    }
+                }
+            }
+
+            return true;
+        }
+
+        public override string FormatErrorMessage(string name)
+        {
+            var template = string.IsNullOrWhiteSpace(ErrorMessage) ? "{0}의 크기는 최대 {1:N0}KB까지 허용됩니다." : ErrorMessage;
+
+            return string.Format(template, name, _maxKBytes);
+        }
+    }
+}

+ 17 - 0
SharedKernel/Attributes/MustBeTrueAttribute.cs

@@ -0,0 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace SharedKernel.Attributes
+{
+    public class MustBeTrueAttribute : ValidationAttribute
+    {
+        protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+        {
+            if (value is bool boolValue && boolValue)
+            {
+                return ValidationResult.Success;
+            }
+
+            return new ValidationResult(ErrorMessage ?? "값은 `true` 여야 합니다.");
+        }
+    }
+}