Merge branch 'vacation'

This commit is contained in:
dyhj625 2025-02-27 13:06:29 +09:00
commit 92904c5001
6 changed files with 404 additions and 255 deletions

View File

@ -7,42 +7,184 @@
/* 휴가 */
.half-day-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
.half-day-buttons .btn.active {
border: 2px solid black;
}
.fc-daygrid-day-events {
max-height: 100px !important;
overflow-y: auto !important;
}
/* 이벤트 선 없게 */
.fc-event {
border: none;
}
/* 오전전반차 그래프 */
.fc-daygrid-event.half-day-am {
width: calc(50% - 4px) !important;
}
/* 오후반차 그래프프 */
.fc-daygrid-event.half-day-pm {
width: calc(50% - 4px) !important;
margin-left: auto !important
}
/* 공휴일,일요일 색상 */
.fc-day-sun .fc-daygrid-day-number,
.fc-col-header-cell:first-child .fc-col-header-cell-cushion {
color: #ff4500 !important;
}
/* 토요일 색상 */
.fc-day-sat .fc-daygrid-day-number,
.fc-col-header-cell:last-child .fc-col-header-cell-cushion {
color: #6076e0 !important;
}
/* 캘린더 날짜 왼쪽 상단 위치하게 */
.fc-daygrid-day-number {
margin-right: auto;
}
/* 데이트피커 뾰족없게게 */
.flatpickr-calendar:before,
.flatpickr-calendar:after {
display: none !important;
}
/* 기본 스타일은 그대로 두고, 데이트피커 인풋의 추가 스타일 정의 */
.fc-toolbar-title {
cursor: pointer;
}
/* 클릭 가능한 날짜 (오늘 + 미래) */
.fc-daygrid-day.clickable {
cursor: pointer;
transition: background-color 0.2s ease-in-out;
}
/* 마우스를 올렸을 때 효과 */
.fc-daygrid-day.clickable:hover {
background-color: rgba(0, 0, 0, 0.05); /* 연한 배경 효과 */
}
/* 주말 (토요일, 일요일) 및 공휴일 */
.fc-day-sat-sun {
cursor: not-allowed !important;
opacity: 0.6; /* 흐려 보이게 */
}
/* 과거 날짜 (오늘 이전) */
.fc-daygrid-day.past {
cursor: not-allowed !important;
opacity: 0.6; /* 흐려 보이게 */
}
/* 기본 이벤트 스타일 */
.fc-daygrid-event {
border: none !important;
border-radius: 4px;
}
/* 오전 반차 (왼쪽 절반) */
.selected-event.half-day-am {
width: 50% !important;
left: 0 !important;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
/* 오후 반차 (오른쪽 절반) */
.selected-event.half-day-pm {
width: 50% !important;
margin-left: auto !important;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
/* 본인 모달 */
/* 닫기 버튼 */
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
}
/* 리스트 아이템 */
.vacation-item {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 5px;
background: #f9f9f9;
}
/* 모달 본문 스크롤 */
.modal-body {
max-height: 130px;
overflow-y: auto;
}
/* 선물하기 모달 */
/* 연차 개수 버튼 */
.count-btn {
font-size: 18px;
padding: 2px 10px;
border: none;
background: #2C3E50;
color: white;
border-radius: 5px;
cursor: pointer;
}
.count-btn:hover {
background: #1d2c44;
}
.count-btn:disabled {
background: #cccccc;
cursor: not-allowed;
}
/* 버튼 컨테이너 (우측 정렬) */
.custom-button-container {
display: flex;
justify-content: flex-end;
align-items: center;
}
/* 버튼 기본 스타일 */
.custom-button {
background: none; /* 배경색 없음 */
border: none; /* 테두리 없음 */
padding: 10px; /* 크기 조정 */
cursor: pointer; /* 클릭 가능하도록 변경 */
}
/* 아이콘 색상 변경 (기본) */
.custom-button i {
color: #282538; /* 기본 아이콘 색상 */
font-size: 25px; /* 아이콘 크기 */
}
/* 버튼 호버 효과 */
.custom-button:hover i {
color: #ff0800; /* 호버 시 아이콘 색상 변경 */
}
/* 모달 배경 투명하게 */
.modal-dialog {
background: none !important; /* 배경 제거 */
box-shadow: none !important; /* 음영 제거 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 내용 스타일 */
.modal-content {
background: #fff; /* 기존 흰색 배경 유지 */
border-radius: 8px;
box-shadow: none !important; /* 내부 음영 제거 */
padding: 20px;
max-width: 500px;
width: 100%;
}
.grayscaleImg {
filter: grayscale(100%);

View File

@ -1,21 +1,26 @@
<template>
<div class="half-day-buttons">
<div class="menu gap-4 justify-content-center mt-5">
<!-- 오전 반차 버튼 -->
<button
class="btn btn-info"
class="btn btn-warning"
:class="{ active: halfDayType === 'AM' }"
@click="toggleHalfDay('AM')"
>
<i class="bi bi-sun"></i>
</button>
<!-- 오후 반차 버튼 -->
<button
class="btn btn-warning"
class="btn btn-info"
:class="{ active: halfDayType === 'PM' }"
@click="toggleHalfDay('PM')"
>
<i class="bi bi-moon"></i>
</button>
<!-- 저장 버튼 -->
<div class="save-button-container">
<button class="btn btn-success" @click="addVacationRequests">
<button class="btn btn-success" @click="addVacationRequests" :disabled="isDisabled">
</button>
</div>
@ -23,21 +28,106 @@
</template>
<script setup>
import { defineEmits, ref } from "vue";
import { defineEmits, ref, defineProps } from "vue";
const emit = defineEmits(["toggleHalfDay", "addVacationRequests"]);
const props = defineProps({
isDisabled: Boolean
});
const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]);
const halfDayType = ref(null);
const toggleHalfDay = (type) => {
halfDayType.value = halfDayType.value === type ? null : type;
halfDayType.value = type;
emit("toggleHalfDay", halfDayType.value);
// 1
setTimeout(() => {
halfDayType.value = null;
}, 1000);
};
//
const resetHalfDay = () => {
halfDayType.value = null;
emit("resetHalfDay");
};
const addVacationRequests = () => {
emit("addVacationRequests");
};
defineExpose({ resetHalfDay });
</script>
<style scoped>
/* 버튼 기본 스타일 */
.btn {
width: 50px;
height: 50px;
border-radius: 50%;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out;
}
/* 마우스를 올렸을 때 */
.btn:hover {
filter: brightness(90%);
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2);
transform: scale(1.05);
}
/* 버튼이 눌렸을 때 */
.btn:active {
transform: scale(0.9);
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
}
/* 선택된 (눌린) 버튼 */
.btn.active {
border: 3px solid #fff; /* 흰색 테두리 강조 */
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
/* AM 버튼 (선택된 상태) */
.btn-warning.active {
background-color: #ffca2c !important; /* 진한 노란색 */
color: black;
}
/* PM 버튼 (선택된 상태) */
.btn-info.active {
background-color: #0b5ed7 !important; /* 진한 파란색 */
color: white;
}
/* ✔ 버튼 */
.btn-success {
font-size: 24px;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out;
}
/* ✔ 버튼 마우스 오버 */
.btn-success:hover {
background-color: #198754;
box-shadow: 0px 4px 10px rgba(25, 135, 84, 0.4);
transform: scale(1.1);
}
/* ✔ 버튼 클릭 */
.btn-success:active {
transform: scale(0.95);
box-shadow: 0px 2px 5px rgba(25, 135, 84, 0.2);
}
</style>

View File

@ -1,30 +1,33 @@
<template>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="modal-content">
<div class="modal-content p-5">
<h5 class="modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5>
<button class="close-btn" @click="closeModal"></button>
<div class="modal-body">
<p>선물할 연차 개수를 선택하세요.</p>
<div class="vacation-control">
<div class="justify-content-center d-sm-flex gap-sm-3 align-items-md-center mt-8">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="grant-count">{{ grantCount }}</span>
<span class="text-dark fw-bold fs-4">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div>
<button class="gift-btn" @click="saveVacationGrant" :disabled="grantCount === 0">
<div class="custom-button-container">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0">
<i class="bx bx-gift"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
import axios from "@api";
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const props = defineProps({
isOpen: Boolean,
targetUser: Object,
@ -72,21 +75,18 @@
count: grantCount.value,
},
];
console.log(props.targetUser)
console.log(payload)
const response = await axios.post("vacation", payload);
console.log(response)
if (response.data && response.data.status === "OK") {
alert("✅ 연차가 부여되었습니다.");
toastStore.onToast('연차가 선물되었습니다.', 's');
await fetchSentVacationCount();
emit("updateVacation");
closeModal();
} else {
alert("🚨 연차 추가 중 오류가 발생했습니다.");
toastStore.onToast(' 연차 선물 중 오류가 발생했습니다.', 'e');
}
} catch (error) {
console.error("🚨 연차 추가 실패:", error);
alert("연차 추가에 실패했습니다.");
toastStore.onToast(' 연차 선물 실패!!.', 'e');
}
};
@ -98,7 +98,6 @@
() => props.isOpen,
async (newVal) => {
if (newVal && props.targetUser && props.targetUser.MEMBERSEQ) {
console.log("🟢 모달이 열렸습니다. 데이터를 로드합니다.");
await fetchSentVacationCount();
}
}
@ -124,81 +123,4 @@
<style scoped>
/* 모달 본문 */
.modal-content {
background: white;
padding: 20px;
border-radius: 12px;
width: 300px;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.15);
text-align: center;
position: relative;
}
/* 닫기 버튼 */
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
}
/* 연차 개수 조정 버튼 */
.vacation-control {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 15px;
}
.count-btn {
font-size: 18px;
padding: 6px 12px;
border: none;
background: #007bff;
color: white;
border-radius: 5px;
cursor: pointer;
}
.count-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
/* 개수 표시 */
.grant-count {
font-size: 20px;
font-weight: bold;
color: #333;
}
/* 선물 아이콘 버튼 */
.gift-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: #28a745;
color: white;
border: none;
border-radius: 8px;
padding: 10px 15px;
margin-top: 15px;
cursor: pointer;
transition: 0.3s;
}
.gift-btn:hover {
background: #218838;
}
.gift-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>

