2026-05-31 15:42:52 +09:00
|
|
|
|
<script setup>
|
2026-06-24 21:31:39 +09:00
|
|
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
|
2026-06-01 22:37:56 +09:00
|
|
|
|
import Sortable from 'sortablejs'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
import { accountApi } from '@/api/accountApi'
|
|
|
|
|
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const categories = ref([])
|
2026-06-01 22:37:56 +09:00
|
|
|
|
const rows = ref([]) // 현재 탭(activeType)의 분류 — 드래그 정렬 대상
|
2026-05-31 15:42:52 +09:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const error = ref(null)
|
|
|
|
|
|
const activeType = ref('EXPENSE') // EXPENSE / INCOME
|
|
|
|
|
|
const newName = ref('')
|
2026-06-22 23:16:46 +09:00
|
|
|
|
const newParent = ref('') // 추가 시 대분류('' = 대분류로 추가)
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-24 21:31:39 +09:00
|
|
|
|
// 인앱 확인 모달 (네이티브 confirm 대신 — PC 팝업 제목 'sb_pt' 노출 방지)
|
|
|
|
|
|
const confirmState = reactive({ open: false, title: '', message: '', onOk: null })
|
|
|
|
|
|
function askConfirm(title, message, onOk) {
|
|
|
|
|
|
confirmState.title = title
|
|
|
|
|
|
confirmState.message = message
|
|
|
|
|
|
confirmState.onOk = onOk
|
|
|
|
|
|
confirmState.open = true
|
|
|
|
|
|
}
|
|
|
|
|
|
function confirmOk() {
|
|
|
|
|
|
const fn = confirmState.onOk
|
|
|
|
|
|
confirmState.open = false
|
|
|
|
|
|
if (fn) fn()
|
|
|
|
|
|
}
|
|
|
|
|
|
// 액션 오류도 네이티브 alert 대신 화면 상단 메시지로
|
|
|
|
|
|
function showError(e, fallback) {
|
|
|
|
|
|
error.value = e.response?.data?.message || fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 22:37:56 +09:00
|
|
|
|
// categories/activeType 변경 시 현재 탭 목록 동기화 (백엔드가 sort_order 순 정렬)
|
|
|
|
|
|
function syncRows() {
|
|
|
|
|
|
rows.value = categories.value.filter((c) => c.type === activeType.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
watch([categories, activeType], syncRows, { immediate: true })
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-24 21:31:39 +09:00
|
|
|
|
// 현재 탭의 대분류(parent 없는 것) 목록 — 추가 폼 대분류 선택용
|
2026-06-22 23:16:46 +09:00
|
|
|
|
const majors = computed(() => rows.value.filter((c) => c.parentId == null))
|
2026-06-23 21:02:09 +09:00
|
|
|
|
// 대분류 → 소분류 트리 (major/children 모두 실제 row 참조 유지 → v-model 바인딩 가능)
|
|
|
|
|
|
const tree = computed(() =>
|
|
|
|
|
|
majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })),
|
|
|
|
|
|
)
|
2026-06-22 23:16:46 +09:00
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
categories.value = await accountApi.categories()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-23 21:02:09 +09:00
|
|
|
|
async function reload() {
|
|
|
|
|
|
await load()
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
initSortable()
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-23 21:02:09 +09:00
|
|
|
|
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
|
2026-06-29 21:36:16 +09:00
|
|
|
|
// 스크롤하다 실수로 순서가 바뀌는 것 방지: 기본은 드래그 비활성, '순서변경' 버튼으로 켠다.
|
2026-06-23 21:02:09 +09:00
|
|
|
|
const majorListEl = ref(null)
|
2026-06-29 21:36:16 +09:00
|
|
|
|
const reorderMode = ref(false)
|
2026-06-30 22:06:02 +09:00
|
|
|
|
|
|
|
|
|
|
// ===== 대분류 펼침(아코디언) — 삼각형 누르면 소분류 표시 =====
|
|
|
|
|
|
const expanded = ref(new Set())
|
|
|
|
|
|
function toggleExpand(id) {
|
|
|
|
|
|
const s = new Set(expanded.value)
|
|
|
|
|
|
if (s.has(id)) s.delete(id)
|
|
|
|
|
|
else s.add(id)
|
|
|
|
|
|
expanded.value = s
|
|
|
|
|
|
}
|
|
|
|
|
|
function isExpanded(id) {
|
|
|
|
|
|
return expanded.value.has(id)
|
|
|
|
|
|
}
|
2026-06-23 21:02:09 +09:00
|
|
|
|
let sortables = []
|
|
|
|
|
|
function destroySortables() {
|
|
|
|
|
|
sortables.forEach((s) => s.destroy())
|
|
|
|
|
|
sortables = []
|
|
|
|
|
|
}
|
2026-06-29 21:36:16 +09:00
|
|
|
|
function toggleReorder() {
|
|
|
|
|
|
reorderMode.value = !reorderMode.value
|
|
|
|
|
|
nextTick(() => initSortable()) // 켜면 생성, 끄면 destroy 후 early-return
|
|
|
|
|
|
}
|
2026-06-01 22:37:56 +09:00
|
|
|
|
function initSortable() {
|
2026-06-23 21:02:09 +09:00
|
|
|
|
destroySortables()
|
2026-06-29 21:36:16 +09:00
|
|
|
|
if (!majorListEl.value || !reorderMode.value) return
|
2026-06-23 21:02:09 +09:00
|
|
|
|
// 대분류 순서 — 블록 단위라 소분류가 함께 이동
|
|
|
|
|
|
sortables.push(
|
|
|
|
|
|
Sortable.create(majorListEl.value, {
|
|
|
|
|
|
handle: '.major-handle',
|
|
|
|
|
|
draggable: '.major-block',
|
|
|
|
|
|
animation: 150,
|
|
|
|
|
|
onEnd: () => {
|
|
|
|
|
|
const ids = [...majorListEl.value.querySelectorAll(':scope > .major-block')].map((li) => Number(li.dataset.id))
|
|
|
|
|
|
persistOrder(ids)
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
// 각 대분류의 소분류 순서 — 고유 group 으로 다른 대분류로는 이동 불가
|
|
|
|
|
|
majorListEl.value.querySelectorAll('.sub-list').forEach((ul) => {
|
|
|
|
|
|
sortables.push(
|
|
|
|
|
|
Sortable.create(ul, {
|
|
|
|
|
|
handle: '.sub-handle',
|
|
|
|
|
|
draggable: '.sub-item',
|
|
|
|
|
|
group: 'sub-' + ul.dataset.parent,
|
|
|
|
|
|
animation: 150,
|
|
|
|
|
|
onEnd: () => {
|
|
|
|
|
|
const ids = [...ul.querySelectorAll(':scope > .sub-item')].map((li) => Number(li.dataset.id))
|
|
|
|
|
|
if (ids.length) persistOrder(ids)
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
2026-06-01 22:37:56 +09:00
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-06-23 21:02:09 +09:00
|
|
|
|
// 로컬 순서 즉시 반영(스냅백 방지) 후 백엔드 저장
|
|
|
|
|
|
function reorderLocal(ids) {
|
|
|
|
|
|
const idSet = new Set(ids)
|
|
|
|
|
|
const moved = ids.map((id) => categories.value.find((c) => c.id === id)).filter(Boolean)
|
|
|
|
|
|
let k = 0
|
|
|
|
|
|
categories.value = categories.value.map((c) => (idSet.has(c.id) ? moved[k++] : c))
|
|
|
|
|
|
syncRows()
|
|
|
|
|
|
}
|
2026-06-01 22:37:56 +09:00
|
|
|
|
async function persistOrder(ids) {
|
2026-06-23 21:02:09 +09:00
|
|
|
|
reorderLocal(ids)
|
2026-06-01 22:37:56 +09:00
|
|
|
|
try {
|
|
|
|
|
|
await accountApi.reorderCategories(activeType.value, ids)
|
|
|
|
|
|
} catch (e) {
|
2026-06-24 21:31:39 +09:00
|
|
|
|
showError(e, '순서 저장에 실패했습니다.')
|
2026-06-01 22:37:56 +09:00
|
|
|
|
}
|
2026-06-23 21:02:09 +09:00
|
|
|
|
await reload()
|
2026-06-01 22:37:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
async function addCategory() {
|
|
|
|
|
|
const name = newName.value.trim()
|
|
|
|
|
|
if (!name) return
|
2026-06-24 21:31:39 +09:00
|
|
|
|
error.value = null
|
2026-05-31 15:42:52 +09:00
|
|
|
|
try {
|
2026-06-30 22:06:02 +09:00
|
|
|
|
const parentId = newParent.value || null
|
|
|
|
|
|
await accountApi.createCategory({ type: activeType.value, name, parentId })
|
2026-05-31 15:42:52 +09:00
|
|
|
|
newName.value = ''
|
2026-06-23 21:02:09 +09:00
|
|
|
|
await reload()
|
2026-06-30 22:06:02 +09:00
|
|
|
|
// 소분류를 추가했으면 그 대분류를 펼쳐 바로 보이게
|
|
|
|
|
|
if (parentId) {
|
|
|
|
|
|
const s = new Set(expanded.value)
|
|
|
|
|
|
s.add(Number(parentId))
|
|
|
|
|
|
expanded.value = s
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
} catch (e) {
|
2026-06-24 21:31:39 +09:00
|
|
|
|
showError(e, '추가에 실패했습니다.')
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveCategory(c) {
|
|
|
|
|
|
const name = (c.name || '').trim()
|
|
|
|
|
|
if (!name) return
|
2026-06-24 21:31:39 +09:00
|
|
|
|
error.value = null
|
2026-05-31 15:42:52 +09:00
|
|
|
|
try {
|
2026-06-22 23:16:46 +09:00
|
|
|
|
// parentId 는 항상 현재값을 함께 전송(미전송 시 대분류로 풀림)
|
|
|
|
|
|
await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
|
2026-06-23 21:02:09 +09:00
|
|
|
|
await reload()
|
2026-05-31 15:42:52 +09:00
|
|
|
|
} catch (e) {
|
2026-06-24 21:31:39 +09:00
|
|
|
|
showError(e, '수정에 실패했습니다.')
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 21:31:39 +09:00
|
|
|
|
function removeCategory(c) {
|
|
|
|
|
|
askConfirm('분류 삭제', `'${c.name}' 분류를 삭제할까요?\n기존 내역의 분류명은 그대로 유지됩니다.`, async () => {
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
await accountApi.removeCategory(c.id)
|
|
|
|
|
|
await reload()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showError(e, '삭제에 실패했습니다.')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 23:03:52 +09:00
|
|
|
|
function importDefaults() {
|
|
|
|
|
|
askConfirm('기본 분류 불러오기', '관리자가 설정한 기본 분류를 내 분류로 가져올까요?\n같은 이름은 건너뜁니다.', async () => {
|
2026-06-24 21:31:39 +09:00
|
|
|
|
error.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
categories.value = await accountApi.importCategories()
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
initSortable()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showError(e, '불러오기에 실패했습니다.')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 21:02:09 +09:00
|
|
|
|
// 탭(수입/지출) 전환 시 재렌더 → 드래그 재초기화
|
|
|
|
|
|
watch(activeType, async () => {
|
2026-06-01 22:37:56 +09:00
|
|
|
|
await nextTick()
|
|
|
|
|
|
initSortable()
|
|
|
|
|
|
})
|
2026-06-23 21:02:09 +09:00
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await reload()
|
|
|
|
|
|
})
|
|
|
|
|
|
onBeforeUnmount(destroySortables)
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<section class="cat">
|
2026-06-22 23:16:46 +09:00
|
|
|
|
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. <b>대분류 아래 소분류</b>로 묶을 수 있고(2단계), 예산·통계는 소분류 기준입니다. 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-27 23:18:42 +09:00
|
|
|
|
<div class="tabs-row">
|
|
|
|
|
|
<div class="tabs">
|
|
|
|
|
|
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
|
|
|
|
|
|
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
|
|
|
|
|
|
</div>
|
2026-06-29 21:36:16 +09:00
|
|
|
|
<div class="tabs-actions">
|
|
|
|
|
|
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
|
|
|
|
|
|
<button type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }" @click="toggleReorder">
|
|
|
|
|
|
{{ reorderMode ? '완료' : '순서변경' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form class="new-cat" @submit.prevent="addCategory">
|
2026-06-22 23:16:46 +09:00
|
|
|
|
<select v-model="newParent" class="parent-sel" title="대분류 선택">
|
|
|
|
|
|
<option value="">대분류로 추가</option>
|
2026-06-24 21:31:39 +09:00
|
|
|
|
<option v-for="m in majors" :key="m.id" :value="m.id">{{ m.name }}</option>
|
2026-06-22 23:16:46 +09:00
|
|
|
|
</select>
|
2026-06-22 23:49:39 +09:00
|
|
|
|
<div class="nc-line">
|
|
|
|
|
|
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
|
|
|
|
|
|
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
|
|
|
|
|
|
</div>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</form>
|
2026-06-29 21:36:16 +09:00
|
|
|
|
<p class="hint sm">
|
|
|
|
|
|
<template v-if="reorderMode">≡ 손잡이를 끌어 순서를 바꾸세요 — <b>대분류</b>는 소분류와 함께, <b>소분류</b>는 그 대분류 안에서만 이동. 끝나면 <b>완료</b>.</template>
|
|
|
|
|
|
<template v-else><b>순서변경</b>을 누르면 분류 순서를 바꿀 수 있어요. (평소엔 스크롤 중 실수 방지로 잠겨 있어요)</template>
|
|
|
|
|
|
</p>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
|
|
<p v-if="error" class="msg error">{{ error }}</p>
|
|
|
|
|
|
<p v-if="loading" class="msg">불러오는 중...</p>
|
|
|
|
|
|
|
2026-06-23 21:02:09 +09:00
|
|
|
|
<ul v-show="!loading && tree.length" ref="majorListEl" class="major-list">
|
|
|
|
|
|
<li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block">
|
|
|
|
|
|
<div class="cat-row major-row">
|
|
|
|
|
|
<div class="cat-main">
|
2026-06-29 21:36:16 +09:00
|
|
|
|
<span v-show="reorderMode" class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)">≡</span>
|
2026-06-30 22:06:02 +09:00
|
|
|
|
<button
|
|
|
|
|
|
type="button" class="expand-btn" :class="{ open: isExpanded(g.major.id), empty: !g.children.length }"
|
|
|
|
|
|
:title="g.children.length ? (isExpanded(g.major.id) ? '소분류 접기' : '소분류 펼치기') : '소분류 없음'"
|
|
|
|
|
|
:disabled="!g.children.length" @click="toggleExpand(g.major.id)"
|
|
|
|
|
|
>▶</button>
|
2026-06-23 21:02:09 +09:00
|
|
|
|
<input v-model="g.major.name" class="cat-name" @keyup.enter="saveCategory(g.major)" />
|
2026-06-30 22:06:02 +09:00
|
|
|
|
<span v-if="g.children.length && !isExpanded(g.major.id)" class="sub-count">{{ g.children.length }}</span>
|
2026-06-23 21:02:09 +09:00
|
|
|
|
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(g.major)" />
|
|
|
|
|
|
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
|
|
|
|
|
|
</div>
|
2026-06-22 23:49:39 +09:00
|
|
|
|
</div>
|
2026-06-30 22:06:02 +09:00
|
|
|
|
<ul v-show="isExpanded(g.major.id) || reorderMode" class="sub-list" :data-parent="g.major.id">
|
2026-06-23 21:02:09 +09:00
|
|
|
|
<li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
|
|
|
|
|
|
<div class="cat-main">
|
2026-06-29 21:36:16 +09:00
|
|
|
|
<span v-show="reorderMode" class="drag-handle sub-handle" title="소분류 순서 변경">≡</span>
|
2026-06-23 21:02:09 +09:00
|
|
|
|
<span class="sub-mark">↳</span>
|
|
|
|
|
|
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
|
|
|
|
|
|
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
|
|
|
|
|
|
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-06-29 09:33:31 +09:00
|
|
|
|
<div v-if="!loading && !tree.length" class="empty-state">
|
|
|
|
|
|
<p class="empty-emoji">🗂️</p>
|
|
|
|
|
|
<p class="empty-title">등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없어요</p>
|
|
|
|
|
|
<p class="empty-desc">기본 분류를 불러오면 식비·교통 등이 한 번에 추가됩니다.</p>
|
|
|
|
|
|
<button type="button" class="empty-cta" @click="importDefaults">기본 분류 불러오기</button>
|
|
|
|
|
|
</div>
|
2026-06-24 21:31:39 +09:00
|
|
|
|
|
|
|
|
|
|
<!-- 인앱 확인 모달 (네이티브 confirm 대체 — 제목 'sb_pt' 노출 방지) -->
|
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
|
<Transition name="fade">
|
|
|
|
|
|
<div v-if="confirmState.open" class="modal-backdrop" @click.self="confirmState.open = false">
|
|
|
|
|
|
<div class="modal confirm-modal" role="dialog" aria-modal="true">
|
|
|
|
|
|
<h2>{{ confirmState.title }}</h2>
|
|
|
|
|
|
<p class="confirm-body">{{ confirmState.message }}</p>
|
|
|
|
|
|
<div class="buttons">
|
|
|
|
|
|
<IconBtn icon="close" title="취소" @click="confirmState.open = false" />
|
|
|
|
|
|
<IconBtn icon="check" title="확인" variant="primary" @click="confirmOk" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</Teleport>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.cat {
|
|
|
|
|
|
max-width: 560px;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
.head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}
|
|
|
|
|
|
.head-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
h1 {
|
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hint {
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
margin: 0.5rem 0 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
input {
|
|
|
|
|
|
padding: 0.5rem 0.7rem;
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: var(--color-background-soft);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
button {
|
|
|
|
|
|
padding: 0.45rem 0.85rem;
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: var(--color-background-soft);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
button.danger {
|
|
|
|
|
|
border-color: #c0392b;
|
|
|
|
|
|
color: #c0392b;
|
|
|
|
|
|
}
|
2026-06-27 23:18:42 +09:00
|
|
|
|
.tabs-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.tabs-row .tabs {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
2026-06-29 21:36:16 +09:00
|
|
|
|
.tabs-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
.reorder-btn.active {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
background: hsla(160, 100%, 37%, 1);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.tabs {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
border-bottom: 1px solid var(--color-border);
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.tabs button {
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
border-bottom: 2px solid transparent;
|
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
padding: 0.6rem 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.tabs button.active {
|
|
|
|
|
|
border-bottom-color: hsla(160, 100%, 37%, 1);
|
|
|
|
|
|
color: hsla(160, 100%, 37%, 1);
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
.new-cat {
|
|
|
|
|
|
display: flex;
|
2026-06-22 23:49:39 +09:00
|
|
|
|
flex-direction: column;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
2026-06-22 23:49:39 +09:00
|
|
|
|
.nc-line {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.nc-line input {
|
2026-05-31 15:42:52 +09:00
|
|
|
|
flex: 1;
|
2026-06-22 23:49:39 +09:00
|
|
|
|
min-width: 0;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-06-23 21:02:09 +09:00
|
|
|
|
.major-list {
|
2026-05-31 15:42:52 +09:00
|
|
|
|
list-style: none;
|
2026-06-03 16:42:07 +09:00
|
|
|
|
padding-left: 0;
|
|
|
|
|
|
margin: 0;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-06-23 21:02:09 +09:00
|
|
|
|
.major-block {
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.major-row {
|
|
|
|
|
|
background: var(--color-background-soft);
|
|
|
|
|
|
border-bottom: 0 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-list {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
padding: 0 0 0 0.4rem;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-item:last-child {
|
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.cat-row {
|
|
|
|
|
|
display: flex;
|
2026-06-22 23:49:39 +09:00
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.35rem;
|
2026-06-23 21:02:09 +09:00
|
|
|
|
padding: 0.5rem 0.5rem;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
border-bottom: 1px solid var(--color-border);
|
2026-06-01 22:37:56 +09:00
|
|
|
|
background: var(--color-background);
|
|
|
|
|
|
}
|
2026-06-22 23:49:39 +09:00
|
|
|
|
.cat-main {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cat-parent {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
padding-left: 1.4rem;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
opacity: 0.85;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cat-parent .pl {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cat-parent .parent-sel {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
2026-06-01 22:37:56 +09:00
|
|
|
|
.drag-handle {
|
|
|
|
|
|
cursor: grab;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
|
padding: 0 0.2rem;
|
|
|
|
|
|
touch-action: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.drag-handle:active {
|
|
|
|
|
|
cursor: grabbing;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
.cat-name {
|
|
|
|
|
|
flex: 1;
|
2026-06-22 23:16:46 +09:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cat-row.child {
|
|
|
|
|
|
padding-left: 1.2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cat-row.child .cat-name {
|
|
|
|
|
|
background: var(--color-background-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-mark {
|
|
|
|
|
|
opacity: 0.45;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-06-30 22:06:02 +09:00
|
|
|
|
/* 대분류 펼침 삼각형 */
|
|
|
|
|
|
.expand-btn {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
width: 1.4rem;
|
|
|
|
|
|
height: 1.4rem;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: transform 0.15s ease;
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
.expand-btn.open {
|
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
.expand-btn.empty {
|
|
|
|
|
|
opacity: 0.15;
|
|
|
|
|
|
cursor: default;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-count {
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
min-width: 1.2rem;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
padding: 0.05rem 0.3rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: var(--color-border);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
opacity: 0.75;
|
|
|
|
|
|
}
|
2026-06-01 22:37:56 +09:00
|
|
|
|
.hint.sm {
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
margin: 0 0 0.75rem;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.msg {
|
|
|
|
|
|
margin: 0.75rem 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg.error {
|
|
|
|
|
|
color: #c0392b;
|
|
|
|
|
|
}
|
2026-06-29 09:33:31 +09:00
|
|
|
|
/* 신규 유저 빈 상태 */
|
|
|
|
|
|
.empty-state {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 2.2rem 1rem 2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-emoji {
|
|
|
|
|
|
font-size: 2.6rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-title {
|
|
|
|
|
|
margin-top: 0.6rem;
|
|
|
|
|
|
font-size: 1.05rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--color-heading);
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-desc {
|
|
|
|
|
|
margin-top: 0.3rem;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-cta {
|
|
|
|
|
|
margin-top: 1.1rem;
|
|
|
|
|
|
padding: 0.7rem 1.6rem;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
background: hsla(160, 100%, 37%, 1);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-cta:hover {
|
|
|
|
|
|
background: hsla(160, 100%, 32%, 1);
|
|
|
|
|
|
}
|
2026-06-24 21:31:39 +09:00
|
|
|
|
/* 인앱 확인 모달 */
|
|
|
|
|
|
.modal-backdrop {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.5);
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 340px;
|
|
|
|
|
|
padding: 1.5rem 1.25rem calc(1.25rem + env(safe-area-inset-bottom));
|
|
|
|
|
|
background: var(--color-background);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal h2 {
|
|
|
|
|
|
font-size: 1.05rem;
|
|
|
|
|
|
margin: 0 0 0.6rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.confirm-body {
|
|
|
|
|
|
margin: 0 0 1rem;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
white-space: pre-line;
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttons {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.fade-enter-active,
|
|
|
|
|
|
.fade-leave-active {
|
|
|
|
|
|
transition: opacity 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.fade-enter-from,
|
|
|
|
|
|
.fade-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</style>
|