Frond-end/Vue

[Vue3] 커스텀 달력 구현

Nellie Kim 2024. 3. 25. 18:15
728x90

 

아래와 같은 커스텀 달력 구현을 완료하였다. 

 

props, emit 을 사용하여 약간 복잡해보이는 로직이어서 조금 어려웠다. 

개발은 환자의 일정 리스트의 개수를 달력에 보여주고, 클릭을 하면 해당 일정의 리스트의 상세내역을 보여준다. 

 

주요 컴포넌트는 단 두개이다. 

 

list 컴포넌트(부모) / ExmpleFull(달력, 자식) 컴포넌트. 


구현 과정

 

list 컴포넌트 :

리스트의 수를 가져와서 {날짜 : 상세데이터} 오브젝트로 만들어주고, 

달력 컴포넌에 props로 해당 오브젝트를 넘겨주고, 

 

 

ExampleFull 컴포넌트 :

달력 컴포넌트에 해당 날짜의 상세데이터.length를 읽어서 표시해준다. 

클릭 시에는 부모 컴포넌트에 emit으로 날짜를 넘겨주고, 

 

 

list 컴포넌트 : 

부모 컴포넌트는 emit 으로 받은 날짜에 해당하는 예약상세데이터를 출력해준다. 

 

 

전체 코드이다. 

 

 

일정 > list 컴포넌트 

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RETURN_CODE } from '@/apis/common'
import { ReserveModel, getReserveScheduleList, getReserveList } from '@/apis/reserve'
import { useRouter } from 'vue-router'

/**
|--------------------------------------------------
| 😃  1. 변수
|--------------------------------------------------
*/
const isRefresh = ref(false)
const router = useRouter()
const reserveType = ref<any>('')
const reserveDate = ref<any>('')
const patientData = ref<any>('') // 환자 정보
const data = ref<ReserveModel>({} as ReserveModel) // 환자 예약 정보
const reservationCounts = ref<any>({}) // 환자 예약 정보 리스트로 저장
const bottom = ref(false) // 바텀 팝업 설정
const popupMsg = ref<any>([]) // 팝업 메시지

/**
|--------------------------------------------------
| 🔑 2. 일반 함수
|--------------------------------------------------
*/

/*  1. 토큰 확인 함수 */
function tokenError(code: string) {
  if (/^30002\d{3}$/.test(code)) {
    Snackbar.error('로그인 후 이용 부탁드립니다')
    router.replace('/login')
  }
}

/** 2. 새로고침 함수  */
function handleRefresh() {
  isRefresh.value = false
}

/** 3. 팝업을 오픈하는 함수 */
function openPopup(date: any) {
  console.log('날짜 클릭 이벤트를 수신했습니다. ::', date)
  bottom.value = true

  popupMsg.value = []

  for (const item of reservationCounts.value[date]) {
    popupMsg.value.push(item)
  }
}

/** 4. 날짜 포매팅 함수 */
const formattedDateTime = formatDateTime('2024-03-26T07:07:00')

function formatDateTime(dateTimeString: string): string {
  const dateTime = new Date(dateTimeString)
  const year = dateTime.getFullYear()
  const month = ('0' + (dateTime.getMonth() + 1)).slice(-2)
  const day = ('0' + dateTime.getDate()).slice(-2)
  const hour = ('0' + dateTime.getHours()).slice(-2)
  const minute = ('0' + dateTime.getMinutes()).slice(-2)

  return `${year}.${month}.${day} ${hour}:${minute}`
}

console.log(formattedDateTime) // 출력: "2024. 03. 26 07:07"

/**
|--------------------------------------------------
| 🎁 3. fetch 함수
|--------------------------------------------------
*/
/**------------------------
  | 🎈 fetch 함수 1
  |------------------------*/