View File

@ -1,29 +1,28 @@
<template>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="modal-content modal-scroll">
<div class="modal-content p-5 modal-scroll">
<h5 class="modal-title">📅 연차 사용 내역</h5>
<button class="close-btn" @click="closeModal"></button>
<!-- 연차 목록 -->
<div class="modal-body" v-if="mergedVacations.length > 0">
<ol class="vacation-list">
<ol class="list-group-numbered px-0 mt-4">
<li
v-for="(vac, index) in mergedVacations"
:key="vac._expandIndex"
class="vacation-item"
>
<!-- Used 항목만 인덱스 표시 -->
<span v-if="vac.category === 'used'" class="vacation-index">
<span v-if="vac.category === 'used'" class="fw-bold text-dark me-2">
{{ usedVacationIndexMap[vac._expandIndex] }})
</span>
<span :class="vac.category === 'used' ? 'minus-symbol' : 'plus-symbol'">
<span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'">
{{ vac.category === 'used' ? '-' : '+' }}
</span>
<span
:style="{ color: userColors[vac.senderId || vac.receiverId] || '#000' }"
class="vacation-date"
>
{{ formatDate(vac.date) }}
</span>
@ -32,7 +31,7 @@
</div>
<!-- 연차 데이터 없음 -->
<p v-else class="no-data">
<p v-else class="text-sm-center mt-10 text-gray">
🚫 사용한 연차가 없습니다.
</p>
</div>
@ -139,86 +138,4 @@ const closeModal = () => {
</script>
<style scoped>
/* 모달 본문 */
.modal-content {
background: white;
padding: 20px;
border-radius: 12px;
width: 300px;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.15);
text-align: center;
position: relative;
}
/* 닫기 버튼 */
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
}
/* 리스트 기본 스타일 */
.vacation-list {
list-style-type: none;
padding-left: 0;
margin-top: 15px;
}
/* 리스트 아이템 */
.vacation-item {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 5px;
background: #f9f9f9;
}
/* 인덱스 (연차 사용 개수) */
.vacation-index {
font-weight: bold;
font-size: 16px;
margin-right: 8px;
color: #333;
}
/* "-" 빨간색 */
.minus-symbol {
color: red;
font-weight: bold;
margin-right: 8px;
}
/* "+" 파란색 */
.plus-symbol {
color: blue;
font-weight: bold;
margin-right: 8px;
}
/* 날짜 스타일 */
.vacation-date {
font-size: 16px;
color: #333;
}
/* 연차 데이터 없음 */
.no-data {
text-align: center;
font-size: 14px;
color: gray;
margin-top: 10px;
}
/* 모달 본문 스크롤: 높이가 300px 이상이면 스크롤바 표시 */
.modal-body {
max-height: 130px;
overflow-y: auto;
}
</style>

