예약 포털 (Vue3 + Firebase) - 서비스 오픈까지

26. 예약 포털 (Vue3 + Vuetify + Firebase) - 카페 메뉴 품절 관리

그랜파 개발자 2025. 6. 13. 15:12

카페 메뉴 품절 관리

메뉴가 품절인 경우에 대한 처리를 생각해 봅시다.

 

품절이 확인 되면 관리자가 우선 메뉴의 품절 상태를 설정합니다.

그러면 온라인 주문 메뉴 리스트에 품절을 표시해야 합니다.

품절된 품목에 대해서는 주문이 되지 않습니다.

 

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

 

운영 대시보드


- src/views/OperationsDashboard.vue

<!-- src/views/OperationsDashboard.vue -->
<template>
  <v-container>
    <v-card>
      <v-card-title class="text-h5 font-weight-bold">운영자 대시보드</v-card-title>
      <v-divider />

      <v-card-text>
        <v-row dense>
          <v-col
            cols="12" sm="6" md="4"
            v-for="link in dashboardLinks"
            :key="link.title"
          >
            <v-card
              class="pa-4 d-flex flex-column align-center justify-center"
              hover
              :to="!link.action && link.route ? link.route : undefined"
              @click="link.action ? link.action() : null"
              style="cursor: pointer;"
            >
              <v-icon :icon="link.icon" size="48" color="primary" class="mb-3" />
              <div class="text-subtitle-1 font-weight-medium">{{ link.title }}</div>
              <div class="text-body-2 text-grey-darken-1">{{ link.description }}</div>
            </v-card>
          </v-col>
        </v-row>
      </v-card-text>
    </v-card>
  </v-container>
</template>

<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

const companyId = route.query.companyId
const companyName = route.query.companyName || ''

const goToMenuList = () => {
  if (!companyId || !companyName) {
    console.error('Missing companyId or companyName')
    alert('회사 정보를 찾을 수 없습니다.')
    return
  }

  router.push({
    name: 'MenuList',
    params: { companyId },
    query: { companyName }
  })
}

const goToSoldOutManager = () => {
  if (!companyId || !companyName) {
    console.error('Missing companyId or companyName')
    alert('회사 정보를 찾을 수 없습니다.')
    return
  }

  router.push({
    name: 'SoldOutManager',
    query: { companyId, companyName }
  })
}

const goToOrderManager = () => {
  if (!companyId || !companyName) {
    console.error('Missing companyId or companyName')
    alert('회사 정보를 찾을 수 없습니다.')
    return
  }

  router.push({
    name: 'OrderManager',
    query: { companyId, companyName }
  })
}

const dashboardLinks = ref([
  {
    title: '메뉴 관리',
    description: '메뉴 추가, 수정, 삭제',
    icon: 'mdi-food',
    action: goToMenuList
  },
  {
    title: '메뉴 품절 관리',
    description: '품절 메뉴 설정 및 해제',
    icon: 'mdi-cancel',
    action: goToSoldOutManager
  },
  {
    title: '고객 주문 확인',
    description: '실시간 주문 목록 보기',
    icon: 'mdi-clipboard-list',
    action: goToOrderManager
  },
  {
    title: '주문 완료 및 픽업 알림',
    description: '주문 상태 업데이트 및 알림 전송',
    route: '/admin/notify',
    icon: 'mdi-bell-ring'
  },
  {
    title: '고객 주문 취소 처리',
    description: '고객 요청 주문 취소 승인',
    route: '/admin/cancel',
    icon: 'mdi-cancel'
  }
])
</script>

 

품절 관리로 이동

const goToSoldOutManager = () => {
  if (!companyId || !companyName) {
    console.error('Missing companyId or companyName')
    alert('회사 정보를 찾을 수 없습니다.')
    return
  }

  router.push({
    name: 'SoldOutManager',
    query: { companyId, companyName }
  })
}

 

카페 메뉴 품절 상태 관리

 

- src/views/SoldOutManager.vue

