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

33. 예약 포털 (Vue3 + Firebase) - 카페 시간대별 매출 분석

그랜파 개발자 2025. 6. 18. 11:50

1. 카페 시간대별 매출 분석의 장점

시간대별로 매출과 주문량을 함께 시각화하면 유용한 인사이트를 얻을 수 있습니다.

 

⏰ 고객 행동 패턴 파악

- 어떤 시간대에 주문이 몰리는지 확인할 수 있어요.

- 예: 오전 11시~12시에 주문이 집중된다면, 점심시간 프로모션이 효과적이었다는 걸 뜻할 수 있죠.

 

💰 매출 대비 효율 분석

- 주문량이 많지만 매출이 낮다면 단가가 낮은 메뉴가 인기가 많다는 신호일 수 있어요.

- 반대로 주문량은 적은데 매출이 높은 시간대는 고가 제품이 주로 팔리는 구간일 수도 있죠.

 

🧑‍🍳 운영 전략 최적화

- 주문과 매출이 많은 시간대엔 인력을 집중 배치하고,

- 한산한 시간엔 직원 교육, 준비 작업 등을 배치하면 효율적인 운영이 가능해요.

 

🎯 시간대별 마케팅 타깃팅

- 매출이 낮은 시간대엔 할인, 적립 이벤트를 시도해보고,

- 높은 시간대엔 추가 판매(업셀링) 전략을 적용해볼 수 있어요.

 

만약 두 데이터(매출 & 주문량)를 이중 Y축 차트로 동시에 보여주어

흐름을 한눈에 비교할 수 있어서 직관적입니다.

 

기간을 입력하면 Firebase Firestore에 저장된 주문 데이터를 기준으로, 입력 기간 내 시간대별 매출(totalAmount)과 주문 수(order count)를 트렌드 차트로 화면에 출력합니다.

 

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

 

2. 카페 시간대별 매출 분석

시간대별 매출 분석 - 데스크탑
시간대별 매출 분석 - 모바일

 

시간대별 매출 분석 뷰

  • src/views/HourlySalesAnalysis.vue
<!-- src/views/HourlySalesAnalysis.vue-->
<template>
  <v-card>
    <!-- 운영 대시보드로 돌아가기 버튼 -->
    <div class="d-flex justify-end mt-4 mb-4 mr-2">
      <v-btn
        text
        color="primary"
        class="text-subtitle-2"
        @click="goToDashboard"
        elevation="0"
      >
        <v-icon left>mdi-arrow-left</v-icon>
        운영 대시보드
      </v-btn>
    </div>

    <v-card-title class="headline font-weight-bold">시간대별 매출 분석</v-card-title>

    <v-card-text>
      <!-- 날짜 선택 -->
      <v-row class="mb-4" align="center" dense>
        <v-col cols="12" sm="5">
          <v-menu
            v-model="menu1"
            :close-on-content-click="false"
            transition="scale-transition"
            offset-y
          >
            <template #activator="{ props }">
              <v-text-field
                v-model="startDateFormatted"
                label="시작일"
                readonly
                v-bind="props"
                prepend-inner-icon="mdi-calendar"
                class="cursor-pointer"
              />
            </template>
            <v-date-picker
              v-model="startDate"
              @update:model-value="menu1 = false"
              :max="endDateFormatted"
            />
          </v-menu>
        </v-col>

        <v-col cols="12" sm="5">
          <v-menu
            v-model="menu2"
            :close-on-content-click="false"
            transition="scale-transition"
            offset-y
          >
            <template #activator="{ props }">
              <v-text-field
                v-model="endDateFormatted"
                label="종료일"
                readonly
                v-bind="props"
                prepend-inner-icon="mdi-calendar"
                class="cursor-pointer"
              />
            </template>
            <v-date-picker
              v-model="endDate"
              @update:model-value="menu2 = false"
              :min="startDateFormatted"
              :max="todayFormatted"
            />
          </v-menu>
        </v-col>

        <v-col cols="12" sm="2" class="d-flex align-center justify-start">
          <v-btn
            color="primary"
            class="ma-0 mb-4"
            @click="loadData"
            :loading="loading"
            elevation="2"
            style="min-width: 80px; padding-left: 16px; padding-right: 16px;"
          >
            조회
          </v-btn>
        </v-col>
      </v-row>

      <!-- 차트 영역 -->
      <v-row>
        <v-col>
          <div style="height: 400px; width: 90%">
            <LineChart
              :labels="labels"
              :totalAmounts="totalAmounts"
              :orderCounts="orderCounts"
            />
          </div>
        </v-col>
      </v-row>
    </v-card-text>
  </v-card>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { format } from 'date-fns'
