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

8. 동네 (예약) 포털 (Vue 3 + Firebase) - 예약하기

그랜파 개발자 2025. 6. 2. 19:57

Firebase Hosting으로 실제 웹 서비스 오픈까지 진행합니다.

서비스 오픈까지 개발을 진행하면서 계속 수정, 변경될 것입니다.

예약하기

업체 등록 정보에 영업 시간을 추가하였습니다.

영업 시간에 따라 영업 상태 (영업중, 영업 종료)를 표시할 수 있습니다.

회원은 홈페이지에서 업체를 선택하여 예약을 할 수 있습니다.

예약 페이지가 열릴 때 라우터 쿼리로 받은 comapnyId로 업체 정보를 가져와 화면에 필요한 정보들을 나타냅니다.

예약 시간은 30분 단위의 타임슬롯을 선택하고,

하나의 예약에 타임 슬롯을 여러개 선택할 수 있습니다.

업체 정보에 영업 시간 추가

영업 시작 시간과 영업 종료 시간을 등록합니다.

업체 정보 보기 또는 예약 화면에서 영업 시간을 보여주고, 영업중인지 여부를 표기합니다.

src/views/RegisterCompany.vue - 업체 등록

<!-- src/views/RegisterCompany.vue-->
<template>
  <v-container class="d-flex justify-center">
    <v-card class="pa-4" max-width="500">
      <v-card-title>업체 등록</v-card-title>

      <v-text-field v-model="name" label="업체명" required />

      <!-- 업종 태그 선택 -->
      <div class="my-3">
        <div class="mb-1">업종 선택</div>
        <v-chip-group v-model="category" column mandatory>
          <v-chip
            v-for="item in categories"
            :key="item"
            :value="item"
            class="ma-1"
            color="primary"
            variant="outlined"
            filter
          >
            {{ item }}
          </v-chip>
        </v-chip-group>
      </div>

      <v-textarea v-model="description" label="소개글" />

      <v-text-field
        v-model="openTime"
        label="영업 시작 시간"
        type="time"
        required
        class="mt-4"
      />

      <v-text-field
        v-model="closeTime"
        label="영업 종료 시간"
        type="time"
        required
      />

      <v-btn color="primary" @click="submit">등록</v-btn>
    </v-card>
  </v-container>
</template>


<script setup>
import { ref } from 'vue'
import { useCompanyStore } from '@/stores/companyStore'
import { useRouter } from 'vue-router'

const companyStore = useCompanyStore()
const router = useRouter()

const name = ref('')
const description = ref('')
const category = ref('')
const categories = ['배달음식', '카페', '소매업', '서비스업', '교육', '병원', '기타']

const openTime = ref('')
const closeTime = ref('')

const submit = async () => {
  if (!name.value || !category.value) {
    alert('업체명과 업종을 입력해주세요.')
    return
  }
  try {
    await companyStore.addCompany({
      name: name.value,
      description: description.value,
      category: category.value,
      openTime: openTime.value,
      closeTime: closeTime.value
    })

    alert('업체가 등록되었습니다.')
    router.push('/my-companies')
  } catch (e) {
    console.error('등록 에러:', e)
    alert(`등록에 실패했습니다: ${e.message}`)
  }
}

</script>

 

src/views/CompanyDetail.vue - 업체 상세 보기

<!-- src/views/CompanyDetail.vue -->
<template>
  <v-container fluid>
    <div class="d-flex justify-center">
      <v-card class="pa-4" width="900">
        <v-card-title>업체 상세 정보</v-card-title>

        <v-card-text>
          <div class="mb-3">
            <strong>업체명:</strong> 
            {{ company?.name || '없음' }}
          </div>

          <div class="mb-3">
            <strong>업종:</strong> 
            {{ company?.category || '없음' }}
          </div>

          <div class="mb-3">
            <strong>소개글:</strong><br />
            <div class="multiline-text">
              {{ company?.description || '없음' }}
            </div>
          </div>

          <!-- 영업시간 및 상태 표시 -->
          <div class="mb-3">
            <strong>영업시간:</strong>
            {{ company?.openTime || '--' }} ~ {{ company?.closeTime || '--' }}
          </div>
          <div class="mb-3" :class="isOpen ? 'text-success' : 'text-error'">
            <strong>영업 상태:</strong>
            {{ isOpen ? '영업중' : '영업 종료' }}
          </div>
        </v-card-text>

        <v-card-actions class="justify-end">
          <v-btn
            v-if="authStore.user"
            color="primary"
            @click="goToReservation"
            :disabled="!isOpen"
          >
            예약하기
          </v-btn>

          <v-btn color="grey" @click="goBack">홈</v-btn>
        </v-card-actions>
      </v-card>
    </div>
  </v-container>
</template>

