This commit is contained in:
khj0414 2025-03-14 15:27:43 +09:00
commit 9e4207de95
31 changed files with 5665 additions and 4642 deletions

View File

@ -1,7 +1,7 @@
<!doctype html>
<html
lang=""
class="light-style layout-navbar-fixed layout-menu-fixed layout-compact"
class="light-style layout-navbar-fixed layout-menu-fixed layout-compact scrollbar-none"
dir="ltr"
data-theme="theme-default"
data-assets-path="/"

8467
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
"@popperjs/core": "^2.11.8",
"@tinymce/tinymce-vue": "^5.1.1",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^13.0.0",
"axios": "^1.7.9",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",

View File

@ -16,6 +16,7 @@
height: 8px !important;
border-radius: 2px !important;
font-size: 0px !important;
margin-left: -0.5% !important;
}
/* 오후 반차 그래프 (오른쪽 절반) */
.fc-daygrid-event.half-day-pm {
@ -24,6 +25,7 @@
margin-left: auto !important;
border-radius: 2px !important;
font-size: 0px !important;
margin-right: -0.5% !important;
}
/* 연차 그래프 (풀) */
.fc-daygrid-event.full-day {
@ -69,7 +71,7 @@ background-color: rgba(0, 0, 0, 0.05); /* 연한 배경 효과 */
.fc-day-sat-sun {
cursor: not-allowed !important;
}
/* 과거 날짜 (오늘 이전) */
/* 과거 날짜 (오늘 -7일일) */
.fc-daygrid-day.past {
cursor: not-allowed !important;
}
@ -422,4 +424,51 @@ cursor: not-allowed !important;
.end-project {
background-color: #ddd !important;
}
/* project list end */
/* project list end */
/* commuters project list */
.commuter-list {
max-height: 358px;
overflow-y: auto;
scrollbar-width: none;
}
/* commuters project list end */
/* Scroll Button */
.scroll-top-btn {
bottom: 20px;
right: 20px;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: opacity 0.4s ease, visibility 0.4s ease, transform 0.4s ease;
}
.scroll-top-btn.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.scroll-top-btn.hidden {
opacity: 0;
visibility: hidden;
transform: translateY(10px);
}
/* Scroll Button end */
/* Mobile */
/* 가로모드 모바일 디바이스 (가로 해상도가 768px 보다 작은 화면에 적용) */
@media (max-width: 767px) {
aside {
display: none !important;
}
}
/* 세로모드 모바일 디바이스 (가로 해상도가 576px 보다 작은 화면에 적용) */
@media (max-width: 575px) { }
/* Mobile end */

View File

@ -124,12 +124,21 @@ const common = {
* @param { String } profileImg
* @returns
*/
getProfileImage(profileImg) {
let profileImgUrl = '/img/icons/icon.png'; // 기본 프로필 이미지 경로
getProfileImage(profileImg, isAnonymous = false) {
const defaultProfileImg = '/img/icons/icon.png'; // 기본 프로필 이미지 경로
const anonymousImg = '/img/avatars/default-Profile.jpg'; // 익명 이미지
let profileImgUrl = isAnonymous ? anonymousImg : defaultProfileImg;
const UserProfile = `${import.meta.env.VITE_SERVER}upload/img/profile/${profileImg}`;
return !profileImg || profileImg === '' ? profileImgUrl : UserProfile;
},
setDefaultImage(event, deafultImg = '/img/icons/icon.png') {
return (event.target.src = deafultImg);
},
showImage(event) {
return (event.target.style.visibility = 'visible');
},
};
export default {

View File

@ -35,6 +35,7 @@
<div class="mt-6">
<template v-if="comment.isEditTextarea">
<textarea v-model="localEditedContent" class="form-control"></textarea>
<span v-if="editCommentAlert" class="invalid-feedback d-block text-start">{{ editCommentAlert }}</span>
<div class="mt-2 d-flex justify-content-end">
<SaveBtn class="btn btn-primary" @click="submitEdit"></SaveBtn>
</div>
@ -60,7 +61,7 @@
</template>
<script setup>
import { defineProps, defineEmits, ref, computed, watch } from 'vue';
import { defineProps, defineEmits, ref, computed, watch, inject } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
@ -109,6 +110,7 @@
password: {
type: String,
},
editCommentAlert: String,
});
// emits
@ -121,6 +123,7 @@
'submitEdit',
'cancelEdit',
'update:password',
'inputDetector',
]);
const filterInput = event => {
@ -165,6 +168,14 @@
},
);
// text
watch(
() => localEditedContent.value,
newVal => {
emit('inputDetector');
},
);
// watch(() => props.comment.isDeleted, () => {
// console.log("BoardComment - isDeleted :", newVal);

View File

