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

32. 예약 포털 (Vue3 + Firebase) - 매출을 vue-chartjs 차트로 시각화

그랜파 개발자 2025. 6. 18. 10:33

매출 분석

일(日), 주(週), 월(月), 년(年) 단위로 매출 트렌드와 성장률 정보를 제공하는 것은

비즈니스 인사이트 도출과 전략 수립에 매우 유용합니다.

 

✅ 1. 빠른 문제 감지와 대응 (일간/주간 매출)

  • 일간 트렌드는 전날 대비 급격한 매출 변동을 감지할 수 있어, 이벤트 실패, 시스템 장애, 고객 이탈 등을 빠르게 파악하고 대응할 수 있습니다
  • 주간 매출 변화는 일시적 현상인지, 지속적 추세인지 판단하게 해주어, 단기 마케팅 전략 조정에 도움을 줍니다.

✅ 2. 시즌성 및 반복 패턴 파악 (월간/연간 매출)

  • 월간 분석은 월초/월말 구매 패턴, 특정 시즌(예: 명절, 세일 시즌) 효과를 분석해, 계획적인 프로모션이나 재고 조정이 가능합니다.
  • 연간 비교는 전년도 대비 성장 여부를 확인하고, 장기적인 성장률과 방향성을 평가할 수 있게 해줍니다.

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. 매출을 vue-chartjs 차트로 시각화

기준일을 입력하면 기준일에 대해 매출 분석을 vue-chartjs로 시각화합니다.

 

데스크탑

 

모바일

 

useSalesSummary() composable은 매출 분석을 위한 강력한 도구입니다. 

Firestore 데이터를 기반으로 오늘, 이번주, 이번달, 올해와 그 이전 기간의 데이터를 가져와 매출 트렌드와 성장율을 계산 계산하여 관리자 대시보드에 보여줍니다

  • 일 매출: 최근 7일간의 일별 매출 트렌드 및 성장율
  • 주 매출: 최근 6주 간의 주간 매출 트렌드 및 성장율
  • 월 매출: 최근 6개월 간의 월별 매출 트렌드 및 성장율
  • 년 매출: 최근 4년 간의 연도별 매출 트렌드 및 성장율

2. 매출 분석 차트 컴포넌트

  • SalesLineChart.vue
<template>
  <Line :data="chartData" :options="chartOptions" />
</template>

<script setup>
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import {
  Chart as ChartJS,
  Title,
  Tooltip,
  Legend,
  LineElement,
  CategoryScale,
  LinearScale,
  PointElement,
  Filler
} from 'chart.js'

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

const props = defineProps({
  labels: { type: Array, required: true },   // 날짜 혹은 기간 라벨
  sales: { type: Array, required: true },    // 매출 데이터
  labelText: { type: String, default: '매출' } // 차트 상단 라벨
})

const chartData = computed(() => ({
  labels: props.labels,
  datasets: [
    {
      label: props.labelText,
      data: props.sales,
      borderColor: '#42a5f5',
      backgroundColor: 'rgba(66,165,245,0.2)',
      tension: 0.3,
      fill: true,
      pointRadius: 5,
      pointHoverRadius: 7
    }
  ]
}))

const chartOptions = {
  responsive: true,
  maintainAspectRatio: false,
  plugins: {
    legend: { position: 'top' },
    title: { display: false }
  },
  scales: {
    y: {
      ticks: {
        callback: value => `${value.toLocaleString()}원`
      }
    }
  }
}
</script>

 

SalesLineChart 컴포넌트 설명

이 SalesLineChart.vue 컴포넌트는 Vue 3와 Chart.js, 그리고 vue-chartjs 라이브러리를 활용해서 매출 데이터를 선형 차트(Line Chart) 로 시각화하는 역할을 합니다. 간단히 구조를 나눠 설명드릴게요:

🧩 구성 요소


1. <template>

<Line :data="chartData" :options="chartOptions" />
  • Line 컴포넌트는 Chart.js의 Line 차트를 나타냅니다.
  • :data와 :options는 각각 차트에 표시할 데이터와 설정을 연결합니다.

2. <script setup>

  • vue-chartjs에서 제공하는 Line 차트와 함께 필요한 Chart.js 모듈들을 register()를 통해 등록합니다.
  • defineProps()로 컴포넌트에 세 가지 props를 받습니다:
    • labels: x축에 표시될 날짜 혹은 기간 (예: ['1월', '2월'])
    • sales: y축에 표시될 매출 값 배열 (예: [10000, 12000])
    • labelText: 차트 상단의 라벨 텍스트 (기본값은 '매출')

