| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- <template>
- <div class="admin--field-list">
- <!-- 상단 액션 영역 -->
- <div class="admin--search-box">
- <div class="admin--search-actions">
- <button
- v-if="hasChanges"
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="isSaving"
- @click="cancelAll"
- >
- 모두 취소
- </button>
- <button
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="selectedIds.length === 0"
- @click="bulkDelete"
- >
- 선택 삭제<span v-if="selectedIds.length"> ({{ selectedIds.length }})</span>
- </button>
- <button class="admin--btn-small admin--btn-small-primary" @click="addNewRow">+ 구분 추가</button>
- <button
- class="admin--btn-small admin--btn-small-danger"
- :disabled="!hasChanges || isSaving"
- @click="bulkSave"
- >
- {{ isSaving ? "저장 중..." : `일괄 저장${hasChanges ? ` (${changeCount})` : ""}` }}
- </button>
- </div>
- </div>
- <!-- 테이블 -->
- <div class="admin--table-wrapper">
- <table class="admin--table">
- <thead>
- <tr>
- <th style="width: 80px;">
- <div class="input--wrap">
- <input
- type="checkbox"
- :checked="isAllSelected"
- :indeterminate.prop="isPartialSelected"
- @change="toggleAll($event.target.checked)"
- aria-label="전체 선택"
- />
- </div>
- </th>
- <th style="">구분명</th>
- <th style="">정렬순서</th>
- <th style="width: 10%;">상태</th>
- <th style="width: 10%">등록일</th>
- <th style="width: 10%">관리</th>
- </tr>
- </thead>
- <tbody>
- <!-- 신규 행들 (테이블 상단) -->
- <tr v-for="n in newRows" :key="'new-' + n._tempId" class="admin--table-row-new">
- <td></td>
- <td @click.stop>
- <input
- v-model="n.name"
- type="text"
- class="admin--form-input admin--inline-input"
- placeholder="구분명 입력"
- @keyup.enter="bulkSave"
- />
- </td>
- <td @click.stop>
- <input
- v-model.number="n.sort_order"
- type="number"
- min="0"
- class="admin--form-input admin--inline-input"
- @keyup.enter="bulkSave"
- />
- </td>
- <td @click.stop>
- <select v-model="n.status_YN" class="admin--form-select admin--inline-input">
- <option value="Y">사용중</option>
- <option value="N">미사용</option>
- </select>
- </td>
- <td class="date">{{ todayLabel }}</td>
- <td @click.stop>
- <button class="admin--btn-small admin--btn-small-secondary" @click="removeNewRow(n._tempId)">제거</button>
- </td>
- </tr>
- <tr v-if="isLoading">
- <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
- </tr>
- <tr v-else-if="!displayedItems || displayedItems.length === 0">
- <td colspan="6" class="admin--table-empty" v-if="newRows.length === 0">등록된 어종구분이 없습니다.</td>
- </tr>
- <tr
- v-else
- v-for="item in displayedItems"
- :key="item.id"
- :class="{
- 'admin--table-row-new': editing[item.id],
- 'admin--table-row-clickable': !editing[item.id],
- }"
- @click="!editing[item.id] && startEdit(item)"
- >
- <td @click.stop>
- <div class="input--wrap">
- <input
- type="checkbox"
- :value="item.id"
- v-model="selectedIds"
- />
- </div>
- </td>
- <!-- 수정 모드 -->
- <template v-if="editing[item.id]">
- <td @click.stop>
- <input
- v-model="editing[item.id].name"
- type="text"
- class="admin--form-input admin--inline-input"
- @keyup.enter="bulkSave"
- />
- </td>
- <td @click.stop>
- <input
- v-model.number="editing[item.id].sort_order"
- type="number"
- min="0"
- class="admin--form-input admin--inline-input"
- @keyup.enter="bulkSave"
- />
- </td>
- <td @click.stop>
- <select v-model="editing[item.id].status_YN" class="admin--form-select admin--inline-input">
- <option value="Y">사용중</option>
- <option value="N">미사용</option>
- </select>
- </td>
- <td class="date">{{ formatDate(item.created_at) }}</td>
- <td @click.stop>
- <button class="admin--btn-small admin--btn-small-secondary" @click="cancelEdit(item.id)">취소</button>
- </td>
- </template>
- <!-- 일반 모드 -->
- <template v-else>
- <td class="admin--table-title">{{ item.name }}</td>
- <td>{{ item.sort_order }}</td>
- <td>
- <span :class="['admin--badge', getStatusBadgeClass(item.status_YN)]">
- {{ getStatusLabel(item.status_YN) }}
- </span>
- </td>
- <td class="date">{{ formatDate(item.created_at) }}</td>
- <td></td>
- </template>
- </tr>
- </tbody>
- </table>
- </div>
- <!-- 토스트 알림 -->
- <Teleport to="body">
- <Transition name="admin--toast">
- <div
- v-if="toast.show"
- class="admin--toast"
- :class="{ 'is-error': toast.type === 'error' }"
- >
- <span class="admin--toast-icon"></span>
- <span class="admin--toast-msg">{{ toast.message }}</span>
- <button class="admin--toast-close" @click="dismissToast">×</button>
- </div>
- </Transition>
- </Teleport>
- <!-- 알림 모달 -->
- <AdminAlertModal
- v-if="alertModal.show"
- :title="alertModal.title"
- :message="alertModal.message"
- :type="alertModal.type"
- @confirm="handleAlertConfirm"
- @cancel="handleAlertCancel"
- @close="closeAlertModal"
- />
- <!-- 페이지네이션 -->
- <div v-if="totalPages > 1" class="admin--pagination">
- <button
- v-if="totalPages > 2"
- class="admin--pagination-btn"
- :disabled="currentPage === 1"
- @click="changePage(1)"
- title="처음"
- >◀◀</button>
- <button
- class="admin--pagination-btn"
- :disabled="currentPage === 1"
- @click="changePage(currentPage - 1)"
- title="이전"
- >◀</button>
- <button
- v-for="page in visiblePages"
- :key="page"
- class="admin--pagination-btn"
- :class="{ 'is-active': page === currentPage }"
- @click="changePage(page)"
- >{{ page }}</button>
- <button
- class="admin--pagination-btn"
- :disabled="currentPage === totalPages"
- @click="changePage(currentPage + 1)"
- title="다음"
- >▶</button>
- <button
- v-if="totalPages > 2"
- class="admin--pagination-btn"
- :disabled="currentPage === totalPages"
- @click="changePage(totalPages)"
- title="끝"
- >▶▶</button>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted } from "vue";
- import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
- definePageMeta({
- layout: "admin",
- middleware: ["auth"],
- });
- const { get, post } = useApi();
- const isLoading = ref(false);
- const isSaving = ref(false);
- const items = ref([]);
- const currentPage = ref(1);
- const perPage = ref(10);
- const totalCount = ref(0);
- const totalPages = ref(0);
- // 토스트
- const toast = ref({ show: false, type: "success", message: "" });
- let toastTimer = null;
- const showToast = (message, type = "success", duration = type === "error" ? 4500 : 2500) => {
- if (toastTimer) clearTimeout(toastTimer);
- toast.value = { show: true, type, message };
- toastTimer = setTimeout(() => { toast.value.show = false; }, duration);
- };
- const dismissToast = () => {
- if (toastTimer) clearTimeout(toastTimer);
- toast.value.show = false;
- };
- // 신규 행들
- const newRows = ref([]); // [{ _tempId, name, sort_order, status_YN }]
- let tempIdCounter = 0;
- // 수정 중인 행들: { [id]: { name, sort_order, status_YN } }
- const editing = ref({});
- // 삭제 예정 ID들 (저장 시 일괄 처리)
- const markedForDeleteIds = ref([]);
- // 화면에 보이는 기존 행들 (삭제 예정 제외)
- const displayedItems = computed(() =>
- items.value.filter((it) => !markedForDeleteIds.value.includes(it.id))
- );
- // 변경 카운트
- const changeCount = computed(
- () => newRows.value.length + Object.keys(editing.value).length + markedForDeleteIds.value.length
- );
- const hasChanges = computed(() => changeCount.value > 0);
- // 오늘 라벨
- const todayLabel = computed(() => {
- const d = new Date();
- return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
- });
- // 체크박스 선택 (보이는 행 기준)
- const selectedIds = ref([]);
- const isAllSelected = computed(
- () => displayedItems.value.length > 0 && displayedItems.value.every((it) => selectedIds.value.includes(it.id))
- );
- const isPartialSelected = computed(
- () => selectedIds.value.length > 0 && !isAllSelected.value
- );
- const toggleAll = (checked) => {
- if (checked) {
- const pageIds = displayedItems.value.map((it) => it.id);
- selectedIds.value = Array.from(new Set([...selectedIds.value, ...pageIds]));
- } else {
- const pageIds = new Set(displayedItems.value.map((it) => it.id));
- selectedIds.value = selectedIds.value.filter((id) => !pageIds.has(id));
- }
- };
- // 알림 모달
- const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
- const showConfirm = (message, onConfirm, title = "확인") => {
- alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
- };
- const closeAlertModal = () => { alertModal.value.show = false; };
- const handleAlertConfirm = () => {
- if (alertModal.value.onConfirm) alertModal.value.onConfirm();
- closeAlertModal();
- };
- const handleAlertCancel = () => closeAlertModal();
- // 페이지 번호
- const visiblePages = computed(() => {
- const pages = [];
- const maxVisible = 5;
- let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
- let end = Math.min(totalPages.value, start + maxVisible - 1);
- if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
- for (let i = start; i <= end; i++) pages.push(i);
- return pages;
- });
- // 데이터 로드
- const loadItems = async () => {
- isLoading.value = true;
- const { data, error } = await get("/species/list", {
- params: { page: currentPage.value, per_page: perPage.value },
- });
- if (error) {
- console.error("[SpeciesList] 목록 로드 실패:", error);
- items.value = [];
- totalCount.value = 0;
- totalPages.value = 0;
- } else if (data?.success && data?.data) {
- items.value = data.data.items || [];
- totalCount.value = data.data.total || 0;
- totalPages.value = data.data.total_pages || 0;
- }
- isLoading.value = false;
- };
- // 신규 행 추가
- const addNewRow = () => {
- dismissToast();
- newRows.value.push({
- _tempId: ++tempIdCounter,
- name: "",
- sort_order: 1,
- status_YN: "Y",
- });
- };
- const removeNewRow = (tempId) => {
- newRows.value = newRows.value.filter((r) => r._tempId !== tempId);
- };
- // 수정 시작
- const startEdit = (item) => {
- dismissToast();
- editing.value = {
- ...editing.value,
- [item.id]: { name: item.name, sort_order: item.sort_order, status_YN: item.status_YN },
- };
- };
- const cancelEdit = (id) => {
- const next = { ...editing.value };
- delete next[id];
- editing.value = next;
- };
- // 모두 취소
- const cancelAll = () => {
- if (!hasChanges.value) return;
- showConfirm(
- "작성·수정·삭제 표시한 모든 변경사항을 취소하시겠습니까?",
- () => {
- newRows.value = [];
- editing.value = {};
- markedForDeleteIds.value = [];
- dismissToast();
- },
- "변경 취소"
- );
- };
- // 일괄 저장
- const bulkSave = async () => {
- dismissToast();
- if (!hasChanges.value) return;
- // 프론트 측 기본 검증
- const creates = newRows.value.map((n) => ({
- name: (n.name || "").trim(),
- sort_order: Number(n.sort_order) || 0,
- status_YN: n.status_YN,
- }));
- const updates = Object.entries(editing.value).map(([id, v]) => ({
- id: Number(id),
- name: (v.name || "").trim(),
- sort_order: Number(v.sort_order) || 0,
- status_YN: v.status_YN,
- }));
- for (let i = 0; i < creates.length; i++) {
- if (!creates[i].name) return showToast(`신규 ${i + 1}행: 구분명을 입력하세요.`, "error");
- if (creates[i].name.length > 30) return showToast(`신규 ${i + 1}행: 구분명 30자 이내`, "error");
- if (creates[i].sort_order < 0) return showToast(`신규 ${i + 1}행: 정렬순서 0 이상`, "error");
- }
- for (let i = 0; i < updates.length; i++) {
- if (!updates[i].name) return showToast(`수정 ${i + 1}행: 구분명을 입력하세요.`, "error");
- if (updates[i].name.length > 30) return showToast(`수정 ${i + 1}행: 구분명 30자 이내`, "error");
- if (updates[i].sort_order < 0) return showToast(`수정 ${i + 1}행: 정렬순서 0 이상`, "error");
- }
- const deletes = [...markedForDeleteIds.value];
- isSaving.value = true;
- try {
- const { data, error } = await post("/species/bulk-save", { creates, updates, deletes });
- if (error || !data?.success) {
- showToast(error?.message || data?.message || "저장에 실패했습니다.", "error");
- return;
- }
- showToast(data.message || "저장되었습니다.", "success");
- newRows.value = [];
- editing.value = {};
- markedForDeleteIds.value = [];
- selectedIds.value = [];
- await loadItems();
- } catch (e) {
- showToast("서버 오류가 발생했습니다.", "error");
- console.error("Bulk save error:", e);
- } finally {
- isSaving.value = false;
- }
- };
- // 선택 삭제 — 즉시 서버 호출 X. 화면에서 사라지고 일괄 저장 시 함께 처리
- const bulkDelete = () => {
- if (selectedIds.value.length === 0) return;
- const ids = [...selectedIds.value];
- // 수정 중이던 행이면 수정 상태도 해제
- const nextEditing = { ...editing.value };
- ids.forEach((id) => { delete nextEditing[id]; });
- editing.value = nextEditing;
- // 삭제 예정에 추가
- markedForDeleteIds.value = Array.from(new Set([...markedForDeleteIds.value, ...ids]));
- selectedIds.value = [];
- dismissToast();
- };
- // 페이지 변경
- const changePage = (page) => {
- if (page < 1 || page > totalPages.value) return;
- if (hasChanges.value) {
- return showConfirm(
- "저장하지 않은 변경사항이 있습니다. 페이지를 이동하면 모두 사라집니다.",
- () => {
- newRows.value = [];
- editing.value = {};
- markedForDeleteIds.value = [];
- selectedIds.value = [];
- currentPage.value = page;
- loadItems();
- window.scrollTo({ top: 0, behavior: "smooth" });
- },
- "페이지 이동"
- );
- }
- currentPage.value = page;
- loadItems();
- window.scrollTo({ top: 0, behavior: "smooth" });
- };
- // 라벨/뱃지
- const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
- const getStatusBadgeClass = (status) => (status === "Y" ? "admin--badge-active" : "admin--badge-ended");
- const formatDate = (dateString) => {
- if (!dateString) return "-";
- const date = new Date(dateString.replace(" ", "T"));
- if (isNaN(date.getTime())) return dateString;
- return date.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
- };
- onMounted(() => {
- loadItems();
- });
- </script>
|