Codes.cshtml 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. @page "{id:int}"
  2. @model Admin.Pages.Store.Coupon.CodesModel
  3. @using Domain.Entities.Store.ValueObject
  4. @{
  5. ViewData["Title"] = "쿠폰 코드 풀";
  6. }
  7. <div class="container-fluid">
  8. <h3>@ViewData["Title"]</h3>
  9. <hr />
  10. <partial name="_StatusMessage" />
  11. <div class="card mb-3">
  12. <div class="card-body">
  13. <dl class="row mb-0">
  14. <dt class="col-sm-2">쿠폰명</dt>
  15. <dd class="col-sm-10"><strong>@Model.CouponName</strong></dd>
  16. <dt class="col-sm-2">게임명</dt>
  17. <dd class="col-sm-10">@Model.GameName</dd>
  18. <dt class="col-sm-2">상품명</dt>
  19. <dd class="col-sm-10 mb-0">@Model.ProductName</dd>
  20. </dl>
  21. </div>
  22. </div>
  23. <div class="row mb-3 g-2">
  24. <div class="col">
  25. <div class="card text-center">
  26. <div class="card-body">
  27. <h6 class="text-muted mb-1">사용 가능</h6>
  28. <h3 class="@(Model.AvailableCount < 10 ? "text-danger" : "text-success")">@Model.AvailableCount</h3>
  29. </div>
  30. </div>
  31. </div>
  32. <div class="col">
  33. <div class="card text-center">
  34. <div class="card-body">
  35. <h6 class="text-muted mb-1">결제 진행 중</h6>
  36. <h3 class="text-warning">@Model.ReservedCount</h3>
  37. </div>
  38. </div>
  39. </div>
  40. <div class="col">
  41. <div class="card text-center">
  42. <div class="card-body">
  43. <h6 class="text-muted mb-1">발급됨</h6>
  44. <h3 class="text-primary">@Model.IssuedCount</h3>
  45. </div>
  46. </div>
  47. </div>
  48. <div class="col">
  49. <div class="card text-center">
  50. <div class="card-body">
  51. <h6 class="text-muted mb-1">사용됨</h6>
  52. <h3 class="text-info">@Model.UsedCount</h3>
  53. </div>
  54. </div>
  55. </div>
  56. <div class="col">
  57. <div class="card text-center">
  58. <div class="card-body">
  59. <h6 class="text-muted mb-1">만료</h6>
  60. <h3 class="text-danger">@Model.ExpiredCount</h3>
  61. </div>
  62. </div>
  63. </div>
  64. </div>
  65. <div class="card mb-3">
  66. <div class="card-header d-flex justify-content-between align-items-center">
  67. <strong>CSV Import / Export</strong>
  68. <div class="btn-group btn-group-sm">
  69. <a class="btn btn-outline-secondary" asp-page-handler="Template">
  70. <i class="bi bi-file-earmark-text"></i> 양식 다운로드
  71. </a>
  72. <a class="btn btn-outline-success" asp-page-handler="Export" asp-route-id="@Model.ID">
  73. <i class="bi bi-download"></i> 전체 다운로드
  74. </a>
  75. </div>
  76. </div>
  77. <div class="card-body">
  78. <form method="post" enctype="multipart/form-data" asp-page-handler="Upload" accept-charset="utf-8">
  79. @Html.AntiForgeryToken()
  80. <div class="row g-2 align-items-center">
  81. <div class="col">
  82. <input asp-for="Upload.CsvFile" type="file" class="form-control" accept=".csv,.txt,text/csv,text/plain,application/csv,application/vnd.ms-excel" />
  83. </div>
  84. <div class="col-auto">
  85. <button type="submit" class="btn btn-primary">Upload</button>
  86. </div>
  87. </div>
  88. <span asp-validation-for="Upload.CsvFile" class="text-danger"></span>
  89. </form>
  90. <p class="small text-muted mt-3 mb-1">
  91. CSV 양식 (UTF-8, 헤더 1행 + 데이터 N행, 최대 10MB):
  92. </p>
  93. <p class="small text-muted mb-1">
  94. <code>ID, Code, Status, IssuedToMemberID, IssuedToMemberName, IssuedToMemberEmail, IssuedAt, UsedAt, CreatedAt</code>
  95. </p>
  96. <ul class="small text-muted mb-0 pb-0">
  97. <li><strong>Status</strong>: <code>0</code> = 미사용(신규 입고) / <code>1</code> = 사용됨</li>
  98. <li>Status=1 인 경우 <code>IssuedToMemberID</code>, <code>IssuedAt</code>, <code>UsedAt</code> 필수. 회원 ID 는 DB에 실존해야 함 (없으면 행 skip)</li>
  99. <li><code>ID</code>, <code>IssuedToMemberName</code>/<code>Email</code>, <code>CreatedAt</code> 는 참고용 — Import 시 무시</li>
  100. <li>일시 형식: <code>yyyy-MM-dd HH:mm:ss</code> (KST)</li>
  101. <li>이미 등록된 코드는 자동 skip</li>
  102. </ul>
  103. </div>
  104. </div>
  105. <div class="row g-2 mb-2">
  106. <div class="col-auto">
  107. <select name="Query.Status" id="statusFilter" class="form-select w-auto" form="fAdminSearch">
  108. <option value="" selected="@(!Model.Query.Status.HasValue)">전체 상태 (@(Model.AvailableCount + Model.ReservedCount + Model.IssuedCount + Model.UsedCount + Model.ExpiredCount))</option>
  109. <option value="@((int)CouponCodeStatus.Available)" selected="@(Model.Query.Status == CouponCodeStatus.Available)">사용 가능 (@Model.AvailableCount)</option>
  110. <option value="@((int)CouponCodeStatus.Reserved)" selected="@(Model.Query.Status == CouponCodeStatus.Reserved)">결제 진행 중 (@Model.ReservedCount)</option>
  111. <option value="@((int)CouponCodeStatus.Issued)" selected="@(Model.Query.Status == CouponCodeStatus.Issued)">발급됨 (@Model.IssuedCount)</option>
  112. <option value="@((int)CouponCodeStatus.Used)" selected="@(Model.Query.Status == CouponCodeStatus.Used)">사용됨 (@Model.UsedCount)</option>
  113. <option value="@((int)CouponCodeStatus.Expired)" selected="@(Model.Query.Status == CouponCodeStatus.Expired)">만료 (@Model.ExpiredCount)</option>
  114. </select>
  115. </div>
  116. <div class="col-auto">
  117. <select name="Query.PerPage" id="perPage" class="form-select w-auto" form="fAdminSearch">
  118. <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
  119. <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
  120. <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
  121. <option value="200" selected="@(Model.Query.PerPage == 200)">200</option>
  122. </select>
  123. </div>
  124. <div class="col align-self-center">
  125. <span class="text-muted">결과: <strong>@Model.Total</strong>개</span>
  126. </div>
  127. <div class="col-auto">
  128. <button type="button" id="btnRestoreCodes" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#restoreCodesModal" disabled>선택 복구</button>
  129. <button type="button" id="btnExpireCodes" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#expireCodesModal" disabled>선택 만료</button>
  130. <button type="button" id="btnDeleteCodes" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteCodesModal" disabled>선택 삭제</button>
  131. <a class="btn btn-secondary" asp-page="/Store/Coupon/Index">목록</a>
  132. </div>
  133. </div>
  134. <form id="fDeleteCodes" method="post" asp-page-handler="DeleteCodes" asp-route-id="@Model.ID" accept-charset="utf-8">
  135. @Html.AntiForgeryToken()
  136. </form>
  137. <form id="fExpireCodes" method="post" asp-page-handler="ExpireCodes" asp-route-id="@Model.ID" accept-charset="utf-8">
  138. @Html.AntiForgeryToken()
  139. </form>
  140. <form id="fRestoreCodes" method="post" asp-page-handler="RestoreCodes" asp-route-id="@Model.ID" accept-charset="utf-8">
  141. @Html.AntiForgeryToken()
  142. </form>
  143. <div class="table-responsive">
  144. <table class="table table-striped table-bordered table-hover">
  145. <thead>
  146. <tr>
  147. <th style="width:3%">
  148. <input type="checkbox" id="checkedAll" class="form-check-input" value="1" title="현재 페이지의 선택 가능한 코드 전체 선택 (사용 가능 / 발급됨 / 만료)" />
  149. </th>
  150. <th style="width:4%">번호</th>
  151. <th>코드</th>
  152. <th style="width:8%">상태</th>
  153. <th style="width:7%">회원ID</th>
  154. <th style="width:10%">회원 별명</th>
  155. <th>이메일</th>
  156. <th style="width:12%">발급일</th>
  157. <th style="width:12%">사용일</th>
  158. <th style="width:10%">등록일</th>
  159. </tr>
  160. </thead>
  161. <tbody>
  162. @if (Model.List == null || Model.List.Count <= 0)
  163. {
  164. <tr><td colspan="10">등록된 코드가 없습니다. 위에서 CSV Import 해주세요.</td></tr>
  165. }
  166. else
  167. {
  168. @foreach (var row in Model.List)
  169. {
  170. var rowClass = row.StatusText switch
  171. {
  172. "사용 가능" => "table-success",
  173. "결제 진행 중" => "table-warning",
  174. "발급됨" => "table-info",
  175. "사용됨" => "table-secondary",
  176. "만료" => "table-danger",
  177. _ => ""
  178. };
  179. // 체크박스 활성 조건: 액션(삭제/만료/복귀) 대상이 될 수 있는 상태만. Reserved/Used 는 제외.
  180. var selectable = row.StatusText is "사용 가능" or "발급됨" or "만료";
  181. var statusKey = row.StatusText switch
  182. {
  183. "사용 가능" => "available",
  184. "발급됨" => "issued",
  185. "만료" => "expired",
  186. _ => ""
  187. };
  188. <tr class="@rowClass">
  189. <td class="justify-self-center">
  190. @if (selectable)
  191. {
  192. <input type="checkbox" name="codeIDs" class="form-check-input list-code-check" value="@row.ID" data-status="@statusKey" />
  193. }
  194. else
  195. {
  196. <input type="checkbox" class="form-check-input" disabled title="결제 진행 중·사용됨 상태는 선택할 수 없습니다" />
  197. }
  198. </td>
  199. <td>@row.Num</td>
  200. <td><code>@row.Code</code></td>
  201. <td>@row.StatusText</td>
  202. <td>@(row.IssuedToMemberID?.ToString() ?? "-")</td>
  203. <td>@(row.IssuedToMemberName ?? "-")</td>
  204. <td>@(row.IssuedToMemberEmail ?? "-")</td>
  205. <td>@(row.IssuedAt ?? "-")</td>
  206. <td>@(row.UsedAt ?? "-")</td>
  207. <td>@row.CreatedAt</td>
  208. </tr>
  209. }
  210. }
  211. </tbody>
  212. </table>
  213. <partial name="_Pagination" model="@Model.Pagination" />
  214. </div>
  215. </div>
  216. <form id="fAdminSearch" method="get" accept-charset="utf-8">
  217. <input type="hidden" name="Query.PageNum" value="@Model.Query.PageNum" />
  218. </form>
  219. <div class="modal fade" id="deleteCodesModal" tabindex="-1">
  220. <div class="modal-dialog">
  221. <div class="modal-content">
  222. <div class="modal-header">
  223. <h5 class="modal-title">쿠폰 코드 삭제</h5>
  224. <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
  225. </div>
  226. <div class="modal-body">
  227. <p>선택한 <strong id="deleteCodesCount">0</strong>개 코드를 삭제합니다.</p>
  228. <p class="text-muted small mb-0">사용 가능(Available) 상태의 코드만 삭제됩니다. 결제 진행 중·발급됨·사용됨·만료 상태는 정합성 보호를 위해 삭제할 수 없습니다.</p>
  229. </div>
  230. <div class="modal-footer">
  231. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
  232. <button type="button" class="btn btn-danger" id="btnDeleteCodesConfirm">삭제</button>
  233. </div>
  234. </div>
  235. </div>
  236. </div>
  237. <div class="modal fade" id="expireCodesModal" tabindex="-1">
  238. <div class="modal-dialog">
  239. <div class="modal-content">
  240. <div class="modal-header">
  241. <h5 class="modal-title">쿠폰 코드 만료 처리</h5>
  242. <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
  243. </div>
  244. <div class="modal-body">
  245. <p>선택한 <strong id="expireCodesCount">0</strong>개 코드를 <strong>만료</strong> 상태로 변경합니다.</p>
  246. <p class="text-muted small mb-0">
  247. 사용 가능(Available) / 발급됨(Issued) 상태만 만료 처리됩니다. 발급됨 코드를 만료하면 해당 회원의 보관함에서 즉시 만료 표시되며 사용할 수 없게 됩니다.
  248. 필요 시 [선택 복귀] 로 되돌릴 수 있습니다.
  249. </p>
  250. </div>
  251. <div class="modal-footer">
  252. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
  253. <button type="button" class="btn btn-warning" id="btnExpireCodesConfirm">만료 처리</button>
  254. </div>
  255. </div>
  256. </div>
  257. </div>
  258. <div class="modal fade" id="restoreCodesModal" tabindex="-1">
  259. <div class="modal-dialog">
  260. <div class="modal-content">
  261. <div class="modal-header">
  262. <h5 class="modal-title">쿠폰 코드 복귀</h5>
  263. <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
  264. </div>
  265. <div class="modal-body">
  266. <p>선택한 <strong id="restoreCodesCount">0</strong>개 만료 코드를 복귀합니다.</p>
  267. <p class="text-muted small mb-0">
  268. 만료(Expired) 상태 코드만 복귀할 수 있습니다. 회원에게 발급되었던 코드는 발급됨(Issued) 으로, 그 외는 사용 가능(Available) 으로 복귀하며 해당 회원의 보관함에서 다시 사용 가능해집니다.
  269. </p>
  270. </div>
  271. <div class="modal-footer">
  272. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
  273. <button type="button" class="btn btn-success" id="btnRestoreCodesConfirm">복귀</button>
  274. </div>
  275. </div>
  276. </div>
  277. </div>
  278. @section Scripts {
  279. <script>
  280. let f = document.getElementById("fAdminSearch");
  281. $(document).on("change", "#statusFilter, #perPage", function() { f.submit(); });
  282. // 체크박스 / 액션 버튼 동기화
  283. const $checks = () => document.querySelectorAll(".list-code-check");
  284. const btnDelete = document.getElementById("btnDeleteCodes");
  285. const btnExpire = document.getElementById("btnExpireCodes");
  286. const btnRestore = document.getElementById("btnRestoreCodes");
  287. const checkedAll = document.getElementById("checkedAll");
  288. function updateActionButtons() {
  289. const checked = document.querySelectorAll(".list-code-check:checked");
  290. const count = checked.length;
  291. const statuses = Array.from(checked).map(c => c.getAttribute("data-status"));
  292. const onlyAvailable = count > 0 && statuses.every(s => s === "available");
  293. const expirable = count > 0 && statuses.every(s => s === "available" || s === "issued");
  294. const onlyExpired = count > 0 && statuses.every(s => s === "expired");
  295. btnDelete.disabled = !onlyAvailable;
  296. btnExpire.disabled = !expirable;
  297. btnRestore.disabled = !onlyExpired;
  298. const setCount = (id) => {
  299. const el = document.getElementById(id);
  300. if (el) el.textContent = count;
  301. };
  302. setCount("deleteCodesCount");
  303. setCount("expireCodesCount");
  304. setCount("restoreCodesCount");
  305. }
  306. $(document).on("change", ".list-code-check", updateActionButtons);
  307. $(document).on("change", "#checkedAll", function() {
  308. $checks().forEach(c => c.checked = checkedAll.checked);
  309. updateActionButtons();
  310. });
  311. function submitForm(formID) {
  312. const form = document.getElementById(formID);
  313. // 기존 동적 hidden input 정리
  314. form.querySelectorAll('input[name="codeIDs"]').forEach(el => el.remove());
  315. // 체크된 코드 ID 들을 hidden input 으로 폼에 주입
  316. document.querySelectorAll(".list-code-check:checked").forEach(c => {
  317. const h = document.createElement("input");
  318. h.type = "hidden";
  319. h.name = "codeIDs";
  320. h.value = c.value;
  321. form.appendChild(h);
  322. });
  323. form.submit();
  324. }
  325. $(document).on("click", "#btnDeleteCodesConfirm", function() { submitForm("fDeleteCodes"); });
  326. $(document).on("click", "#btnExpireCodesConfirm", function() { submitForm("fExpireCodes"); });
  327. $(document).on("click", "#btnRestoreCodesConfirm", function() { submitForm("fRestoreCodes"); });
  328. </script>
  329. }