카페 메뉴 관리
메뉴는 카테고리, 이름, 설명, 가격, 가능한 토핑, 옵션의 항목을 가지고 있습니다.
또한 각 메뉴는 이미지도 가지고 있습니다.
메뉴를 등록하기 위하여
카테고리, 토핑, 옵션은 별도로 등록하였습니다.
그리고 메뉴 이미지도 준비 하였습니다.
메뉴 등록 화면의 상단에는 메뉴를 등록하기 위한 폼이 있고
하단에는 등록된 메뉴의 리스트를 보여줍니다.
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
1. 메뉴 등록
메뉴는 다음과 같이 등록합니다.
- 메뉴 이미지 선택
- 메뉴 이름, 설명, 가격 등의 정보 입력
- 가능한 토핑 선택
- Ice/Hot 옵션 선택
- 메뉴 저장
- 등록된 메뉴 리스트에 나타남
등록된 카테고리 순서에 따라 메뉴를 보여 줍니다.
리스트에서 메뉴를 선택하여 마우스로 누른 채 이동 (드래그)하면 메뉴의 순서를 바꿀 수 있습니다.
2. firebase storage에 메뉴 이미지 업로드
Firebase Cloud Storage
Firebase Cloud Storage는 Google Cloud 기반의 파일 저장 서비스로, 앱에서 이미지, 오디오, 비디오 및 기타 사용자 생성 콘텐츠를 안전하게 저장하고 제공할 수 있도록 도와줍니다. 주요 기능은 다음과 같습니다:
🔹 주요 특징
- 강력한 보안: Firebase 인증과 통합되어 파일 접근을 제어할 수 있습니다.
- 자동 확장: 앱이 성장해도 별도의 설정 없이 자동으로 확장됩니다.
- 네트워크 복원력: 업로드 및 다운로드가 중단되더라도 다시 시작할 수 있습니다.
- 다양한 플랫폼 지원: iOS, Android, Web, Flutter, Unity 등에서 사용 가능.
메뉴 이미지 등록 로직
- 로컬 PC에서 이미지를 선택하면
- 이미지를 firebase storage에 업로드한 후 url를 돌려 받습니다.
- 이 url을 메뉴의 이미지 항목에 저장합니다.
const uploadImage = async (file) => {
const fileName = file.name || 'image.png'
const encodedName = encodeURIComponent(fileName)
const storageReference = storageRef(storage,
`menus/${companyId}/${Date.now()}_${encodedName}`)
await uploadBytes(storageReference, file)
return await getDownloadURL(storageReference)
}
encodeURIComponent()
encodeURIComponent() 함수는 JavaScript에서 사용되는 함수로, URI(Uniform Resource Identifier) 구성 요소를 안전하게 인코딩하는 역할을 합니다. 즉, URL에 포함될 수 없는 특수 문자들을 변환하여 올바르게 전달될 수 있도록 도와줍니다.
주요 특징
- 특수 문자 인코딩: &, =, ?, / 등의 문자를 변환하여 URL에서 안전하게 사용할 수 있도록 합니다.
- 공백 처리: 공백( )은 %20으로 변환됩니다.
- 사용 예시:
const query = "JavaScript & Web Development";
const encodedQuery = encodeURIComponent(query);
console.log(encodedQuery); // 출력: "JavaScript%20%26%20Web%20Development"
- encodeURI()와 차이점: encodeURI()는 전체 URL을 인코딩하지만, encodeURIComponent()는 URL의 특정 구성 요소(예: 쿼리 문자열)를 인코딩하는 데 사용됩니다.
uploadBytes()
uploadBytes()는 Firebase Cloud Storage에서 파일을 업로드할 때 사용하는 메서드입니다.
이 메서드는 바이트 배열(Uint8Array)을 사용하여 파일을 업로드할 수 있도록 도와줍니다.
주요 특징
- 파일 업로드: uploadBytes()를 사용하면 Blob, File, 또는 Uint8Array 형식의 데이터를 Firebase Cloud Storage에 업로드할 수 있습니다.
- 경로 지정: 업로드할 파일의 경로를 정확하게 지정해야 합니다. 루트 경로에 직접 업로드하면 오류가 발생할 수 있으므로, 적절한 하위 경로를 설정해야 합니다.
- 사용 예시:
import { getStorage, ref, uploadBytes } from "firebase/storage";
const storage = getStorage();
const storageRef = ref(storage, 'images/myImage.png'); // 저장할 경로 지정
const file = new Uint8Array([/* 파일 데이터 */]);
uploadBytes(storageRef, file).then((snapshot) => {
console.log('파일 업로드 성공!', snapshot);
});
주의할 점
- 루트 경로 업로드 오류: 루트 경로에 직접 업로드하면 storageinvalid-root-operation 오류가 발생할 수 있습니다. 따라서 .child('파일명')을 사용하여 하위 경로를 지정해야 합니다.
- 파일 메타데이터 설정: 업로드할 때 파일의 contentType을 지정하면 올바른 MIME 타입으로 저장할 수 있습니다.
getDownloadURL()
getDownloadURL()은 Firebase Cloud Storage에서 저장된 파일의 다운로드 URL을 가져오는 메서드입니다. 이 URL을 사용하면 파일을 직접 접근하거나 공유할 수 있습니다.
주요 특징
- 파일 접근: 저장된 파일의 URL을 가져와서 웹이나 앱에서 사용할 수 있습니다.
- 비동기 처리: getDownloadURL()은 비동기적으로 실행되며, 성공하면 다운로드 URL을 반환합니다.
- 사용 예시:
import { getStorage, ref, getDownloadURL } from "firebase/storage";
const storage = getStorage();
const storageRef = ref(storage, 'images/myImage.png');
getDownloadURL(storageRef)
.then((url) => {
console.log('다운로드 URL:', url);
})
.catch((error) => {
console.error('오류 발생:', error);
});
- 활용 방법: 다운로드 URL을 얻은 후, 이를
태그의 src 속성에 넣거나 API 요청에 활용할 수 있습니다.
3. 카테고리, 토핑, 옵션 선택
카테고리, 토핑, 옵션은 다중 선택을 할 수 있습니다.
<v-select>
<v-select> 는 Vuetify 프레임워크에서 제공하는 드롭다운 선택 컴포넌트입니다.
Vue.js 기반 프로젝트에서 스타일이 잘 정리된 선택 박스를 쉽게 구현할 수 있도록 도와줍니다.
주요 특징
- 사용자 정의 가능: 아이콘, 라벨, 플레이스홀더 등을 추가할 수 있습니다.
- 다중 선택 지원: 여러 개의 항목을 선택할 수 있습니다.
- 검색 기능 포함: 입력 필드를 통해 항목을 검색할 수 있습니다.
- 반응형 디자인: 다양한 화면 크기에 맞춰 자동 조정됩니다.
사용 예시
<template>
<v-select
v-model="selectedItem"
:items="['옵션 1', '옵션 2', '옵션 3']"
label="항목 선택"
></v-select>
</template>
<script>
export default {
data() {
return {
selectedItem: null
};
}
};
</script>
4. 메뉴 리스트
메뉴는 카테고리를 가지고 있고,
이 카테고리에는 sortOrder를 가지고 있고, 별도 컬렉션에 저장됩니다.
각 카테고리에 속하는 메뉴들도 sortOrder를 가지고 있습니다.
메뉴를 나타낼 때는
카테고리 순서로 카테고리를 나열하고
각 카테고리 속의 메뉴들을 순서대로 나열합니다.
composables - useMenus.js
- fetchMenus
const fetchMenus = async () => {
loading.value = true
// 1. 카테고리 가져오기 (sortOrder 순으로 정렬)
const categorySnapshot = await getDocs(
query(
collection(db, 'companies', companyId, 'categories'),
orderBy('sortOrder', 'asc')
)
)
const categoryList = categorySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}))
// 2. 메뉴 전체 가져오기
const menuSnapshot = await getDocs(
collection(db, 'companies', companyId, 'menus')
)
const menuList = menuSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}))
// 3. 카테고리 순서대로 메뉴를 그룹핑
const grouped = categoryList.map(category => {
return {
categoryId: category.id,
categoryName: category.name,
sortOrder: category.sortOrder,
menus: menuList
.filter(menu => menu.categoryId === category.id)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
}
})
menus.value = grouped
loading.value = false
}
5. MenuManagement 컴포넌트 template
- 등록된 메뉴 리스트
<!-- 등록된 메뉴 리스트 -->
<v-card class="pa-4 mx-auto mt-4" style="max-width: 700px;">
<v-list lines="two" density="comfortable">
<template v-for="(menuList, category) in groupedMenus" :key="category">
<v-list-subheader class="text-h6 font-weight-bold">{{ category }}</v-list-subheader>
<!-- 드래그 가능한 리스트 -->
<draggable
v-model="groupedMenus[category]"
item-key="id"
:group="{ name: 'menus' }"
@end="() => onSort(category)"
>
<template #item="{ element: menu, index }">
<div>
<v-list-item :data-id="menu.id">
<template #prepend>
<v-avatar size="90" rounded>
<v-img :src="menu.imageUrl" />
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold">{{ menu.name }}</v-list-item-title>
<div v-if="menu.description">설명: {{ menu.description }}</div>
<div>가격: {{ Number(menu.price).toLocaleString() }}원</div>
<div>
토핑:
<span v-if="menu.toppingIds?.length">
{{ getToppingNames(menu.toppingIds).join(', ') || '토핑 없음' }}
</span>
<span v-else>없음</span>
</div>
<div>
옵션:
<span v-if="menu.optionIds?.length">
{{ getOptionNames(menu.optionIds).join(', ') }}
</span>
<span v-else>없음</span>
</div>
<template #append>
<v-btn size="small" color="primary" class="mr-2" @click="onEdit(menu)">수정</v-btn>
<v-btn size="small" color="error" @click="onDelete(menu.id)">삭제</v-btn>
</template>
</v-list-item>
<v-divider v-if="index < menuList.length - 1" />
</div>
</template>
</draggable>
<v-divider class="my-4" />
</template>
</v-list>
</v-card>
코드 설명
<draggable
v-model="groupedMenus[category]"
item-key="id"
:group="{ name: 'menus' }"
@end="() => onSort(category)"
>
- v-model="groupedMenus[category]": groupedMenus 객체에서 특정 category에 해당하는 항목을 바인딩합니다.
- item-key="id": 각 항목의 고유 키(id)를 설정하여 Vue가 효율적으로 렌더링할 수 있도록 합니다.
- :group="{ name: 'menus' }": 같은 그룹(menus) 내에서 항목을 이동할 수 있도록 설정합니다.
- @end="() => onSort(category)": 드래그가 끝난 후 onSort(category) 함수를 실행하여 정렬된 데이터를 반영합니다.
Vue.Draggable는 Vue.js에서 드래그 앤 드롭 기능을 구현할 수 있도록 도와주는 컴포넌트입니다. 이 라이브러리는 Sortable.js를 기반으로 하며, 리스트 항목을 자유롭게 이동하고 정렬할 수 있습니다.
6. 메뉴 관리 component
- sr/views/MenuManagement.vue
<!-- sr/views/MenuManagement.vue -->
<template>
<v-container>
<v-card class="pa-4 mx-auto" style="max-width: 700px;">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6">
{{ companyName }} - 메뉴 등록
</span>
<v-spacer />
<!-- 메뉴로 이동 버튼 -->
<v-btn variant="text" class="mt-4" color="primary" @click="goToMenu">
메뉴로 가기
</v-btn>
</v-card-title>
<v-form @submit.prevent="onSubmit" ref="formRef">
<!-- 카테고리 선택 -->
<v-select
v-model="form.categoryId"
:items="categories"
item-title="name"
item-value="id"
label="카테고리 선택"
required
/>
<v-text-field v-model="form.name" label="메뉴 이름" required />
<v-textarea v-model="form.description" rows="3" label="설명" />
<v-text-field
v-model.number="form.price"
label="가격"
type="number"
min="0"
required
/>
<!-- 토핑 선택 (다중) -->
<v-select
v-model="form.toppingIds"
:items="toppings"
item-title="name"
item-value="id"
label="선택 가능한 토핑"
multiple
chips
/>
<!-- 옵션 선택 (다중) -->
<v-select
v-model="form.optionIds"
:items="options"
item-title="name"
item-value="id"
label="선택 가능한 옵션"
multiple
chips
/>
<!-- 이미지 업로드 -->
<v-file-input
label="메뉴 이미지 선택"
accept="image/*"
@change="onImageChange"
show-size
clearable
/>
<v-card-actions>
<v-spacer />
<v-btn type="submit" color="primary" :loading="loading">
{{ isEditMode ? '수정' : '등록' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
<!-- 등록된 메뉴 리스트 -->
<v-card class="pa-4 mx-auto mt-4" style="max-width: 700px;">
<v-list lines="two" density="comfortable">
<template v-for="(menuList, category) in groupedMenus" :key="category">
<v-list-subheader class="text-h6 font-weight-bold">{{ category }}</v-list-subheader>
<!-- 드래그 가능한 리스트 -->
<draggable
v-model="groupedMenus[category]"
item-key="id"
:group="{ name: 'menus' }"
@end="() => onSort(category)"
>
<template #item="{ element: menu, index }">
<div>
<v-list-item :data-id="menu.id">
<template #prepend>
<v-avatar size="90" rounded>
<v-img :src="menu.imageUrl" />
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold">{{ menu.name }}</v-list-item-title>
<div v-if="menu.description">설명: {{ menu.description }}</div>
<div>가격: {{ Number(menu.price).toLocaleString() }}원</div>
<div>
토핑:
<span v-if="menu.toppingIds?.length">
{{ getToppingNames(menu.toppingIds).join(', ') || '토핑 없음' }}
</span>
<span v-else>없음</span>
</div>
<div>
옵션:
<span v-if="menu.optionIds?.length">
{{ getOptionNames(menu.optionIds).join(', ') }}
</span>
<span v-else>없음</span>
</div>
<template #append>
<v-btn size="small" color="primary" class="mr-2" @click="onEdit(menu)">수정</v-btn>
<v-btn size="small" color="error" @click="onDelete(menu.id)">삭제</v-btn>
</template>
</v-list-item>
<v-divider v-if="index < menuList.length - 1" />
</div>
</template>
</draggable>
<v-divider class="my-4" />
</template>
</v-list>
</v-card>
</v-container>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMenus } from '@/composables/useMenus'
import draggable from 'vuedraggable'
const route = useRoute()
const router = useRouter()
const companyId = route.params.companyId
const companyName = route.query.companyName || ''
const {
menus,
categories,
toppings,
options,
loading,
fetchMenus,
addMenu,
deleteMenu,
updateMenu,
updateMenuOrder,
getCategories,
getToppings,
getOptions
} = useMenus(companyId)
const formRef = ref(null)
const form = reactive({
categoryId: '',
name: '',
description: '',
price: 0,
toppingIds: [],
optionIds: []
})
const imageFile = ref(null)
const isEditMode = ref(false)
const editingMenuId = ref(null)
const onSort = async (category) => {
const sortedMenus = groupedMenus.value[category]
// 메뉴 배열 menus.value도 순서에 맞게 재정렬 필요 (옵션)
await updateMenuOrder(sortedMenus) // composable 함수 호출
await fetchMenus() // 변경 반영 위해 다시 불러오기 (필요시)
}
const onImageChange = (event) => {
const files = event.target.files;
imageFile.value = files && files.length > 0 ? files[0] : null;
}
const groupedMenus = computed(() => {
const result = {}
menus.value.forEach(group => {
result[group.categoryName] = group.menus
})
return result
})
const onSubmit = async () => {
if (!form.categoryId || !form.name || form.price <= 0) {
alert('카테고리, 이름, 가격은 필수입니다.')
return
}
const menuData = {
categoryId: form.categoryId,
name: form.name,
description: form.description,
price: form.price,
toppingIds: form.toppingIds,
optionIds: form.optionIds,
...(isEditMode.value && form.imageUrl ? { imageUrl: form.imageUrl } : {})
}
if (isEditMode.value && editingMenuId.value) {
await updateMenu(editingMenuId.value, menuData, imageFile.value)
alert('메뉴가 수정되었습니다.')
} else {
menuData.sortOrder = 0;
await addMenu(menuData, imageFile.value)
alert('메뉴가 등록되었습니다.')
}
// 폼 초기화
resetForm()
}
const resetForm = () => {
form.categoryId = ''
form.name = ''
form.description = ''
form.price = 0
form.toppingIds = []
form.optionIds = []
form.imageUrl = ''
imageFile.value = null
isEditMode.value = false
editingMenuId.value = null
if (formRef.value) formRef.value.resetValidation()
}
const onEdit = (menu) => {
form.categoryId = menu.categoryId
form.name = menu.name
form.description = menu.description
form.price = menu.price
form.toppingIds = [...(menu.toppingIds || [])]
form.optionIds = [...(menu.optionIds || [])]
form.imageUrl = menu.imageUrl || ''
imageFile.value = null
editingMenuId.value = menu.id
isEditMode.value = true
}
const onDelete = async (id) => {
if (confirm('정말 삭제하시겠습니까?')) {
await deleteMenu(id)
alert('삭제되었습니다.')
}
}
const getCategoryName = (id) => {
const cat = categories.value.find(c => c.id === id)
return cat ? cat.name : '알 수 없음'
}
const getToppingNames = (ids) => {
const result = toppings.value
.filter(t => ids.includes(t.id))
.map(t => t.name)
return result;
}
const getOptionNames = (ids) => {
const result = options.value
.filter(o => ids.includes(o.id))
.map(o => o.name)
return result;
}
const goToMenu = () => {
router.push({ name: 'MenuList', params: { companyId }, query: { companyName } })
}
onMounted(async () => {
await Promise.all([getCategories(), getToppings(), getOptions(), fetchMenus()])
})
</script>
8. 메뉴 관리 composable
- src/composables/useMenus.js
// src/composables/useMenus.js
import { ref as storageRef, uploadBytes, getDownloadURL } from 'firebase/storage'
import { db, storage } from '@/firebase'
import {
collection,
addDoc,
getDocs,
doc,
updateDoc,
deleteDoc,
query,
where,
orderBy,
writeBatch,
serverTimestamp
} from 'firebase/firestore'
import { ref } from 'vue'
export function useMenus(companyId) {
const menus = ref([])
const categories = ref([])
const toppings = ref([])
const options = ref([])
const loading = ref(false)
const fetchMenus = async () => {
loading.value = true
// 1. 카테고리 가져오기 (sortOrder 순으로 정렬)
const categorySnapshot = await getDocs(
query(
collection(db, 'companies', companyId, 'categories'),
orderBy('sortOrder', 'asc')
)
)
const categoryList = categorySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}))
// 2. 메뉴 전체 가져오기
const menuSnapshot = await getDocs(
collection(db, 'companies', companyId, 'menus')
)
const menuList = menuSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}))
// 3. 카테고리 순서대로 메뉴를 그룹핑
const grouped = categoryList.map(category => {
return {
categoryId: category.id,
categoryName: category.name,
sortOrder: category.sortOrder,
menus: menuList
.filter(menu => menu.categoryId === category.id)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
}
})
menus.value = grouped
loading.value = false
}
const uploadImage = async (file) => {
const fileName = file.name || 'image.png'
const encodedName = encodeURIComponent(fileName)
const storageReference = storageRef(storage,
`menus/${companyId}/${Date.now()}_${encodedName}`)
await uploadBytes(storageReference, file)
return await getDownloadURL(storageReference)
}
const addMenu = async (menu, imageFile) => {
loading.value = true
const imageUrl = await uploadImage(imageFile)
const payload = {
...menu,
imageUrl: imageUrl || '',
createdAt: serverTimestamp(),
updatedAt: serverTimestamp()
}
await addDoc(collection(db, 'companies', companyId, 'menus'), payload)
await fetchMenus()
loading.value = false
}
const updateMenu = async (menuId, menu, imageFile) => {
loading.value = true
let imageUrl = menu.imageUrl || ''
if (imageFile) {
imageUrl = await uploadImage(imageFile)
}
const payload = {
...menu,
imageUrl,
updatedAt: serverTimestamp()
}
delete payload.id
await updateDoc(doc(db, 'companies', companyId, 'menus', menuId), payload)
await fetchMenus()
loading.value = false
}
const updateMenuOrder = async (sortedMenus) => {
loading.value = true
try {
const batch = writeBatch(db)
sortedMenus.forEach((menu, index) => {
const menuRef = doc(db, 'companies', companyId, 'menus', menu.id)
batch.update(menuRef, { sortOrder: index })
})
await batch.commit()
} catch (error) {
console.error('메뉴 순서 저장 실패:', error)
}
loading.value = false
}
const deleteMenu = async (menuId) => {
loading.value = true
await deleteDoc(doc(db, 'companies', companyId, 'menus', menuId))
await fetchMenus()
loading.value = false
}
const getCategories = async () => {
const q = query(
collection(db, 'companies', companyId, 'categories'),
orderBy('sortOrder', 'asc')
)
const snapshot = await getDocs(q)
categories.value = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }))
}
const getToppings = async () => {
const q = query(
collection(db, 'companies', companyId, 'toppings'),
orderBy('sortOrder', 'asc')
)
const snapshot = await getDocs(q)
toppings.value = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }))
}
const getOptions = async () => {
const q = query(
collection(db, 'companies', companyId, 'icehotOptions'),
orderBy('sortOrder', 'asc')
)
const snapshot = await getDocs(q)
options.value = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }))
}
return {
menus,
categories,
toppings,
options,
loading,
fetchMenus,
addMenu,
updateMenu,
updateMenuOrder,
deleteMenu,
getCategories,
getToppings,
getOptions
}
}
'예약 포털 (Vue3 + Firebase) - 서비스 오픈까지' 카테고리의 다른 글
24. 예약 포털 (Vue3 + Vuetify + Firebase) - 카페 온라인 주문 (0) | 2025.06.12 |
---|---|
23. 예약 포털 (Vue3 + Vuetify + Firebase) - 카페 메뉴 관리 완성 (1) | 2025.06.11 |
21. 동네 포털 (Vue 3 + Vuetify) - Firebase Storage에 메뉴 이미지 업로드 (1) | 2025.06.10 |
20. 동네 포털 (Vue 3 + Vuetify + Firebase) - 음료 Ice/Hot 옵션 관리 (0) | 2025.06.09 |
19. 동네 포털 (Vue 3 + Vuetify + Firebase) - 온라인 주문 음료 토핑 관리 (0) | 2025.06.08 |