<!-- src/views/SoldOutManager.vue -->
<template>
  <v-container>
    <!-- 카테고리 탭 -->
    <div class="d-flex flex-wrap mb-4" style="gap: 12px;">
      <v-chip
        v-for="category in menus"
        :key="category.categoryId"
        color="primary"
        class="text-white cursor-pointer"
        @click="scrollToCategory(category.categoryId)"
      >
        {{ category.categoryName }}
      </v-chip>
    </div>

    <!-- 메뉴 카드 목록 -->
    <v-card>
      <v-card-title class="text-h6">메뉴 품절 상태 관리</v-card-title>
      <v-divider />
      <v-card-text>
        <v-row>
          <v-col
            v-for="category in menus"
            :key="category.categoryId"
            cols="12"
          >
            <!-- 카테고리 구역 (스크롤 타겟) -->
            <div :ref="el => categoryRefs[category.categoryId] = el" />
            <div class="text-h6 mb-2">{{ category.categoryName }}</div>

            <v-row>
              <v-col
                v-for="menu in category.menus"
                :key="menu.id"
                cols="12"
                sm="6"
                md="4"
              >
                <v-card class="pa-2 d-flex flex-column align-center text-center">
                  <v-img :src="menu.imageUrl" width="160" height="160" class="mb-2" cover />
                  <div class="text-subtitle-1 font-weight-bold">{{ menu.name }}</div>
                  <div class="text-body-2 mb-2">가격: {{ menu.price.toLocaleString() }}원</div>

                  <v-switch
                    v-model="menu.isSoldOut"
                    label="매진 상태"
                    color="red"
                    inset
                    @change="() => updateMenu(menu.id, menu)"
                  />
                </v-card>
              </v-col>
            </v-row>
          </v-col>
        </v-row>
      </v-card-text>
    </v-card>

    <!-- 맨 위로 버튼 (스크롤 시에만 보임) -->
    <v-btn
      v-if="showScrollTop"
      icon
      color="primary"
      class="position-fixed"
      style="bottom: 24px; right: 24px; z-index: 10"
      @click="scrollToTop"
    >
      <v-icon>mdi-arrow-up</v-icon>
    </v-btn>

  </v-container>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMenus } from '@/composables/useMenus'

const route = useRoute()
const companyId = route.query.companyId
const { menus, fetchMenus, updateMenu } = useMenus(companyId)

// 카테고리 DOM 참조 저장용
const categoryRefs = ref({})
const showScrollTop = ref(false)

// 특정 카테고리로 스크롤 이동
const scrollToCategory = (categoryId) => {
  const el = categoryRefs.value[categoryId]
  if (el?.scrollIntoView) {
    el.scrollIntoView({ behavior: 'smooth', block: 'start' })
  }
}

// 맨 위로 이동
const scrollToTop = () => {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}

// 스크롤 감지하여 버튼 표시 토글
const handleScroll = () => {
  showScrollTop.value = window.scrollY > 100
}

onMounted(() => {
  fetchMenus()
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})
</script>

 

 

온라인 주문 품절 메뉴

 

- src/views/OrderPage.vue