/** 1. 예약 스케줄 리스트 조회 호출 */
async function fetchData() {
  let response
  const params = {
    patientId: 3 // TODO 로그인 사용자로 수정해야 함
  } as ReserveModel

  if (history.state.type) params.reserveType = history.state.type
  if (history.state.type) params.reserveDt = history.state.date

  /** 환자 정보 조회 */
  response = await getReserveScheduleList(params)
  if (response.code !== RETURN_CODE.SUCCESS) {
    console.log('예약 정보를 가져오는 중 오류가 발생했습니다.')
    return
  }
  patientData.value = response.data
  console.log('response- getReserveScheduleList .....::', response.data)

  /** 환자 예약 리스트 조회 */
  response = await getReserveList(params)
  if (response.code !== RETURN_CODE.SUCCESS) {
    console.log('예약 정보를 가져오는 중 오류가 발생했습니다.')
    return
  }

  tokenError(response.code)

  data.value = response.data

  for (const item of data.value.dataList) {
    let dateString = item.reserveDt?.toString()
    reserveDate.value = dateString?.slice(8, 10)

    // 날짜-예약정보 (키-밸류) 오브젝트 만들기
    if (reserveDate.value) {
      if (reservationCounts.value[reserveDate.value]) {
        reservationCounts.value[reserveDate.value].push(item)
      } else {
        reservationCounts.value[reserveDate.value] = [item]
      }
    }
  }

  // 결과 출력
  for (const date in reservationCounts) {
    console.log(`{${date}: ${reservationCounts.value[date]}}`)
  }
}

/**
|--------------------------------------------------
| 🎁 4. LifeCycle 함수
|--------------------------------------------------
*/

onMounted(() => {
  fetchData()
})
</script>

<!-- /**
|--------------------------------------------------
| 🎨 화면
|--------------------------------------------------
*/ -->

<template>
  <div class="message">
    <var-pull-refresh v-model="isRefresh" @refresh="handleRefresh">
      <app-header title="일정">
        <template #left>
          <app-back />
        </template>
        <template #right>
          <app-side-menu />
        </template>
      </app-header>
      <div class="message-list">
        <div class="message-item">
          <div class="message-item-detail">
            <div class="message-item-detail-header-centered-box">
              <var-paper>{{ patientData?.patientName }}님</var-paper>
              <var-paper>환자 등록번호 : {{ patientData?.patientId }}</var-paper>
            </div>
            <div class="message-item-detail-header-centered-box">
              <ExampleFull
                :reserveDate="reserveDate"
                :reserveType="reserveType"
                :reservationCounts="reservationCounts"
                :data="data"
                @dateEmit="openPopup"
              />
            </div>
            <div class="message-item-detail-description"></div>
          </div>
        </div>
      </div>
    </var-pull-refresh>
  </div>
  <var-button type="primary" block @click="bottom = true" style="display: none"> Below Popup </var-button>

  <router-stack-view />
  <!-- 아래 팝업  -->
  <var-popup position="bottom" v-model:show="bottom">
    <div class="popup-example-block">
      <div v-for="(item, i) in popupMsg" :key="i">
        {{ formatDateTime(item.reserveDt) }} <br />
        <div style="font-weight: bold">
          {{ item.name }}<span style="display: block; text-align: right">{{ item.status }}</span>
        </div>
        {{ item.comment }}<br /><br />
        <hr />
      </div>
    </div>
  </var-popup>
</template>

<style lang="less" scoped>
.message {
  padding: calc(var(--app-bar-height)) 0 0;

  &-list {
    padding: 3px 0;
  }

  &-item {
    position: relative;
    display: flex;
    padding-top: 3px;

    &-avatar {
      flex-shrink: 0;
      margin: 0 18px;
    }

    &-detail {
      width: 100%;
      border-bottom: thin solid var(--divider-color);
      padding-bottom: 5px;

      &-header {
        display: flex;
        justify-content: space-between;
        align-items: center;

        &-name {
          color: var(--app-title-color);
          font-size: 16px;
          width: 190px;
        }

        &-date {
          color: var(--app-subtitle-color);
          font-size: 14px;
          margin-bottom: 2px;
        }

        &-centered-box {
          /* 위아래 여백 조절 */
          margin: 10px;
          padding: 17px;
          border: 1.5px solid rgb(222, 218, 218);
          border-radius: 10px;
          color: rgb(49, 49, 165);
        }
      }

      &-description {
        color: var(--app-subtitle-color);
        font-size: 15px;
        margin-top: 6px;
      }
    }
  }

  .var-steps {
    margin: 10px;
  }

  .var-step {
    margin: 15px;
  }
}
.var-step__vertical-content {
  margin: 15px !important;
  border: 1.5px solid rgb(222, 218, 218) !important;
  border-radius: 10px !important;
}

