list.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. <template>
  2. <div class="admin--field-list">
  3. <!-- 상단 액션 영역 -->
  4. <div class="admin--search-box">
  5. <div class="admin--search-actions">
  6. <button
  7. v-if="hasChanges"
  8. class="admin--btn-small admin--btn-small-secondary"
  9. :disabled="isSaving"
  10. @click="cancelAll"
  11. >
  12. 모두 취소
  13. </button>
  14. <button
  15. class="admin--btn-small admin--btn-small-secondary"
  16. :disabled="selectedIds.length === 0"
  17. @click="bulkDelete"
  18. >
  19. 선택 삭제<span v-if="selectedIds.length"> ({{ selectedIds.length }})</span>
  20. </button>
  21. <button class="admin--btn-small admin--btn-small-primary" @click="addNewRow">+ 구분 추가</button>
  22. <button
  23. class="admin--btn-small admin--btn-small-danger"
  24. :disabled="!hasChanges || isSaving"
  25. @click="bulkSave"
  26. >
  27. {{ isSaving ? "저장 중..." : `일괄 저장${hasChanges ? ` (${changeCount})` : ""}` }}
  28. </button>
  29. </div>
  30. </div>
  31. <!-- 테이블 -->
  32. <div class="admin--table-wrapper">
  33. <table class="admin--table">
  34. <thead>
  35. <tr>
  36. <th style="width: 80px;">
  37. <div class="input--wrap">
  38. <input
  39. type="checkbox"
  40. :checked="isAllSelected"
  41. :indeterminate.prop="isPartialSelected"
  42. @change="toggleAll($event.target.checked)"
  43. aria-label="전체 선택"
  44. />
  45. </div>
  46. </th>
  47. <th style="">구분명</th>
  48. <th style="width: 20%;">정렬순서</th>
  49. <th style="width: 10%;">상태</th>
  50. <th style="width: 10%">등록일</th>
  51. <th style="width: 10%">관리</th>
  52. </tr>
  53. </thead>
  54. <tbody>
  55. <!-- 신규 행들 (테이블 상단) -->
  56. <tr v-for="n in newRows" :key="'new-' + n._tempId" class="admin--table-row-new">
  57. <td></td>
  58. <td @click.stop>
  59. <input
  60. v-model="n.name"
  61. type="text"
  62. class="admin--form-input admin--inline-input"
  63. placeholder="구분명 입력"
  64. @keyup.enter="bulkSave"
  65. />
  66. </td>
  67. <td @click.stop>
  68. <input
  69. v-model.number="n.sort_order"
  70. type="number"
  71. min="0"
  72. class="admin--form-input admin--inline-input"
  73. @keyup.enter="bulkSave"
  74. />
  75. </td>
  76. <td @click.stop>
  77. <select v-model="n.status_YN" class="admin--form-select admin--inline-input">
  78. <option value="Y">사용중</option>
  79. <option value="N">미사용</option>
  80. </select>
  81. </td>
  82. <td class="date">{{ todayLabel }}</td>
  83. <td @click.stop>
  84. <button class="admin--btn-small admin--btn-small-secondary" @click="removeNewRow(n._tempId)">제거</button>
  85. </td>
  86. </tr>
  87. <tr v-if="isLoading">
  88. <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
  89. </tr>
  90. <tr v-else-if="!displayedItems || displayedItems.length === 0">
  91. <td colspan="6" class="admin--table-empty" v-if="newRows.length === 0">등록된 어종구분이 없습니다.</td>
  92. </tr>
  93. <tr
  94. v-else
  95. v-for="item in displayedItems"
  96. :key="item.id"
  97. :class="{
  98. 'admin--table-row-new': editing[item.id],
  99. 'admin--table-row-clickable': !editing[item.id],
  100. }"
  101. @click="!editing[item.id] && startEdit(item)"
  102. >
  103. <td @click.stop>
  104. <div class="input--wrap">
  105. <input
  106. type="checkbox"
  107. :value="item.id"
  108. v-model="selectedIds"
  109. />
  110. </div>
  111. </td>
  112. <!-- 수정 모드 -->
  113. <template v-if="editing[item.id]">
  114. <td @click.stop>
  115. <input
  116. v-model="editing[item.id].name"
  117. type="text"
  118. class="admin--form-input admin--inline-input"
  119. @keyup.enter="bulkSave"
  120. />
  121. </td>
  122. <td @click.stop>
  123. <input
  124. v-model.number="editing[item.id].sort_order"
  125. type="number"
  126. min="0"
  127. class="admin--form-input admin--inline-input center"
  128. @keyup.enter="bulkSave"
  129. />
  130. </td>
  131. <td @click.stop>
  132. <select v-model="editing[item.id].status_YN" class="admin--form-select admin--inline-input">
  133. <option value="Y">사용중</option>
  134. <option value="N">미사용</option>
  135. </select>
  136. </td>
  137. <td class="date">{{ formatDate(item.created_at) }}</td>
  138. <td @click.stop>
  139. <button class="admin--btn-small admin--btn-small-secondary" @click="cancelEdit(item.id)">취소</button>
  140. </td>
  141. </template>
  142. <!-- 일반 모드 -->
  143. <template v-else>
  144. <td class="admin--table-title">{{ item.name }}</td>
  145. <td>{{ item.sort_order }}</td>
  146. <td>
  147. <span :class="['admin--badge', getStatusBadgeClass(item.status_YN)]">
  148. {{ getStatusLabel(item.status_YN) }}
  149. </span>
  150. </td>
  151. <td class="date">{{ formatDate(item.created_at) }}</td>
  152. <td></td>
  153. </template>
  154. </tr>
  155. </tbody>
  156. </table>
  157. </div>
  158. <!-- 토스트 알림 -->
  159. <Teleport to="body">
  160. <Transition name="admin--toast">
  161. <div
  162. v-if="toast.show"
  163. class="admin--toast"
  164. :class="{ 'is-error': toast.type === 'error' }"
  165. >
  166. <span class="admin--toast-icon"></span>
  167. <span class="admin--toast-msg">{{ toast.message }}</span>
  168. <button class="admin--toast-close" @click="dismissToast">×</button>
  169. </div>
  170. </Transition>
  171. </Teleport>
  172. <!-- 알림 모달 -->
  173. <AdminAlertModal
  174. v-if="alertModal.show"
  175. :title="alertModal.title"
  176. :message="alertModal.message"
  177. :type="alertModal.type"
  178. @confirm="handleAlertConfirm"
  179. @cancel="handleAlertCancel"
  180. @close="closeAlertModal"
  181. />
  182. <!-- 페이지네이션 -->
  183. <div v-if="totalPages > 1" class="admin--pagination">
  184. <button
  185. v-if="totalPages > 2"
  186. class="admin--pagination-btn"
  187. :disabled="currentPage === 1"
  188. @click="changePage(1)"
  189. title="처음"
  190. >◀◀</button>
  191. <button
  192. class="admin--pagination-btn"
  193. :disabled="currentPage === 1"
  194. @click="changePage(currentPage - 1)"
  195. title="이전"
  196. >◀</button>
  197. <button
  198. v-for="page in visiblePages"
  199. :key="page"
  200. class="admin--pagination-btn"
  201. :class="{ 'is-active': page === currentPage }"
  202. @click="changePage(page)"
  203. >{{ page }}</button>
  204. <button
  205. class="admin--pagination-btn"
  206. :disabled="currentPage === totalPages"
  207. @click="changePage(currentPage + 1)"
  208. title="다음"
  209. >▶</button>
  210. <button
  211. v-if="totalPages > 2"
  212. class="admin--pagination-btn"
  213. :disabled="currentPage === totalPages"
  214. @click="changePage(totalPages)"
  215. title="끝"
  216. >▶▶</button>
  217. </div>
  218. </div>
  219. </template>
  220. <script setup>
  221. import { ref, computed, onMounted } from "vue";
  222. import AdminAlertModal from "~/components/admin/AdminAlertModal.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 currentPage = ref(1);
  232. const perPage = ref(10);
  233. const totalCount = ref(0);
  234. const totalPages = ref(0);
  235. // 토스트
  236. const toast = ref({ show: false, type: "success", message: "" });
  237. let toastTimer = null;
  238. const showToast = (message, type = "success", duration = type === "error" ? 4500 : 2500) => {
  239. if (toastTimer) clearTimeout(toastTimer);
  240. toast.value = { show: true, type, message };
  241. toastTimer = setTimeout(() => { toast.value.show = false; }, duration);
  242. };
  243. const dismissToast = () => {
  244. if (toastTimer) clearTimeout(toastTimer);
  245. toast.value.show = false;
  246. };
  247. // 신규 행들
  248. const newRows = ref([]); // [{ _tempId, name, sort_order, status_YN }]
  249. let tempIdCounter = 0;
  250. // 수정 중인 행들: { [id]: { name, sort_order, status_YN } }
  251. const editing = ref({});
  252. // 삭제 예정 ID들 (저장 시 일괄 처리)
  253. const markedForDeleteIds = ref([]);
  254. // 화면에 보이는 기존 행들 (삭제 예정 제외)
  255. const displayedItems = computed(() =>
  256. items.value.filter((it) => !markedForDeleteIds.value.includes(it.id))
  257. );
  258. // 변경 카운트
  259. const changeCount = computed(
  260. () => newRows.value.length + Object.keys(editing.value).length + markedForDeleteIds.value.length
  261. );
  262. const hasChanges = computed(() => changeCount.value > 0);
  263. // 오늘 라벨
  264. const todayLabel = computed(() => {
  265. const d = new Date();
  266. return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
  267. });
  268. // 체크박스 선택 (보이는 행 기준)
  269. const selectedIds = ref([]);
  270. const isAllSelected = computed(
  271. () => displayedItems.value.length > 0 && displayedItems.value.every((it) => selectedIds.value.includes(it.id))
  272. );
  273. const isPartialSelected = computed(
  274. () => selectedIds.value.length > 0 && !isAllSelected.value
  275. );
  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. // 데이터 로드
  307. const loadItems = async () => {
  308. isLoading.value = true;
  309. const { data, error } = await get("/species/list", {
  310. params: { page: currentPage.value, per_page: perPage.value },
  311. });
  312. if (error) {
  313. console.error("[SpeciesList] 목록 로드 실패:", error);
  314. items.value = [];
  315. totalCount.value = 0;
  316. totalPages.value = 0;
  317. } else if (data?.success && data?.data) {
  318. items.value = data.data.items || [];
  319. totalCount.value = data.data.total || 0;
  320. totalPages.value = data.data.total_pages || 0;
  321. }
  322. isLoading.value = false;
  323. };
  324. // 신규 행 추가
  325. const addNewRow = () => {
  326. dismissToast();
  327. newRows.value.push({
  328. _tempId: ++tempIdCounter,
  329. name: "",
  330. sort_order: 1,
  331. status_YN: "Y",
  332. });
  333. };
  334. const removeNewRow = (tempId) => {
  335. newRows.value = newRows.value.filter((r) => r._tempId !== tempId);
  336. };
  337. // 수정 시작
  338. const startEdit = (item) => {
  339. dismissToast();
  340. editing.value = {
  341. ...editing.value,
  342. [item.id]: { name: item.name, sort_order: item.sort_order, status_YN: item.status_YN },
  343. };
  344. };
  345. const cancelEdit = (id) => {
  346. const next = { ...editing.value };
  347. delete next[id];
  348. editing.value = next;
  349. };
  350. // 모두 취소
  351. const cancelAll = () => {
  352. if (!hasChanges.value) return;
  353. showConfirm(
  354. "작성·수정·삭제 표시한 모든 변경사항을 취소하시겠습니까?",
  355. () => {
  356. newRows.value = [];
  357. editing.value = {};
  358. markedForDeleteIds.value = [];
  359. dismissToast();
  360. },
  361. "변경 취소"
  362. );
  363. };
  364. // 일괄 저장
  365. const bulkSave = async () => {
  366. dismissToast();
  367. if (!hasChanges.value) return;
  368. // 프론트 측 기본 검증
  369. const creates = newRows.value.map((n) => ({
  370. name: (n.name || "").trim(),
  371. sort_order: Number(n.sort_order) || 0,
  372. status_YN: n.status_YN,
  373. }));
  374. const updates = Object.entries(editing.value).map(([id, v]) => ({
  375. id: Number(id),
  376. name: (v.name || "").trim(),
  377. sort_order: Number(v.sort_order) || 0,
  378. status_YN: v.status_YN,
  379. }));
  380. for (let i = 0; i < creates.length; i++) {
  381. if (!creates[i].name) return showToast(`신규 ${i + 1}행: 구분명을 입력하세요.`, "error");
  382. if (creates[i].name.length > 30) return showToast(`신규 ${i + 1}행: 구분명 30자 이내`, "error");
  383. if (creates[i].sort_order < 0) return showToast(`신규 ${i + 1}행: 정렬순서 0 이상`, "error");
  384. }
  385. for (let i = 0; i < updates.length; i++) {
  386. if (!updates[i].name) return showToast(`수정 ${i + 1}행: 구분명을 입력하세요.`, "error");
  387. if (updates[i].name.length > 30) return showToast(`수정 ${i + 1}행: 구분명 30자 이내`, "error");
  388. if (updates[i].sort_order < 0) return showToast(`수정 ${i + 1}행: 정렬순서 0 이상`, "error");
  389. }
  390. const deletes = [...markedForDeleteIds.value];
  391. isSaving.value = true;
  392. try {
  393. const { data, error } = await post("/species/bulk-save", { creates, updates, deletes });
  394. if (error || !data?.success) {
  395. showToast(error?.message || data?.message || "저장에 실패했습니다.", "error");
  396. return;
  397. }
  398. showToast(data.message || "저장되었습니다.", "success");
  399. newRows.value = [];
  400. editing.value = {};
  401. markedForDeleteIds.value = [];
  402. selectedIds.value = [];
  403. await loadItems();
  404. } catch (e) {
  405. showToast("서버 오류가 발생했습니다.", "error");
  406. console.error("Bulk save error:", e);
  407. } finally {
  408. isSaving.value = false;
  409. }
  410. };
  411. // 선택 삭제 — 즉시 서버 호출 X. 화면에서 사라지고 일괄 저장 시 함께 처리
  412. const bulkDelete = () => {
  413. if (selectedIds.value.length === 0) return;
  414. const ids = [...selectedIds.value];
  415. // 수정 중이던 행이면 수정 상태도 해제
  416. const nextEditing = { ...editing.value };
  417. ids.forEach((id) => { delete nextEditing[id]; });
  418. editing.value = nextEditing;
  419. // 삭제 예정에 추가
  420. markedForDeleteIds.value = Array.from(new Set([...markedForDeleteIds.value, ...ids]));
  421. selectedIds.value = [];
  422. dismissToast();
  423. };
  424. // 페이지 변경
  425. const changePage = (page) => {
  426. if (page < 1 || page > totalPages.value) return;
  427. if (hasChanges.value) {
  428. return showConfirm(
  429. "저장하지 않은 변경사항이 있습니다. 페이지를 이동하면 모두 사라집니다.",
  430. () => {
  431. newRows.value = [];
  432. editing.value = {};
  433. markedForDeleteIds.value = [];
  434. selectedIds.value = [];
  435. currentPage.value = page;
  436. loadItems();
  437. window.scrollTo({ top: 0, behavior: "smooth" });
  438. },
  439. "페이지 이동"
  440. );
  441. }
  442. currentPage.value = page;
  443. loadItems();
  444. window.scrollTo({ top: 0, behavior: "smooth" });
  445. };
  446. // 라벨/뱃지
  447. const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
  448. const getStatusBadgeClass = (status) => (status === "Y" ? "admin--badge-active" : "admin--badge-ended");
  449. const formatDate = (dateString) => {
  450. if (!dateString) return "-";
  451. const date = new Date(dateString.replace(" ", "T"));
  452. if (isNaN(date.getTime())) return dateString;
  453. return date.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
  454. };
  455. onMounted(() => {
  456. loadItems();
  457. });
  458. </script>