18. 동네 포털 (Vue 3 + Vuetify + Firebase) - 메뉴 카테고리 관리
메뉴 카테고리 관리
테이크아웃 커피점의 메뉴 관리를 생각해 봅니다.
음료의 종류가 생각보다 많습니다.
음료 외에 토핑, 사이드 메뉴까지 있습니다.
우선 메뉴 관리를 위하여
카테고리를 등록하여
카테고리 별로 메뉴를 볼 수 있도록 하겠습니다.
카테고리를 등록하면
등록한 카테고리는 리스트에 나타납니다.
카테고리 리스트의 카테고리 옆 삭제 아이콘을 누르면 카테고리가 삭제 됩니다.
카테고리의 순서는 메뉴를 나타낼 때 카테고리 순서로 나타내기 위해 필요합니다.
카테고리 리스트에서 카테고리를 드래그하면 순서를 변경할 수 있습니다.
https://github.com/inetsos/downtown
GitHub - inetsos/downtown: 동네 포털 - Vue 3 + Firebase
동네 포털 - Vue 3 + Firebase. Contribute to inetsos/downtown development by creating an account on GitHub.
github.com
vuedraggable
vuedraggable는 Vue.js에서 드래그 앤 드롭(Drag & Drop) 기능을 쉽게 구현할 수 있게 해주는 인기 라이브러리입니다. 이 라이브러리는 SortableJS를 기반으로 만들어졌으며, 리스트 재정렬이나 카드 이동 같은 UI 기능을 쉽게 구현할 수 있게 도와줍니다.
v-data-table
v-data-table은 Vuetify에서 제공하는 강력한 표 형식의 데이터 표시 컴포넌트입니다. 데이터 목록을 표로 보여주고, 정렬, 페이징, 필터링, 선택, 사용자 정의 셀 렌더링 등 다양한 기능을 제공합니다.
메뉴 관리 페이지에 ‘카테고리 관리’
Vue.js에서 드래그 앤 드롭(Drag & Drop)
드래그 아이콘을 누르고 드래그 하면 순서 변경할 수 있습니다.
삭제 아이콘을 누르면 등록된 카테고리가 삭제 됩니다.
src/views/CategoryManagement - 메뉴 카테고리 관리
<!-- src/views/CategoryManagement -->
<template>
<v-container>
<v-card class="pa-4 mx-auto" style="max-width: 600px;">
<v-card-title class="d-flex justify-space-between align-center">
<span>{{ companyName }} - 카테고리 관리 (드래그로 순서 변경)</span>
<v-spacer />
<v-btn variant="text" class="mt-4" color="primary" @click="goToMenu">
메뉴로 가기
</v-btn>
</v-card-title>
<v-form @submit.prevent="addCategory">
<v-row class="align-center">
<v-col cols="12" md="6">
<v-text-field v-model="newCategoryName" label="카테고리 이름" />
</v-col>
<v-col cols="12" md="6" class="d-flex justify-end">
<v-btn color="primary" type="submit" class="mt-2 mt-md-0">
등록
</v-btn>
</v-col>
</v-row>
</v-form>
<v-divider class="my-4" />
<!-- v-table with draggable rows -->
<v-table>
<thead>
<tr>
<th style="width: 40px;"></th>
<th>카테고리명</th>
<th class="text-end" style="width: 80px;">순서</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<draggable
tag="tbody"
v-model="categories"
item-key="id"
handle=".drag-handle"
@end="saveOrder"
>
<template #item="{ element }">
<tr>
<td>
<v-icon class="drag-handle" color="grey darken-1" size="20" style="cursor: grab;">
mdi-drag
</v-icon>
</td>
<td>{{ element.name }}</td>
<td class="text-end">{{ element.sortOrder }}</td>
<td>
<v-icon
color="error"
class="cursor-pointer"
@click="confirmDelete(element.id)"
>
mdi-delete
</v-icon>
</td>
</tr>
</template>
</draggable>
</v-table>
</v-card>
</v-container>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCategoryManager } from '@/composables/useCategoryManager'
import draggable from 'vuedraggable'
const router = useRouter()
const route = useRoute()
const companyId = route.params.companyId
const companyName = route.query.companyName || ''
const {
categories,
newCategoryName,
fetchCategories,
addCategory,
deleteCategory,
saveOrder,
} = useCategoryManager(companyId)
const goToMenu = () => {
router.push({ name: 'MenuList', params: { companyId }, query: { companyName } })
}
onMounted(fetchCategories)
function confirmDelete(id) {
if (window.confirm('정말 삭제하시겠습니까?')) {
deleteCategory(id)
}
}
</script>
<style scoped>
.drag-handle {
cursor: grab;
}
</style>
src/composables/useCategoryManager.js - 카테고리 관리 DB 연동
// src/composables/useCategoryManager.js
import { ref } from 'vue'
import { collection, getDocs, addDoc, deleteDoc, doc, updateDoc } from 'firebase/firestore'
import { db } from '@/firebase'
export function useCategoryManager(companyId) {
const categories = ref([])
const newCategoryName = ref('')
const menusByCategory = ref({})
const categoryNames = ref([])
const fetchCategories = async () => {
const snap = await getDocs(collection(db, 'companies', companyId, 'categories'))
categories.value = snap.docs
.map(doc => ({ id: doc.id, ...doc.data() }))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
}
const addCategory = async () => {
if (!newCategoryName.value.trim()) return
const maxSort = categories.value.reduce((max, c) => Math.max(max, c.sortOrder ?? 0), 0)
await addDoc(collection(db, 'companies', companyId, 'categories'), {
name: newCategoryName.value.trim(),
sortOrder: maxSort + 1,
})
newCategoryName.value = ''
await fetchCategories()
}
const deleteCategory = async (id) => {
await deleteDoc(doc(db, 'companies', companyId, 'categories', id))
await fetchCategories()
}
const saveOrder = async () => {
const batchUpdates = categories.value.map((cat, index) =>
updateDoc(doc(db, 'companies', companyId, 'categories', cat.id), {
sortOrder: index,
})
)
await Promise.all(batchUpdates)
await fetchCategories()
}
const groupMenusByCategory = (allMenus) => {
const grouped = {}
allMenus.forEach(menu => {
const category = menu.category || '기타'
if (!grouped[category]) grouped[category] = []
grouped[category].push(menu)
})
menusByCategory.value = grouped
categoryNames.value = Object.keys(grouped).sort((a, b) => {
const aOrder = categories.value.find(c => c.name === a)?.sortOrder ?? 9999
const bOrder = categories.value.find(c => c.name === b)?.sortOrder ?? 9999
return aOrder - bOrder
})
//console.log('categoryNames.value:', categoryNames.value)
}
return {
categories,
newCategoryName,
categoryNames,
menusByCategory,
fetchCategories,
addCategory,
deleteCategory,
saveOrder,
groupMenusByCategory
}
}