.var-step__vertical-tag {
  margin: 100px !important; /* 원하는 간격을 지정합니다. */
}

.popup-example-block {
  padding: 24px;
  width: 100%;
  height: 40vh;
  font-size: 12px;
}
</style>

 

 

달력 ExampleFull 컴포넌트

<script setup lang="ts">
// Using the publish version, you would do this instead:
// import { CalendarView, CalendarViewHeader, CalendarMath } from "vue-simple-calendar"
import CalendarView from '../vue-simple-calendar/CalendarView.vue'
import CalendarViewHeader from '../vue-simple-calendar/CalendarViewHeader.vue'
import CalendarMath from '../vue-simple-calendar/CalendarMath'
import { ICalendarItem, INormalizedCalendarItem } from './ICalendarItem'
import { onMounted, reactive, defineEmits } from 'vue'

/**
|--------------------------------------------------
| 😃  1. 변수
|--------------------------------------------------
*/

const date = ref('')
const type = ref('')
const count = ref({})

// 현재 달을 가져오는 함수 (선택적으로 시간 및 분도 포함)
const thisMonth = (d: number, h?: number, m?: number): Date => {
  const t = new Date()
  return new Date(t.getFullYear(), t.getMonth(), d, h || 0, m || 0)
}

// 부모 컴포넌트로부터 받은 props 정의
const props = defineProps<{ reserveDate: string; reserveType: string; data: any; reservationCounts: any }>() // 부모Component에서 받은값

// 상태 객체를 반응적으로 정의
interface IExampleState {
  showDate: Date // 캘린더에 표시되는 날짜
  message: string // 상태 메시지
  startingDayOfWeek: number // 주의 시작 요일 (0: Sunday, 1: Monday, ..., 6: Saturday)
  disablePast: boolean // 과거 날짜 사용 금지 여부
  disableFuture: boolean // 미래 날짜 사용 금지 여부
  displayPeriodUom: string // 표시 기간 단위 (예: 'month', 'year')
  displayPeriodCount: number // 표시 기간 개수
  displayWeekNumbers: boolean // 주 번호 표시 여부
  showTimes: boolean // 시간 표시 여부
  selectionStart?: Date // 선택 시작 날짜
  selectionEnd?: Date // 선택 종료 날짜
  newItemTitle: string // 새로운 아이템 제목
  newItemStartDate: string // 새로운 아이템 시작 날짜
  newItemEndDate: string // 새로운 아이템 종료 날짜
  useDefaultTheme: boolean // 기본 테마 사용 여부
  useHolidayTheme: boolean // 휴일 테마 사용 여부
  useTodayIcons: boolean // 오늘 아이콘 사용 여부
  items: ICalendarItem[] // 캘린더에 표시되는 아이템 배열
}

const state = reactive({
  showDate: thisMonth(1), // 캘린더에 표시할 현재 달의 첫 날짜
  message: '', // 상태 메시지
  startingDayOfWeek: 0, // 주의 시작 요일 (0: Sunday, 1: Monday, ..., 6: Saturday)
  disablePast: false, // 과거 날짜 사용 금지 여부
  disableFuture: false, // 미래 날짜 사용 금지 여부
  displayPeriodUom: 'month', // 표시 기간의 단위 (예: 'month', 'year')
  displayPeriodCount: 1, // 표시할 기간의 개수
  displayWeekNumbers: false, // 주 번호 표시 여부
  // showTimes: true, // 시간 표시 여부
  selectionStart: undefined, // 선택 시작 날짜
  selectionEnd: undefined, // 선택 종료 날짜
  newItemTitle: '', // 새로운 아이템 제목
  newItemStartDate: '', // 새로운 아이템 시작 날짜
  newItemEndDate: '', // 새로운 아이템 종료 날짜
  useDefaultTheme: true, // 기본 테마 사용 여부
  // useHolidayTheme: true, // 휴일 테마 사용 여부
  useTodayIcons: true, // 오늘 아이콘 사용 여부
  items: [] as ICalendarItem[]
} as IExampleState)

// 계산된 속성 정의
const themeClasses = computed(() => ({
  'theme-default': state.useDefaultTheme
  // "holiday-us-traditional": state.useHolidayTheme,
  // "holiday-us-official": state.useHolidayTheme,
}))