@ -12,50 +12,62 @@
<!-- 텍스트박스 -->
<div class="w-100">
<textarea
class="form-control"
class="form-control mb-2"
placeholder="댓글 달기"
rows="3"
:maxlength="maxLength"
v-model="comment"
@input="alertTextHandler"
></textarea>
<span v-if="commentAlert" class="invalid-feedback d-block text-start ms-2">{{ commentAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ textAlert }}</span>
<span v-if="commentAlert" class="invalid-feedback d-inline text-start ms-2 mb-2">{{ commentAlert }}</span>
<span v-else class="invalid-feedback d-inline text-start ms-2">{{ textAlert }}</span>
</div>
</div>
<!-- 옵션 버튼 섹션 -->
<div class="d-flex justify-content-between flex-wrap mt-4">
<div class="d-flex flex-wrap align-items-center">
<!-- 익명 체크박스 (익명게시판일 경우에만)-->
<div v-if="unknown" class="form-check form-check-inline mb-0 me-4">
<input class="form-check-input" type="checkbox" :id="`checkboxAnnonymous${commnetId}`" v-model="isCheck" />
<label class="form-check-label" :for="`checkboxAnnonymous${commnetId}`">익명</label>
</div>
<!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) -->
<template v-if="isCheck">
<div class="d-flex align-items-center flex-grow-1">
<label class="form-label mb-0 me-3" for="basic-default-password">비밀번호</label>
<div class="d-flex justify-content-between mt-1">
<div class="row g-2">
<div class="d-flex flex-wrap align-items-center mb-2">
<!-- 익명 체크박스 (익명게시판일 경우에만)-->
<div v-if="unknown" class="form-check form-check-inline mb-0 me-4 d-flex align-items-center">
<input
type="password"
id="basic-default-password"
class="form-control flex-grow-1"
autocomplete="new-password"
v-model="password"
placeholder="비밀번호 입력"
@input="passwordAlertTextHandler"
class="form-check-input me-2"
type="checkbox"
:id="`checkboxAnnonymous${commnetId}`"
v-model="isCheck"
@change="pwd2AlertHandler"
/>
<label class="form-check-label" :for="`checkboxAnnonymous${commnetId}`">익명</label>
</div>
</template>
<!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) -->
<template v-if="isCheck">
<div class="d-flex align-items-center col">
<input
type="password"
id="basic-default-password"
class="form-control w-80"
autocomplete="new-password"
v-model="password"
placeholder="비밀번호 입력"
@input="passwordAlertTextHandler"
/>
</div>
</template>
</div>
<div class="row">
<div style="width: 70px"></div>
<div class="col">
<span v-if="passwordAlert" class="invalid-feedback d-inline">{{ passwordAlert }}</span>
<span v-else class="invalid-feedback d-inline">{{ passwordAlert2 }}</span>
</div>
</div>
</div>
<!-- 답변 쓰기 버튼 -->
<div class="ms-auto mt-3 mt-md-0">
<SaveBtn class="btn btn-primary" @click="handleCommentSubmit"></SaveBtn>
</div>
<span v-if="passwordAlert" class="invalid-feedback d-block text-start ms-2">{{ passwordAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ passwordAlert2 }}</span>
</div>
</div>
</div>
@ -136,6 +148,11 @@
resetCommentForm();
};
//
const pwd2AlertHandler = () => {
if (isCheck.value === false) passwordAlert2.value = '';
};
//
const resetCommentForm = () => {
comment.value = '';

View File

@ -11,14 +11,16 @@
:passwordCommentAlert="passwordCommentAlert || ''"
:currentPasswordCommentId="currentPasswordCommentId"
:password="password"
:editCommentAlert="editCommentAlert[comment.commentId]"
@editClick="handleEditClick"
@deleteClick="handleDeleteClick"
@submitPassword="submitPassword"
@submitComment="submitComment"
@submitEdit="handleSubmitEdit"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="handleCancelEdit"
@updateReaction="reactionData => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
@update:password="updatePassword"
@inputDetector="$emit('inputDetector')"
>
<!-- 대댓글 -->
<template #reply>
@ -35,14 +37,16 @@
:currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert"
:password="password"
:editCommentAlert="editCommentAlert[child.commentId]"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent, child.commentId)"
@cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)"
@inputDetector="$emit('inputDetector')"
/>
</li>
</ul>
@ -95,6 +99,7 @@
index: {
type: Number,
},
editCommentAlert: Object,
});
const emit = defineEmits([
@ -106,6 +111,7 @@
'clearPassword',
'submitEdit',
'update:password',
'inputDetector',
]);
const submitComment = replyData => {

View File

@ -2,7 +2,13 @@
<div class="d-flex align-items-center flex-wrap">
<div class="d-flex align-items-center">
<div class="avatar me-2">
<img :src="getProfileImage(profileImg)" alt="Avatar" class="rounded-circle" />
<img
:src="getProfileImage(profileImg)"
alt="user"
class="rounded-circle"
@error="setDefaultImage($event)"
@load="showImage($event)"
/>
</div>
<div class="me-2">
@ -120,6 +126,14 @@
//
const getProfileImage = profileImg => {
return $common.getProfileImage(profileImg);
return $common.getProfileImage(profileImg, true);
};
const setDefaultImage = e => {
return $common.setDefaultImage(e);
};
const showImage = e => {
return $common.showImage(e);
};
</script>

View File

@ -0,0 +1,32 @@
<template>
<button
@click="scrollToTop"
class="scroll-top-btn rounded-pill btn-icon btn-primary position-fixed shadow z-5 border-0"
:class="{ 'visible': showButton, 'hidden': !showButton }"
>
<i class='bx bx-chevron-up'></i>
</button>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const showButton = ref(false);
const handleScroll = () => {
showButton.value = window.scrollY > 200;
};
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
onMounted(() => {
window.addEventListener("scroll", handleScroll);
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>

View File

@ -1,247 +0,0 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card app-calendar-wrapper">
<div class="row g-0">
<div class="col-3 border-end text-center">
<div class="card-body pb-0">
<img v-if="user" :src="`${baseUrl}upload/img/profile/${user.profile}`" alt="Profile Image" class="w-px-50 h-auto rounded-circle" @error="$event.target.src = '/img/icons/icon.png'"/>
<p class="mt-2">
{{ user.name }}
</p>
<div class="row g-0">
<div class="col-6 pe-1">
<p>출근시간</p>
<button class="btn btn-outline-primary border-3 w-100 py-0">
<i class='bx bx-run fs-2'></i>
</button>
</div>
<div class="col-6 ps-1">
<p>퇴근시간</p>
<button class="btn btn-outline-secondary border-3 w-100 py-0">
<i class='bx bxs-door-open fs-2'></i>
</button>
</div>
<div v-for="post in project" :key="post.PROJCTSEQ" class="border border-2 mt-3" :style="`border-color: ${post.projctcolor} !important; color: ${post.projctcolor} !important;`">
{{ post.PROJCTNAM }}
</div>
</div>
</div>
</div>
<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>
</div>
</div>
</div>
<center-modal :display="isModalVisible" @close="isModalVisible = $event">
<template #title> Add Event </template>
<template #body>
<FormInput
title="이벤트 제목"
name="event"
:is-essential="true"
:is-alert="eventAlert"
@update:data="eventTitle = $event"
/>
<FormInput
title="이벤트 날짜"
type="date"
name="eventDate"
:is-essential="true"
:is-alert="eventDateAlert"
@update:data="eventDate = $event"
/>
</template>
<template #footer>
<button @click="addEvent">추가</button>
</template>
</center-modal>
</template>
<script setup>
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import CenterModal from '@c/modal/CenterModal.vue';
import { inject, onMounted, reactive, ref, watch } from 'vue';
import $api from '@api';
import { isEmpty } from '@/common/utils';
import FormInput from '../input/FormInput.vue';
import 'flatpickr/dist/flatpickr.min.css';
import '@/assets/css/app-calendar.css';
import { fetchHolidays } from '@c/calendar/holiday';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const user = ref({});
const project = ref({});
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const dayjs = inject('dayjs');
const fullCalendarRef = ref(null);
const calendarEvents = ref([]);
const isModalVisible = ref(false);
const eventAlert = ref(false);
const eventDateAlert = ref(false);
const eventTitle = ref('');
const eventDate = ref('');
const selectedDate = ref(null);
//
const handleDateSelect = (selectedDates) => {
if (selectedDates.length > 0) {
// YYYY-MM-DD
const selectedDate = dayjs(selectedDates[0]).format('YYYY-MM-DD');
eventDate.value = selectedDate;
showModal(); //
}
};
//
const fetchData = async () => {
// FullCalendar API
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
// ,
const date = calendarApi.currentData.viewTitle;
const dateArr = date.split(' ');
let currentYear = dateArr[0].trim();
let currentMonth = dateArr[1].trim();
const regex = /\D/g;
//
currentYear = parseInt(currentYear.replace(regex, ''), 10);
currentMonth = parseInt(currentMonth.replace(regex, ''), 10);
try {
//
const holidayEvents = await fetchHolidays(currentYear, String(currentMonth).padStart(2, '0'));
//
const existingEvents = calendarEvents.value.filter(event => !event.classNames?.includes('holiday-event'));
//
calendarEvents.value = [...existingEvents, ...holidayEvents];
} catch (error) {
console.error('공휴일 정보 로딩 실패:', error);
}
};
// (, , )
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(); //
}
//
await fetchData();
};
//
const showModal = () => {
isModalVisible.value = true;
};
//
const closeModal = () => {
isModalVisible.value = false;
//
eventTitle.value = '';
eventDate.value = '';
};
//
const addEvent = () => {
//
if (!checkEvent()) {
//
calendarEvents.value.push({
title: eventTitle.value,
start: eventDate.value,
backgroundColor: '#4CAF50' //
});
closeModal(); //
}
};
//
const checkEvent = () => {
//
eventAlert.value = isEmpty(eventTitle.value);
eventDateAlert.value = isEmpty(eventDate.value);
// true ( )
return eventAlert.value || eventDateAlert.value;
};
//
const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin], //
initialView: 'dayGridMonth', // ()
headerToolbar: { //
left: 'today', // :
center: 'title', // : ()
right: 'prev,next', // : /
},
locale: 'kr', //
events: calendarEvents, //
eventOrder: 'sortIdx', //
selectable: true, //
dateClick: handleDateSelect, //
droppable: false, //
eventDisplay: 'block', //
//
customButtons: {
prev: {
text: 'PREV', //
click: () => moveCalendar(1), //
},
today: {
text: 'TODAY', //
click: () => moveCalendar(3), //
},
next: {
text: 'NEXT', //
click: () => moveCalendar(2), //
},
},
});
// ( )
watch(() => fullCalendarRef.value?.getApi().currentData.viewTitle, async () => {
await fetchData();
});
console.log(project)
onMounted(async () => {
await fetchData();
await userStore.userInfo();
user.value = userStore.user;
await projectStore.getProjectList();
project.value = projectStore.projectList;
});
</script>