import { useRoute, useRouter } from 'vue-router'
import LineChart from '@/components/HourlySalesChart.vue'
import { useSalesSummary } from '@/composables/useSalesSummary'

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

const companyId = ref(route.query.companyId || '')
const companyName = ref(route.query.companyName || '')

watch(() => route.query.companyId, (newId) => {
  companyId.value = newId || ''
})

const goToDashboard = () => {
  router.push({
    name: 'OperationsDashboard',
    query: { companyId: companyId.value, companyName: companyName.value },
  })
}

const {
  hourlyLabels,
  hourlyTotalAmounts,
  hourlyOrderCounts,
  loadHourlySales,
} = useSalesSummary(companyId.value)

const getZeroTime = (date) => {
  const d = new Date(date)
  d.setHours(0, 0, 0, 0)
  return d
}

const today = new Date()
const startDate = ref(getZeroTime(today))
const endDate = ref(today)

const menu1 = ref(false)
const menu2 = ref(false)

const loading = ref(false)

const startDateFormatted = computed({
  get: () => format(startDate.value, 'yyyy-MM-dd'),
  set: (val) => {
    if (val) {
      const d = new Date(val)
      startDate.value = getZeroTime(d)
    }
  },
})

const endDateFormatted = computed({
  get: () => format(endDate.value, 'yyyy-MM-dd'),
  set: (val) => {
    if (val) {
      endDate.value = new Date(val)
    }
  },
})

const todayFormatted = format(today, 'yyyy-MM-dd')

const labels = ref([])
const totalAmounts = ref([])
const orderCounts = ref([])

async function loadData() {
  if (!companyId.value) {
    alert('회사 ID가 없습니다.')
    return
  }

  if (startDate.value > endDate.value) {
    alert('시작일이 종료일보다 클 수 없습니다.')
    return
  }

  loading.value = true
  try {
    await loadHourlySales(companyId.value, startDate.value, endDate.value)
    labels.value = hourlyLabels.value
    totalAmounts.value = hourlyTotalAmounts.value
    orderCounts.value = hourlyOrderCounts.value
  } finally {
    loading.value = false
  }
}

watch(companyId, (newVal) => {
  if (newVal) loadData()
})

if (companyId.value) {
  loadData()
}
</script>

 

3. 시간대별 매출 분석 차트

1. useSalesSummary composable

- 시간대별 매출 : loadHourlySales

  // 시간대별 매출
  const hourlyLabels = ref([])
  const hourlyTotalAmounts = ref([])
  const hourlyOrderCounts = ref([])

  const loadHourlySales = async (companyId, startDate, endDate) => {
    const ordersRef = collection(db, "companies", companyId, "orders")
    const q = query(
      ordersRef,
      where("createdAt", ">=", Timestamp.fromDate(startDate)),
      where("createdAt", "<=", Timestamp.fromDate(endDate))
    )
    const snapshot = await getDocs(q)

    const hourlyData = new Array(24).fill(0)
    const hourlyCounts = new Array(24).fill(0)

    snapshot.forEach(doc => {
      const order = doc.data()
      const hour = order.createdAt.toDate().getHours()
      hourlyData[hour] += order.totalAmount || 0
      hourlyCounts[hour] += 1
    })

    hourlyLabels.value = Array.from({ length: 24 }, (_, i) => `${i}시`)
    hourlyTotalAmounts.value = hourlyData
    hourlyOrderCounts.value = hourlyCounts
  }

 

