Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 898955ab35 | |||
| 71f6abc61f |
@ -6,8 +6,8 @@
|
||||
"@/*" : ["src/*"],
|
||||
"@a/*": ["src/assets/*"],
|
||||
"@c/*": ["src/components/*"],
|
||||
"@v/*": ["src/view/*"],
|
||||
"@l/*": ["src/layout/*"],
|
||||
"@v/*": ["src/views/*"],
|
||||
"@l/*": ["src/layouts/*"],
|
||||
"@s/*": ["src/stores/*"],
|
||||
"@p/*": ["src/common/plugin/*"],
|
||||
"@api": ["./src/common/axios-interceptor.js"]
|
||||
|
||||
@ -3,13 +3,12 @@
|
||||
<li>
|
||||
<BoardProfile profileName=곤데리 :showDetail="false" :author="true" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<button type="button" class="btn btn-text-primary" @click="toggleComment">답변달기</button>
|
||||
<PlusButton @click="toggleComment"/>
|
||||
<BoardComentArea v-if="comment" />
|
||||
<ul class="list-unstyled twoDepth">
|
||||
<li>
|
||||
<BoardProfile profileName=곤데리2 :showDetail="false" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<button type="button" class="btn btn-text-primary" @click="toggleComment">답변달기</button>
|
||||
<BoardComentArea v-if="comment" />
|
||||
</li>
|
||||
</ul>
|
||||
@ -17,13 +16,13 @@
|
||||
<li>
|
||||
<BoardProfile profileName=곤데리 :showDetail="false" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<button type="button" class="btn btn-text-primary" @click="toggleComment">답변달기</button>
|
||||
<PlusButton @click="toggleComment"/>
|
||||
<BoardComentArea v-if="comment" />
|
||||
</li>
|
||||
<li>
|
||||
<BoardProfile :showDetail="false" :unknown="false" />
|
||||
<BoardProfile profileName=곤데리 :showDetail="false" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<button type="button" class="btn btn-text-primary" @click="toggleComment">답변달기</button>
|
||||
<PlusButton @click="toggleComment"/>
|
||||
<BoardComentArea v-if="comment" />
|
||||
</li>
|
||||
</ul>
|
||||
@ -36,6 +35,7 @@ import BoardProfile from './BoardProfile.vue';
|
||||
import BoardComentArea from './BoardComentArea.vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import Pagination from '../pagination/Pagination.vue';
|
||||
import PlusButton from '../button/PlusButton.vue';
|
||||
|
||||
const comment = ref(false);
|
||||
|
||||
|
||||
@ -24,12 +24,8 @@
|
||||
</div>
|
||||
<div class="ms-auto btn-area">
|
||||
<template v-if="showDetail">
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class='bx bx-edit-alt'></i>
|
||||
</button>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class='bx bx-trash' ></i>
|
||||
</button>
|
||||
<EditButton />
|
||||
<DeleteButton />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="author">
|
||||
@ -47,6 +43,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DeleteButton from '../button/DeleteButton.vue';
|
||||
import EditButton from '../button/EditButton.vue';
|
||||
import BoardRecommendBtn from './BoardRecommendBtn.vue';
|
||||
|
||||
defineProps({
|
||||
|
||||
13
src/components/button/DeleteButton.vue
Normal file
13
src/components/button/DeleteButton.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class='bx bx-trash' ></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DeleteButton',
|
||||
methods: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
13
src/components/button/EditButton.vue
Normal file
13
src/components/button/EditButton.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class="bx bx-edit-alt"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EditButton',
|
||||
methods: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
13
src/components/button/PlusButton.vue
Normal file
13
src/components/button/PlusButton.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class="icon-base bx bx-plus"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PlusButton',
|
||||
methods: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -1,17 +1,26 @@
|
||||
<template>
|
||||
<div class="mb-4 row">
|
||||
<label :for="name" class="col-md-2 col-form-label">{{ title }} </label>
|
||||
<label :for="name" class="col-md-2 col-form-label">{{ title }}</label>
|
||||
<div class="col-md-10">
|
||||
<input class="form-control" type="file" :id="name" @change="changeHandler" multiple />
|
||||
<input
|
||||
class="form-control"
|
||||
type="file"
|
||||
:id="name"
|
||||
@change="changeHandler"
|
||||
multiple
|
||||
/>
|
||||
<div v-if="showError" class="text-danger mt-1">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="isAlert ? 'display-block' : ''">{{ errorMsg }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref ,computed} from 'vue';
|
||||
import { fileMsg } from '@/common/msgEnum';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Props
|
||||
const prop = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@ -23,24 +32,57 @@ const prop = defineProps({
|
||||
default: 'nameplz',
|
||||
required: true,
|
||||
},
|
||||
isAlert : {
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:data']);
|
||||
const errorMsg = ref(fileMsg.FileMaxSizeMsg);
|
||||
const emits = defineEmits(['update:data', 'update:isValid']);
|
||||
|
||||
//파일 검사 하는거 만들어야겠지...
|
||||
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_COUNT = 5; // 최대 파일 개수
|
||||
const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; // 허용된 파일 유형
|
||||
|
||||
const showError = ref(false);
|
||||
const fileMsgKey = ref(''); // 에러 메시지 키
|
||||
|
||||
const changeHandler = (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
emits('update:data', files);
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const invalidFiles = files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type));
|
||||
|
||||
// 파일 검증 로직
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxSizeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (files.length > MAX_FILE_COUNT) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxLengthMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (invalidFiles.length > 0) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileNotTypeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else {
|
||||
showError.value = false;
|
||||
fileMsgKey.value = '';
|
||||
emits('update:data', files);
|
||||
emits('update:isValid', true);
|
||||
}
|
||||
};
|
||||
|
||||
// Computed: 에러 메시지 가져오기
|
||||
const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.text-danger {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -43,6 +43,12 @@
|
||||
<div class="text-truncate">Board</div>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li class="menu-item" :class="$route.path.includes('/vacation') ? 'active' : ''">
|
||||
<RouterLink class="menu-link" to="/vacation">
|
||||
<i class="menu-icon tf-icons bx bx-calendar"></i>
|
||||
<div class="text-truncate">vacation</div>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li class="menu-item" :class="$route.path.includes('/sample') ? 'active' : ''">
|
||||
<RouterLink class="menu-link" to="/sample"> <i class="bi "></i>
|
||||
<i class="menu-icon tf-icons bx bx-calendar"></i>
|
||||
|
||||
@ -5,7 +5,7 @@ import BoardWrite from '@v/board/BoardWrite.vue';
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@v/MainView.vue'),
|
||||
component: () => import('@/views/vacation/MainView.vue'),
|
||||
// meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
@ -26,6 +26,10 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/vacation',
|
||||
component: () => import('@v/vacation/VacationManagement.vue'),
|
||||
},
|
||||
{
|
||||
path: '/sample',
|
||||
component: () => import('@c/calendar/SampleCalendar.vue'),
|
||||
|
||||
20
src/stores/calendarStore.js
Normal file
20
src/stores/calendarStore.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const events = ref([]);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
const response = await axios.get('/api/calendar/events');
|
||||
events.value = response.data;
|
||||
};
|
||||
|
||||
const addEvent = async (event) => {
|
||||
await axios.post('/api/calendar/event', event);
|
||||
fetchEvents();
|
||||
};
|
||||
|
||||
export default {
|
||||
events,
|
||||
fetchEvents,
|
||||
addEvent,
|
||||
};
|
||||
55
src/views/AddEventModal.vue
Normal file
55
src/views/AddEventModal.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>휴가 추가</h3>
|
||||
<input type="text" v-model="title" placeholder="제목" />
|
||||
<input type="date" v-model="date" />
|
||||
<button @click="addEvent">추가</button>
|
||||
<button @click="$emit('close')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import calendarStore from '@s/calendarStore';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
title: '',
|
||||
date: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
addEvent() {
|
||||
if (this.title && this.date) {
|
||||
calendarStore.addEvent({ title: this.title, start: this.date });
|
||||
this.$emit('close');
|
||||
} else {
|
||||
alert('모든 필드를 입력해주세요.');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
width: 300px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
56
src/views/vacation/ProfileList.vue
Normal file
56
src/views/vacation/ProfileList.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="profile-list">
|
||||
<div v-for="profile in profiles" :key="profile.id" class="profile">
|
||||
<img :src="profile.avatar" alt="프로필 사진" class="avatar" />
|
||||
<div class="info">
|
||||
<p class="name">{{ profile.name }}</p>
|
||||
<p class="vacation-count">남은 휴가: {{ profile.remainingVacations }}일</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
profiles: [
|
||||
{ id: 1, name: '김철수', avatar: '/avatars/user1.png', remainingVacations: 15 },
|
||||
{ id: 2, name: '박영희', avatar: '/avatars/user2.png', remainingVacations: 11 },
|
||||
{ id: 3, name: '이민호', avatar: '/avatars/user3.png', remainingVacations: 10 },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.vacation-count {
|
||||
color: gray;
|
||||
}
|
||||
</style>
|
||||
181
src/views/vacation/VacationManagement.vue
Normal file
181
src/views/vacation/VacationManagement.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="vacation-management">
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="save-button-container">
|
||||
<button class="btn btn-success" @click="addVacationRequest">✔</button>
|
||||
</div>
|
||||
<div class="card app-calendar-wrapper">
|
||||
<div class="row g-0">
|
||||
<div class="col app-calendar-content">
|
||||
<div class="card shadow-none border-0">
|
||||
<div class="card-body pb-0">
|
||||
<full-calendar
|
||||
ref="fullCalendarRef"
|
||||
:events="calendarEvents"
|
||||
:options="calendarOptions"
|
||||
defaultView="dayGridMonth"
|
||||
class="flatpickr-calendar-only"
|
||||
>
|
||||
</full-calendar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="half-day-buttons">
|
||||
<button class="btn btn-info" :class="{ active: halfDayType === 'AM' }" @click="toggleHalfDay('AM')">☀️ 오전반차</button>
|
||||
<button class="btn btn-warning" :class="{ active: halfDayType === 'PM' }" @click="toggleHalfDay('PM')">🌙 오후반차</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import 'flatpickr/dist/flatpickr.min.css';
|
||||
import '@/assets/css/app-calendar.css';
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
const fullCalendarRef = ref(null);
|
||||
const calendarEvents = ref([]);
|
||||
const selectedDates = ref([]);
|
||||
const halfDayType = ref(null); // 오전/오후 반차 선택
|
||||
|
||||
const calendarOptions = reactive({
|
||||
plugins: [dayGridPlugin, interactionPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'today',
|
||||
center: 'title',
|
||||
right: 'prev,next',
|
||||
},
|
||||
locale: 'ko',
|
||||
selectable: true,
|
||||
dateClick: handleDateClick,
|
||||
});
|
||||
|
||||
function handleDateClick(info) {
|
||||
const date = info.dateStr;
|
||||
const dayElement = info.dayEl;
|
||||
|
||||
if (!selectedDates.value.includes(date)) {
|
||||
selectedDates.value.push(date);
|
||||
if (halfDayType.value === 'AM') {
|
||||
dayElement.style.backgroundImage = 'linear-gradient(to bottom, #ade3ff 50%, transparent 50%)';
|
||||
} else if (halfDayType.value === 'PM') {
|
||||
dayElement.style.backgroundImage = 'linear-gradient(to top, #ade3ff 50%, transparent 50%)';
|
||||
} else {
|
||||
dayElement.style.backgroundColor = '#ade3ff';
|
||||
}
|
||||
} else {
|
||||
selectedDates.value = selectedDates.value.filter((d) => d !== date);
|
||||
dayElement.style.backgroundColor = '';
|
||||
dayElement.style.backgroundImage = '';
|
||||
}
|
||||
|
||||
halfDayType.value = null; // 날짜 클릭 후 반차 선택 초기화
|
||||
}
|
||||
|
||||
function toggleHalfDay(type) {
|
||||
halfDayType.value = halfDayType.value === type ? null : type;
|
||||
}
|
||||
|
||||
function addVacationRequest() {
|
||||
if (selectedDates.value.length === 0) {
|
||||
alert('Please select at least one date.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newEvents = selectedDates.value.map((date) => ({
|
||||
title: halfDayType.value ? `${halfDayType.value} Half Day Vacation` : 'Vacation',
|
||||
start: date,
|
||||
allDay: true,
|
||||
}));
|
||||
|
||||
calendarEvents.value = [...calendarEvents.value, ...newEvents];
|
||||
alert(`Vacation added for dates: ${selectedDates.value.join(', ')} as ${halfDayType.value || 'Full Day'}`);
|
||||
selectedDates.value = [];
|
||||
halfDayType.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vacation-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.save-button-container {
|
||||
position: fixed;
|
||||
top: 900px; /* 탑바 아래로 간격 조정 */
|
||||
right: 400px;
|
||||
z-index: 1050; /* 탑바보다 높은 값 */
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.save-button-container {
|
||||
top: 70px; /* 모바일에서 탑바 아래 간격 조정 */
|
||||
right: 5px;
|
||||
left: 5px;
|
||||
width: calc(100% - 10px); /* 모바일 화면에 맞게 크기 조정 */
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.half-day-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.fc-day-sun .fc-col-header-cell-cushion,
|
||||
.fc-day-sun a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.fc-day-sat .fc-col-header-cell-cushion,
|
||||
.fc-day-sat a {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.flatpickr-calendar-only input.flatpickr-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flatpickr-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pt-2.px-3 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flatpickr-calendar {
|
||||
position: relative !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #ffc107;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user