View File

@ -0,0 +1,176 @@
<template>
<div class="row g-0">
<div class="col-6 pe-1">
<button
class="btn border-3 w-100 py-0 h-px-50"
:class="workTime ? 'p-0 btn-primary pe-none' : 'btn-outline-primary'"
@click="setWorkTime"
>
<i v-if="!workTime" class="bx bx-run fs-2"></i>
<span v-if="workTime" class="ql-size-12px">{{ workTime }}</span>
</button>
</div>
<div class="col-6 ps-1">
<button
class="btn btn-outline-secondary border-3 w-100 py-0 h-px-50"
@click="setLeaveTime"
>
<i v-if="!leaveTime" class='bx bxs-door-open fs-2'></i>
<span v-if="leaveTime" class="ql-size-12px">{{ leaveTime }}</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, onMounted, watch } from 'vue';
import $api from '@api';
import { useGeolocation } from '@vueuse/core';
const props = defineProps({
userId: {
type: Number,
required: false
},
checkedInProject: {
type: Object,
required: false
}
});
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated']);
const workTime = ref(null);
const leaveTime = ref(null)
const userLocation = ref(null);
//
const { coords, isSupported, error } = useGeolocation({
enableHighAccuracy: true,
});
//
const getAddress = (lat, lng) => {
return new Promise((resolve, reject) => {
const geocoder = new kakao.maps.services.Geocoder();
const coord = new kakao.maps.LatLng(lat, lng);
geocoder.coord2Address(coord.getLng(), coord.getLat(), (result, status) => {
if (status === kakao.maps.services.Status.OK) {
const address = result[0].address.address_name;
resolve(address);
} else {
reject('주소를 가져올 수 없습니다.');
}
});
});
};
//
const getLocation = async () => {
if (!isSupported.value) {
alert('브라우저가 위치 정보를 지원하지 않습니다.');
return null;
}
if (error.value) {
alert(`위치 정보를 가져오는데 실패했습니다: ${error.value.message}`);
return null;
}
if (coords.value) {
userLocation.value = {
lat: coords.value.latitude,
lng: coords.value.longitude,
};
try {
const address = await getAddress(coords.value.latitude, coords.value.longitude);
return address;
} catch (error) {
alert(error);
return null;
}
}
return null;
};
//
const todayCommuterInfo = async () => {
if (!props.userId) return;
const res = await $api.get(`commuters/today/${props.userId}`);
if (res.status === 200) {
const commuterInfo = res.data.data[0];
if (commuterInfo) {
workTime.value = commuterInfo.COMMUTCMT;
leaveTime.value = commuterInfo.COMMUTLVE;
//
emit('workTimeUpdated', workTime.value);
emit('leaveTimeUpdated', leaveTime.value);
}
}
};
//
const setWorkTime = async () => {
//
if (workTime.value) return;
//
const address = await getLocation();
$api.post('commuters/insert', {
memberSeq: props.userId,
projctSeq: props.checkedInProject.PROJCTSEQ,
commutLvt: null,
commutArr: address,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('workTimeUpdated', true);
}
});
};
//
const setLeaveTime = () => {
$api.patch('commuters/updateLve', {
memberSeq: props.userId,
commutLve: leaveTime.value || null,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
//
emit('leaveTimeUpdated', true);
}
});
};
// props
watch(() => props.userId, async () => {
if (props.userId) {
await todayCommuterInfo();
}
});
watch(() => props.checkedInProject, () => {
//
}, { deep: true });
onMounted(async () => {
await todayCommuterInfo();
});
//
defineExpose({
todayCommuterInfo,
workTime,
leaveTime
});
</script>

View File