3. chartData

const chartData = computed(() => ({
  labels: props.labels,
  datasets: [
    {
      label: props.labelText,
      data: props.sales,
      borderColor: '#42a5f5',
      backgroundColor: 'rgba(66,165,245,0.2)',
      tension: 0.3, // 곡선의 부드러움
      fill: true,
      pointRadius: 5,
      pointHoverRadius: 7
    }
  ]
}))
  • 실제 선형 그래프에 표시될 데이터입니다.
  • 곡선을 부드럽게 만들고 배경 색을 채우는 등의 설정이 포함되어 있습니다.

4. chartOptions

const chartOptions = {
  responsive: true,
  maintainAspectRatio: false,
  plugins: {
    legend: { position: 'top' },
    title: { display: false }
  },
  scales: {
    y: {
      ticks: {
        callback: value => `${value.toLocaleString()}원`
      }
    }
  }
}

 

  • 차트의 전체적인 옵션을 설정합니다:
  • 반응형 및 비율 유지 설정
  • 범례 위치 설정
  • y축의 숫자에 ‘원’ 단위를 붙이는 커스텀 포맷 설정

3. 매출 분석 View

  • 기준일을 입력하면 일, 주, 월, 년의 매출 트렌드 차트와 성장률을 화면에 출력합니다.
- 매출 트렌드 차트

<SalesLineChart :labels="dayLabels" :sales="daySales" label-text="일 매출" />
<SalesLineChart :labels="weekLabels" :sales="weekSales" label-text="주 매출"/>
<SalesLineChart :labels="monthLabels" :sales="monthSales" label-text="월 매출"/>
<SalesLineChart :labels="yearLabels" :sales="yearSales" label-text="년 매출" />
  • src/views/SalesAnalysis.vue
