localhost-front/src/views/vacation/VacationManagement.vue
2025-03-25 10:44:09 +09:00

705 lines
26 KiB
Vue

<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card app-calendar-wrapper">
<div class="row g-0">
<!-- Sidebar: 사이드바 영역 -->
<div class="col-3 border-end text-center" id="app-calendar-sidebar">
<div>
<div class="card-body">
<HalfDayButtons
ref="halfDayButtonsRef"
@toggleHalfDay="toggleHalfDay"
@addVacationRequests="saveVacationChanges"
:isDisabled="!hasChanges"
:selectedDate="selectedDate"
/>
<ProfileList
@profileClick="handleProfileClick"
:remainingVacationData="remainingVacationData"
/>
</div>
<VacationModal
v-if="isModalOpen"
:isOpen="isModalOpen"
:myVacations="filteredMyVacations"
:receivedVacations="filteredReceivedVacations"
:userColors="userColors"
@close="isModalOpen = false"
/>
<VacationGrantModal
v-if="isGrantModalOpen"
:isOpen="isGrantModalOpen"
:targetUser="selectedUser"
:remainingQuota="remainingVacationData[selectedUser?.MEMBERSEQ] || 0"
@close="isGrantModalOpen = false"
@updateVacation="fetchRemainingVacation"
/>
</div>
</div>
<!-- Main Content: 캘린더 영역 -->
<div class="col app-calendar-content">
<div class="card shadow-none border-0">
<div class="card-body" style="position: relative;">
<full-calendar
ref="fullCalendarRef"
:options="calendarOptions"
class="flatpickr-calendar-only"
/>
<!-- 숨겨진 데이트피커 인풋 -->
<input
ref="calendarDatepicker"
type="text"
style="display: none; position: absolute;"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted, nextTick, computed, watch, onBeforeUnmount } from "vue";
import axios from "@api";
import "bootstrap-icons/font/bootstrap-icons.css";
// 달력
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
// Flatpickr, MonthSelect
import flatpickr from "flatpickr";
import monthSelectPlugin from "flatpickr/dist/plugins/monthSelect/index";
import "flatpickr/dist/flatpickr.min.css";
import "flatpickr/dist/plugins/monthSelect/style.css";
// 컴포넌트
import HalfDayButtons from "@c/button/HalfDayButtons.vue";
import ProfileList from "@c/vacation/ProfileList.vue";
import VacationModal from "@c/modal/VacationModal.vue";
import VacationGrantModal from "@c/modal/VacationGrantModal.vue";
import { fetchHolidays } from "@c/calendar/holiday.js";
// 스토어
import { useUserStore } from "@s/userList";
import { useUserInfoStore } from "@s/useUserInfoStore";
import { useToastStore } from '@s/toastStore';
// 라우터
import { useRouter } from "vue-router";
const router = useRouter();
// 스토어
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const userListStore = useUserStore();
// 프로필
const userList = ref([]);
const userColors = ref({});
// 휴가정보
const myVacations = ref([]);
const receivedVacations = ref([]);
const remainingVacationData = ref({});
// 모달상태
const isModalOpen = ref(false);
const isGrantModalOpen = ref(false);
// FullCalendar 및 이벤트 관련
const fullCalendarRef = ref(null);
const calendarEvents = ref([]);
const selectedDates = ref(new Map());
const halfDayType = ref(null);
const vacationCodeMap = ref({});
const holidayDates = ref(new Set());
const fetchedEvents = ref([]);
const halfDayButtonsRef = ref(null);
const selectedDate = ref(null);
const selectedUser = ref(null);
const lastRemainingYear = ref(new Date().getFullYear());
const lastRemainingMonth = ref(String(new Date().getMonth() + 1).padStart(2, "0"));
// 데이트피커 인풋 ref
const calendarDatepicker = ref(null);
let fpInstance = null;
/* 변경사항 여부 확인 */
const hasChanges = computed(() => {
return (
selectedDates.value.size > 0 ||
myVacations.value.some(vac => selectedDates.value.has(vac.date.split("T")[0]))
);
});
// 캘린더 이동 함수 (이전, 다음, 오늘)
const moveCalendar = async (value = 0) => {
const calendarApi = fullCalendarRef.value?.getApi();
if (value === 1) {
calendarApi.prev(); // 이전 달로 이동
} else if (value === 2) {
calendarApi.next(); // 다음 달로 이동
} else if (value === 3) {
calendarApi.today(); // 오늘 날짜로 이동
}
};
/* 캘린더 설정 */
// 풀 캘린더 옵션,이벤트
const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
headerToolbar: {
left: "today",
center: "title",
right: "prev,next",
},
contentHeight:"auto",
locale: "ko",
selectable: false,
dateClick: handleDateClick,
datesSet: handleMonthChange,
events: calendarEvents,
customButtons: {
prev: {
text: 'PREV',
click: () => moveCalendar(1),
},
today: {
text: 'TODAY',
click: () => moveCalendar(3),
},
next: {
text: 'NEXT',
click: () => moveCalendar(2),
},
},
});
// 캘린더 월 변경
function handleMonthChange(viewInfo) {
const currentDate = viewInfo.view.currentStart;
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
loadCalendarData(year, month);
}
// 캘린더 클릭
function handleDateClick(info) {
if (!info.date || !info.dateStr) {
return;
}
const clickedDateStr = info.dateStr;
const clickedDate = info.date;
const todayStr = new Date().toISOString().split("T")[0];
const todayObj = new Date(todayStr);
const oneWeekAgoObj = new Date(todayObj);
oneWeekAgoObj.setDate(todayObj.getDate() - 8); // 오늘 기준 7일 전 날짜
// 주말(토, 일) 또는 공휴일 또는 오늘 -7일 날짜 → 클릭 불가능
if (
clickedDate.getDay() === 0 || // 일요일
clickedDate.getDay() === 6 || // 토요일
holidayDates.value.has(clickedDateStr) || // 공휴일
clickedDateStr <= oneWeekAgoObj.toISOString().split("T")[0] // 오늘 -7일 날짜 포함 과거 날짜 클릭 방지
) {
return;
}
// 기존 값 확인
const currentValue = selectedDates.value.get(clickedDateStr);
const isMyVacation = myVacations.value.some(vac => vac.date.substring(0, 10) === clickedDateStr && !vac.receiverId);
if (!selectedDates.value.has(clickedDateStr) && isMyVacation && halfDayType.value) {
const existingVacation = myVacations.value.find(vac => vac.date.substring(0, 10) === clickedDateStr && !vac.receiverId);
const selectedType =
halfDayType.value === "AM" ? "700101" :
halfDayType.value === "PM" ? "700102" : "700103";
if (existingVacation.type === selectedType) {
toastStore.onToast("이미 사용한 연차입니다.", "e");
if (halfDayButtonsRef.value) {
halfDayButtonsRef.value.resetHalfDay();
}
halfDayType.value = null;
return;
}
}
// 이미 활성화된 날짜를 한 번 더 클릭하면 비활성화
if (currentValue && currentValue !== "delete") {
selectedDates.value.delete(clickedDateStr);
updateCalendarEvents();
return;
}
// 버튼을 누르지 않았을 때 - 삭제 모드
if (!halfDayType.value) {
if (isMyVacation) {
if (currentValue === "delete") {
selectedDates.value.delete(clickedDateStr);
} else {
selectedDates.value.set(clickedDateStr, "delete");
}
} else {
selectedDates.value.set(clickedDateStr, "700103");
}
updateCalendarEvents();
return;
}
// 버튼을 눌렀을 때 - 기존 휴가 삭제 후 새로운 값 추가
if (isMyVacation) {
selectedDates.value.set(clickedDateStr, "delete");
}
const type = halfDayType.value === "AM" ? "700101" :
halfDayType.value === "PM" ? "700102" :
"700103"; // 풀연차
selectedDates.value.set(clickedDateStr, type);
// 버튼을 한 번 사용 후 자동 해제 (일회성)
halfDayType.value = null;
if (halfDayButtonsRef.value) {
halfDayButtonsRef.value.resetHalfDay();
}
updateCalendarEvents();
}
function markClickableDates() {
nextTick(() => {
const todayStr = new Date().toISOString().split("T")[0]; // 오늘 날짜 (YYYY-MM-DD)
const todayObj = new Date(todayStr);
const oneWeekAgoObj = new Date(todayObj);
oneWeekAgoObj.setDate(todayObj.getDate() - 8); // 오늘 기준 7일 전 날짜
document.querySelectorAll(".fc-daygrid-day").forEach((cell) => {
const dateStr = cell.getAttribute("data-date");
if (!dateStr) return; // 날짜가 없으면 스킵
const dateObj = new Date(dateStr);
// 주말(토요일, 일요일) 또는 공휴일 또는 오늘 -7일 날짜 → 클릭 불가능
if (
dateObj.getDay() === 0 || // 일요일
dateObj.getDay() === 6 || // 토요일
holidayDates.value.has(dateStr) || // 공휴일
dateObj.getTime() <= oneWeekAgoObj.getTime() // 오늘 -7일 날짜
) {
cell.classList.remove("clickable");
cell.classList.add("fc-day-sat-sun");
cell.removeEventListener("click", handleDateClick); // 클릭 이벤트 제거
}
// 오늘 -6일부터 미래 날짜까지 클릭 가능
else {
cell.classList.add("clickable");
cell.classList.remove("past", "fc-day-sat-sun");
cell.addEventListener("click", handleDateClick); // 클릭 이벤트 추가
}
});
});
}
//캘린더에 데이터 로드
async function loadCalendarData(year, month) {
if (lastRemainingYear.value !== year || lastRemainingMonth.value !== month) {
await fetchRemainingVacation();
lastRemainingYear.value = year;
lastRemainingMonth.value = month;
}
fetchedEvents.value = [];
const [vacationEvents, holidayEvents] = await Promise.all([
fetchVacationData(year, month),
fetchHolidays(year, month),
]);
holidayDates.value = new Set(holidayEvents.map((event) => event.start));
fetchedEvents.value = [...vacationEvents, ...holidayEvents];
updateCalendarEvents();
await nextTick();
fullCalendarRef.value.getApi().refetchEvents();
}
/* 프로필 구역 */
// 프로필 클릭 시 모달 열기
const handleProfileClick = async (user) => {
try {
if (isModalOpen.value && user.MEMBERSEQ === userStore.user.id) {
return;
}
if (isGrantModalOpen.value && selectedUser.value?.MEMBERSEQ === user.MEMBERSEQ) {
return;
}
isModalOpen.value = false;
isGrantModalOpen.value = false;
if (user.MEMBERSEQ === userStore.user.id) {
const displayedYear = lastRemainingYear.value;
await fetchVacationHistory(displayedYear);
isModalOpen.value = true;
} else {
selectedUser.value = user;
isGrantModalOpen.value = true;
}
} catch (error) {
}
};
// 프로필 사원 리스트
const fetchUserList = async () => {
try {
await userListStore.fetchUserList();
userList.value = [...userListStore.userList];
if (!userList.value.length) {
console.warn("📌 사용자 목록이 비어 있음!");
return;
}
// 사용자별 색상 저장
userColors.value = {};
userList.value.forEach((user) => {
userColors.value[user.MEMBERSEQ] = user.usercolor;
});
} catch (error) {
}
};
// 사원별 남은 연차 개수
const fetchRemainingVacation = async () => {
try {
const response = await axios.get("vacation/remaining");
if (response.status === 200) {
remainingVacationData.value = response.data.data.reduce((acc, vacation) => {
acc[vacation.employeeId] = vacation.remainingQuota;
return acc;
}, {});
}
} catch (error) {
}
};
// 로그인한 사원이 사용한 휴가 필터링
const filteredMyVacations = computed(() => {
return myVacations.value.filter(vac => {
const dateStr = vac.date;
const year = dateStr ? dateStr.split("T")[0].substring(0, 4) : null;
return year === String(lastRemainingYear.value);
});
});
// 로그인한 사원이 받은 휴가 필터링
const filteredReceivedVacations = computed(() => {
return receivedVacations.value.filter(vac => {
const dateStr = vac.date;
const year = dateStr ? dateStr.split("T")[0].substring(0, 4) : null;
return dateStr && year === String(lastRemainingYear.value);
});
});
/* 휴가 저장 */
// 휴가 변경사항 저장
async function saveVacationChanges() {
if (!hasChanges.value) return;
const selectedDatesArray = Array.from(selectedDates.value);
// 전체 연도를 고려하여 삭제 및 추가할 데이터를 분리
const vacationChanges = selectedDatesArray.reduce((acc, [date, type]) => {
if (type !== "delete") {
acc.add.push({ date, type });
} else {
acc.delete.push(date);
}
return acc;
}, { add: [], delete: [] });
try {
// 모든 연도의 데이터를 고려하여 삭제할 ID 찾기
const allYears = new Set(vacationChanges.delete.map(date => date.split("-")[0]));
let vacationIdsToDelete = [];
for (const year of allYears) {
await fetchVacationHistory(year); // 각 연도의 최신 데이터를 가져오기
const vacationsToDelete = myVacations.value.filter(vac => {
if (!vac.date) return false;
const vacDate = vac.date.split("T")[0];
return vacationChanges.delete.includes(vacDate) && !vac.receiverId;
});
vacationIdsToDelete.push(...vacationsToDelete.map(vac => vac.id));
}
if (vacationChanges.add.length > 0 || vacationIdsToDelete.length > 0) {
const response = await axios.post("vacation/batchUpdate", {
add: vacationChanges.add,
delete: vacationIdsToDelete,
});
if (response.data && response.data.status === "OK") {
toastStore.onToast(`휴가 변경 사항이 저장되었습니다.`, 's');
// 삭제된 ID를 반영하여 모든 연도의 `myVacations.value`를 업데이트
myVacations.value = myVacations.value.filter(vac => !vacationIdsToDelete.includes(vac.id));
// 삭제 후 최신 데이터 불러오기 (기존 데이터를 유지하면서 추가)
const yearsToUpdate = new Set(
[...vacationChanges.add.map(v => v.date.split("-")[0]),
...vacationChanges.delete.map(v => v.split("-")[0])]
);
for (const year of yearsToUpdate) {
const updatedVacations = await fetchVacationHistory(year);
if (updatedVacations) {
myVacations.value = [...myVacations.value, ...updatedVacations.filter(newVac =>
!myVacations.value.some(oldVac => oldVac.id === newVac.id)
)];
}
}
} else {
toastStore.onToast(`휴가 변경 중 오류가 발생했습니다.`, 'e');
}
}
await fetchRemainingVacation();
selectedDates.value.clear();
updateCalendarEvents();
// 캘린더 새로고침
const currentDate = fullCalendarRef.value.getApi().getDate();
await loadCalendarData(currentDate.getFullYear(), currentDate.getMonth() + 1);
} catch (error) {
toastStore.onToast('휴가 저장 요청에 실패했습니다.', 'e');
}
}
/* 휴가 조회 */
// 로그인 사용자의 연차 사용 내역
async function fetchVacationHistory(year) {
try {
const response = await axios.get(`vacation/history?year=${year}`);
if (response.status === 200 && response.data) {
myVacations.value = response.data.data.usedVacations || [];
receivedVacations.value = response.data.data.receivedVacations || []
}
} catch (error) {
}
}
// 모든 사원 연차 내역 및 그래프화
async function fetchVacationData(year, month) {
try {
const response = await axios.get(`vacation/list/${year}/${month}`);
if (response.status === 200) {
const vacationList = response.data;
// 회원 정보가 없거나 색상 정보가 없는 데이터는 제외
const filteredVacations = vacationList.filter(vac =>
userColors.value[vac.MEMBERSEQ] && userColors.value[vac.MEMBERSEQ] && !vac.LOCVACRMM
);
const events = filteredVacations.map(vac => {
let dateStr = vac.LOCVACUDT ? vac.LOCVACUDT.split("T")[0] : "";
let backgroundColor = userColors.value[vac.MEMBERSEQ];
return {
title: getVacationType(vac.LOCVACTYP),
start: dateStr,
backgroundColor,
classNames: [getVacationTypeClass(vac.LOCVACTYP)],
saved: true,
memberSeq: vac.MEMBERSEQ,
};
}).filter(event => event.start);
return events;
} else {
console.warn("📌 휴가 데이터를 불러오지 못함");
return [];
}
} catch (error) {
return [];
}
}
// 캘린더 이벤트 업데이트
function updateCalendarEvents() {
const selectedEvents = Array.from(selectedDates.value)
.filter(([date, type]) => type !== "delete")
.map(([date, type]) => ({
start: date,
backgroundColor: "rgb(113 212 243 / 76%)", // 클릭하면 하늘색
textColor: "#fff",
display: "background",
classNames: [getVacationTypeClass(type), "selected-event"] // 선택된 날짜 클래스 추가
}));
const filteredFetchedEvents = fetchedEvents.value.filter(event => {
if (event.saved && selectedDates.value.get(event.start) === "delete") {
if (event.memberSeq === userStore.user.id) {
return false;
}
}
return true;
});
calendarEvents.value = [...filteredFetchedEvents, ...selectedEvents];
nextTick(() => {
const todayStr = new Date().toISOString().split("T")[0];
const todayElement = document.querySelector(`.fc-daygrid-day[data-date="${todayStr}"]`);
if (todayElement) {
// 오늘 날짜가 선택된 경우 배경 제거
if (selectedDates.value.has(todayStr)) {
todayElement.classList.remove("fc-day-today"); // 기본 스타일 제거
todayElement.classList.add("selected-event"); // 선택된 날짜 스타일 적용
// 🔹 오전 반차일 경우 'half-day-am' 클래스 추가
if (selectedDates.value.get(todayStr) === "700101") {
todayElement.classList.add("half-day-am");
todayElement.classList.remove("half-day-pm");
}
// 🔹 오후 반차일 경우 'half-day-pm' 클래스 추가
else if (selectedDates.value.get(todayStr) === "700102") {
todayElement.classList.add("half-day-pm");
todayElement.classList.remove("half-day-am");
} else {
todayElement.classList.remove("half-day-am", "half-day-pm");
}
} else {
todayElement.classList.add("fc-day-today"); // 기본 스타일 복원
todayElement.classList.remove("selected-event", "half-day-am", "half-day-pm"); // 선택된 상태 해제
}
}
});
}
// 휴가 종류에 따른 클래스명
const getVacationTypeClass = (type) => {
if (type === "700101") return "half-day-am";
if (type === "700102") return "half-day-pm";
return "full-day";
};
// 휴가종류
const fetchVacationCodes = async () => {
try {
const response = await axios.get("vacation/codes");
if (response.status === 200 && response.data) {
vacationCodeMap.value = response.data.data.reduce((acc, item) => {
acc[item.code] = item.name;
return acc;
}, {});
} else {
console.warn("❌ 공통 코드 데이터를 불러오지 못했습니다.");
}
} catch (error) {
}
};
const getVacationType = (typeCode) => {
return vacationCodeMap.value[typeCode] || "기타";
};
/* 버튼 */
// 반차버튼 토글
function toggleHalfDay(type) {
halfDayType.value = halfDayType.value === type ? null : type;
}
/* 페이지 이동 시 변경 사항 확인 */
router.beforeEach((to, from, next) => {
if (hasChanges.value) {
const answer = window.confirm("저장하지 않은 변경 사항이 있습니다. 이동하시겠습니까?");
if (!answer) {
return next(false);
}
}
selectedDates.value.clear();
next();
});
onBeforeUnmount(() => {
window.removeEventListener("beforeunload", preventUnsavedChanges);
});
/* 새로고침 또는 페이지 종료 시 알림 */
function preventUnsavedChanges(event) {
if (hasChanges.value) {
event.preventDefault();
event.returnValue = "";
}
}
/* watch */
watch(() => lastRemainingYear.value, async (newYear, oldYear) => {
if (newYear !== oldYear) {
await fetchVacationHistory(newYear);
}
});
// 새로운 휴가추가 시 반차 버튼 초기화
watch(
() => Array.from(selectedDates.value.keys()), // 선택된 날짜 리스트 감시
(newKeys) => {
if (halfDayButtonsRef.value && !halfDayType.value) {
halfDayButtonsRef.value.resetHalfDay();
}
},
{ deep: true }
);
// selectedDates가 변경될 때 버튼 상태 즉시 업데이트
watch(
() => Array.from(selectedDates.value.keys()),
(newKeys) => {
},
{ deep: true }
);
watch([holidayDates, lastRemainingYear, lastRemainingMonth], () => {
markClickableDates();
});
/* onMounted */
// onMounted 및 달력 변경 시 실행
onMounted(() => {
markClickableDates();
});
// onMounted 시 데이터 로드
onMounted(async () => {
await userStore.userInfo();
await fetchUserList();
await fetchVacationCodes();
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
await fetchVacationData(year, month);
await loadCalendarData(year, month);
await fetchRemainingVacation();
const currentYear = new Date().getFullYear();
await fetchVacationHistory(currentYear);
window.addEventListener("beforeunload", preventUnsavedChanges);
// Flatpickr 초기화 (달 선택)
fpInstance = flatpickr(calendarDatepicker.value, {
dateFormat: "Y-m",
plugins: [
new monthSelectPlugin({
shorthand: true,
dateFormat: "Y-m",
altFormat: "F Y"
})
],
onOpen: function() {
document.querySelector('.flatpickr-input').style.visibility = 'hidden';
},
onChange: function(selectedDatesArr, dateStr) {
// 선택한 달의 첫날로 달력을 이동
fullCalendarRef.value.getApi().gotoDate(dateStr + "-01");
const [year, month] = dateStr.split("-");
lastRemainingYear.value = parseInt(year, 10);
lastRemainingMonth.value = month;
loadCalendarData(lastRemainingYear.value, lastRemainingMonth.value);
},
onClose: function() {
calendarDatepicker.value.style.display = "none";
}
});
// FullCalendar 년월월(.fc-toolbar-title) 클릭 시 데이트피커 열기
nextTick(() => {
const titleEl = document.querySelector('.fc-toolbar-title');
if (titleEl) {
titleEl.style.cursor = 'pointer';
titleEl.addEventListener('click', () => {
const dpEl = calendarDatepicker.value;
dpEl.style.display = 'block';
dpEl.style.position = 'fixed';
dpEl.style.top = '18%';
dpEl.style.left = '50%';
dpEl.style.transform = 'translate(-50%, -50%)';
dpEl.style.zIndex = '9999';
dpEl.style.border = 'none';
dpEl.style.outline = 'none';
dpEl.style.backgroundColor = 'transparent';
fpInstance.open();
});
}
});
})
</script>
<style>
</style>