<script setup>
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCompanyStore } from '@/stores/companyStore'
import { useAuthStore } from '@/stores/authStore'

const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
const authStore = useAuthStore()

const id = route.params.id
const company = computed(() =>
  companyStore.companies.find((c) => c.id === id)
)

onMounted(() => {
  if (!company.value) {
    companyStore.fetchMyCompanies()
  }
})

// 현재 시간이 영업시간 내인지 계산하는 함수
const isOpen = computed(() => {
  if (!company.value?.openTime || !company.value?.closeTime) return false

  const now = new Date()

  // 현재 시간을 "HH:mm" 형태로 맞추기
  const nowStr = now.toTimeString().slice(0, 5)

  // "HH:mm" -> 분으로 변환 (예: 13:30 => 13*60 + 30)
  const toMinutes = (timeStr) => {
    const [h, m] = timeStr.split(':').map(Number)
    return h * 60 + m
  }

  const openMinutes = toMinutes(company.value.openTime)
  const closeMinutes = toMinutes(company.value.closeTime)
  const nowMinutes = toMinutes(nowStr)

  // 영업시간이 자정 넘는 경우(예: 22:00~02:00) 처리
  if (closeMinutes < openMinutes) {
    return nowMinutes >= openMinutes || nowMinutes < closeMinutes
  } else {
    return nowMinutes >= openMinutes && nowMinutes < closeMinutes
  }
})

const goToReservation = () => {
  router.push(`/reservation/${id}`)
}

const goBack = () => {
  router.push('/')
}
</script>

<style scoped>
.multiline-text {
  white-space: pre-line;
}

.text-success {
  color: green;
}
.text-error {
  color: red;
}
</style>

예약하기

예약 정보에는 업체 구분을 위한 comapnyId와 예약 회원에 대한 userId 정보가 포함되어 있습니다.

예약 상태는 ‘대기중’, ‘승인’, ‘거부’, ‘ 취소’가 있습니다.

회원이 예약을 하면 ‘대기중’,

업체의 관리자가 ‘승인’ 또는 ’거부’를 할 수 있고,

회원은 예약을 ‘취소’할 수 있습니다.

Firestore의 reservations 컬렉션 구조

  • 회원의 예약 정보를 저장합니다.
reservations (컬렉션)
├── {reservationId} (문서 ID, 자동 생성 또는 UUID)
    ├── userId: string          // 예약한 사용자 ID
    ├── companyId: string       // 예약 대상 업체 ID
    ├── date: string            // 'YYYY-MM-DD'
    ├── timeSlots: array        // ['09:00~10:00', ...]
    ├── memo: string
    ├── status: string          // '예약완료', '취소됨' 등
    ├── createdAt: timestamp

 

 

 

src/views/Home.vue - 등록 업체 리스트

<!-- src/views/Home.vue -->
<template>
  <v-container>
    <v-card-title>우리 동네 '예약 포털' 입니다.</v-card-title>

    <v-select
      v-model="selectedCategory"
      :items="['전체', ...categories]"
      label="업종 필터"
      class="mb-4"
      clearable
    />

    <v-row>
      <v-col
        v-for="company in filteredCompanies"
        :key="company.id"
        cols="12"
        md="6"
      >
        <v-list-item
          class="border rounded pa-3 mb-0"
          style="cursor: pointer"
          @click="goToDetail(company.id)"
        >
          <div>
            <v-icon color="primary" class="mr-2">mdi-storefront</v-icon>
            <b>{{ company.name }}</b> ( {{ company.category }} )
          </div>
          <div class="mb-2">
            {{ company.description || '소개글 없음' }}
          </div>

          <!-- ✅ 영업시간 및 상태 -->
          <div v-if="company.openTime && company.closeTime" class="mb-2 text-black">
            영업시간: {{ company.openTime }} ~ {{ company.closeTime }}
            <v-chip
              :color="isOpenNow(company) ? 'green' : 'red'"
              size="x-small"
              class="ml-2"
            >
              {{ isOpenNow(company) ? '영업 중' : '영업 종료' }}
            </v-chip>
          </div>


          <!-- ✅ 로그인된 사용자에게만 표시 -->
          <div v-if="authStore.user">
            <v-btn
              color="primary"
              size="small"
              @click.stop="goToReservation(company.id)"
            >
              예약하기
            </v-btn>
          </div>
        </v-list-item>
      </v-col>
    </v-row>
  </v-container>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCompanyStore } from '@/stores/companyStore'
import { useAuthStore } from '@/stores/authStore' // ✅ 추가

const companyStore = useCompanyStore()
const authStore = useAuthStore() // ✅ 추가
const router = useRouter()

const selectedCategory = ref('전체')
const categories = ['배달음식', '카페', '소매업', '서비스업', '교육', '병원', '기타']