// 날짜 클래스를 계산하는 메서드 정의
const myDateClasses = (): Record<string, string[]> => {
  // This was added to demonstrate the dateClasses prop. Note in particular that the
  // keys of the object are `yyyy-mm-dd` ISO date strings (not dates), and the values
  // for those keys are strings or string arrays. Keep in mind that your CSS to style these
  // may need to be fairly specific to make it override your theme's styles. See the
  // CSS at the bottom of this component to see how these are styled.
  const o = {} as Record<string, string[]>
  const theFirst = thisMonth(1)
  const ides = [2, 4, 6, 9].includes(theFirst.getMonth()) ? 15 : 13
  const idesDate = thisMonth(ides)
  o[CalendarMath.isoYearMonthDay(idesDate)] = ['ides']
  o[CalendarMath.isoYearMonthDay(thisMonth(21))] = ['do-you-remember', 'the-21st']
  return o
}

// 마운트 후 수행할 작업 정의
onMounted((): void => {
  state.newItemStartDate = CalendarMath.isoYearMonthDay(CalendarMath.today())
  state.newItemEndDate = CalendarMath.isoYearMonthDay(CalendarMath.today())
})

// props 로드 시 수행
watch(props, () => {
  date.value = props.reserveDate
  type.value = props.reserveType
  count.value = props.reservationCounts

  console.log('count.value :::::', count.value)
  for (const date in count.value) {
    state.items.push({
      id: date,
      startDate: thisMonth(Number(date)), // 아이템 시작 날짜 설정
      // title: props.reserveType, // 아이템 제목 설정
      title: count.value[date].length,
      url: 'https://en.wikipedia.org/wiki/Birthday' // 아이템 URL 설정
    })
  }
})

/**
|--------------------------------------------------
| 🔑 2. 일반 함수
|--------------------------------------------------
*/

// 부모 컴포넌트에 메시지를 전달하기 위한 이벤트 정의
const emit = defineEmits(['dateEmit'])

// 날짜 클릭을 처리하는 함수
const onClickDay = (d: Date): void => {
  state.selectionStart = undefined
  state.selectionEnd = undefined
  state.message = `You clicked: ${d.toLocaleDateString()}`

  let dateString = d.toString().slice(8, 10) // 26

  for (const date in props.reservationCounts) {
    console.log('🍀 date  ---- ', date)
    if (date === dateString) {
      emit('dateEmit', date)
    }
  }
}

// 항목 클릭을 처리하는 함수
const onClickItem = (item: INormalizedCalendarItem): void => {
  state.message = `You clicked: ${item.title}`

  let dateString = item.originalItem.startDate.toString().slice(8, 10) // 26

  for (const date in props.reservationCounts) {
    console.log('🍀 date  ---- ', date)
    if (date === dateString) {
      emit('dateEmit', date)
    }
  }
}

// 표시할 날짜 설정을 처리하는 함수
const setShowDate = (d: Date): void => {
  state.message = `Changing calendar view to ${d.toLocaleDateString()}`
  state.showDate = d
}

// 선택 설정을 처리하는 함수
const setSelection = (dateRange: Date[]): void => {
  state.selectionEnd = dateRange[1]
  state.selectionStart = dateRange[0]
}

// 선택 완료를 처리하는 함수
const finishSelection = (dateRange: Date[]): void => {
  setSelection(dateRange)
  state.message = `You selected: ${state.selectionStart?.toLocaleDateString() ?? 'n/a'} - ${
    state.selectionEnd?.toLocaleDateString() ?? 'n/a'
  }`
}

// 드롭 이벤트를 처리하는 함수
const onDrop = (item: INormalizedCalendarItem, date: Date): void => {
  state.message = `You dropped ${item.id} on ${date.toLocaleDateString()}`
  // 이전 시작 날짜와 선택한 날짜 사이의 차이를 결정하고,
  // 해당 차이를 시작 및 종료 날짜에 모두 적용하여 항목을 이동합니다.
  const eLength = CalendarMath.dayDiff(item.startDate, date)
  item.originalItem.startDate = CalendarMath.addDays(item.startDate, eLength)
  item.originalItem.endDate = CalendarMath.addDays(item.endDate, eLength)
}