@ -0,0 +1,411 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y pb-0">
<div class="card app-calendar-wrapper">
<div class="row g-0">
<div class="col-3 border-end text-center">
<div class="card-body">
<img v-if="user" :src="`${baseUrl}upload/img/profile/${user.profile}`" alt="Profile Image" class="w-px-50 h-px-50 rounded-circle" @error="$event.target.src = '/img/icons/icon.png'"/>
<p class="mt-2 fw-bold">
{{ user.name }}
</p>
<CommuterBtn
:userId="user.id"
:checkedInProject="checkedInProject || {}"
@workTimeUpdated="handleWorkTimeUpdate"
@leaveTimeUpdated="handleLeaveTimeUpdate"
ref="workTimeComponentRef"
/>
<CommuterProjectList
:project="project"
:commuters="commuters"
:baseUrl="baseUrl"
:user="user"
:selectedProject="selectedProject"
:checkedInProject="checkedInProject"
@drop="handleProjectDrop"
/>
</div>
</div>
<div class="col app-calendar-content">
<div class="card shadow-none border-0">
<div class="card-body">
<full-calendar
ref="fullCalendarRef"
:events="calendarEvents"
:options="calendarOptions"
defaultView="dayGridMonth"
class="flatpickr-calendar-only"
>
</full-calendar>
</div>
</div>
</div>
</div>
</div>
</div>
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title>
{{ eventDate }}
</template>
<template #body>
<div v-if="selectedDateCommuters.length > 0">
<div v-for="(commuter, index) in selectedDateCommuters" :key="index">
<div class="d-flex align-items-center my-2">
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
class="rounded-circle me-2 w-px-50 h-px-50"
@error="$event.target.src = '/img/icons/icon.png'">
<span class="text-white fw-bold rounded py-1 px-3" :style="`background: ${commuter.projctcolor} !important;`">{{ commuter.memberName }}</span>
<div class="ms-auto text-start fw-bold">
{{ commuter.COMMUTCMT }} ~ {{ commuter.COMMUTLVE || "00:00:00" }}
</div>
</div>
</div>
</div>
</template>
<template #footer>
<BackBtn @click="closeModal" />
</template>
</CenterModal>
</template>
<script setup>
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import CenterModal from '@c/modal/CenterModal.vue';
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import $api from '@api';
import 'flatpickr/dist/flatpickr.min.css';
import '@/assets/css/app-calendar.css';
import { fetchHolidays } from '@c/calendar/holiday';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
import CommuterBtn from '@c/commuters/CommuterBtn.vue';
import CommuterProjectList from '@c/commuters/CommuterProjectList.vue';
import BackBtn from '@c/button/BackBtn.vue';
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const user = ref({});
const project = ref({});
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const dayjs = inject('dayjs');
const fullCalendarRef = ref(null);
const workTimeComponentRef = ref(null);
const calendarEvents = ref([]);
const eventDate = ref('');
const selectedProject = ref(null);
const checkedInProject = ref(null);
//
const isModalOpen = ref(false);
const commuters = ref([]);
const monthlyCommuters = ref([]);
//
const handleWorkTimeUpdate = () => {
todaysCommuter();
loadCommuters();
};
const handleLeaveTimeUpdate = () => {
todaysCommuter();
};
// (ProjectList )
const handleProjectDrop = ({ event, targetProject }) => {
//
const draggedProjectData = JSON.parse(event.dataTransfer.getData('application/json'));
//
if (draggedProjectData.PROJCTSEQ === targetProject.PROJCTSEQ) {
return;
}
//
checkedInProject.value = targetProject;
projectStore.setSelectedProject(targetProject);
// select
selectedProject.value = targetProject.PROJCTSEQ;
$api.patch('commuters/update', {
projctSeq: targetProject.PROJCTSEQ,
memberSeq: user.value.id,
}).then(res => {
if (res.status === 200) {
todaysCommuter();
loadCommuters();
}
});
};
//
const todaysCommuter = async () => {
const res = await $api.get(`commuters/todays`);
if (res.status === 200 ) {
commuters.value = res.data.data;
}
};
//
const fetchData = async () => {
// FullCalendar API
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
// ,
const date = calendarApi.currentData.viewTitle;
const dateArr = date.split(' ');
let currentYear = dateArr[0].trim();
let currentMonth = dateArr[1].trim();
const regex = /\D/g;
//
currentYear = parseInt(currentYear.replace(regex, ''), 10);
currentMonth = parseInt(currentMonth.replace(regex, ''), 10);
try {
//
const holidayEvents = await fetchHolidays(currentYear, String(currentMonth).padStart(2, '0'));
//
const existingEvents = calendarEvents.value.filter(event => !event.classNames?.includes('holiday-event'));
//
calendarEvents.value = [...existingEvents, ...holidayEvents];
//
await loadCommuters();
} catch (error) {
console.error('공휴일 정보 로딩 실패:', error);
}
};
// (, , )
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(); //
}
await fetchData();
};
//
const isSelectableDate = (date) => {
const checkDate = dayjs(date);
const isWeekend = checkDate.day() === 0 || checkDate.day() === 6;
//
const isHoliday = calendarEvents.value.some(event =>
event.classNames?.includes('holiday-event') &&
dayjs(event.start).format('YYYY-MM-DD') === checkDate.format('YYYY-MM-DD')
);
return !isWeekend && !isHoliday;
};
//
let todayElement = null;
const handleDateClick = (info) => {
const clickedDate = dayjs(info.date).format('YYYY-MM-DD');
//
const dateCommuters = monthlyCommuters.value.filter(commuter =>
commuter.COMMUTDAY === clickedDate
);
//
if (dateCommuters.length > 0) {
eventDate.value = clickedDate;
isModalOpen.value = true;
}
if (isSelectableDate(info.date)) {
const isToday = dayjs(info.date).isSame(dayjs(), 'day');
if (isToday) {
//
todayElement = info.dayEl;
todayElement.classList.remove('fc-day-today');
} else if (todayElement) {
//
todayElement.classList.add('fc-day-today');
todayElement = null;
}
}
};
// todayElement
document.addEventListener('click', (event) => {
if (todayElement && !event.target.closest('.fc-daygrid-day')) {
todayElement.classList.add('fc-day-today');
todayElement = null;
}
}, true);
//
const getCellClassNames = (arg) => {
const cellDate = dayjs(arg.date);
const classes = [];
// (, , )
if (!isSelectableDate(cellDate)) {
classes.push('fc-day-sat-sun');
}
return classes;
};
//
const loadCommuters = async () => {
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
const date = calendarApi.currentData.viewTitle;
const dateArr = date.split(' ');
let currentYear = dateArr[0].trim();
let currentMonth = dateArr[1].trim();
const regex = /\D/g;
currentYear = parseInt(currentYear.replace(regex, ''), 10);
currentMonth = parseInt(currentMonth.replace(regex, ''), 10);
const res = await $api.get('commuters/month', {
params: {
year: currentYear,
month: currentMonth
}
});
if (res.status === 200) {
//
monthlyCommuters.value = res.data.data;
document.querySelectorAll('.fc-daygrid-day-events img.rounded-circle').forEach(img => {
img.remove();
});
monthlyCommuters.value.forEach(commuter => {
const date = commuter.COMMUTDAY;
const dateCell = document.querySelector(`.fc-day[data-date="${date}"]`) ||
document.querySelector(`.fc-daygrid-day[data-date="${date}"]`);
if (dateCell) {
const dayEvents = dateCell.querySelector('.fc-daygrid-day-events');
if (dayEvents) {
dayEvents.classList.add('text-center');
//
const profileImg = document.createElement('img');
profileImg.src = `${baseUrl}upload/img/profile/${commuter.profile}`;
profileImg.className = 'rounded-circle w-px-20 h-px-20 mx-1 mb-1 position-relative z-5 m-auto';
profileImg.style.border = `2px solid ${commuter.projctcolor}`;
profileImg.onerror = () => { profileImg.src = '/img/icons/icon.png'; };
dayEvents.appendChild(profileImg);
}
}
});
}
};
//
const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'today',
center: 'title',
right: 'prev,next',
},
locale: 'kr',
events: calendarEvents,
eventOrder: 'sortIdx',
contentHeight:"auto",
// eventContent: calendarCommuter,
//
selectable: true,
selectAllow: (selectInfo) => isSelectableDate(selectInfo.start),
dateClick: handleDateClick,
dayCellClassNames: getCellClassNames,
//
unselectAuto: true,
droppable: false,
eventDisplay: 'block',
//
customButtons: {
prev: {
text: 'PREV',
click: () => moveCalendar(1),
},
today: {
text: 'TODAY',
click: () => moveCalendar(3),
},
next: {
text: 'NEXT',
click: () => moveCalendar(2),
},
},
});
// ( )
watch(() => fullCalendarRef.value?.getApi().currentData.viewTitle, async () => {
await fetchData();
});
// selectedProject
watch(() => projectStore.selectedProject, (newProject) => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
checkedInProject.value = newProject;
}
});
//
const openModal = () => {
isModalOpen.value = true;
};
//
const closeModal = () => {
isModalOpen.value = false;
};
const selectedDateCommuters = computed(() => {
return monthlyCommuters.value.filter(commuter =>
commuter.COMMUTDAY === eventDate.value
);
});
onMounted(async () => {
await fetchData();
await userStore.userInfo();
user.value = userStore.user;
await projectStore.getProjectList('', '', 'true');
project.value = projectStore.projectList;
await todaysCommuter();
//
const storedProject = projectStore.getSelectedProject();
if (storedProject) {
selectedProject.value = storedProject.PROJCTSEQ;
checkedInProject.value = storedProject;
}
});
</script>

View File