<!-- src/views/SalesAnalysis.vue -->
<template>
  <v-container>
    <!-- 운영 대시보드로 돌아가기 버튼 -->
    <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="align-center mb-4">
        <v-col cols="12" md="3" class="py-1">
          <v-text-field
            v-model="baseDate"
            type="date"
            label="기준 날짜"
            dense
            hide-details
          />
        </v-col>
        <v-col cols="12" md="3" class="py-1">
          <v-btn
            color="primary"
            @click="loadSummaryByDate"
            class="mt-1 mt-md-0 btn-responsive"
          >
            조회
          </v-btn>

        </v-col>
      </v-row>

      <!-- 일 매출 -->
      <v-row v-if="salesSummary.day.count >= 0" class="mb-6">
        <v-col cols="12" md="6">
          <v-card outlined>
            <v-card-title class="headline">일 매출</v-card-title>
            <v-card-text>
              <p>총 매출: <strong>{{ salesSummary.day.total.toLocaleString() }}원</strong></p>
              <p>주문 수: <strong>{{ salesSummary.day.count }}건</strong></p>
              <p>평균 주문 금액: <strong>{{ Math.round(salesSummary.day.avg).toLocaleString() }}원</strong></p>
              <p>성장률 (전일 대비): 
                <strong v-if="salesSummary.day.growth !== null" :class="{'text-success': salesSummary.day.growth >= 0, 'text-error': salesSummary.day.growth < 0}">
                  {{ salesSummary.day.growth.toFixed(2) }}%
                </strong>
                <span v-else>N/A</span>
              </p>
              <div class="mt-4" style="width: 90%">
                <SalesLineChart :labels="dayLabels" :sales="daySales" label-text="일 매출" />
              </div>
            </v-card-text>
          </v-card>
        </v-col>

        <!-- 주 매출 -->
        <v-col cols="12" md="6" v-if="salesSummary.week.count >= 0">
          <v-card outlined>
            <v-card-title class="headline">주 매출</v-card-title>
            <v-card-text>
              <p>총 매출: <strong>{{ salesSummary.week.total.toLocaleString() }}원</strong></p>
              <p>주문 수: <strong>{{ salesSummary.week.count }}건</strong></p>
              <p>평균 주문 금액: <strong>{{ Math.round(salesSummary.week.avg).toLocaleString() }}원</strong></p>
              <p>성장률 (전주 대비): 
                <strong v-if="salesSummary.week.growth !== null" :class="{'text-success': salesSummary.week.growth >= 0, 'text-error': salesSummary.week.growth < 0}">
                  {{ salesSummary.week.growth.toFixed(2) }}%
                </strong>
                <span v-else>N/A</span>
              </p>
              <div class="mt-4" style="width: 90%">
                <SalesLineChart :labels="weekLabels" :sales="weekSales" label-text="주 매출"/>
              </div>
            </v-card-text>
          </v-card>
        </v-col>

        <!-- 월 매출 -->
        <v-col cols="12" md="6" v-if="salesSummary.month.count >= 0">
          <v-card outlined>
            <v-card-title class="headline">월 매출</v-card-title>
            <v-card-text>
              <p>총 매출: <strong>{{ salesSummary.month.total.toLocaleString() }}원</strong></p>
              <p>주문 수: <strong>{{ salesSummary.month.count.toLocaleString() }}건</strong></p>
              <p>평균 주문 금액: <strong>{{ Math.round(salesSummary.month.avg).toLocaleString() }}원</strong></p>
              <p>성장률 (전월 대비): 
                <strong v-if="salesSummary.month.growth !== null" :class="{'text-success': salesSummary.month.growth >= 0, 'text-error': salesSummary.month.growth < 0}">
                  {{ salesSummary.month.growth.toFixed(2) }}%
                </strong>
                <span v-else>N/A</span>
              </p>
              <div class="mt-4" style="width: 90%">
                <SalesLineChart :labels="monthLabels" :sales="monthSales" label-text="월 매출"/>
              </div>
            </v-card-text>
          </v-card>
        </v-col>

        <!-- 연 매출 -->
        <v-col cols="12" md="6" v-if="salesSummary.year.count >= 0">
          <v-card outlined>
            <v-card-title class="headline">연 매출</v-card-title>
            <v-card-text>
              <p>총 매출: <strong>{{ salesSummary.year.total.toLocaleString() }}원</strong></p>
              <p>주문 수: <strong>{{ salesSummary.year.count.toLocaleString() }}건</strong></p>
              <p>평균 주문 금액: <strong>{{ Math.round(salesSummary.year.avg).toLocaleString() }}원</strong></p>
              <p>성장률 (전년 대비): 
                <strong v-if="salesSummary.year.growth !== null" :class="{'text-success': salesSummary.year.growth >= 0, 'text-error': salesSummary.year.growth < 0}">
                  {{ salesSummary.year.growth.toFixed(2) }}%
                </strong>
                <span v-else>N/A</span>
              </p>
              <div class="mt-4" style="width: 90%">
                <SalesLineChart :labels="yearLabels" :sales="yearSales" label-text="년 매출" />
              </div>
            </v-card-text>
          </v-card>
        </v-col>
      </v-row>
    </v-card-text>
  </v-container>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSalesSummary } from '@/composables/useSalesSummary'
import SalesLineChart from '@/components/SalesLineChart.vue'

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

const companyId = route.query.companyId
const companyName = route.query.companyName

const {
  salesSummary,
  loadSummary,
  dayLabels, daySales,
  weekLabels, weekSales,
  monthLabels, monthSales,
  yearLabels, yearSales
} = useSalesSummary(companyId)

const baseDate = ref('')
//const baseDate = ref(new Date().toISOString().split('T')[0])

// 기본 날짜 없이도 초기 데이터를 로드할 수 있게 하려면 초기값 세팅 가능
baseDate.value = new Date().toISOString().split('T')[0]  // 오늘 날짜

const loadSummaryByDate = () => {
  if (!baseDate.value) {
    alert('기준 날짜를 선택해주세요.')
    return
  }

  loadSummary(baseDate.value)
}

watch(baseDate, (newDate) => {
  if (newDate) {
    loadSummary(newDate)
  }
})


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

<style scoped>
.text-success {
  color: #4caf50;
}
.text-error {
  color: #f44336;
}

.btn-responsive {
  width: 100%; /* 모바일용 - 꽉 차게 */
  height: 40px;
  font-size: 16px;
}

@media (min-width: 960px) {
  .btn-responsive {
    width: auto;  /* 데스크탑에서는 내용 크기만큼 */
    height: 30px !important;
    font-size: 14px !important;
  }
}

</style>