<!-- src/views/OrderPage.vue -->
<template>
  <v-container fluid>
    <v-card flat>
      <v-card-title class="text-h6 px-4">{{ companyName }} 메뉴</v-card-title>

      <!-- 카테고리 태그 선택 -->
      <div class="px-4 py-2 overflow-x-auto whitespace-nowrap">
        <v-chip-group
          v-model="selectedCategoryId"
          class="flex-nowrap"
          mandatory
          selected-class="bg-primary text-white"
        >
          <v-chip
            v-for="group in menus"
            :key="group.categoryId"
            :value="group.categoryId"
            variant="elevated"
            class="ma-1"
          >
            {{ group.categoryName }}
          </v-chip>
        </v-chip-group>
      </div>

      <v-divider class="my-2" />

      <!-- 메뉴 카드 리스트 -->
      <v-row dense class="px-2" justify="center">
        <v-col
          v-for="menu in filteredMenus"
          :key="menu.id"
          cols="12"
          class="d-flex justify-center"
        >
          <v-card
            class="mt-4 pa-2 d-flex flex-column align-center"
            max-width="600"
            style="width: 100%;"
            elevation="1"
            rounded="lg"
            :class="{ 'sold-out': menu.isSoldOut }"
            @click="!menu.isSoldOut && selectMenu(menu)"
          >
            <v-img
              :src="menu.imageUrl"
              cover
              width="220"
              height="220"
              class="rounded-lg mb-2"
            />
            <div class="font-weight-bold text-subtitle-1 text-center">
              {{ menu.name }}
            </div>
            <div class="text-body-2 text-grey-darken-1 text-center">
              {{ menu.description }}
            </div>
            <div class="mt-1 text-body-1 text-primary text-center">
              {{ (menu.price ?? 0).toLocaleString() }}원
            </div>

            <div v-if="menu.isSoldOut" class="sold-out-label text-center text-red">
              품절
            </div>

            <!-- 토핑/옵션 선택 UI: 항상 열려 있음 -->
            <div class="mt-6 px-2 text-center">
              <div class="mb-2">
                <strong>토핑 선택</strong>
                <v-checkbox
                  v-for="t in filteredToppings(menu)"
                  :key="t.id"
                  :label="`${t.name} (+${Number(t.price).toLocaleString()}원)`"
                  :value="t.id"
                  v-model="selectedToppings[menu.id]"
                  density="compact"
                  hide-details
                  :disabled="menu.isSoldOut"
                />
              </div>

              <div>
                <strong>옵션 선택</strong>
                <v-radio-group v-model="selectedOption[menu.id]" density="compact">
                  <v-radio
                    v-for="o in filteredOptions(menu)"
                    :key="o.id"
                    :label="o.name"
                    :value="o.id"
                    :disabled="menu.isSoldOut"
                  />
                </v-radio-group>
              </div>

              <v-row class="mt-2 mb-4" dense justify="center" style="gap: 10px;">
                <v-col cols="auto">
                  <v-btn
                    color="primary"
                    style="min-width: 120px;"
                    @click.stop="addToCart(menu)"
                    :disabled="menu.isSoldOut"
                  >
                    담기
                  </v-btn>
                </v-col>
                <v-col cols="auto">
                  <v-btn color="secondary" style="min-width: 120px;" @click="goToCart">
                    장바구니
                  </v-btn>
                </v-col>
              </v-row>
            </div>
          </v-card>
        </v-col>
      </v-row>

      <v-alert v-if="!filteredMenus.length" type="info" class="ma-4">
        해당 카테고리에 메뉴가 없습니다.
      </v-alert>
    </v-card>
  </v-container>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMenus } from '@/composables/useMenus'
import { useToppingManagement } from '@/composables/useToppingManagement'
import { useIceHotManager } from '@/composables/useIceHotManager'

const route = useRoute()
const router = useRouter()

const companyId = route.query.companyId ?? ''
const companyName = route.query.companyName || ''

// 메뉴 관리
const { menus, fetchMenus } = useMenus(companyId)
const selectedCategoryId = ref(null)
const selectedMenuId = ref(null)

// 토핑 관리 (Firestore에서 불러옴)
const {
  toppings,
  loadToppings,
} = useToppingManagement(companyId)

// 옵션 관리 (Firestore에서 불러옴)
const {
  options,
  fetchOptions: fetchIceHotOptions,
} = useIceHotManager(companyId)

// 메뉴별 선택 상태 (객체: key = menu.id)
const selectedToppings = ref({})
const selectedOption = ref({})

// 필터링된 메뉴
const filteredMenus = computed(() => {
  const group = menus.value.find(g => g.categoryId === selectedCategoryId.value)
  return group?.menus || []
})

watch(filteredMenus, (newMenus) => {
  newMenus.forEach(menu => {
    // 토핑 초기화
    if (!selectedToppings.value[menu.id]) {
      selectedToppings.value[menu.id] = []
    }

    // 옵션 자동 선택 로직
    const validOptions = filteredOptions(menu)
    if (validOptions.length === 1) {
      selectedOption.value[menu.id] = validOptions[0].id
    } else if (!selectedOption.value[menu.id]) {
      selectedOption.value[menu.id] = null // 명시적으로 비움
    }
  })
}, { immediate: true })