// 항목 추가를 처리하는 함수
// const clickTestAddItem = (): void => {
// 	state.items.push({
// 		startDate: CalendarMath.fromIsoStringToLocalDate(state.newItemStartDate),
// 		endDate: CalendarMath.fromIsoStringToLocalDate(state.newItemEndDate),
// 		title: state.newItemTitle,
// 		id: "e" + Math.random().toString(36).substring(2, 11),
// 	})
// 	state.message = "You added a calendar item!"
// }
</script>

<!-- /**
|--------------------------------------------------
| 🎨 화면
|--------------------------------------------------
*/ -->
<template>
  <div id="example-full">
    <div class="calendar-parent">
      <CalendarView
        :items="state.items"
        :show-date="state.showDate"
        :time-format-options="{ hour: 'numeric', minute: '2-digit' }"
        :enable-drag-drop="true"
        :disable-past="state.disablePast"
        :disable-future="state.disableFuture"
        :show-times="state.showTimes"
        :date-classes="myDateClasses()"
        :display-period-uom="state.displayPeriodUom"
        :display-period-count="state.displayPeriodCount"
        :starting-day-of-week="state.startingDayOfWeek"
        :class="themeClasses"
        :period-changed-callback="periodChanged"
        :current-period-label="state.useTodayIcons ? 'icons' : ''"
        :displayWeekNumbers="state.displayWeekNumbers"
        :enable-date-selection="true"
        :selection-start="state.selectionStart"
        :selection-end="state.selectionEnd"
        @date-selection-start="setSelection"
        @date-selection="setSelection"
        @date-selection-finish="finishSelection"
        @drop-on-date="onDrop"
        @click-date="onClickDay"
        @click-item="onClickItem"
      >
        <!-- 달력 헤더  -->
        <template #header="{ headerProps }">
          <CalendarViewHeader :header-props="headerProps" @input="setShowDate" />
        </template>

        <template> </template>
      </CalendarView>
    </div>
  </div>
</template>

<style>
@import 'https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css';
/* npm 패키지를 사용하는 앱의 경우 아래 URL은 /node_modules/vue-simple-calendar/dist/css/를 참조해야 합니다 */
@import '/css/gcal.css';
@import '/css/holidays-us.css';
@import '/css/holidays-ue.css';

/* 전체 예제 컨테이너 스타일 */
#example-full {
  display: flex;
  flex-direction: row;
  font-family: Calibri, sans-serif;
  width: 85vw; /* 전체 너비는 화면 너비의 86%로 설정 */
  min-width: 10rem; /* 최소 너비는 30rem으로 설정 */
  max-width: 30rem; /* 최대 너비는 90rem으로 설정 */
  min-height: 75vh; /* 최소 높이는 55rem으로 설정!!!!!!!!!! 여기를 조절해야돼 !!!!*/
  margin-left: auto; /* 왼쪽 여백을 자동으로 설정하여 가운데 정렬 */
  margin-right: auto; /* 오른쪽 여백을 자동으로 설정하여 가운데 정렬 */
}

/* #example-full .calendar-controls {
	margin-right: 1rem;
	min-width: 14rem;
	max-width: 14rem;
} */

/* 달력 부모 컨테이너 스타일 */
#example-full .calendar-parent {
  display: flex;
  flex-direction: column;
  flex-grow: 1;
  overflow-x: hidden;
  overflow-y: hidden;
  max-height: 98vh; /* 최대 높이는 화면 높이의 90%로 설정 */
  background-color: white; /* 배경색은 흰색으로 설정 */
}

/* 달력의 각 주의 높이를 조정하는 스타일 */
#example-full .cv-wrapper.period-month.periodCount-2 .cv-week,
#example-full .cv-wrapper.period-month.periodCount-3 .cv-week,
#example-full .cv-wrapper.period-year .cv-week {
  min-height: 7rem; /* 주당 최소 높이는 7rem으로 설정 */
}

/* 특정 날짜에 배경색을 변경하는 예제 - 분홍색 */
/* #example-full .theme-default .cv-day.ides {
  background-color: #ffe0e0;
} */

/* 날짜 요소에 아이콘을 추가하는 예제 */
#example-full .ides .cv-day-number::before {
  content: '\271D';
}

/* #example-full .cv-day.do-you-remember.the-21st .cv-day-number::after {
  content: '\1F30D\1F32C\1F525';
} */
</style>