onMounted(() => {
  companyStore.fetchAllCompanies()
})

const goToDetail = (id) => {
  router.push(`/company/${id}`)
}

const goToReservation = (companyId) => { 
  //console.log(companyId, authStore.profile.name);
  router.push({
    path: '/reservation',
    query: {
      companyId,
      username: authStore.profile.name
    }
  })
}

// 카테고리 필터링된 목록
const filteredCompanies = computed(() => {
  if (selectedCategory.value === '전체' || !selectedCategory.value) {
    return companyStore.companies
  }
  return companyStore.companies.filter(
    (c) => c.category === selectedCategory.value
  )
})

function isOpenNow(company) {
  if (!company.openTime || !company.closeTime) return false;

  const now = new Date();
  const currentTime = now.getHours() * 60 + now.getMinutes();

  const [openHour, openMinute] = company.openTime.split(':').map(Number);
  const [closeHour, closeMinute] = company.closeTime.split(':').map(Number);

  const openTime = openHour * 60 + openMinute;
  const closeTime = closeHour * 60 + closeMinute;

  return currentTime >= openTime && currentTime < closeTime;
}

</script>

 

src/views/Reservation.vue - 예약 하기

<!-- src/views/Reservation.vue-->
<template>
  <v-container max-width="600" class="mx-auto">
    <h2> </h2> 
    <v-form @submit.prevent="submitReservation" ref="formRef">
      <v-card class="pa-4" elevation="2">
        <v-card-title class="text-h6 font-weight-bold">
          {{ companyName }} ({{ companyCategory }}) - 예약하기
        </v-card-title> 

        <!-- 영업시간 및 상태 표시 -->
        <span class="mb-3 ml-4 mr-4">
          <strong>영업시간:</strong>
          {{ openTime || '--' }} ~ {{ closeTime || '--' }}
        </span>
        <strong>영업 상태:</strong>
        <span class="mb-3 ml-2" :class="isOpen ? 'text-success' : 'text-error'">            
          {{ isOpen ? '영업중' : '영업 종료' }}
        </span>

        <v-card-subtitle class="text-end">{{ username }} </v-card-subtitle>
        <v-card-text>
          <!-- 날짜 선택 -->
          <v-text-field
            label="예약 날짜"
            v-model="form.date"
            type="date"
            :rules="[rules.required, rules.notPast]"
            required
            dense
            class="mb-4"
          />

          <!-- 시간 선택 -->
          <div class="mb-2 font-weight-medium">시간 선택</div>
          <v-row class="mb-4">
            <v-col
              v-for="slot in allTimeSlots"
              :key="slot"
              cols="3"
              class="pa-0"
            >
              <v-checkbox
                v-model="form.timeSlots"
                :label="slot"
                :value="slot"
                color="primary"
                hide-details
                density="compact"
                class="ma-0"
              />
            </v-col>
          </v-row>

          <!-- 메모 -->
          <v-textarea
            label="메모"
            v-model="form.memo"
            auto-grow
            clearable
            rows="2"
            class="mb-4 mt-4"
          />

          <!-- 버튼 -->
          <v-btn type="submit" color="primary" block>예약하기</v-btn>
        </v-card-text>
      </v-card>
    </v-form>

    <!-- 예약 성공 모달 -->
    <v-dialog
      v-model="dialog"
      max-width="400"
      @click:outside="closeDialog"
      @keydown.esc="closeDialog"
    >
      <v-card v-if="reservationResult">
        <v-card-title class="headline">예약 완료</v-card-title>
        <v-card-text>
          <v-table>
            <tbody>
              <tr>
                <th class="text-left">예약 ID</th>
                <td>{{ reservationResult.id }}</td>
              </tr>
              <tr>
                <th class="text-left">예약 날짜</th>
                <td>{{ reservationResult.date }}</td>
              </tr>
              <tr>
                <th class="text-left">시간</th>
                <td>{{ reservationResult.timeSlots?.join(', ') || '' }}</td>
              </tr>
              <tr>
                <th class="text-left">메모</th>
                <td>{{ reservationResult.memo || '없음' }}</td>
              </tr>
              <tr>
                <th class="text-left">상태</th>
                <td>{{ reservationResult.status }}</td>
              </tr>
              <tr>
                <th class="text-left">생성 시간</th>
                <td>{{ formatDateTime(reservationResult.createdAt) }}</td>
              </tr>
            </tbody>
          </v-table>
        </v-card-text>
        <v-card-actions>
          <v-spacer />
          <v-btn color="primary" text @click="closeDialog">확인</v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </v-container>
</template>


<script setup>
import { ref, onMounted, computed, watch  } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCompanyStore } from '@/stores/companyStore'
import { useReservationStore } from '@/stores/reservationStore'

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

