list.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. <template>
  2. <div class="admin--field-list">
  3. <!-- 상단 액션 영역 -->
  4. <div class="admin--search-box type2">
  5. <div class="admin--search--inner--box">
  6. <div class="admin--search-form">
  7. <select v-model="filterTypeId" @change="onSearch" class="admin--form-select admin--search-select">
  8. <option value="">전체</option>
  9. <option value="null">구분 없음</option>
  10. <option v-for="t in typeOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
  11. </select>
  12. <input
  13. v-model="searchQuery"
  14. type="text"
  15. placeholder="어종명 검색"
  16. @keyup.enter="onSearch"
  17. class="admin--form-input admin--search-input"
  18. />
  19. <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
  20. <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
  21. </div>
  22. <div class="admin--search-actions">
  23. <button
  24. v-if="hasChanges"
  25. class="admin--btn-small admin--btn-small-secondary"
  26. :disabled="isSaving"
  27. @click="cancelAll"
  28. >모두 취소</button>
  29. <button
  30. class="admin--btn-small admin--btn-small-secondary"
  31. :disabled="selectedIds.length === 0"
  32. @click="bulkDelete"
  33. >
  34. 선택 삭제<span v-if="selectedIds.length"> ({{ selectedIds.length }})</span>
  35. </button>
  36. <button class="admin--btn-small admin--btn-small-primary" @click="addNewRow">+ 어종 추가</button>
  37. <button
  38. v-if="hasChanges || isSaving"
  39. class="admin--btn-small admin--btn-small-danger"
  40. :disabled="isSaving"
  41. @click="bulkSave"
  42. >
  43. {{ isSaving ? "저장 중..." : `일괄 저장 (${changeCount})` }}
  44. </button>
  45. </div>
  46. </div>
  47. <div class="admin--search--inner--box">
  48. <div class="admin--search-form">
  49. <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
  50. <span class="admin--date-separator">-</span>
  51. <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
  52. <div class="admin--quick-range">
  53. <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('today')">오늘</button>
  54. <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('7d')">7일</button>
  55. <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('15d')">15일</button>
  56. <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1m')">1개월</button>
  57. <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('3m')">3개월</button>
  58. <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1y')">1년</button>
  59. </div>
  60. </div>
  61. </div>
  62. </div>
  63. <p class="admin--table--top--text">길이 기준 : CM</p>
  64. <div class="admin--table-wrapper">
  65. <table class="admin--table fish--table">
  66. <thead>
  67. <tr>
  68. <th style="width: 36px;">
  69. <div class="input--wrap">
  70. <input
  71. type="checkbox"
  72. :checked="isAllSelected"
  73. :indeterminate.prop="isPartialSelected"
  74. @change="toggleAll($event.target.checked)"
  75. aria-label="전체 선택"
  76. />
  77. </div>
  78. </th>
  79. <th style="width: 140px;">어종</th>
  80. <th style="width: 100px;">구분</th>
  81. <th style="width: 68px;">최소금지</th>
  82. <th style="width: 68px;">최대길이</th>
  83. <th style="width: 140px;">1단계</th>
  84. <th style="width: 140px;">2단계</th>
  85. <th style="width: 140px;">3단계</th>
  86. <th style="width: 140px;">4단계</th>
  87. <th style="width: 140px;">5단계</th>
  88. <th style="width: 90px;">등록일</th>
  89. <th style="width: 60px;">관리</th>
  90. </tr>
  91. </thead>
  92. <tbody>
  93. <!-- 신규 행 -->
  94. <tr v-for="n in newRows" :key="'new-' + n._tempId" class="admin--table-row-new">
  95. <td></td>
  96. <td @click.stop>
  97. <input v-model="n.name" type="text" class="admin--form-input admin--inline-input" placeholder="어종명" @keyup.enter="bulkSave" />
  98. </td>
  99. <td @click.stop>
  100. <select v-model="n.type_id" class="admin--form-select admin--inline-input">
  101. <option value="">선택</option>
  102. <option v-for="t in typeOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
  103. </select>
  104. </td>
  105. <td @click.stop>
  106. <input v-model.number="n.min" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
  107. </td>
  108. <td @click.stop>
  109. <input v-model.number="n.max" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
  110. </td>
  111. <td v-for="r in 5" :key="'r' + r + 'new' + n._tempId" @click.stop>
  112. <div class="admin--range-cell">
  113. <input v-model.number="n[`round${r}_min`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="min" @input="limitDigits" @keyup.enter="bulkSave" />
  114. <span>~</span>
  115. <input v-model.number="n[`round${r}_max`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="max" @input="limitDigits" @keyup.enter="bulkSave" />
  116. </div>
  117. </td>
  118. <td class="date">{{ todayLabel }}</td>
  119. <td @click.stop>
  120. <button class="admin--btn-small mw--0 admin--btn-small-secondary" @click="removeNewRow(n._tempId)">제거</button>
  121. </td>
  122. </tr>
  123. <tr v-if="isLoading">
  124. <td colspan="12" class="admin--table-loading">데이터를 불러오는 중...</td>
  125. </tr>
  126. <tr v-else-if="!displayedItems || displayedItems.length === 0">
  127. <td colspan="12" class="admin--table-empty" v-if="newRows.length === 0">등록된 어종이 없습니다.</td>
  128. </tr>
  129. <tr
  130. v-else
  131. v-for="item in displayedItems"
  132. :key="item.id"
  133. :class="{
  134. 'admin--table-row-new': editing[item.id],
  135. 'admin--table-row-clickable': !editing[item.id],
  136. }"
  137. @click="!editing[item.id] && startEdit(item)"
  138. >
  139. <td @click.stop style="padding: 8px;">
  140. <div class="input--wrap">
  141. <input type="checkbox" :value="item.id" v-model="selectedIds" />
  142. </div>
  143. </td>
  144. <!-- 수정 모드 -->
  145. <template v-if="editing[item.id]">
  146. <td @click.stop style="padding: 8px;">
  147. <input v-model="editing[item.id].name" type="text" class="admin--form-input admin--inline-input" @keyup.enter="bulkSave" />
  148. </td>
  149. <td @click.stop>
  150. <select v-model="editing[item.id].type_id" class="admin--form-select admin--inline-input">
  151. <option value="">선택</option>
  152. <option v-for="t in typeOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
  153. </select>
  154. </td>
  155. <td @click.stop>
  156. <input v-model.number="editing[item.id].min" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
  157. </td>
  158. <td @click.stop>
  159. <input v-model.number="editing[item.id].max" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
  160. </td>
  161. <td v-for="r in 5" :key="'r' + r + 'edit' + item.id" @click.stop>
  162. <div class="admin--range-cell">
  163. <input v-model.number="editing[item.id][`round${r}_min`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="min" @input="limitDigits" @keyup.enter="bulkSave" />
  164. <span>~</span>
  165. <input v-model.number="editing[item.id][`round${r}_max`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="max" @input="limitDigits" @keyup.enter="bulkSave" />
  166. </div>
  167. </td>
  168. <td class="date">{{ formatDate(item.created_at) }}</td>
  169. <td @click.stop>
  170. <button class="admin--btn-small mw--0 admin--btn-small-secondary" @click="cancelEdit(item.id)">취소</button>
  171. </td>
  172. </template>
  173. <!-- 일반 모드 -->
  174. <template v-else>
  175. <td class="admin--table-title">{{ item.name }}</td>
  176. <td>{{ item.type_name || "-" }}</td>
  177. <td>{{ item.min }}</td>
  178. <td>{{ item.max }}</td>
  179. <td v-for="r in 5" :key="'r' + r + 'view' + item.id">
  180. {{ item[`round${r}_min`] }} ~ {{ item[`round${r}_max`] }}
  181. </td>
  182. <td class="date">{{ formatDate(item.created_at) }}</td>
  183. <td></td>
  184. </template>
  185. </tr>
  186. </tbody>
  187. </table>
  188. </div>
  189. <!-- 토스트 -->
  190. <Teleport to="body">
  191. <Transition name="admin--toast">
  192. <div v-if="toast.show" class="admin--toast" :class="{ 'is-error': toast.type === 'error' }">
  193. <span class="admin--toast-icon"></span>
  194. <span class="admin--toast-msg">{{ toast.message }}</span>
  195. <button class="admin--toast-close" @click="dismissToast">×</button>
  196. </div>
  197. </Transition>
  198. </Teleport>
  199. <!-- 알림 모달 -->
  200. <AdminAlertModal
  201. v-if="alertModal.show"
  202. :title="alertModal.title"
  203. :message="alertModal.message"
  204. :type="alertModal.type"
  205. @confirm="handleAlertConfirm"
  206. @cancel="handleAlertCancel"
  207. @close="closeAlertModal"
  208. />
  209. <!-- 페이지네이션 -->
  210. <div v-if="totalPages > 1" class="admin--pagination">
  211. <button v-if="totalPages > 2" class="admin--pagination-btn" :disabled="currentPage === 1" @click="changePage(1)" title="처음">◀◀</button>
  212. <button class="admin--pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)" title="이전">◀</button>
  213. <button v-for="page in visiblePages" :key="page" class="admin--pagination-btn" :class="{ 'is-active': page === currentPage }" @click="changePage(page)">{{ page }}</button>
  214. <button class="admin--pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)" title="다음">▶</button>
  215. <button v-if="totalPages > 2" class="admin--pagination-btn" :disabled="currentPage === totalPages" @click="changePage(totalPages)" title="끝">▶▶</button>
  216. </div>
  217. </div>
  218. </template>
  219. <script setup>
  220. import { ref, computed, onMounted } from "vue";
  221. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  222. import DatePicker from "~/components/admin/DatePicker.vue";
  223. definePageMeta({
  224. layout: "admin",
  225. middleware: ["auth"],
  226. });
  227. const { get, post } = useApi();
  228. const isLoading = ref(false);
  229. const isSaving = ref(false);
  230. const items = ref([]);
  231. const typeOptions = ref([]);
  232. const currentPage = ref(1);
  233. const perPage = ref(10);
  234. const totalCount = ref(0);
  235. const totalPages = ref(0);
  236. // 검색
  237. const searchQuery = ref("");
  238. const filterTypeId = ref("");
  239. const startDate = ref("");
  240. const endDate = ref("");
  241. // 토스트
  242. const toast = ref({ show: false, type: "success", message: "" });
  243. let toastTimer = null;
  244. const showToast = (message, type = "success", duration = type === "error" ? 4500 : 2500) => {
  245. if (toastTimer) clearTimeout(toastTimer);
  246. toast.value = { show: true, type, message };
  247. toastTimer = setTimeout(() => { toast.value.show = false; }, duration);
  248. };
  249. const dismissToast = () => {
  250. if (toastTimer) clearTimeout(toastTimer);
  251. toast.value.show = false;
  252. };
  253. // 신규/수정/삭제 상태
  254. const newRows = ref([]);
  255. let tempIdCounter = 0;
  256. const editing = ref({});
  257. const markedForDeleteIds = ref([]);
  258. const displayedItems = computed(() =>
  259. items.value.filter((it) => !markedForDeleteIds.value.includes(it.id))
  260. );
  261. const changeCount = computed(
  262. () => newRows.value.length + Object.keys(editing.value).length + markedForDeleteIds.value.length
  263. );
  264. const hasChanges = computed(() => changeCount.value > 0);
  265. // 오늘 라벨
  266. const todayLabel = computed(() => {
  267. const d = new Date();
  268. return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
  269. });
  270. // 체크박스
  271. const selectedIds = ref([]);
  272. const isAllSelected = computed(
  273. () => displayedItems.value.length > 0 && displayedItems.value.every((it) => selectedIds.value.includes(it.id))
  274. );
  275. const isPartialSelected = computed(() => selectedIds.value.length > 0 && !isAllSelected.value);
  276. const toggleAll = (checked) => {
  277. if (checked) {
  278. const pageIds = displayedItems.value.map((it) => it.id);
  279. selectedIds.value = Array.from(new Set([...selectedIds.value, ...pageIds]));
  280. } else {
  281. const pageIds = new Set(displayedItems.value.map((it) => it.id));
  282. selectedIds.value = selectedIds.value.filter((id) => !pageIds.has(id));
  283. }
  284. };
  285. // 알림 모달
  286. const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
  287. const showConfirm = (message, onConfirm, title = "확인") => {
  288. alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
  289. };
  290. const closeAlertModal = () => { alertModal.value.show = false; };
  291. const handleAlertConfirm = () => {
  292. if (alertModal.value.onConfirm) alertModal.value.onConfirm();
  293. closeAlertModal();
  294. };
  295. const handleAlertCancel = () => closeAlertModal();
  296. // 페이지 번호
  297. const visiblePages = computed(() => {
  298. const pages = [];
  299. const maxVisible = 5;
  300. let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
  301. let end = Math.min(totalPages.value, start + maxVisible - 1);
  302. if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
  303. for (let i = start; i <= end; i++) pages.push(i);
  304. return pages;
  305. });
  306. // YYYY-MM-DD 포맷
  307. const toYMD = (d) => {
  308. const y = d.getFullYear();
  309. const m = String(d.getMonth() + 1).padStart(2, "0");
  310. const day = String(d.getDate()).padStart(2, "0");
  311. return `${y}-${m}-${day}`;
  312. };
  313. // 빠른 기간 선택
  314. const setRange = (kind) => {
  315. const end = toYMD(new Date());
  316. const startDt = new Date();
  317. switch (kind) {
  318. case "today": break;
  319. case "7d": startDt.setDate(startDt.getDate() - 7); break;
  320. case "15d": startDt.setDate(startDt.getDate() - 15); break;
  321. case "1m": startDt.setMonth(startDt.getMonth() - 1); break;
  322. case "3m": startDt.setMonth(startDt.getMonth() - 3); break;
  323. case "1y": startDt.setFullYear(startDt.getFullYear() - 1); break;
  324. }
  325. startDate.value = toYMD(startDt);
  326. endDate.value = end;
  327. onSearch();
  328. };
  329. // 구분 옵션 로드 (species_type)
  330. const loadTypeOptions = async () => {
  331. const { data } = await get("/species/list", { params: { per_page: 1000 } });
  332. if (data?.success) typeOptions.value = data.data.items || [];
  333. };
  334. // 데이터 로드
  335. const loadItems = async () => {
  336. isLoading.value = true;
  337. const params = { page: currentPage.value, per_page: perPage.value };
  338. if (searchQuery.value) params.search = searchQuery.value;
  339. if (filterTypeId.value) params.type_id = filterTypeId.value;
  340. if (startDate.value) params.start_date = startDate.value;
  341. if (endDate.value) params.end_date = endDate.value;
  342. const { data, error } = await get("/species-quest/list", { params });
  343. if (error) {
  344. items.value = []; totalCount.value = 0; totalPages.value = 0;
  345. } else if (data?.success && data?.data) {
  346. items.value = data.data.items || [];
  347. totalCount.value = data.data.total || 0;
  348. totalPages.value = data.data.total_pages || 0;
  349. }
  350. isLoading.value = false;
  351. };
  352. // 검색
  353. const onSearch = () => {
  354. currentPage.value = 1;
  355. loadItems();
  356. };
  357. const resetSearch = () => {
  358. searchQuery.value = "";
  359. filterTypeId.value = "";
  360. startDate.value = "";
  361. endDate.value = "";
  362. currentPage.value = 1;
  363. loadItems();
  364. };
  365. // 신규 행 추가
  366. const blankRow = () => ({
  367. _tempId: ++tempIdCounter,
  368. type_id: "",
  369. name: "",
  370. min: 0,
  371. max: 0,
  372. round1_min: 0, round1_max: 0,
  373. round2_min: 0, round2_max: 0,
  374. round3_min: 0, round3_max: 0,
  375. round4_min: 0, round4_max: 0,
  376. round5_min: 0, round5_max: 0,
  377. });
  378. const addNewRow = () => {
  379. dismissToast();
  380. newRows.value.push(blankRow());
  381. };
  382. const removeNewRow = (tempId) => {
  383. newRows.value = newRows.value.filter((r) => r._tempId !== tempId);
  384. };
  385. // 수정 시작
  386. const startEdit = (item) => {
  387. dismissToast();
  388. editing.value = {
  389. ...editing.value,
  390. [item.id]: {
  391. type_id: item.type_id ?? "",
  392. name: item.name ?? "",
  393. min: item.min ?? 0,
  394. max: item.max ?? 0,
  395. round1_min: item.round1_min ?? 0, round1_max: item.round1_max ?? 0,
  396. round2_min: item.round2_min ?? 0, round2_max: item.round2_max ?? 0,
  397. round3_min: item.round3_min ?? 0, round3_max: item.round3_max ?? 0,
  398. round4_min: item.round4_min ?? 0, round4_max: item.round4_max ?? 0,
  399. round5_min: item.round5_min ?? 0, round5_max: item.round5_max ?? 0,
  400. },
  401. };
  402. };
  403. const cancelEdit = (id) => {
  404. const next = { ...editing.value };
  405. delete next[id];
  406. editing.value = next;
  407. };
  408. // 모두 취소
  409. const cancelAll = () => {
  410. if (!hasChanges.value) return;
  411. showConfirm(
  412. "작성·수정·삭제 표시한 모든 변경사항을 취소하시겠습니까?",
  413. () => {
  414. newRows.value = [];
  415. editing.value = {};
  416. markedForDeleteIds.value = [];
  417. dismissToast();
  418. },
  419. "변경 취소"
  420. );
  421. };
  422. // number 인풋 자릿수 제한 — 입력 즉시 자르고 v-model 동기화
  423. const limitDigits = (e, max = 4) => {
  424. const v = e.target.value;
  425. if (v.length > max) {
  426. e.target.value = v.slice(0, max);
  427. e.target.dispatchEvent(new Event("input", { bubbles: true }));
  428. }
  429. };
  430. // 한 행 검증 (프론트) — 구분은 선택사항 (저장 시 별도 확인 모달로 한 번 더 물음)
  431. const validateRow = (row, label) => {
  432. const name = (row.name || "").trim();
  433. if (!name) return `${label}: 어종명을 입력하세요.`;
  434. if (name.length > 50) return `${label}: 어종명은 50자 이내`;
  435. const num = (v) => (v === null || v === "" || isNaN(Number(v))) ? null : Number(v);
  436. const min = num(row.min); const max = num(row.max);
  437. if (min === null) return `${label}: 최소금지를 입력하세요.`;
  438. if (max === null) return `${label}: 최대길이를 입력하세요.`;
  439. if (max < min) return `${label}: 최대길이는 최소금지 이상`;
  440. for (let r = 1; r <= 5; r++) {
  441. const rmin = num(row[`round${r}_min`]);
  442. const rmax = num(row[`round${r}_max`]);
  443. if (rmin === null) return `${label}: ${r}라운드 최소를 입력하세요.`;
  444. if (rmax === null) return `${label}: ${r}라운드 최대를 입력하세요.`;
  445. if (rmin > rmax) return `${label}: ${r}라운드 최소는 최대보다 작거나 같아야 합니다.`;
  446. if (rmax > max) return `${label}: ${r}라운드 최대는 최대길이(${max})보다 클 수 없습니다.`;
  447. }
  448. if (Number(row.round1_min) < min) return `${label}: 1라운드 최소는 최소금지(${min})보다 작을 수 없습니다.`;
  449. return null;
  450. };
  451. // 일괄 저장
  452. const bulkSave = async () => {
  453. dismissToast();
  454. if (!hasChanges.value) return;
  455. const creates = newRows.value.map((n) => ({ ...n }));
  456. const updates = Object.entries(editing.value).map(([id, v]) => ({ id: Number(id), ...v }));
  457. // 기본 검증 (구분 제외)
  458. for (let i = 0; i < creates.length; i++) {
  459. const err = validateRow(creates[i], `신규 ${i + 1}행`);
  460. if (err) return showToast(err, "error");
  461. }
  462. for (let i = 0; i < updates.length; i++) {
  463. const err = validateRow(updates[i], `수정 ${i + 1}행`);
  464. if (err) return showToast(err, "error");
  465. }
  466. const deletes = [...markedForDeleteIds.value];
  467. // 실제 서버 호출
  468. const doSave = async () => {
  469. isSaving.value = true;
  470. try {
  471. const { data, error } = await post("/species-quest/bulk-save", { creates, updates, deletes });
  472. if (error || !data?.success) {
  473. showToast(error?.message || data?.message || "저장 실패", "error");
  474. return;
  475. }
  476. showToast(data.message || "저장되었습니다.", "success");
  477. newRows.value = [];
  478. editing.value = {};
  479. markedForDeleteIds.value = [];
  480. selectedIds.value = [];
  481. await loadItems();
  482. } catch (e) {
  483. showToast("서버 오류가 발생했습니다.", "error");
  484. console.error("Bulk save error:", e);
  485. } finally {
  486. isSaving.value = false;
  487. }
  488. };
  489. // 구분 미선택 행 카운트 → 확인 모달
  490. const missingCount = [...creates, ...updates].filter((r) => !r.type_id).length;
  491. if (missingCount > 0) {
  492. showConfirm(
  493. `${missingCount}개 행에 구분이 선택되지 않았습니다.\n그대로 저장하시겠습니까?`,
  494. doSave,
  495. "구분 미선택"
  496. );
  497. return;
  498. }
  499. await doSave();
  500. };
  501. // 선택 삭제 — 즉시 X, 일괄 저장 시 처리
  502. const bulkDelete = () => {
  503. if (selectedIds.value.length === 0) return;
  504. const ids = [...selectedIds.value];
  505. const nextEditing = { ...editing.value };
  506. ids.forEach((id) => { delete nextEditing[id]; });
  507. editing.value = nextEditing;
  508. markedForDeleteIds.value = Array.from(new Set([...markedForDeleteIds.value, ...ids]));
  509. selectedIds.value = [];
  510. dismissToast();
  511. };
  512. // 페이지 변경
  513. const changePage = (page) => {
  514. if (page < 1 || page > totalPages.value) return;
  515. if (hasChanges.value) {
  516. return showConfirm(
  517. "저장하지 않은 변경사항이 있습니다. 페이지를 이동하면 모두 사라집니다.",
  518. () => {
  519. newRows.value = [];
  520. editing.value = {};
  521. markedForDeleteIds.value = [];
  522. selectedIds.value = [];
  523. currentPage.value = page;
  524. loadItems();
  525. window.scrollTo({ top: 0, behavior: "smooth" });
  526. },
  527. "페이지 이동"
  528. );
  529. }
  530. currentPage.value = page;
  531. loadItems();
  532. window.scrollTo({ top: 0, behavior: "smooth" });
  533. };
  534. const formatDate = (dateString) => {
  535. if (!dateString) return "-";
  536. const date = new Date(dateString.replace(" ", "T"));
  537. if (isNaN(date.getTime())) return dateString;
  538. return date.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
  539. };
  540. onMounted(async () => {
  541. await loadTypeOptions();
  542. await loadItems();
  543. });
  544. </script>