LimitConfig.cshtml 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. @page "{id:int}"
  2. @model Admin.Pages.Store.Product.LimitConfigModel
  3. @using Domain.Entities.Store.ValueObject
  4. @{
  5. ViewData["Title"] = "상품 판매 정책";
  6. }
  7. <div class="container">
  8. <h3>@ViewData["Title"]</h3>
  9. <hr />
  10. <partial name="_StatusMessage" />
  11. <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
  12. <input type="hidden" asp-for="Input.ProductID" />
  13. <div class="row mb-3">
  14. <label class="col-sm-2 col-form-label">상품</label>
  15. <div class="col-sm-10 d-flex align-items-center gap-2">
  16. @if (!string.IsNullOrEmpty(Model.ProductThumbnail))
  17. {
  18. <img src="@Model.ProductThumbnail" alt="@Model.ProductName" style="width:128px;height:128px;object-fit:cover;border:1px solid #eee;border-radius:4px;" /><br/>
  19. }
  20. <p>[@Model.ProductID] @Model.ProductName</p>
  21. </div>
  22. </div>
  23. <hr />
  24. <div class="row mb-3">
  25. <label asp-for="Input.UseQuantityLimit" class="col-sm-2 col-form-label">수량 제한</label>
  26. <div class="col-sm-10">
  27. <div class="form-check">
  28. <input class="form-check-input" type="checkbox" asp-for="Input.UseQuantityLimit" id="useQuantityLimit" />
  29. <label class="form-check-label" for="useQuantityLimit">사용합니다.</label>
  30. </div>
  31. <div class="form-text text-muted">
  32. 회원당 누적 구매 수량을 제한합니다.
  33. </div>
  34. </div>
  35. </div>
  36. <div class="row mb-3">
  37. <label asp-for="Input.MaxQuantity" class="col-sm-2 col-form-label">최대 구매 수량</label>
  38. <div class="col-sm-10">
  39. <div class="input-group w-auto d-inline-flex">
  40. <input type="number" min="0" max="9999" step="1" maxlength="4" asp-for="Input.MaxQuantity" class="form-control w-auto" id="maxQuantity" />
  41. <label for="maxQuantity" class="input-group-text">개</label>
  42. </div>
  43. <div class="form-text text-muted">
  44. 예) 0 = 제한 없음, 1 = 1개만, 9999 = 최대 9999개까지
  45. </div>
  46. <span asp-validation-for="Input.MaxQuantity" class="text-danger"></span>
  47. </div>
  48. </div>
  49. <hr />
  50. <div class="row mb-3">
  51. <label asp-for="Input.UsePeriodLimit" class="col-sm-2 col-form-label">기간 제한</label>
  52. <div class="col-sm-10">
  53. <div class="form-check">
  54. <input class="form-check-input" type="checkbox" asp-for="Input.UsePeriodLimit" id="usePeriodLimit" />
  55. <label class="form-check-label" for="usePeriodLimit">사용합니다.</label>
  56. </div>
  57. <div class="form-text text-muted">
  58. 위 누적 수량을 일정 기간 윈도우로 제한합니다. 미사용 시 전체 기간 누적.
  59. </div>
  60. </div>
  61. </div>
  62. <div class="row mb-3">
  63. <label class="col-sm-2">기간 유형</label>
  64. <div class="col-sm-10">
  65. @{
  66. var pt = Model.Input.PeriodType;
  67. }
  68. <div class="form-check form-check-inline">
  69. <input class="form-check-input" type="radio" name="Input.PeriodType" id="periodType_0" value="@((int)LimitPeriodType.None)" @(pt == LimitPeriodType.None ? "checked" : null) />
  70. <label class="form-check-label" for="periodType_0">사용 안함</label>
  71. </div>
  72. <div class="form-check form-check-inline">
  73. <input class="form-check-input" type="radio" name="Input.PeriodType" id="periodType_1" value="@((int)LimitPeriodType.Day)" @(pt == LimitPeriodType.Day ? "checked" : null) />
  74. <label class="form-check-label" for="periodType_1">1일</label>
  75. </div>
  76. <div class="form-check form-check-inline">
  77. <input class="form-check-input" type="radio" name="Input.PeriodType" id="periodType_2" value="@((int)LimitPeriodType.Week)" @(pt == LimitPeriodType.Week ? "checked" : null) />
  78. <label class="form-check-label" for="periodType_2">1주</label>
  79. </div>
  80. <div class="form-check form-check-inline">
  81. <input class="form-check-input" type="radio" name="Input.PeriodType" id="periodType_3" value="@((int)LimitPeriodType.Month)" @(pt == LimitPeriodType.Month ? "checked" : null) />
  82. <label class="form-check-label" for="periodType_3">30일</label>
  83. </div>
  84. <div class="form-check form-check-inline">
  85. <input class="form-check-input" type="radio" name="Input.PeriodType" id="periodType_4" value="@((int)LimitPeriodType.Custom)" @(pt == LimitPeriodType.Custom ? "checked" : null) />
  86. <label class="form-check-label" for="periodType_4">시간 지정</label>
  87. </div>
  88. <div id="periodCustomBox" class="mt-2" hidden>
  89. <div class="row g-2 align-items-end">
  90. <div class="col-md-5">
  91. <label asp-for="Input.PeriodStartAt" class="form-label">시작 시각</label>
  92. <input asp-for="Input.PeriodStartAt" class="form-control" type="datetime-local" step="1" id="periodStartAt" />
  93. <span asp-validation-for="Input.PeriodStartAt" class="text-danger"></span>
  94. </div>
  95. <div class="col-md-5">
  96. <label asp-for="Input.PeriodEndAt" class="form-label">종료 시각</label>
  97. <input asp-for="Input.PeriodEndAt" class="form-control" type="datetime-local" step="1" id="periodEndAt" />
  98. <span asp-validation-for="Input.PeriodEndAt" class="text-danger"></span>
  99. </div>
  100. </div>
  101. <div class="form-text">
  102. 예) 시작 2026-05-01 00:00:00 ~ 종료 2026-05-31 23:59:59 (KST 입력, UTC 저장)
  103. </div>
  104. <div id="periodRangeError" class="text-danger small mt-1" hidden>종료 시각은 시작 시각보다 이후여야 합니다.</div>
  105. </div>
  106. <div id="windowPreviewBox" class="alert alert-info mt-3 mb-0 py-2 px-3 small" role="status">
  107. <div class="fw-bold mb-1">
  108. <i class="fa-solid fa-clock"></i>미리보기 <small class="text-muted ms-1">(현재 KST 기준)</small>
  109. </div>
  110. <div id="windowPreviewText" class="font-monospace">-</div>
  111. </div>
  112. </div>
  113. </div>
  114. <hr />
  115. <div class="d-grid gap-2 text-center d-md-block">
  116. <button type="submit" class="btn btn-success" id="btnSubmit">저장</button>
  117. <a href="/Store/Product/Edit/@Model.ProductID@(Model.QueryString)" class="btn btn-secondary">상품 수정으로</a>
  118. <a href="/Store/Product" class="btn btn-light">상품 목록</a>
  119. </div>
  120. <br />
  121. </form>
  122. </div>
  123. @section Scripts {
  124. <partial name="_ValidationScriptsPartial" />
  125. <script>
  126. (function () {
  127. var previewText = document.getElementById('windowPreviewText');
  128. var startInput = document.getElementById('periodStartAt');
  129. var endInput = document.getElementById('periodEndAt');
  130. var usePeriodLimit = document.getElementById('usePeriodLimit');
  131. var rangeError = document.getElementById('periodRangeError');
  132. var btnSubmit = document.getElementById('btnSubmit');
  133. var form = document.getElementById('fAdminWrite');
  134. function pad(n) { return String(n).padStart(2, '0'); }
  135. function formatKst(date) {
  136. return date.getFullYear() + '-' +
  137. pad(date.getMonth() + 1) + '-' +
  138. pad(date.getDate()) + ' ' +
  139. pad(date.getHours()) + ':' +
  140. pad(date.getMinutes()) + ':' +
  141. pad(date.getSeconds());
  142. }
  143. function parseLocal(value) {
  144. if (!value) {
  145. return null;
  146. }
  147. // datetime-local 의 value 는 "YYYY-MM-DDTHH:mm" 또는 "YYYY-MM-DDTHH:mm:ss" — 로컬 시각으로 해석.
  148. var d = new Date(value);
  149. return isNaN(d.getTime()) ? null : d;
  150. }
  151. function formatDurationLabel(ms) {
  152. if (ms <= 0) {
  153. return '0초';
  154. }
  155. var totalSec = Math.floor(ms / 1000);
  156. var days = Math.floor(totalSec / 86400);
  157. var hours = Math.floor((totalSec % 86400) / 3600);
  158. var minutes = Math.floor((totalSec % 3600) / 60);
  159. var seconds = totalSec % 60;
  160. var parts = [];
  161. if (days > 0) { parts.push(days + '일'); }
  162. if (hours > 0) { parts.push(hours + '시간'); }
  163. if (minutes > 0) { parts.push(minutes + '분'); }
  164. if (seconds > 0) { parts.push(seconds + '초'); }
  165. return parts.length > 0 ? parts.join(' ') : '0초';
  166. }
  167. function togglePeriodCustomBox() {
  168. var val = document.querySelector('input[name="Input.PeriodType"]:checked');
  169. if (val && val.value === '4') {
  170. document.getElementById('periodCustomBox').removeAttribute('hidden');
  171. } else {
  172. document.getElementById('periodCustomBox').setAttribute('hidden', 'true');
  173. }
  174. }
  175. // PeriodType 라디오 → UsePeriodLimit 체크박스 자동 sync
  176. function syncFromPeriodType() {
  177. var checked = document.querySelector('input[name="Input.PeriodType"]:checked');
  178. if (!checked || !usePeriodLimit) {
  179. return;
  180. }
  181. if (checked.value === '0') {
  182. usePeriodLimit.checked = false;
  183. } else {
  184. usePeriodLimit.checked = true;
  185. }
  186. }
  187. // UsePeriodLimit 체크박스 → PeriodType 라디오 자동 sync
  188. function syncFromUsePeriodLimit() {
  189. if (!usePeriodLimit) {
  190. return;
  191. }
  192. var checked = document.querySelector('input[name="Input.PeriodType"]:checked');
  193. var currentValue = checked ? checked.value : '0';
  194. if (!usePeriodLimit.checked) {
  195. var noneRadio = document.getElementById('periodType_0');
  196. if (noneRadio) {
  197. noneRadio.checked = true;
  198. }
  199. } else if (currentValue === '0') {
  200. var dayRadio = document.getElementById('periodType_1');
  201. if (dayRadio) {
  202. dayRadio.checked = true;
  203. }
  204. }
  205. }
  206. function validateCustomRange() {
  207. var checked = document.querySelector('input[name="Input.PeriodType"]:checked');
  208. var isCustom = checked && checked.value === '4';
  209. var useLimit = usePeriodLimit && usePeriodLimit.checked;
  210. // Custom + 기간 제한 ON 일 때만 검증
  211. if (!isCustom || !useLimit) {
  212. rangeError.setAttribute('hidden', 'true');
  213. if (btnSubmit) { btnSubmit.disabled = false; }
  214. return true;
  215. }
  216. var s = parseLocal(startInput && startInput.value);
  217. var e = parseLocal(endInput && endInput.value);
  218. if (s && e && e.getTime() <= s.getTime()) {
  219. rangeError.removeAttribute('hidden');
  220. if (btnSubmit) { btnSubmit.disabled = true; }
  221. return false;
  222. }
  223. rangeError.setAttribute('hidden', 'true');
  224. if (btnSubmit) { btnSubmit.disabled = false; }
  225. return true;
  226. }
  227. function updateWindowPreview() {
  228. var checked = document.querySelector('input[name="Input.PeriodType"]:checked');
  229. var typeVal = checked ? checked.value : '0';
  230. // 기간 제한 자체가 꺼져 있으면 안내
  231. if (usePeriodLimit && !usePeriodLimit.checked) {
  232. previewText.innerHTML = '<span class="text-secondary">기간 제한 미사용 — 전체 누적(all-time) 기준으로 계산됩니다.</span>';
  233. return;
  234. }
  235. var hours = 0;
  236. switch (typeVal) {
  237. case '0': // None
  238. previewText.innerHTML = '<span class="text-secondary">기간 유형 "사용 안함" — 전체 누적(all-time) 기준으로 계산됩니다.</span>';
  239. return;
  240. case '1': hours = 24; break;
  241. case '2': hours = 168; break;
  242. case '3': hours = 720; break;
  243. case '4': {
  244. var s = parseLocal(startInput && startInput.value);
  245. var e = parseLocal(endInput && endInput.value);
  246. if (!s || !e) {
  247. previewText.innerHTML = '<span class="text-danger">시작/종료 시각을 모두 입력하세요.</span>';
  248. return;
  249. }
  250. if (e.getTime() <= s.getTime()) {
  251. previewText.innerHTML = '<span class="text-danger">종료 시각은 시작 시각보다 이후여야 합니다.</span>';
  252. return;
  253. }
  254. var durMs = e.getTime() - s.getTime();
  255. previewText.innerHTML =
  256. '<strong>' + formatKst(s) + '</strong>' +
  257. ' &nbsp;~&nbsp; <strong>' + formatKst(e) + '</strong>' +
  258. ' <span class="text-muted ms-1">(' + formatDurationLabel(durMs) + ' 윈도우)</span>';
  259. return;
  260. }
  261. default:
  262. previewText.textContent = '-';
  263. return;
  264. }
  265. // rolling 유형 (Day/Week/Month)
  266. var now = new Date();
  267. var end = new Date(now.getTime() + hours * 60 * 60 * 1000);
  268. previewText.innerHTML =
  269. '<strong>' + formatKst(now) + '</strong>' +
  270. ' &nbsp;~&nbsp; <strong>' + formatKst(end) + '</strong>' +
  271. ' <span class="text-muted ms-1">(' + hours + '시간 윈도우)</span>';
  272. }
  273. function refresh() {
  274. togglePeriodCustomBox();
  275. validateCustomRange();
  276. updateWindowPreview();
  277. }
  278. // 초기 반영
  279. refresh();
  280. // 라디오 변경 시 — 체크박스 자동 sync 후 refresh
  281. document.querySelectorAll('input[name="Input.PeriodType"]').forEach(function (el) {
  282. el.addEventListener('change', function () {
  283. syncFromPeriodType();
  284. refresh();
  285. });
  286. });
  287. // datetime 입력 변경 시 반영
  288. if (startInput) {
  289. startInput.addEventListener('input', refresh);
  290. startInput.addEventListener('change', refresh);
  291. }
  292. if (endInput) {
  293. endInput.addEventListener('input', refresh);
  294. endInput.addEventListener('change', refresh);
  295. }
  296. // UsePeriodLimit 체크 변경 시 — 라디오 자동 sync 후 refresh
  297. if (usePeriodLimit) {
  298. usePeriodLimit.addEventListener('change', function () {
  299. syncFromUsePeriodLimit();
  300. refresh();
  301. });
  302. }
  303. // 제출 직전 최종 검증 + sync 안전망
  304. if (form) {
  305. form.addEventListener('submit', function (ev) {
  306. // PeriodType != None 이면 UsePeriodLimit 강제 ON (서버 안전망과 동일 동작)
  307. syncFromPeriodType();
  308. if (!validateCustomRange()) {
  309. ev.preventDefault();
  310. alert('종료 시각은 시작 시각보다 이후여야 합니다.');
  311. return false;
  312. }
  313. });
  314. }
  315. })();
  316. </script>
  317. }