예약 포털 (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 }
})