Firebase Firestore에 저장된 주문 데이터를 기준으로,

입력한 기간 내 시간대별 매출(totalAmount)과 주문 수(order count)를 분석하는 함수

 

🧾 데이터 구조

const hourlyLabels = ref([])
const hourlyTotalAmounts = ref([])
const hourlyOrderCounts = ref([])
  • hourlyLabels: X축 라벨 (예: "13시", "14시" 등 24시간)
  • hourlyTotalAmounts: 각 시간대의 총 매출
  • hourlyOrderCounts: 각 시간대의 주문 건수

⚙️ loadHourlySales 함수

const loadHourlySales = async (companyId, startDate, endDate) => { ... }
  • companyId, startDate, endDate를 받아 해당 회사의 특정 기간 동안의 주문 데이터를 불러옵니다.

📦 Firestore 쿼리

const q = query(
  ordersRef,
  where("createdAt", ">=", Timestamp.fromDate(startDate)),
  where("createdAt", "<=", Timestamp.fromDate(endDate))
)
  • Firestore의 "companies/{companyId}/orders" 경로에서 createdAt 필드 기준으로 기간 내 데이터를 조회합니다.

⏲️ 시간별 집계

const hourlyData = new Array(24).fill(0)
const hourlyCounts = new Array(24).fill(0)
  • 0시부터 23시까지, 24시간을 기준으로 초기화한 배열입니다.
  • 각 주문에 대해 createdAt에서 시간(hour) 만 추출해서 해당 인덱스 위치에:
  • totalAmount 값을 더하고
  • 주문 수를 1씩 증가시킵니다.

📊 결과 저장

hourlyLabels.value = Array.from({ length: 24 }, (_, i) => `${i}시`)
hourlyTotalAmounts.value = hourlyData
hourlyOrderCounts.value = hourlyCounts
  • 이 값을 Vue에서 반응형으로 사용할 수 있도록 ref()에 담은 값을 업데이트합니다.

이 함수로 만든 hourlyTotalAmounts와 hourlyOrderCounts 데이터를 기반으로 Line 차트로 시각화합니다.

2. 시간대별 매출 분석 차트

  • src/components/HourlySalesChart.vue
<!-- src/components/HourlySalesChart.vue -->
<template>
  <Line :data="chartData" :options="chartOptions" />
</template>

<script setup>
import { computed } from "vue";
import { Line } from "vue-chartjs";
// chart.js 플러그인 및 구성 요소 수동 등록
import {
  Chart as ChartJS,
  LineElement,
  PointElement,
  LinearScale,
  Title,
  Tooltip,
  Legend,
  CategoryScale,
  Filler 
} from 'chart.js';

ChartJS.register(
  LineElement,
  PointElement,
  LinearScale,
  Title,
  Tooltip,
  Legend,
  CategoryScale,
  Filler 
);

const props = defineProps({
  labels: Array,
  totalAmounts: Array,
  orderCounts: Array,
});

const chartData = computed(() => ({
  labels: Array.isArray(props.labels) ? props.labels : [],
  datasets: [
    {
      label: "매출액 (원)",
      data: Array.isArray(props.totalAmounts) ? props.totalAmounts : [],
      borderColor: "#42a5f5",
      backgroundColor: "rgba(66,165,245,0.2)",
      yAxisID: "y",
      tension: 0.3,
      fill: true,
      pointRadius: 4,
      pointHoverRadius: 6,
    },
    {
      label: "주문 건수",
      data: Array.isArray(props.orderCounts) ? props.orderCounts : [],
      borderColor: "#f44336",
      backgroundColor: "rgba(244,67,54,0.2)",
      yAxisID: "y1",
      tension: 0.3,
      fill: true,
      pointRadius: 4,
      pointHoverRadius: 6,
    },
  ],
}));