const companyId = route.query.companyId ?? ''
const username = route.query.username ?? ''

const companyStore = useCompanyStore()
const reservationStore = useReservationStore()
const formRef = ref()

const today = new Date().toISOString().split('T')[0]
const form = ref({
  companyId: '',
  date: today,
  timeSlots: [],
  memo: '',
})

const allTimeSlots = [
  '09:00~09:30', '09:30~10:00',
  '10:00~10:30', '10:30~11:00',
  '11:00~11:30', '11:30~12:00',
  '12:00~12:30', '12:30~13:00',
  '13:00~13:30', '13:30~14:00',
  '14:00~14:30', '14:30~15:00',
  '15:00~15:30', '15:30~16:00',
  '16:00~16:30', '16:30~17:00',
  '17:00~17:30', '17:30~18:00',
]

const rules = {
  required: v => (Array.isArray(v) ? v.length > 0 : !!v) || '필수 입력 항목입니다',
  notPast: v => {
    if (!v) return true
    const selectedDate = new Date(v)
    const today = new Date()
    today.setHours(0, 0, 0, 0) // 오늘 00:00으로 초기화
    return selectedDate >= today || '오늘 이전 날짜는 선택할 수 없습니다'
  }
}

// 모달 열림 상태
const dialog = ref(false)

// 예약 결과 저장용
const reservationResult = ref(null)

//const company = ref(null);
// 컴포넌트에서 사용할 회사 정보
const company = computed(() => companyStore.company)
const companyName = computed(() => company.value?.name || '')
const companyCategory = computed(() => company.value?.category || '')
const openTime = computed(() => company.value?.openTime || '00:00')
const closeTime = computed(() => company.value?.closeTime || '00:00')

const submitReservation = async () => {
  const { valid } = await formRef.value.validate()
  if (!valid) return

  try {
    form.value.companyId = companyId
    const res = await reservationStore.submitReservation(form.value)
    reservationResult.value = res // 결과 저장
    dialog.value = true           // 모달 열기
  } catch (err) {
    alert('예약 실패: ' + (err.response?.data?.message || err.message))
  }
}

const closeDialog = () => {
  dialog.value = false
  reservationResult.value = null
  // 필요하면 폼 초기화
  //form.value = { companyId, date: '', timeSlots: [], memo: '' }
  router.push('/')
}

const formatDateTime = (value) => {
  const date = new Date(value)
  return date.toLocaleString('ko-KR', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit'
  })
}

// 현재 시간이 영업시간 내인지 계산하는 함수
const isOpen = computed(() => {
  if (!company.value?.openTime || !company.value?.closeTime) return false

  const now = new Date()

  // 현재 시간을 "HH:mm" 형태로 맞추기
  const nowStr = now.toTimeString().slice(0, 5)

  // "HH:mm" -> 분으로 변환 (예: 13:30 => 13*60 + 30)
  const toMinutes = (timeStr) => {
    const [h, m] = timeStr.split(':').map(Number)
    return h * 60 + m
  }

  const openMinutes = toMinutes(company.value.openTime)
  const closeMinutes = toMinutes(company.value.closeTime)
  const nowMinutes = toMinutes(nowStr)

  // 영업시간이 자정 넘는 경우(예: 22:00~02:00) 처리
  if (closeMinutes < openMinutes) {
    return nowMinutes >= openMinutes || nowMinutes < closeMinutes
  } else {
    return nowMinutes >= openMinutes && nowMinutes < closeMinutes
  }
})

onMounted(async () => { 
  //console.log('router.query.companyId :', route.query.companyId );
  if (companyId) {
    await companyStore.fetchCompany(companyId)
  }
})
</script>

<style scoped>
/* 테이블 셀에 여백 주기 */
.v-simple-table th,
.v-simple-table td {
  padding: 12px 16px;
}
</style>

 

src/stores/reservationStore.js

// src/stores/reservationStore.js
import { defineStore } from 'pinia'
import { db } from '@/firebase'
import { collection, addDoc, serverTimestamp } from 'firebase/firestore'
import { useAuthStore } from './authStore'

export const useReservationStore = defineStore('reservation', () => {
  const authStore = useAuthStore()

  const submitReservation = async (form) => {
    if (!authStore.user) throw new Error('로그인이 필요합니다.')

    const data = {
      userId: authStore.user.uid,
      companyId: form.companyId, // companyId는 라우터나 props에서 받아야 함
      date: form.date,
      timeSlots: form.timeSlots,
      memo: form.memo,
      status: '대기중',
      createdAt: serverTimestamp(),
    }

    const docRef = await addDoc(collection(db, 'reservations'), data)

    return {
      id: docRef.id,
      ...data,
      createdAt: new Date(), // 클라이언트에 보여주기 위한 용도
    }
  }

  return { submitReservation }
})