// 메뉴별 허용 토핑 필터링 함수
const filteredToppings = (menu) => {
  if (!menu.toppingIds?.length) return []
  const result = toppings.value.filter(t => menu.toppingIds.includes(t.id))
  return result;
}

// 메뉴별 허용 옵션 필터링 함수
const filteredOptions = (menu) => {
  if (!menu.optionIds?.length) return []
  return options.value.filter(o => menu.optionIds.includes(o.id))
}

// 메뉴 클릭 토글 (선택/해제)
const selectMenu = (menu) => {
  if (selectedMenuId.value === menu.id) {
    selectedMenuId.value = null
  } else {
    selectedMenuId.value = menu.id
  }
}

const addToCart = (menu) => {
  if (menu.isSoldOut) {
    alert('해당 메뉴는 매진되어 주문할 수 없습니다.')
    return
  }

  const toppingsSelectedIds = selectedToppings.value[menu.id] || []
  const optionSelectedId = selectedOption.value[menu.id]

  // 옵션이 2개 이상인데 선택하지 않은 경우
  const optionsForMenu = filteredOptions(menu)
  if (optionsForMenu.length >= 2 && !optionSelectedId) {
    alert('옵션을 선택해주세요.')
    return
  }

  const selectedData = {
    menuId: menu.id,
    name: menu.name,
    price: menu.price,
    toppings: toppings.value.filter(t => toppingsSelectedIds.includes(t.id)),
    option: options.value.find(o => o.id === optionSelectedId) || null,
    imageUrl: menu.imageUrl,
  }

  const cart = JSON.parse(localStorage.getItem('cart') || '[]')
  cart.push(selectedData)
  localStorage.setItem('cart', JSON.stringify(cart))
}

const goToCart = () => {
  router.push({
    path: '/cart',
    query: {
      companyId: route.query.companyId,
      companyName: route.query.companyName
    }
  })
}

onMounted(async () => {
  await fetchMenus()
  await loadToppings()
  await fetchIceHotOptions()
  selectedCategoryId.value = menus.value[0]?.categoryId ?? null
})
</script>

<style scoped>
.sold-out {
  filter: grayscale(80%);
  pointer-events: none;
  opacity: 0.6;
  user-select: none;
}

.sold-out-label {
  font-weight: bold;
  margin-top: 8px;
  font-size: 1.5rem;
}

</style>

 

 

🌟 핵심 기능 요약

영역 설명
카테고리 선택 메뉴 그룹을 v-chip으로 표시하고 선택 시 메뉴 필터링
메뉴 카드 리스트 메뉴 이미지, 이름, 설명, 가격 표시 + 품절 상태 처리
토핑 선택 v-checkbox로 선택 가능. 메뉴별로 허용된 토핑만 표시, 여러개 선택 가능.
옵션 선택 (ICE/HOT 등) v-radio-group으로 제공. 1개일 경우 자동 선택됨, 하나만 선택. 선택하지 않으면 주문 안됨
장바구니 기능 선택된 정보(LocalStorage 활용)를 기반으로 메뉴를 담을 수 있음
품절 처리 isSoldOut이 true면 회색 처리 + 클릭 방지

 

🧠 중요한 로직 설명

  • selectedToppings / selectedOption: 메뉴 ID를 key로 갖는 객체. 각각의 메뉴에 대해 선택된 토핑/옵션 상태를 저장합니다.
  • filteredMenus: 선택된 카테고리에 해당하는 메뉴들만 표시합니다.
  • filteredToppings / filteredOptions: 메뉴에 허용된 토핑/옵션만 필터링하여 보여줍니다.
  • 옵션 자동 선택: 옵션이 1개일 경우 자동으로 선택됩니다.
  • addToCart: 현재 선택 상태를 localStorage에 추가하며, 담기 전에 옵션 선택 여부도 유효성 검사합니다.
  • goToCart: /cart 페이지로 라우팅하면서 query 정보도 전달합니다.

💡 스타일 처리

  • .sold-out 클래스는 품절인 메뉴를 회색 처리 및 클릭 방지 기능을 포함하고 있습니다.
  • .sold-out-label은 “품절” 텍스트를 시각적으로 강조합니다.