View File

@ -91,7 +91,9 @@ import router from '@/router';
import axios from '@api';
import SaveButton from '@c/button/SaveBtn.vue';
import BackButton from '@c/button/BackBtn.vue'
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const categoryList = ref([]);
const title = ref('');
const password = ref('');
@ -113,6 +115,11 @@ const fetchCategories = async () => {
try {
const response = await axios.get('board/categories');
categoryList.value = response.data.data;
// "" (CMNCODNAM '' )
const freeCategory = categoryList.value.find(category => category.CMNCODNAM === '자유');
if (freeCategory) {
categoryValue.value = freeCategory.CMNCODVAL; //
}
} catch (error) {
console.error('카테고리 불러오기 오류:', error);
}
@ -167,11 +174,11 @@ const write = async () => {
}
}
alert('게시물이 작성되었습니다.');
toastStore.onToast('게시물이 작성되었습니다.', 's');
goList();
} catch (error) {
console.error(error);
alert('게시물 작성 중 오류가 발생했습니다.');
toastStore.onToast('게시물 작성 중 오류가 발생했습니다.', 'e');
}
};
</script>

View File

@ -9,6 +9,7 @@
<HalfDayButtons
@toggleHalfDay="toggleHalfDay"
@addVacationRequests="saveVacationChanges"
:isDisabled="!hasChanges"
/>
</div>
<ProfileList
@ -78,7 +79,9 @@
import { useUserStore } from "@s/userList";
import { useUserInfoStore } from "@s/useUserInfoStore";
import { fetchHolidays } from "@c/calendar/holiday.js";
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const userListStore = useUserStore();
const userList = ref([]);
@ -101,11 +104,74 @@
const vacationCodeMap = ref({});
const holidayDates = ref(new Set());
const fetchedEvents = ref([]);
const halfDayButtonsRef = ref(null);
// 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]))
);
});
/** ✅ selectedDates가 변경될 때 버튼 상태 즉시 업데이트 */
watch(
() => Array.from(selectedDates.value.keys()), // keys() Array
(newKeys) => {
},
{ deep: true }
);
function handleDateClick(info) {
const clickedDateStr = info.dateStr;
const clickedDate = info.date;
const todayStr = new Date().toISOString().split("T")[0];
if (
clickedDate.getDay() === 0 ||
clickedDate.getDay() === 6 ||
holidayDates.value.has(clickedDateStr) ||
clickedDateStr < todayStr
) {
return;
}
const isMyVacation = myVacations.value.some(vac => {
const vacDate = vac.date ? String(vac.date).substring(0, 10) : "";
return vacDate === clickedDateStr && !vac.receiverId;
});
if (isMyVacation) {
if (selectedDates.value.get(clickedDateStr) === "delete") {
selectedDates.value.delete(clickedDateStr);
} else {
selectedDates.value.set(clickedDateStr, "delete");
}
updateCalendarEvents();
return;
}
if (selectedDates.value.has(clickedDateStr)) {
selectedDates.value.delete(clickedDateStr);
updateCalendarEvents();
return;
}
const type = halfDayType.value
? (halfDayType.value === "AM" ? "700101" : "700102")
: "700103";
selectedDates.value.set(clickedDateStr, type);
halfDayType.value = null;
updateCalendarEvents();
//
if (halfDayButtonsRef.value) {
halfDayButtonsRef.value.resetHalfDay();
}
}
const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
@ -156,11 +222,22 @@
if (titleEl) {
titleEl.style.cursor = 'pointer';
titleEl.addEventListener('click', () => {
//
const dpEl = calendarDatepicker.value;
dpEl.style.display = 'block';
dpEl.style.position = 'fixed';
dpEl.style.top = '22%';
dpEl.style.left = '66%';
dpEl.style.transform = 'translate(-50%, -50%)';
dpEl.style.zIndex = '9999';
dpEl.style.border = 'none';
dpEl.style.outline = 'none';
dpEl.style.backgroundColor = 'transparent';
fpInstance.open();
});
}
});
});
})
// API ( )
async function fetchVacationHistory(year) {
@ -272,7 +349,6 @@
const selectedEvents = Array.from(selectedDates.value)
.filter(([date, type]) => type !== "delete")
.map(([date, type]) => ({
title: getVacationType(type),
start: date,
backgroundColor: "rgb(113 212 243 / 76%)",
textColor: "#fff",
@ -298,47 +374,6 @@
return "full-day";
};
function handleDateClick(info) {
const clickedDateStr = info.dateStr;
const clickedDate = info.date;
const todayStr = new Date().toISOString().split("T")[0];
if (
clickedDate.getDay() === 0 ||
clickedDate.getDay() === 6 ||
holidayDates.value.has(clickedDateStr) ||
clickedDateStr < todayStr
) {
return;
}
const isMyVacation = myVacations.value.some(vac => {
const vacDate = vac.date ? String(vac.date).substring(0, 10) : "";
return vacDate === clickedDateStr && !vac.receiverId;
});
if (isMyVacation) {
if (selectedDates.value.get(clickedDateStr) === "delete") {
selectedDates.value.delete(clickedDateStr);
} else {
selectedDates.value.set(clickedDateStr, "delete");
}
updateCalendarEvents();
return;
}
if (selectedDates.value.has(clickedDateStr)) {
selectedDates.value.delete(clickedDateStr);
updateCalendarEvents();
return;
}
const type = halfDayType.value
? (halfDayType.value === "AM" ? "700101" : "700102")
: "700103";
selectedDates.value.set(clickedDateStr, type);
halfDayType.value = null;
updateCalendarEvents();
}
function toggleHalfDay(type) {
halfDayType.value = halfDayType.value === type ? null : type;
}
@ -381,6 +416,7 @@
}
async function saveVacationChanges() {
if (!hasChanges.value) return;
const selectedDatesArray = Array.from(selectedDates.value);
const vacationsToAdd = selectedDatesArray
.filter(([date, type]) => type !== "delete")
@ -405,7 +441,7 @@
delete: vacationsToDelete
});
if (response.data && response.data.status === "OK") {
alert("✅ 휴가 변경 사항이 저장되었습니다.");
toastStore.onToast('휴가 변경 사항이 저장되었습니다.', 's');
await fetchRemainingVacation();
if (isModalOpen.value) {
await fetchVacationHistory(lastRemainingYear.value);
@ -415,11 +451,11 @@
selectedDates.value.clear();
updateCalendarEvents();
} else {
alert("❌ 휴가 저장 중 오류가 발생했습니다.");
toastStore.onToast('휴가 저장 중 오류가 발생했습니다.', 'e');
}
} catch (error) {
console.error("🚨 휴가 변경 저장 실패:", error);
alert("❌ 휴가 저장 요청에 실패했습니다.");
toastStore.onToast('휴가 저장 요청에 실패했습니다.', 'e');
}
}
@ -447,6 +483,46 @@
await nextTick();
fullCalendarRef.value.getApi().refetchEvents();
}
/** ✅ 오늘 이후의 날짜만 클릭 가능하도록 설정 */
function markClickableDates() {
nextTick(() => {
const todayStr = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const todayObj = new Date(todayStr);
document.querySelectorAll(".fc-daygrid-day").forEach((cell) => {
const dateStr = cell.getAttribute("data-date");
if (!dateStr) return; //
const dateObj = new Date(dateStr);
// (, )
if (dateObj.getDay() === 0 || dateObj.getDay() === 6 || holidayDates.value.has(dateStr)) {
cell.classList.remove("clickable");
cell.classList.add("fc-day-sat-sun");
}
// ( )
else if (dateObj < todayObj) {
cell.classList.remove("clickable");
cell.classList.add("past"); //
}
// & ( )
else {
cell.classList.add("clickable");
cell.classList.remove("past", "fc-day-sat-sun");
}
});
});
}
/** ✅ onMounted 및 달력 변경 시 실행 */
onMounted(() => {
markClickableDates();
});
watch([holidayDates, lastRemainingYear, lastRemainingMonth], () => {
markClickableDates();
});
onMounted(async () => {
await fetchUserList();
@ -460,10 +536,5 @@
</script>
<style>
/* 기본 스타일은 그대로 두고, 데이트피커 인풋의 추가 스타일 정의 */
.fc-toolbar-title {
cursor: pointer;
}
/* 데이트피커 인풋은 Flatpickr에서 동적으로 스타일 적용됨 */
</style>