@ -0,0 +1,82 @@
<template>
<div class="commuter-list">
<div v-for="post in project" :key="post.PROJCTSEQ"
class="border border-2 mt-3 card p-2"
:style="`border-color: ${post.projctcolor} !important; color: ${post.projctcolor} !important;`"
@dragover="allowDrop($event)"
@drop="handleDrop($event, post)">
<p class="mb-1">
{{ post.PROJCTNAM }}
</p>
<div class="row gx-2">
<div v-for="commuter in commuters.filter(c => c.PROJCTNAM === post.PROJCTNAM)" :key="commuter.COMMUTCMT" class="col-4">
<div class="ratio ratio-1x1">
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
alt="User Profile"
class="rounded-circle"
:class="isCurrentUser(commuter) ? 'cursor-pointer' : ''"
:draggable="isCurrentUser(commuter)"
@dragstart="isCurrentUser(commuter) ? dragStart($event, post) : null"
@error="$event.target.src = '/img/icons/icon.png'">
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
project: {
type: Object,
required: false
},
commuters: {
type: Array,
required: false
},
baseUrl: {
type: String,
required: false
},
user: {
type: Object,
required: false
},
selectedProject: {
type: Number,
default: null
},
checkedInProject: {
type: Object,
default: null
}
});
const emit = defineEmits(['drop', 'update:selectedProject', 'update:checkedInProject']);
//
const isCurrentUser = (commuter) => {
return props.user && commuter && commuter.MEMBERSEQ === props.user.id;
};
//
const dragStart = (event, project) => {
//
event.dataTransfer.setData('application/json', JSON.stringify(project));
event.dataTransfer.effectAllowed = 'copy';
};
//
const allowDrop = (event) => {
event.preventDefault();
};
//
const handleDrop = (event, targetProject) => {
event.preventDefault();
emit('drop', { event, targetProject });
};
</script>

View File