const chartOptions = {
  responsive: true,
  maintainAspectRatio: false,
  interaction: {
    mode: "index",
    intersect: false,
  },
  stacked: false,
  plugins: {
    legend: { position: "top" },
    title: { display: false },
  },
  scales: {
    y: {
      type: "linear",
      display: true,
      position: "left",
      ticks: {
        callback: (value) => `${value.toLocaleString()}원`,
      },
    },
    y1: {
      type: "linear",
      display: true,
      position: "right",
      grid: { drawOnChartArea: false },
      ticks: {
        callback: (value) => `${value}건`,
      },
    },
  },
};
</script>

 

3. 시간대별 매출 분석 차트 설명

Vue 3Chart.js, 그리고 vue-chartjs를 활용하여

매출액과 주문 건수를 동시에 시각화하는 이중 Y축 선형 차트(Line Chart)를 구현하였습니다.

 

🧩 구성 개요

  • labels: X축 라벨 (예: 0시 ~ 23시)
  • totalAmounts: Y축 왼쪽에 표시될 매출 데이터 (y축)
  • orderCounts: Y축 오른쪽에 표시될 주문 건수 데이터 (y1축)

📐 <template>

<Line :data="chartData" :options="chartOptions" />

 

  • Line 컴포넌트는 vue-chartjs에서 제공하는 차트로, 여기에 chartData와 chartOptions를 전달하여 차트를 렌더링합니다.

🧠 <script setup> 주요 구성

1. Chart.js 구성요소 등록

ChartJS.register(...)

 

  • 사용하려는 차트 요소와 플러그인을 직접 등록해야 합니다 (Chart.js 3 이상에서는 필수).

 

2. Props 정의

const props = defineProps({
  labels: Array,
  totalAmounts: Array,
  orderCounts: Array,
});
  • 부모 컴포넌트로부터 X축 라벨, 매출액, 주문 건수 데이터를 받아옵니다.

3. 차트 데이터 (chartData)

const chartData = computed(() => ({
  labels: props.labels,
  datasets: [
    { label: "매출액", data: props.totalAmounts, yAxisID: "y", ... },
    { label: "주문 건수", data: props.orderCounts, yAxisID: "y1", ... },
  ]
}))
  • 두 개의 데이터셋을 구성하여 각각 다른 Y축에 매핑합니다.
  • yAxisID: "y" → 왼쪽 Y축 (매출)
  • yAxisID: "y1" → 오른쪽 Y축 (건수)
  • 선 색상과 곡선, 포인트 스타일도 커스터마이징 되어 있어요.

4. 차트 옵션 (chartOptions)

const chartOptions = {
  responsive: true,
  maintainAspectRatio: false,
  interaction: { mode: "index", intersect: false },
  stacked: false,
  plugins: { ... },
  scales: {
    y: {
      position: "left",
      ticks: { callback: (value) => `${value.toLocaleString()}원` },
    },
    y1: {
      position: "right",
      ticks: { callback: (value) => `${value}건` },
      grid: { drawOnChartArea: false },
    }
  }
};
  • 반응형 차트로 설정하고, 두 개의 독립적인 Y축(y, y1)을 설정합니다.
  • Y축마다 포맷 콜백을 주어 원, 건 단위를 명확히 구분합니다.
  • drawOnChartArea: false → 오른쪽 Y축은 왼쪽과 겹치지 않게 별도 격자선을 그리지 않도록 설정

🪄 요약

이 컴포넌트는 매출액과 주문량의 시간대별 흐름을 한 눈에 비교할 수 있어, 운영 인사이트를 얻을 수 있습니다.

예: “주문은 많았지만 매출은 낮았던 시간대” 또는 그 반대 상황을 쉽게 파악할 수 있습니다.