@ -128,7 +128,7 @@
title="종료일"
type="date"
name="endDay"
:min="todays"
:min="selectedProject.PROJCTSTR"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
/>
@ -161,7 +161,7 @@
</template>
<script setup>
import { defineProps, onMounted, ref, computed, watch, inject } from 'vue';
import { defineProps, onMounted, ref, computed, watch } from 'vue';
import UserList from '@c/user/UserList.vue';
import CenterModal from '@c/modal/CenterModal.vue';
import $api from '@api';
@ -177,10 +177,12 @@ import ArrInput from '@c/input/ArrInput.vue';
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import commonApi from '@/common/commonApi';
import { useProjectStore } from '@/stores/useProjectStore';
//
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
// Props
const props = defineProps({
@ -254,11 +256,6 @@ const isProjectCreator = computed(() => {
return user.value?.id === props.projctCreatorId;
});
// dayjs
const dayjs = inject('dayjs');
// YYYY-MM-DD
const todays = dayjs().format('YYYY-MM-DD');
// ( )
const isProjectExpired = computed(() => {
@ -330,6 +327,19 @@ const closeModal = () => {
//
const openEditModal = () => {
selectedProject.value = {
PROJCTSEQ: props.projctSeq,
PROJCTNAM: props.title,
PROJCTSTR: props.strdate,
PROJCTEND: props.enddate,
PROJCTZIP: props.addressZip,
PROJCTARR: props.address,
PROJCTDTL: props.addressdtail,
PROJCTDES: props.description,
PROJCTCOL: props.projctCol,
projctcolor: props.projctColor,
};
isEditModalOpen.value = true;
originalColor.value = props.projctCol;
};
@ -364,6 +374,18 @@ const hasChanges = computed(() => {
selectedProject.value.PROJCTCOL !== props.projctCol;
});
//
watch(
() => selectedProject.value.PROJCTSTR, // (strdate)
(newStartDate) => {
if (newStartDate && new Date(newStartDate) > new Date(selectedProject.value.PROJCTEND)) {
//
selectedProject.value.PROJCTEND = newStartDate;
}
}
);
//
const handleUpdate = () => {
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
@ -393,7 +415,7 @@ const handleUpdate = () => {
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
closeEditModal();
//
emit('update');
}
});
@ -448,7 +470,8 @@ const handleDelete = () => {
.then(res => {
if (res.status === 200) {
toastStore.onToast('삭제가 완료되었습니다.', 's');
location.reload()
projectStore.getProjectList();
projectStore.getMemberProjects();
}
})
};

View File

@ -69,7 +69,7 @@
:type="'date'"
name="endDay"
:modelValue="endDay"
:min = "today"
:min = "startDay"
@update:modelValue="endDay = $event"
/>
@ -207,19 +207,14 @@
// ::
const handleAddressUpdate = (data) => {
addressData.value = data;
};
} ;
//
watch([startDay, endDay], () => {
if (startDay.value && endDay.value) {
const start = new Date(startDay.value);
const end = new Date(endDay.value);
if (end < start) {
endDay.value = startDay.value;
}
//
watch(startDay, (newStartDate) => {
if (new Date(newStartDate) > new Date(endDay.value)) {
endDay.value = '';
}
}, { flush: 'post' });
});
//
const handleCreate = async () => {
@ -247,6 +242,8 @@
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
closeCreateModal();
getProjectList();
projectStore.getMemberProjects();
formReset();
}
});
};

View File

@ -5,7 +5,7 @@
for="profilePic"
class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer"
id="profileLabel"
style="width: 100px; height: 100px; background-image: url(public/img/avatars/default-Profile.jpg); background-repeat: no-repeat;"
style="width: 100px; height: 100px; background-image: url(img/avatars/default-Profile.jpg); background-repeat: no-repeat"
>
</label>
@ -53,14 +53,14 @@
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
<FormSelect
title="비밀번호 힌트"
name="pwhint"
:is-essential="true"
:is-row="false"
:is-label="true"
:is-common="true"
:data="pwhintList"
@update:data="pwhint = $event"
title="비밀번호 힌트"
name="pwhint"
:is-essential="true"
:is-row="false"
:is-label="true"
:is-common="true"
:data="pwhintList"
@update:data="pwhint = $event"
/>
<UserFormInput
@ -143,11 +143,13 @@
name="phone"
:isEssential="true"
:is-alert="phoneAlert"
@update:data="phone = $event.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1')"
@update:data="phone = $event"
@update:alert="phoneAlert = $event"
@blur="checkPhoneDuplicate"
:maxlength="11"
:value="phone"
@keypress="onlyNumber"
/>
<span v-if="phoneError" class="invalid-feedback d-block">{{ phoneError }}</span>
@ -160,9 +162,9 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import $api from '@api';
import commonApi from '@/common/commonApi'
import commonApi from '@/common/commonApi';
import UserFormInput from '@c/input/UserFormInput.vue';
import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue';
@ -185,13 +187,13 @@
const birth = ref('');
const address = ref('');
const detailAddress = ref('');
const postcode = ref(''); //
const postcode = ref(''); //
const phone = ref('');
const phoneError = ref('');
const color = ref(''); // color
const colorError = ref('');
const mbti = ref(''); // MBTI
const pwhint = ref(''); // pwhint
const mbti = ref(''); // MBTI
const pwhint = ref(''); // pwhint
const profilAlert = ref(false);
const idAlert = ref(false);
@ -209,7 +211,6 @@
const toastStore = useToastStore();
//
const profileValid = (size, type) => {
const maxSize = 5 * 1024 * 1024;
@ -238,7 +239,7 @@
// ,
if (!profileValid(file.size, file.type)) {
e.target.value = '';
profileLabel.style.backgroundImage = 'url("public/img/avatars/default-Profile.jpg")';
profileLabel.style.backgroundImage = 'url("img/avatars/default-Profile.jpg")';
return false;
}
@ -275,16 +276,17 @@
// , mbti,
const { colorList, mbtiList, pwhintList } = commonApi({
loadColor: true, colorType: 'YON',
loadColor: true,
colorType: 'YON',
loadMbti: true,
loadPwhint: true,
});
//
const handleAddressUpdate = (addressData) => {
const handleAddressUpdate = addressData => {
address.value = addressData.address;
detailAddress.value = addressData.detailAddress;
postcode.value = addressData.postcode; //
postcode.value = addressData.postcode; //
};
//
@ -311,7 +313,7 @@
}
};
const handleColorUpdate = async (newColor) => {
const handleColorUpdate = async newColor => {
color.value = newColor;
colorError.value = '';
colorErrorAlert.value = false;
@ -319,10 +321,16 @@
await checkColorDuplicate();
}
const onlyNumber = (event) => {
//
if (!/^[0-9]$/.test(event.key)) {
event.preventDefault();
}
};
//
const handleSubmit = async () => {
await checkColorDuplicate();
idAlert.value = id.value.trim() === '';
@ -334,6 +342,12 @@
addressAlert.value = address.value.trim() === '';
phoneAlert.value = phone.value.trim() === '';
if (!/^\d+$/.test(phone.value)) {
phoneAlert.value = true;
} else {
phoneAlert.value = false;
}
//
if (!profile.value) {
profilerr.value = '프로필 이미지를 선택해주세요.';
@ -343,9 +357,21 @@
profilAlert.value = false;
}
if (profilAlert.value || idAlert.value || idErrorAlert.value || passwordAlert.value || passwordcheckAlert.value ||
passwordcheckErrorAlert.value || pwhintResAlert.value || nameAlert.value || birthAlert.value ||
addressAlert.value || phoneAlert.value || phoneErrorAlert.value || colorErrorAlert.value) {
if (
profilAlert.value ||
idAlert.value ||
idErrorAlert.value ||
passwordAlert.value ||
passwordcheckAlert.value ||
passwordcheckErrorAlert.value ||
pwhintResAlert.value ||
nameAlert.value ||
birthAlert.value ||
addressAlert.value ||
phoneAlert.value ||
phoneErrorAlert.value ||
colorErrorAlert.value
) {
return;
}
@ -364,7 +390,7 @@
formData.append('memberMbt', mbti.value);
formData.append('memberPrf', profile.value);
const response = await $api.post('/user/join', formData, { isFormData : true });
const response = await $api.post('/user/join', formData, { isFormData: true });
if (response.status === 200) {
toastStore.onToast('등록신청이 완료되었습니다. 관리자 승인 후 이용가능합니다.', 's');
@ -372,4 +398,3 @@
}
};
</script>

View File

@ -28,6 +28,7 @@
<script setup>
import { onMounted, ref, nextTick, computed } from 'vue';
import { useUserStore } from '@s/userList';
import { useProjectStore } from '@s/useProjectStore';
import $api from '@api';
const emit = defineEmits(['user-list-update']);
@ -106,7 +107,7 @@ const isUserDisabled = (user) => {
// / DB
// showOnlyActive true toggleDisable
const toggleDisable = async (index) => {
if (props.showOnlyActive) return; // showOnlyActive true
if (props.showOnlyActive) return;
const user = displayedUserList.value[index];
if (user) {
@ -125,6 +126,11 @@ const toggleDisable = async (index) => {
if (originalIndex !== -1) {
userList.value[originalIndex].PROJCTYON = newParticipationStatus ? '0' : '1';
}
//
const projectStore = useProjectStore();
await projectStore.getProjectList('', '', 'true');
await projectStore.getMemberProjects();
}
} else {
// userList

View File

@ -87,27 +87,6 @@ profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}
const setDefaultImage = (event) => (event.target.src = defaultProfile);
const showImage = (event) => (event.target.style.visibility = "visible");
//
const profileSize = computed(() => {
const totalUsers = userList.value.length;
if (windowWidth.value >= 1850) {
if (totalUsers <= 10) return "80px";
if (totalUsers <= 15) return "60px";
return "45px";
} else if (windowWidth.value >= 1500) {
if (totalUsers <= 10) return "60px";
if (totalUsers <= 15) return "40px";
return "30px";
} else if (windowWidth.value >= 900) {
if (totalUsers <= 10) return "48px";
if (totalUsers <= 15) return "30px";
return "20px";
} else {
return "35px";
}
});
const getDynamicStyle = (user) => ({
borderWidth: "3px",
borderColor: user.usercolor || "#ccc",

View File

@ -23,6 +23,8 @@
<!-- Drag Target Area To SlideIn Menu On Small Screens -->
<div class="drag-target"></div>
<ScrollTopButton />
</div>
</template>
<script setup>
@ -32,6 +34,7 @@
import TheChat from './TheChat.vue';
import { nextTick } from 'vue';
import { wait } from '@/common/utils';
import ScrollTopButton from '@c/button/ScrollTopButton.vue';
window.isDarkStyle = window.Helpers.isDarkStyle();

View File

@ -37,7 +37,7 @@ const sendMessage = () => {
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
z-index: 1000;
z-index: 4;
}
</style>

View File

@ -1,17 +1,7 @@
<template>
<footer class="content-footer footer bg-footer-theme">
<div class="container-xxl">
<div class="footer-container d-flex align-items-center justify-content-between py-4 flex-md-row flex-column">
<div class="text-body">
©2024
<!-- <script>
document.write(new Date().getFullYear())
</script> -->
, made with by
<a href="https://themeselection.com/" target="_blank" class="footer-link">ThemeSelection</a>
</div>
<div class="d-none d-lg-inline-block"></div>
</div>
</div>
</footer>
</template>

View File

@ -74,6 +74,12 @@
<div class="text-truncate">Commuters</div>
</RouterLink>
</li>
<li v-if="userId === allowedUserId" class="menu-item" :class="$route.path.includes('/authorization') ? 'active' : ''">
<RouterLink class="menu-link" to="/authorization">
<i class="menu-icon icon-base bx bx-user-check"></i>
<div class="text-truncate">Authorization</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>
@ -86,7 +92,13 @@
</template>
<script setup>
import router from '@/router';
import { computed } from "vue";
import { useUserInfoStore } from '@s/useUserInfoStore';
const userStore = useUserInfoStore();
const allowedUserId = 26; // ID (!!)
const userId = computed(() => userStore.user?.id ?? null);
</script>
<style lang="scss" scoped></style>

View File

@ -8,6 +8,17 @@
<div class="navbar-nav-right d-flex align-items-center" id="navbar-collapse">
<ul class="navbar-nav flex-row align-items-center ms-auto">
<select class="form-select py-1" id="name" v-model="selectedProject" @change="updateSelectedProject">
<!-- 내가 참여하고 있는 프로젝트 그룹 -->
<option v-for="item in myProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
{{ item.PROJCTNAM }}
</option>
<!-- 전체 프로젝트 그룹 -->
<option v-for="item in otherProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
{{ item.PROJCTNAM }}
</option>
</select>
<!-- <button class="btn p-1" @click="switchToLightMode"><i class="bx bxs-sun link-warning"></i></button> -->
<!-- <button class="btn p-1" @click="switchToDarkMode"><i class="bx bxs-moon"></i></button> -->
@ -234,36 +245,95 @@
<script setup>
import { useAuthStore } from '@s/useAuthStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
import { useRouter } from 'vue-router';
import { useThemeStore } from '@s/darkmode';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import $api from '@api';
const user = ref(null);
//const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const baseUrl = import.meta.env.VITE_SERVER;
const authStore = useAuthStore();
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const router = useRouter();
const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();
const user = ref(null);
const selectedProject = ref(null);
//
const myProjects = computed(() => {
return projectStore.memberProjectList || [];
});
//
const otherProjects = computed(() => {
if (!projectStore.projectList || !projectStore.memberProjectList) return [];
// ID
const myProjectIds = projectStore.memberProjectList.map(p => p.PROJCTSEQ);
//
return projectStore.projectList.filter(p => !myProjectIds.includes(p.PROJCTSEQ));
});
//
const updateSelectedProject = () => {
if (!selectedProject.value) return;
//
const selected = projectStore.projectList.find(
project => project.PROJCTSEQ === selectedProject.value
);
if (selected) {
projectStore.setSelectedProject(selected);
}
};
//
watch(() => projectStore.selectedProject, (newProject) => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
}
});
// const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();
onMounted(async () => {
if (isDarkMode) {
switchToDarkMode();
} else {
switchToLightMode();
}
// if (isDarkMode) {
// switchToDarkMode();
// } else {
// switchToLightMode();
// }
await userStore.userInfo();
user.value = userStore.user;
await projectStore.getProjectList('', '', 'true');
//
await projectStore.getMemberProjects();
//
const storedProject = projectStore.getSelectedProject();
if (storedProject) {
selectedProject.value = storedProject.PROJCTSEQ;
} else if (projectStore.memberProjectList.length > 0) {
//
selectedProject.value = projectStore.memberProjectList[0].PROJCTSEQ;
projectStore.setSelectedProject(projectStore.memberProjectList[0]);
}
});
const handleLogout = async () => {
await authStore.logout();
router.push('/login');
};
</script>
<style scoped>
</style>

View File

@ -1,6 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@s/useAuthStore';
import { useUserInfoStore } from '@s/useUserInfoStore';
// 초기 렌더링 속도를 위해 지연 로딩 사용
const routes = [
@ -85,8 +85,9 @@ const routes = [
component: () => import('@v/commuters/TheCommuters.vue'),
},
{
path: '/sample',
component: () => import('@c/calendar/SampleCalendar.vue'),
path: '/authorization',
component: () => import('@v/admin/TheAuthorization.vue'),
meta: { requiresAuth: true }
},
{
path: "/:anything(.*)",
@ -102,38 +103,27 @@ const router = createRouter({
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
await authStore.checkAuthStatus(); // 로그인 상태 확인
const allowedUserId = 26; // 특정 ID (변경필요!!)
const userStore = useUserInfoStore();
const userId = userStore.user?.id ?? null;
// 로그인이 필요한 페이지인데 로그인되지 않은 경우 → 로그인 페이지로 이동
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// 로그인이 필요한 페이지인데 로그인되지 않은 경우 → 로그인 페이지로 이동
next({ name: 'Login' });
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
next({ name: 'Home' });
} else {
next();
return next({ name: 'Login', query: { redirect: to.fullPath } });
}
// Authorization 페이지는 ID가 26이 아니면 접근 차단
if (to.path === "/authorization" && userId !== allowedUserId) {
return next("/");
}
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
if (to.meta.requiresGuest && authStore.isAuthenticated) {
return next({ name: 'Home' });
}
// 모든 조건을 통과하면 정상적으로 이동
next();
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// 최초 앱 로드 시 인증 상태 체크
await authStore.checkAuthStatus()
// 현재 라우트에 인증이 필요한지 확인
const requiresAuth = to.meta.requiresAuth === true
if (requiresAuth && !authStore.isAuthenticated) {
// 인증되지 않은 사용자를 로그인 페이지로 리다이렉트
// 원래 가려던 페이지를 쿼리 파라미터로 전달
next({
name: 'Login',
query: { redirect: to.fullPath }
})
} else {
next()
}
})
export default router

View File

@ -6,22 +6,96 @@
설명 : 프로젝트 목록
*/
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import $api from '@api';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
export const useProjectStore = defineStore('project', () => {
const projectList = ref([]);
const memberProjectList = ref([]);
const selectedProject = ref(null);
const userStore = useUserInfoStore();
const getProjectList = async (searchText = '', selectedYear = '') => {
// 전체 프로젝트 가져오기
const getProjectList = async (searchText = '', selectedYear = '', excludeEnded = '') => {
const res = await $api.get('project/select', {
params: {
searchKeyword: searchText || '',
category: selectedYear || '',
excludeEnded: excludeEnded
},
});
projectList.value = res.data.data.projectList;
};
// 사용자가 속한 프로젝트 목록 가져오기
const getMemberProjects = async () => {
if (!userStore.user) return; // 로그인한 사용자 확인
return { projectList, getProjectList };
const res = await $api.get(`project/${userStore.user.id}`);
memberProjectList.value = res.data.data;
if (memberProjectList.value.length > 0 && !selectedProject.value) {
setSelectedProject(memberProjectList.value[0]);
}
};
const setSelectedProject = (project) => {
selectedProject.value = project ? { ...project } : null;
if (project) {
localStorage.setItem('selectedProject', JSON.stringify(project));
} else {
localStorage.removeItem('selectedProject');
}
};
const getSelectedProject = () => {
if (!selectedProject.value) {
const storedProject = localStorage.getItem('selectedProject');
if (storedProject) {
selectedProject.value = JSON.parse(storedProject);
}
}
return selectedProject.value;
};
// 프로젝트 리스트가 변경될 때 자동으로 반응
watch(projectList, (newList) => {
// 선택된 프로젝트가 없고 목록이 있는 경우
if (!selectedProject.value && newList.length > 0) {
// 사용자가 속한 프로젝트가 있는지 먼저 확인
if (memberProjectList.value.length > 0) {
setSelectedProject(memberProjectList.value[0]);
} else {
setSelectedProject(newList[0]);
}
}
});
watch(memberProjectList, (newList) => {
if (newList.length > 0) {
// 현재 선택된 프로젝트가 없는 경우 첫 번째 항목 선택
if (!selectedProject.value) {
setSelectedProject(newList[0]);
} else {
// 선택된 프로젝트가 있는 경우 목록에 있는지 확인
const exists = newList.some(project => project.PROJCTSEQ === selectedProject.value.PROJCTSEQ);
if (!exists) {
setSelectedProject(newList[0]);
}
}
}
});
return {
projectList,
selectedProject,
getProjectList,
memberProjectList,
getMemberProjects,
setSelectedProject,
getSelectedProject
};
});

View File

@ -0,0 +1,162 @@
<template>
<div class="container text-center flex-grow-1 container-p-y">
<div class="card">
<div class="card-header d-flex flex-column">
<h3>관리자 권한 부여</h3>
<div class="user-card-container">
<div v-for="user in users" :key="user.id" class="user-card">
<!-- 프로필 사진 -->
<img :src="getProfileImage(user.photo)" class="profile-img" alt="프로필 사진" @error="setDefaultImage" />
<!-- 사용자 정보 -->
<div class="user-info">
<h5>{{ user.name }}</h5>
</div>
<!-- 권한 토글 버튼 -->
<label class="switch">
<input type="checkbox" :checked="user.isAdmin" @change="toggleAdmin(user)" />
<span class="slider round"></span>
</label>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from '@api';
import { useToastStore } from '@s/toastStore';
const users = ref([]);
const toastStore = useToastStore();
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, "");
const defaultProfile = "/img/icons/icon.png";
//
async function fetchUsers() {
try {
const response = await axios.get('admin/users'); // API
// API
if (!response.data || !Array.isArray(response.data.data)) {
throw new Error("올바른 데이터 형식이 아닙니다.");
}
// ( )
users.value = response.data.data.map(user => ({
id: user.MEMBERSEQ,
name: user.MEMBERNAM,
photo: user.MEMBERPRF ? `${baseUrl}upload/img/profile/${user.MEMBERPRF}` : defaultProfile,
color: user.MEMBERCOL,
isAdmin: user.MEMBERROL === 'ROLE_ADMIN',
}));
} catch (error) {
console.error('사용자 목록을 불러오는 중 오류 발생:', error);
toastStore.onToast('사용자 목록을 불러오지 못했습니다.', 'e');
}
}
//
function getProfileImage(photo) {
return photo || defaultProfile;
}
//
function setDefaultImage(event) {
event.target.src = defaultProfile;
}
//
async function toggleAdmin(user) {
const requestData = {
id: user.id,
role: user.isAdmin ? 'MEMBER' : 'ADMIN'
};
try {
const response = await axios.put('admin/role', requestData); // API
if (response.status === 200) {
user.isAdmin = !user.isAdmin;
toastStore.onToast(`'${user.name}'의 권한이 '${requestData.role}'(으)로 변경되었습니다.`, 's');
} else {
throw new Error('권한 변경 실패');
}
} catch (error) {
console.error('권한 변경 중 오류 발생:', error);
toastStore.onToast('권한 변경에 실패했습니다.', 'e');
}
}
onMounted(fetchUsers);
</script>
<style scoped>
.user-card-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
margin-top: 20px;
}
.user-card {
width: 200px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 10px;
background-color: #fff;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 10px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
margin-top: 5px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 4px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4CAF50;
}
input:checked + .slider:before {
transform: translateX(24px);
}
</style>

View File

@ -291,5 +291,7 @@ onMounted(() => {
color: #ff5733;
border-radius: 4px;
padding: 2px 6px;
position: relative;
top: -1px;
}
</style>

View File

@ -109,6 +109,7 @@
:passwordCommentAlert="passwordCommentAlert"
:currentPasswordCommentId="currentPasswordCommentId"
:password="password"
:editCommentAlert="editCommentAlert"
@editClick="editComment"
@deleteClick="deleteComment"
@updateReaction="handleCommentReaction"
@ -118,6 +119,7 @@
@cancelEdit="handleCancelEdit"
@submitEdit="handleSubmitEdit"
@update:password="updatePassword"
@inputDetector="inputDetector"
/>
<Pagination v-if="pagination.pages" v-bind="pagination" @update:currentPage="handlePageChange" />
</div>
@ -133,11 +135,13 @@
import BoardCommentList from '@c/board/BoardCommentList.vue';
import BoardRecommendBtn from '@c/button/BoardRecommendBtn.vue';
import Pagination from '@c/pagination/Pagination.vue';
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, inject } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useToastStore } from '@s/toastStore';
import axios from '@api';
const $common = inject('common');
//
const profileName = ref('');
const boardTitle = ref('제목 없음');
@ -161,6 +165,7 @@
const unknown = computed(() => profileName.value === '익명');
const currentUserId = computed(() => userStore?.user?.id); // id
const authorId = ref(''); // id
const editCommentAlert = ref({}); //,
const isAuthor = computed(() => currentUserId.value === authorId.value);
const commentsWithAuthStatus = computed(() => {
@ -234,6 +239,7 @@
const inputCheck = () => {
passwordAlert.value = '';
};
//
const fetchBoardDetails = async () => {
const response = await axios.get(`board/${currentBoardId.value}`);
@ -437,6 +443,11 @@
}
};
// ,
const inputDetector = () => {
editCommentAlert.value = {};
};
//
const editClick = unknown => {
const isUnknown = unknown?.unknown ?? false;
@ -706,10 +717,10 @@
if (targetComment) {
// console.log('',targetComment)
// " ." ,
// " ." ,
targetComment.content = '댓글이 삭제되었습니다.';
targetComment.author = '알 수 없음'; //
targetComment.isDeleted = true; //
targetComment.isDeleted = true; //
}
} else {
toastStore.onToast('댓글 삭제에 실패했습니다.', 'e');
@ -721,6 +732,7 @@
//
const handleSubmitEdit = async (comment, editedContent) => {
if (!checkValidation(comment, editedContent)) return;
togglePassword();
try {
const response = await axios.put(`board/comment/${comment.commentId}`, {
@ -744,6 +756,15 @@
}
};
// , .
const checkValidation = (comment, content) => {
if (!$common.isNotEmpty(content)) {
editCommentAlert.value[comment.commentId] = '내용을 입력하세요';
return false;
}
return true;
};
// ( )
const handleCancelEdit = comment => {
const targetComment = findCommentById(comment.commentId, comments.value);
@ -799,8 +820,23 @@
});
</script>
<style>
.board-content img {
.board-content {
max-width: 100%;
height: auto;
overflow: hidden;
word-wrap: break-word;
}
.board-content img {
max-width: 100% !important;
width: 100% !important;
height: auto !important;
display: block;
object-fit: contain;
}
.board-content table {
max-width: 100%;
overflow-x: auto;
display: block;
}
</style>

View File

@ -3,5 +3,5 @@
</template>
<script setup>
import CommuteCalendar from '@c/commuters/CommuteCalendar.vue';
import CommuteCalendar from '@c/commuters/CommuterCalendar.vue';
</script>

View File

@ -152,23 +152,30 @@ function handleMonthChange(viewInfo) {
loadCalendarData(year, month);
}
//
//
function handleDateClick(info) {
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 < todayStr
clickedDate.getDay() === 0 || //
clickedDate.getDay() === 6 || //
holidayDates.value.has(clickedDateStr) || //
clickedDateStr <= oneWeekAgoObj.toISOString().split("T")[0] // -7
) {
return;
}
const isMyVacation = myVacations.value.some(vac => {
const vacDate = vac.date ? vac.date.substring(0, 10) : "";
return vacDate === clickedDateStr && !vac.receiverId;
});
if (isMyVacation) {
if (selectedDates.value.get(clickedDateStr) === "delete") {
selectedDates.value.delete(clickedDateStr);
@ -178,45 +185,55 @@ function handleDateClick(info) {
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();
}
}
//
function markClickableDates() {
nextTick(() => {
const todayStr = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
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);
// (, )
if (dateObj.getDay() === 0 || dateObj.getDay() === 6 || holidayDates.value.has(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); //
}
// ( )
else if (dateObj < todayObj) {
cell.classList.remove("clickable");
cell.classList.add("past"); //
}
// & ( )
// -6
else {
cell.classList.add("clickable");
cell.classList.remove("past", "fc-day-sat-sun");
cell.addEventListener("click", handleDateClick); //
}
});
});
@ -439,11 +456,12 @@ function updateCalendarEvents() {
.filter(([date, type]) => type !== "delete")
.map(([date, type]) => ({
start: date,
backgroundColor: "rgb(113 212 243 / 76%)",
backgroundColor: "rgb(113 212 243 / 76%)", //
textColor: "#fff",
display: "background",
classNames: [getVacationTypeClass(type), "selected-event"]
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) {
@ -452,7 +470,37 @@ function updateCalendarEvents() {
}
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) => {