Compare commits

..

No commits in common. "main" and "boardmodify2" have entirely different histories.

113 changed files with 8974 additions and 14445 deletions

View File

@ -1,7 +1,6 @@
VITE_DOMAIN = https://192.168.0.251:5100/ VITE_DOMAIN = https://192.168.0.251:5173/
# VITE_LOGIN_URL = http://localhost:10325/ms/ # VITE_LOGIN_URL = http://localhost:10325/ms/
VITE_SERVER = https://192.168.0.251:10300/ VITE_SERVER = https://192.168.0.251:10300/
VITE_API_URL = https://192.168.0.251:10300/api/ VITE_API_URL = https://192.168.0.251:10300/api/
VITE_TEST_URL = https://192.168.0.251:10300/test/ VITE_TEST_URL = https://192.168.0.251:10300/test/
VITE_SERVER_IMG_URL = https://192.168.0.251:10300/upload/img/
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492 VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492

View File

@ -3,5 +3,4 @@ VITE_DOMAIN = http://localhost:5173/
VITE_SERVER = http://localhost:10325/ VITE_SERVER = http://localhost:10325/
VITE_API_URL = http://localhost:10325/api/ VITE_API_URL = http://localhost:10325/api/
VITE_TEST_URL = http://localhost:10325/test/ VITE_TEST_URL = http://localhost:10325/test/
VITE_SERVER_IMG_URL = http://localhost:10325/upload/img/
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492 VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492

8485
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,163 +1,21 @@
/* 1) */
@font-face {
font-family: 'NanumSquareRound';
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_two@1.0/NanumSquareRound.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'NanumSquareRound', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
}
/* 2) */
/* @font-face {
font-family: 'Pretendard-Regular';
src: url('https://cdn.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
}
body {
font-family: 'Pretendard-Regular', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 3) */
/* @font-face {
font-family: 'NEXON Lv1 Gothic OTF';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_20-04@2.1/NEXON Lv1 Gothic OTF.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'NEXON Lv1 Gothic OTF', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 4) */
/* @font-face {
font-family: 'SUITE-Regular';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2304-2@1.0/SUITE-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
body {
font-family: 'SUITE-Regular', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 5) */
/* @font-face {
font-family: 'GoyangIlsan';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_one@1.0/GoyangIlsan.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'GoyangIlsan', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 6) */
/* @font-face {
font-family: 'GowunDodum-Regular';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/GowunDodum-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'GowunDodum-Regular', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 7) */
/* @font-face {
font-family: 'EASTARJET-Medium';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_231029@1.1/EASTARJET-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
}
body {
font-family: 'EASTARJET-Medium', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 8) */
/* @font-face {
font-family: 'HakgyoansimChulseokbuTTF-B';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/2408-5@1.0/HakgyoansimChulseokbuTTF-B.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
body {
font-family: 'HakgyoansimChulseokbuTTF-B', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 9) */
/* @font-face {
font-family: 'GongGothicMedium';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_20-10@1.0/GongGothicMedium.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'GongGothicMedium', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* @font-face {
font-family: 'MangoDdobak-B';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/2405-3@1.1/MangoDdobak-B.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
body {
font-family: 'MangoDdobak-B', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 나눔고딕 */ /* 나눔고딕 */
@import url('https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap');
/* D2Coding 폰트 */
@font-face { @font-face {
font-family: 'D2Coding'; font-family: 'D2Coding';
src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('truetype'); src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('ttc');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
/* Consolas 폰트 */
@font-face { @font-face {
font-family: 'Consolas'; font-family: 'Consolas';
src: url('/font/Consolas.woff') format('font-woff'); src: url('/font/Consolas.woff') format('woff');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
/* 툴바에서 선택 가능한 폰트 */ /* 툴바에서 선택 가능한 폰트 및 크기 스타일 */
.ql-font-nanum-gothic { .ql-font-nanum-gothic {
font-family: 'Nanum Gothic', sans-serif; font-family: 'Nanum Gothic', sans-serif;
} }
@ -175,10 +33,24 @@ body {
} }
/* 폰트 크기 스타일 */ /* 폰트 크기 스타일 */
.ql-size-12px { font-size: 12px; } .ql-size-12px {
.ql-size-14px { font-size: 14px; } font-size: 12px;
.ql-size-16px { font-size: 16px; } }
.ql-size-18px { font-size: 18px; } .ql-size-14px {
.ql-size-24px { font-size: 24px; } font-size: 14px;
.ql-size-32px { font-size: 32px; } }
.ql-size-48px { font-size: 48px; } .ql-size-16px {
font-size: 16px;
}
.ql-size-18px {
font-size: 18px;
}
.ql-size-24px {
font-size: 24px;
}
.ql-size-32px {
font-size: 32px;
}
.ql-size-48px {
font-size: 48px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@ -15304,12 +15304,12 @@ html:not(.layout-menu-fixed) .menu-inner-shadow {
} }
@media (max-width: 1199.98px) { @media (max-width: 1199.98px) {
.layout-navbar-fixed .layout-navbar.navbar-detached { .layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - (1.625rem * 2)) width: calc(100% - (1.625rem * 2)) !important;
} }
} }
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.layout-navbar-fixed .layout-navbar.navbar-detached { .layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - (1rem * 2)) width: calc(100% - (1rem * 2)) !important;
} }
} }
.layout-navbar-fixed.layout-menu-collapsed .layout-navbar.navbar-detached { .layout-navbar-fixed.layout-menu-collapsed .layout-navbar.navbar-detached {
@ -15317,12 +15317,12 @@ html:not(.layout-menu-fixed) .menu-inner-shadow {
} }
@media (max-width: 1199.98px) { @media (max-width: 1199.98px) {
.layout-navbar.navbar-detached { .layout-navbar.navbar-detached {
width: calc(100vw - (100vw - 100%) - (1.625rem * 2)) width: calc(100vw - (100vw - 100%) - (1.625rem * 2)) !important;
} }
} }
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.layout-navbar.navbar-detached { .layout-navbar.navbar-detached {
width: calc(100vw - (100vw - 100%) - (1rem * 2)) width: calc(100vw - (100vw - 100%) - (1rem * 2)) !important;
} }
} }
.layout-menu-collapsed .layout-navbar.navbar-detached, .layout-without-menu .layout-navbar.navbar-detached { .layout-menu-collapsed .layout-navbar.navbar-detached, .layout-without-menu .layout-navbar.navbar-detached {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,6 @@
<template> <template>
<link rel="stylesheet" href="/css/font.css">
<component :is="layout"> <component :is="layout">
<template #content> <template #content>
<LoadingSpinner :isLoading="loadingStore.isLoading" />
<router-view></router-view> <router-view></router-view>
</template> </template>
</component> </component>
@ -14,10 +12,7 @@ import { useRoute } from 'vue-router';
import NormalLayout from './layouts/NormalLayout.vue'; import NormalLayout from './layouts/NormalLayout.vue';
import NoLayout from './layouts/NoLayout.vue'; import NoLayout from './layouts/NoLayout.vue';
import ToastModal from '@c/modal/ToastModal.vue'; import ToastModal from '@c/modal/ToastModal.vue';
import { useLoadingStore } from "@s/loadingStore";
import LoadingSpinner from "@v/LoadingPage.vue";
const loadingStore = useLoadingStore();
const route = useRoute(); const route = useRoute();
const layout = computed(() => { const layout = computed(() => {

View File

@ -1,7 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import router from '@/router'; import { useRoute } from 'vue-router';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useLoadingStore } from '@s/loadingStore';
const $api = axios.create({ const $api = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
@ -15,9 +14,6 @@ const $api = axios.create({
*/ */
$api.interceptors.request.use( $api.interceptors.request.use(
function (config) { function (config) {
const loadingStore = useLoadingStore();
loadingStore.startLoading();
let contentType = 'application/json'; let contentType = 'application/json';
if (config.isFormData) contentType = 'multipart/form-data'; if (config.isFormData) contentType = 'multipart/form-data';
@ -28,8 +24,6 @@ $api.interceptors.request.use(
return config; return config;
}, },
function (error) { function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
// 요청 오류가 있는 작업 수행 // 요청 오류가 있는 작업 수행
return Promise.reject(error); return Promise.reject(error);
}, },
@ -38,80 +32,36 @@ $api.interceptors.request.use(
// 응답 인터셉터 추가하기 // 응답 인터셉터 추가하기
$api.interceptors.response.use( $api.interceptors.response.use(
function (response) { function (response) {
const loadingStore = useLoadingStore(); // 2xx 범위의 응답 처리
loadingStore.stopLoading();
// 로그인 요청일 경우 (헤더에 isLogin이 true로 설정된 경우)
if (response.config.headers && response.config.headers.isLogin) {
return response;
}
// 테스트 부탁
// 로그인 실패, 커스텀 에러 응답 처리
if (response.data.code > 10000) {
const toastStore = useToastStore();
const errorCode = response.data.code;
const errorMessage = response.data.message || '알 수 없는 오류가 발생했습니다.';
// 서버에서 보낸 메시지 사용
toastStore.onToast(errorMessage, 'e');
// 특정 에러 코드에 대한 추가 처리만 수행
if (errorCode === 10001) {
router.push('/login');
}
// 오류 응답 반환
return response;
}
// 일반 성공 응답 처리
return response; return response;
}, },
function (error) { function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
const toastStore = useToastStore(); const toastStore = useToastStore();
// 오류 응답 처리
// 로그인 요청 별도 처리 (헤더에 isLogin이 true로 설정된 경우)
if (error.config && error.config.headers && error.config.headers.isLogin) {
// 로그인 페이지 오류 토스트 메시지 표시 X
return Promise.reject(error);
}
// 에러 응답에 커스텀 메시지가 포함되어 있다면 해당 메시지 사용
// if (error.response && error.response.data && error.response.data.message) {
// toastStore.onToast(error.response.data.message, 'e');
// } else if (error.response) {
if (error.response) { if (error.response) {
// 기본 HTTP 에러 처리
switch (error.response.status) { switch (error.response.status) {
case 400:
toastStore.onToast('잘못된 요청입니다.', 'e');
router.push('/error/400');
break;
case 401: case 401:
toastStore.onToast('인증이 필요합니다.', 'e'); if (!error.config.headers.isLogin) {
router.push('/login'); toastStore.onToast('인증이 필요합니다.', 'e');
}
break; break;
case 403: case 403:
toastStore.onToast('접근 권한이 없습니다.', 'e'); toastStore.onToast('접근 권한이 없습니다.', 'e');
break; break;
case 404: case 404:
toastStore.onToast('요청한 페이지를 찾을 수 없습니다.', 'e'); toastStore.onToast('요청한 페이지를 찾을 수 없습니다.', 'e');
router.push('/error/404');
break; break;
case 500: case 500:
toastStore.onToast('서버 오류가 발생했습니다.', 'e'); toastStore.onToast('서버 오류가 발생했습니다.', 'e');
router.push('/error/500');
break; break;
default: default:
toastStore.onToast('알 수 없는 오류가 발생했습니다.', 'e'); toastStore.onToast('알 수 없는 오류가 발생했습니다.', 'e');
} }
} else if (error.request) { } else if (error.request) {
// 요청이 전송되었으나 응답을 받지 못한 경우
toastStore.onToast('서버와 통신할 수 없습니다.', 'e'); toastStore.onToast('서버와 통신할 수 없습니다.', 'e');
} else { } else {
// 요청 설정 중에 오류가 발생한 경우
toastStore.onToast('요청 중 오류가 발생했습니다.', 'e'); toastStore.onToast('요청 중 오류가 발생했습니다.', 'e');
} }
@ -119,5 +69,4 @@ $api.interceptors.response.use(
}, },
); );
export default $api; export default $api;

View File

@ -1,20 +1,18 @@
/* /*
작성자 : 공현지 작성자 : 공현지
작성일 : 2025-01-17 작성일 : 2025-01-17
수정자 : 박성용 수정자 :
수정일 : 2025-03-11 수정일 :
설명 : 공통 스크립트 설명 : 공통 스크립트
*/ */
import Quill from 'quill'; import Quill from 'quill';
/* /*
*템플릿 사용법 : $common.변수 *템플릿 사용법 : $common.변수
*setup() 사용법 : *setup() 사용법 :
const { appContext } = getCurrentInstance(); const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common; const $common = appContext.config.globalProperties.$common;
or $common.변수
import { inject } from 'vue';
const $common = inject('common');
*/ */
const common = { const common = {
// JSON 문자열로 Delta 타입을 변환 // JSON 문자열로 Delta 타입을 변환
@ -47,11 +45,11 @@ const common = {
* *
* @param {string} dateStr * @param {string} dateStr
* @return * @return
* 1. Date type 경우 예시 '2025-02-24 12:02' * 1. Date type 경우 예시 '25-02-24 12:02'
* 2. Date type 아닌 경우 입력값 리턴 * 2. Date type 아닌 경우 입력값 리턴
* *
*/ */
dateFormatter(dateStr, type = null) { dateFormatter(dateStr) {
const date = new Date(dateStr); const date = new Date(dateStr);
const dateCheck = date.getTime(); const dateCheck = date.getTime();
@ -59,28 +57,13 @@ const common = {
return dateStr; return dateStr;
} else { } else {
const { year, month, day, hours, minutes } = this.formatDateTime(date); const { year, month, day, hours, minutes } = this.formatDateTime(date);
let callback = ''; return `${year}-${month}-${day} ${hours}:${minutes}`;
if (type == 'YMD') {
callback = `${year}-${month}-${day}`;
} else if (type == 'MD') {
callback = `${month}-${day}`;
} else if (type == 'T') {
callback = `${hours}:${minutes}`;
} else {
callback = `${year}-${month}-${day} ${hours}:${minutes}`;
}
return callback;
} }
}, },
formatDateTime(dateStr) { formatDateTime(date) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) return dateStr;
const zeroFormat = num => (num < 10 ? `0${num}` : num); const zeroFormat = num => (num < 10 ? `0${num}` : num);
return { return {
year: date.getFullYear(), year: date.getFullYear(),
month: zeroFormat(date.getMonth() + 1), month: zeroFormat(date.getMonth() + 1),
@ -91,126 +74,13 @@ const common = {
}; };
}, },
// 오늘 날짜시간 조회
getToday() {
const date = new Date();
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
};
},
// 해당 날짜가 오늘인지 확인
isToday(dateStr) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) return '날짜 타입 에러';
const today = new Date();
return date.toDateString() === today.toDateString();
},
// 해당 월, 일에 맞는 목록 필터링
filterTargetByDate(target, key, month, day) {
if (!Array.isArray(target) || target.length === 0) return [];
return [...target].filter(item => {
if (!item[key]) return false;
const date = new Date(item[key]);
const MatchingMonth = date.getMonth() + 1 === parseInt(month, 10);
const MatchingDay = date.getDate() === parseInt(day, 10);
return MatchingMonth && MatchingDay;
});
},
/**
* 빈값 확인
*
* @param {} obj
* @returns
*/
isNotEmpty(obj) { isNotEmpty(obj) {
if (obj === null || obj === undefined) { if (obj === null || obj === undefined) return false;
return false; if (typeof obj === 'string' && obj.trim() === '') return false;
} if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) return false;
if (typeof obj === 'string' && obj.trim() === '') {
return false;
}
if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) {
return false;
}
return true; return true;
}, },
/**
* 에디터에 내용이 있는지 확인
*
* @param { Quill } content
* @returns true: 없음, false: 있음
*/
isNotValidContent(content) {
if (!content.value?.ops?.length) return true;
// 이미지 포함 여부 확인
const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
// 텍스트 포함 여부 확인
const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
// 텍스트 또는 이미지가 하나라도 있으면 유효한 내용
return !(hasText || hasImage);
},
/**
* 확인
*
* @param { ref } text ex) inNotValidInput(data.value);
* @returns
*/
isNotValidInput(text) {
return text.trim().length === 0;
},
/**
* 프로필 이미지 반환
*
* @param { String } profileImg
* @returns
*/
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');
},
addHyphenToPhoneNumber(phoneNum) {
const phoneNumber = phoneNum;
const length = phoneNumber.length;
if (length >= 9) {
return phoneNumber.replace(/[^0-9]/g, '').replace(/^(\d{2,3})(\d{3,4})(\d{4})$/, `$1-$2-$3`);
} else {
return phoneNum;
}
},
}; };
export default { export default {

View File

@ -8,42 +8,28 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import $api from '@api'; import $api from '@api';
const colorList = ref([]); const commonApi = (options = {}) => {
const mbtiList = ref([]); const colorList = ref([]);
const pwhintList = ref([]); const mbtiList = ref([]);
const yearCategory = ref([]); const pwhintList = ref([]);
const cateList = ref([]); const yearCategory = ref([]);
const cateList = ref([]);
const refreshColorList = async (type = 'YNP') => { // type 파라미터를 추가로 받도록 수정
const response = await $api.get(`user/color`, { const CommonCode = async (path, endpoint, targetList, type = null) => {
params: { type } const params = type ? { type } : {};
}); const response = await $api.get(`${path}/${endpoint}`, {
params
if (response.data && response.data.data) { });
colorList.value = response.data.data.map(item => ({ targetList.value = response.data.data.map(item => ({
label: item.CMNCODNAM, label: item.CMNCODNAM,
value: item.CMNCODVAL, value: item.CMNCODVAL,
})); }));
} };
return colorList.value;
};
// CommonCode 함수를 외부에서도 접근할 수 있게 변경
const CommonCode = async (path, endpoint, targetList, type = null) => {
const params = type ? { type } : {};
const response = await $api.get(`${path}/${endpoint}`, {
params
});
targetList.value = response.data.data.map(item => ({
label: item.CMNCODNAM,
value: item.CMNCODVAL,
}));
};
const commonApi = (options = {}) => {
onMounted(async () => { onMounted(async () => {
// 요청할 데이터가 옵션으로 전달 -> 그에 맞게 호출 // 요청할 데이터가 옵션으로 전달 -> 그에 맞게 호출
// color 옵션에 type 포함
if (options.loadColor) { if (options.loadColor) {
await CommonCode("user", "color", colorList, options.colorType); await CommonCode("user", "color", colorList, options.colorType);
} }
@ -53,15 +39,7 @@ const commonApi = (options = {}) => {
if (options.loadCateList) await CommonCode("worddict", "getWordCategory", cateList); if (options.loadCateList) await CommonCode("worddict", "getWordCategory", cateList);
}); });
return { return { colorList, mbtiList, pwhintList, yearCategory, cateList };
colorList,
mbtiList,
pwhintList,
yearCategory,
cateList,
refreshColorList
};
}; };
export { refreshColorList };
export default commonApi; export default commonApi;

View File

@ -4,31 +4,26 @@
:unknown="comment.author === '익명'" :unknown="comment.author === '익명'"
:isCommentAuthor="isCommentAuthor" :isCommentAuthor="isCommentAuthor"
:boardId="comment.boardId" :boardId="comment.boardId"
:profileName="displayName" :profileName="comment.author"
:date="comment.createdAt" :date="comment.createdAt"
:comment="comment" :comment="comment"
:profileImg="comment.profileImg"
:showDetail="false" :showDetail="false"
:isLike="!isLike" :isLike="!isLike"
:isCommentPassword="isCommentPassword" :isCommentPassword="isCommentPassword"
:isCommentProfile="true" :isCommentProfile="true"
:is-edit-pushed="isEditPushed"
:is-delete-pushed="isDeletePushed"
@editClick="handleEditClick" @editClick="handleEditClick"
@deleteClick="$emit('deleteClick', comment)" @deleteClick="$emit('deleteClick', comment)"
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
/> />
<!-- 댓글 비밀번호 입력창 (익명일 경우) --> <!-- 댓글 비밀번호 입력창 (익명일 경우) -->
<div v-if="currentPasswordCommentId === comment.commentId && unknown && comment.author == '익명'" class="mt-3 w-px-200 ms-auto"> <div v-if="currentPasswordCommentId === comment.commentId && unknown" class="mt-3 w-25 ms-auto">
<div class="input-group"> <div class="input-group">
<input <input
type="password" type="password"
class="form-control" class="form-control"
:value="password" :value="password"
autocomplete="new-password"
maxlength="8"
placeholder="비밀번호 입력" placeholder="비밀번호 입력"
@input="filterInput" @input="$emit('update:password', $event.target.value.trim())"
/> />
<button class="btn btn-primary" @click="logPasswordAndEmit">확인</button> <button class="btn btn-primary" @click="logPasswordAndEmit">확인</button>
</div> </div>
@ -37,208 +32,166 @@
<div class="mt-6"> <div class="mt-6">
<template v-if="comment.isEditTextarea"> <template v-if="comment.isEditTextarea">
<textarea v-model="localEditedContent" class="form-control" maxLength="500"></textarea> <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"> <div class="mt-2 d-flex justify-content-end">
<SaveBtn class="btn btn-primary" @click="submitEdit" :isEnabled="disabled"></SaveBtn> <SaveBtn class="btn btn-primary" @click="submitEdit"></SaveBtn>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="m-0" style="white-space: pre-wrap">{{ comment.content }}</div> <p class="m-0">{{ comment.content }}</p>
</template> </template>
</div> </div>
<!-- <p>현재 isDeleted : {{ isDeleted }}</p> --> <!-- <p>현재 isDeleted : {{ isDeleted }}</p> -->
<!-- <template v-if="isDeleted"> <!-- <template v-if="isDeleted">
<p class="m-0 text-muted">댓글이 삭제되었습니다.</p> <p class="m-0 text-muted">댓글이 삭제되었습니다.</p>
</template> --> </template> -->
<PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6"> <PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6"/>
<i class="icon-base bx bx-plus beforeRotate" :class="{ rotate: isComment }"></i> <BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment"/>
</PlusButton>
<BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment" :commnetId="comment.commentId" /> <!-- 대댓글 -->
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
<slot name="reply"></slot> <li
v-for="child in comment.children"
:key="child.commentId"
class="mt-8 pt-6 ps-10 border-top"
>
<BoardComment
:comment="child"
:unknown="child.author === '익명'"
:isPlusButton="false"
:isLike="true"
:isCommentProfile="true"
:isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert"
:password="password"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)"
/>
</li>
</ul>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref, computed, watch, inject } from 'vue'; import { defineProps, defineEmits, ref, computed, watch } from 'vue';
import BoardProfile from './BoardProfile.vue'; import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue'; import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue'; import PlusButton from '../button/PlusBtn.vue';
import SaveBtn from '../button/SaveBtn.vue'; import SaveBtn from '../button/SaveBtn.vue';
const props = defineProps({ const props = defineProps({
comment: { comment: {
type: Object, type: Object,
required: true, required: true,
}, },
unknown: { unknown: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
nickname: { isCommentAuthor: {
type: String, type: Boolean,
default: '', default: false,
}, },
isCommentAuthor: { isPlusButton: {
type: Boolean, type: Boolean,
default: false, default: true,
}, },
isLike: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false
},
isDeleted: {
type: Boolean,
default: false
},
isCommentPassword: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: ''
},
currentPasswordCommentId: {
type: Number
},
password:{
type: String
},
});
isPlusButton: { // emits
type: Boolean, const emit = defineEmits(['submitComment', 'updateReaction', 'editClick', 'deleteClick', 'submitPassword', 'submitEdit', 'cancelEdit', 'update:password']);
default: true,
}, const localEditedContent = ref(props.comment.content);
isLike: {
type: Boolean, //
default: false, const isComment = ref(false);
}, const toggleComment = () => {
isEditTextarea: { isComment.value = !isComment.value;
type: Boolean, };
default: false,
}, //
isDeleted: { const submitComment = (newComment) => {
type: Boolean, emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP });
default: false, isComment.value = false;
}, };
isCommentPassword: {
type: Boolean, // ,
default: false, const handleUpdateReaction = (reactionData) => {
}, emit('updateReaction', {
passwordCommentAlert: { boardId: props.comment.boardId,
type: String, commentId: props.comment.commentId || reactionData.commentId,
default: '', ...reactionData,
},
currentPasswordCommentId: {
type: Number,
},
password: {
type: String,
},
// isEditPushed: {
// type: Boolean,
// required: false,
// },
// isDeletePushed: {
// type: Boolean,
// required: false,
// },
editCommentAlert: String,
}); });
const isEditPushed = ref(false); };
const isDeletePushed = ref(false);
const displayName = computed(() => { //
return props.nickname ? props.nickname : props.comment.author; const logPasswordAndEmit = () => {
}); emit('submitPassword', props.comment, props.password);
};
// emits watch(() => props.comment.isEditTextarea, (newVal) => {
const emit = defineEmits([ if (newVal) {
'submitComment', localEditedContent.value = props.comment.content;
'updateReaction', }
'editClick', });
'deleteClick',
'submitPassword',
'submitEdit',
'cancelEdit',
'update:password',
'inputDetector',
]);
const filterInput = event => { // watch(() => props.comment.isDeleted, () => {
event.target.value = event.target.value.replace(/\s/g, ''); // // console.log("BoardComment - isDeleted :", newVal);
emit('update:password', event.target.value);
};
const localEditedContent = ref(props.comment.content); // if (newVal) {
const isModifyContent = ref(props.comment.content); // localEditedContent.value = " ."; // UI
const disabled = ref(false); // props.comment.isEditTextarea = false;
// }
// });
// //
const isComment = ref(false); const submitEdit = () => {
const toggleComment = () => { emit('submitEdit', props.comment, localEditedContent.value);
isComment.value = !isComment.value; };
};
// const handleEditClick = () => {
const submitComment = newComment => { emit('editClick', props.comment);
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP }); }
isComment.value = false;
};
// , const handleReplyEditClick = (comment) => {
const handleUpdateReaction = reactionData => { emit('editClick', comment);
emit('updateReaction', { }
boardId: props.comment.boardId,
commentId: props.comment.commentId || reactionData.commentId,
...reactionData,
});
};
//
const logPasswordAndEmit = () => {
emit('submitPassword', props.comment, props.password);
};
const handleInject = inject('isBtnPushed');
// ,
watch(
() => handleInject.value,
(newValue, oldValue) => {
if (newValue) {
if (newValue.target == props.comment.commentId) {
isEditPushed.value = newValue.isEditPushed;
isDeletePushed.value = newValue.isDeletePushed;
} else {
isEditPushed.value = false;
isDeletePushed.value = false;
}
}
},
);
watch(
() => props.comment.isEditTextarea,
newVal => {
if (newVal) {
localEditedContent.value = props.comment.content;
}
},
);
// text
watch(
() => localEditedContent.value,
newVal => {
if (JSON.stringify(isModifyContent.value) == JSON.stringify(newVal)) {
disabled.value = false;
return;
}
disabled.value = true;
emit('inputDetector');
},
);
// watch(() => props.comment.isDeleted, () => {
// console.log("BoardComment - isDeleted :", newVal);
// if (newVal) {
// localEditedContent.value = " ."; // UI
// props.comment.isEditTextarea = false;
// }
// });
//
const submitEdit = () => {
emit('submitEdit', props.comment, localEditedContent.value);
};
const handleEditClick = () => {
emit('editClick', props.comment);
};
</script> </script>

View File

@ -3,77 +3,46 @@
<div class="card-body"> <div class="card-body">
<!-- 댓글 입력 섹션 --> <!-- 댓글 입력 섹션 -->
<div class="d-flex justify-content-start align-items-top"> <div class="d-flex justify-content-start align-items-top">
<!-- 프로필섹션 -->
<!-- <div class="avatar-wrapper">
<div v-if="!unknown" class="avatar me-4">
<img src="/img/avatars/11.png" alt="Avatar" class="rounded-circle">
</div>
</div> -->
<!-- 텍스트박스 -->
<div class="w-100"> <div class="w-100">
<textarea <textarea class="form-control" placeholder="댓글 달기" rows="3" v-model="comment"></textarea>
class="form-control mb-1" <span v-if="commentAlert" class="invalid-feedback d-block text-start ms-2">{{ commentAlert }}</span>
placeholder="댓글 달기" <span v-else class="invalid-feedback d-block text-start ms-2">{{ textAlert }}</span>
rows="3"
:maxlength="maxLength"
v-model="comment"
@input="clearAlert('comment')"
></textarea>
<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">{{ textAlert }}</span>
</div> </div>
</div> </div>
<!-- 옵션 버튼 섹션 --> <!-- 옵션 버튼 섹션 -->
<div class="d-flex justify-content-between align-items-center mt-1 pb-4"> <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-2"> <!-- 익명 체크박스 (익명게시판일 경우에만)-->
<input <div v-if="unknown" class="form-check form-check-inline mb-0 me-4">
class="form-check-input" <input class="form-check-input" type="checkbox" id="inlineCheckbox1" v-model="isCheck" />
type="checkbox" <label class="form-check-label" for="inlineCheckbox1">익명</label>
:id="`checkboxAnnonymous${commnetId}`"
v-model="isCheck"
@change="pwd2AlertHandler"
/>
<label class="form-check-label text-nowrap" :for="`checkboxAnnonymous${commnetId}`">익명</label>
</div>
<!-- 중앙: 닉네임 & 비밀번호 입력 필드 (가로 정렬) -->
<div v-if="isCheck" class="d-flex flex-grow-1 gap-2">
<!-- 닉네임 입력 영역 -->
<div class="position-relative">
<input
type="text"
class="form-control mb-1"
v-model="nickname"
placeholder="닉네임"
@input="clearAlert('nickname')"
@keypress="noSpace"
:maxlength="6"
/>
<!-- 닉네임 경고 메시지 -->
<div v-if="nicknameAlert" class="position-absolute text-danger small top-100 start-0">
{{ nicknameAlert }}
</div>
</div> </div>
<!-- 비밀번호 입력 영역 --> <!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) -->
<div class="position-relative"> <div v-if="isCheck" class="d-flex align-items-center flex-grow-1">
<label class="form-label mb-0 me-3" for="basic-default-password">비밀번호</label>
<input <input
type="password" type="password"
id="basic-default-password" id="basic-default-password"
class="form-control mb-1" class="form-control flex-grow-1"
autocomplete="new-password"
v-model="password" v-model="password"
placeholder="비밀번호" placeholder="비밀번호 입력"
maxlength="8"
@input="
password = password.replace(/\s/g, '');
clearAlert('password');
"
/> />
<!-- 비밀번호 경고 메시지 --> <span v-if="passwordAlert" class="invalid-feedback d-block text-start ms-2">{{ passwordAlert }}</span>
<div v-if="passwordAlert2" class="position-absolute text-danger small top-100 start-0"> <span v-else class="invalid-feedback d-block text-start ms-2">{{ passwordAlert2 }}</span>
{{ passwordAlert2 }}
</div>
</div> </div>
</div> </div>
<!-- 오른쪽: 답변 쓰기 버튼 --> <!-- 답변 쓰기 버튼 -->
<div class="ms-auto"> <div class="ms-auto mt-3 mt-md-0">
<SaveBtn class="btn btn-primary" @click="handleCommentSubmit"></SaveBtn> <SaveBtn class="btn btn-primary" @click="handleCommentSubmit"></SaveBtn>
</div> </div>
</div> </div>
@ -82,124 +51,78 @@
</template> </template>
<script setup> <script setup>
import { ref, defineEmits, defineProps, watch, inject } from 'vue'; import { ref, defineEmits, defineProps, watch, inject } from 'vue';
import SaveBtn from '../button/SaveBtn.vue'; import SaveBtn from '../button/SaveBtn.vue';
const props = defineProps({ const props = defineProps({
unknown: { unknown: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
parentId: { parentId: {
type: Number, type: Number,
default: 0, default: 0,
}, },
passwordAlert: { passwordAlert: {
type: String, type: String,
default: '', default: '',
}, },
commentAlert: { commentAlert: {
type: String, type: String,
default: '', default: '',
}, },
maxLength: { });
type: Number,
default: 500, const $common = inject('common');
}, const comment = ref('');
commnetId: { const password = ref('');
type: Number, const isCheck = ref(false);
}, const textAlert = ref('');
const passwordAlert2 = ref('');
const emit = defineEmits(['submitComment']);
const handleCommentSubmit = () => {
if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력하세요';
return false;
} else {
textAlert.value = '';
}
if (isCheck.value && !$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력하세요';
return false;
} else {
passwordAlert2.value = '';
}
//
emit('submitComment', {
comment: comment.value,
password: isCheck.value ? password.value : '',
isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
}); });
const noSpace = (e) => { //
if (e.key === ' ') e.preventDefault(); resetCommentForm();
}; };
const $common = inject('common'); //
const comment = ref(''); const resetCommentForm = () => {
const password = ref(''); comment.value = '';
const isCheck = ref(false); password.value = '';
const textAlert = ref(''); isCheck.value = false;
const nicknameAlert = ref(''); };
const passwordAlert2 = ref('');
const nickname = ref('');
const emit = defineEmits(['submitComment']); watch(
() => props.passwordAlert,
// () => {
const clearAlert = field => { if (!props.passwordAlert) {
if (field === 'comment') textAlert.value = ''; resetCommentForm();
if (field === 'nickname') nicknameAlert.value = '';
if (field === 'password') passwordAlert2.value = '';
};
const handleCommentSubmit = () => {
let isValid = true;
//
if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력해주세요.';
isValid = false;
} else {
textAlert.value = '';
} }
}
// & );
if (isCheck.value) {
if (!$common.isNotEmpty(nickname.value)) {
nicknameAlert.value = '닉네임을 입력해주세요.';
isValid = false;
} else {
nicknameAlert.value = '';
}
if (!$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력해주세요.';
password.value = '';
isValid = false;
} else {
passwordAlert2.value = '';
}
}
//
if (!isValid) return;
//
emit('submitComment', {
comment: comment.value,
nickname: isCheck.value ? nickname.value : '',
password: isCheck.value ? password.value : '',
isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
});
//
resetCommentForm();
};
// &
const pwd2AlertHandler = () => {
if (!isCheck.value) {
passwordAlert2.value = '';
nicknameAlert.value = '';
}
};
//
const resetCommentForm = () => {
comment.value = '';
password.value = '';
nickname.value = '';
isCheck.value = false;
};
watch(
() => props.passwordAlert,
() => {
if (!props.passwordAlert) {
resetCommentForm();
}
},
);
</script> </script>

View File

@ -1,176 +1,124 @@
<template> <template>
<ul class="list-unstyled mt-10"> <ul class="list-unstyled mt-10">
<li v-for="comment in comments" :key="comment.commentId" class="mt-6 border-bottom pb-6"> <li
v-for="comment in comments"
:key="comment.commentId"
class="mt-6 border-bottom pb-6"
>
<BoardComment <BoardComment
:unknown="unknown" :unknown="unknown"
:comment="comment" :comment="comment"
:isCommentAuthor="comment.isCommentAuthor" :isCommentAuthor="comment.isCommentAuthor"
:isEditTextarea="comment.isEditTextarea" :isEditTextarea="comment.isEditTextarea"
:isDeleted="isDeleted" :isDeleted="isDeleted"
:nickname="comment.nickname" :isCommentPassword="isCommentPassword"
:isCommentPassword="isCommentPassword"
:passwordCommentAlert="passwordCommentAlert || ''" :passwordCommentAlert="passwordCommentAlert || ''"
:currentPasswordCommentId="currentPasswordCommentId" :currentPasswordCommentId="currentPasswordCommentId"
:password="password" :password="password"
:editCommentAlert="editCommentAlert[comment.commentId]"
:is-edit-pushed="comment.isEditPushed"
:is-delete-pushed="comment.isDeletePushed"
@editClick="handleEditClick" @editClick="handleEditClick"
@deleteClick="handleDeleteClick" @deleteClick="handleDeleteClick"
@submitPassword="submitPassword" @submitPassword="submitPassword"
@submitComment="submitComment" @submitComment="submitComment"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)" @submitEdit="handleSubmitEdit"
@cancelEdit="handleCancelEdit" @cancelEdit="handleCancelEdit"
@updateReaction="reactionData => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)" @updateReaction="(reactionData) => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
@update:password="updatePassword" @update:password="updatePassword"
@inputDetector="$emit('inputDetector')" />
>
<!-- 대댓글 -->
<template #reply>
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
<li v-for="(child, index) in comment.children" :key="child.commentId" class="mt-8 pt-6 ps-10 border-top">
<BoardComment
:comment="child"
:unknown="child.author === '익명'"
:isPlusButton="false"
:isLike="true"
:isCommentProfile="true"
:nickname="child.nickname"
:isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert"
:password="password"
:editCommentAlert="editCommentAlert[child.commentId]"
:is-edit-pushed="child.isEditPushed"
:is-delete-pushed="child.isDeletePushed"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@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>
</template>
</BoardComment>
</li> </li>
</ul> </ul>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, watch } from 'vue'; import { defineProps, defineEmits } from 'vue';
import BoardComment from './BoardComment.vue'; import BoardComment from './BoardComment.vue'
const props = defineProps({ const props = defineProps({
comments: { comments: {
type: Array, type: Array,
required: true, required: true,
default: () => [], default: () => []
}, },
unknown: { unknown: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
isCommentAuthor: { isCommentAuthor: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCommentPassword: { isCommentPassword: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isEditTextarea: { isEditTextarea: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isDeleted: { isDeleted: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
passwordCommentAlert: { passwordCommentAlert: {
type: String, type: String,
default: '', default: ''
}, },
currentPasswordCommentId: { currentPasswordCommentId: {
type: Number, type: Number
}, },
password: { password:{
type: String, type: String
}, },
index: { });
type: Number,
},
editCommentAlert: Object,
});
const emit = defineEmits([ const emit = defineEmits(['submitComment', 'updateReaction', 'editClick', 'deleteClick', 'submitPassword', 'clearPassword','submitEdit', 'update:password']);
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'clearPassword',
'submitEdit',
'update:password',
'inputDetector',
]);
const submitComment = replyData => { const submitComment = (replyData) => {
emit('submitComment', replyData); emit('submitComment', replyData);
};
const handleUpdateReaction = (reactionData, commentId, boardId) => {
const updatedReactionData = {
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
}; };
const handleUpdateReaction = (reactionData, commentId, boardId) => { emit('updateReaction', updatedReactionData);
const updatedReactionData = { }
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
};
emit('updateReaction', updatedReactionData); const submitPassword = (comment, password) => {
}; emit('submitPassword', comment, password);
};
const submitPassword = (comment, password) => { const handleEditClick = (comment) => {
emit('submitPassword', comment, password); if (comment.parentId) {
}; emit('editClick', comment); //
} else {
emit('editClick', comment); //
}
};
const handleEditClick = comment => { const handleSubmitEdit = (comment, editedContent) => {
if (comment.parentId) { emit("submitEdit", comment, editedContent);
emit('editClick', comment); // };
} else {
emit('editClick', comment); //
}
};
const handleSubmitEdit = (comment, editedContent) => { const handleDeleteClick = (comment) => {
emit('submitEdit', comment, editedContent); if (comment.parentId) {
}; emit('deleteClick', comment); //
} else {
emit('deleteClick', comment); //
}
};
const handleDeleteClick = comment => { const handleCancelEdit = (comment) => {
if (comment.parentId) { if (comment.parentId) {
emit('deleteClick', comment); // emit('cancelEdit', comment); //
} else { } else {
emit('deleteClick', comment); // emit('cancelEdit', comment); //
} }
}; };
const handleCancelEdit = comment => { const updatePassword = (newPassword) => {
if (comment.parentId) { emit('update:password', newPassword);
emit('cancelEdit', comment); // };
} else {
emit('cancelEdit', comment); //
}
};
const updatePassword = newPassword => {
emit('update:password', newPassword);
};
const handleReplyEditClick = comment => {
emit('editClick', comment);
};
</script> </script>

View File

@ -1,23 +1,17 @@
<template> <template>
<div class="d-flex align-items-center flex-wrap"> <div class="d-flex align-items-center flex-wrap">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="avatar me-2 cursor-none"> <div v-if="!unknown" class="avatar me-2">
<img <img :src="getProfileImage(profilePath)" alt="Avatar" class="rounded-circle" />
:src="getProfileImage(profileImg)"
alt="user"
class="rounded-circle profile-img"
@error="setDefaultImage($event)"
@load="showImage($event)"
/>
</div> </div>
<div class="me-2"> <div class="me-2">
<h6 class="mb-0">{{ profileName ? profileName : nickname }}</h6> <h6 class="mb-0">{{ profileName }}</h6>
<div class="profile-detail"> <div class="profile-detail">
<span>{{ date }}</span> <span>{{ date }}</span>
<template v-if="showDetail"> <template v-if="showDetail">
<span class="ms-2"> <i class="fa-regular fa-eye"></i> {{ views }} </span> <span class="ms-2"> <i class="fa-regular fa-eye"></i> {{ views }} </span>
<span v-if="unknown" class="ms-1"> <i class="bx bx-comment"></i> {{ commentNum }} </span> <span class="ms-1"> <i class="bx bx-comment"></i> {{ commentNum }} </span>
</template> </template>
</div> </div>
</div> </div>
@ -26,37 +20,28 @@
<div class="ms-auto text-end"> <div class="ms-auto text-end">
<!-- 수정, 삭제 버튼 --> <!-- 수정, 삭제 버튼 -->
<template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)"> <template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)">
<div class="float-end ms-1"> <EditButton @click.stop="editClick" />
<slot name="gobackBtn"></slot> <DeleteButton @click.stop="deleteClick" />
<EditButton @click.stop="editClick" :is-pushed="isEditPushed" />
<DeleteButton :class="'ms-1'" @click.stop="deleteClick" :is-pushed="isDeletePushed" />
</div>
</template>
<template v-else>
<div class="float-end ms-1">
<slot name="gobackBtn"></slot>
</div>
</template> </template>
<!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) --> <!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) -->
<BoardRecommendBtn <BoardRecommendBtn v-if="isLike" :boardId="boardId" :comment="comment" @updateReaction="handleUpdateReaction" />
v-if="isLike && !isDeletedComment"
:boardId="boardId"
:comment="comment"
:likeClicked="comment.likeClicked"
:dislikeClicked="comment.dislikeClicked"
@updateReaction="handleUpdateReaction"
/>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, defineProps, defineEmits, inject, onMounted } from 'vue'; import { computed, defineProps, defineEmits } from 'vue';
import DeleteButton from '../button/DeleteBtn.vue'; import DeleteButton from '../button/DeleteBtn.vue';
import EditButton from '../button/EditBtn.vue'; import EditButton from '../button/EditBtn.vue';
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue'; import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
//
const defaultProfile = '/img/icons/icon.png';
// (Vue )
const baseUrl = 'http://localhost:10325/'; // API URL
// Props // Props
const props = defineProps({ const props = defineProps({
comment: { comment: {
@ -75,7 +60,7 @@
type: String, type: String,
default: '', default: '',
}, },
nickname: { profilePath: {
type: String, type: String,
default: '', default: '',
}, },
@ -109,22 +94,9 @@
type: Boolean, type: Boolean,
default: false, default: false,
}, },
profileImg: {
type: String,
default: false,
},
isEditPushed: {
type: Boolean,
require: false,
},
isDeletePushed: {
type: Boolean,
require: false,
},
}); });
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']); const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
const $common = inject('common');
const isDeletedComment = computed(() => { const isDeletedComment = computed(() => {
return props.comment?.content === '삭제된 댓글입니다' && props.comment?.updateAtRaw !== props.comment?.createdAtRaw; return props.comment?.content === '삭제된 댓글입니다' && props.comment?.updateAtRaw !== props.comment?.createdAtRaw;
@ -150,15 +122,7 @@
}; };
// //
const getProfileImage = profileImg => { const getProfileImage = profilePath => {
return $common.getProfileImage(profileImg, props.unknown); return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
};
const setDefaultImage = e => {
return $common.setDefaultImage(e);
};
const showImage = e => {
return $common.showImage(e);
}; };
</script> </script>

View File

@ -1,89 +1,124 @@
<template v-if="isRecommend"> <template v-if="isRecommend">
<button <button class="btn btn-label-primary btn-icon" :class="{'clicked': likeClicked, 'big': bigBtn}" @click="handleLike">
class="btn btn-label-primary btn-icon me-1" <i class="fa-regular fa-thumbs-up"></i> <span class="num">{{ likeCount }}</span>
:class="{ clicked: likeClicked, big: bigBtn, active: props.likeClicked }"
@click="handleLike"
>
<i class="fa-regular fa-thumbs-up"></i> <span class="ms-1">{{ likeCount }}</span>
</button> </button>
<button <button class="btn btn-label-danger btn-icon" :class="{'clicked': dislikeClicked, 'big': bigBtn}" @click="handleDislike">
class="btn btn-label-danger btn-icon" <i class="fa-regular fa-thumbs-down"></i> <span class="num">{{ dislikeCount }}</span>
:class="{ clicked: dislikeClicked, big: bigBtn, active: props.dislikeClicked }"
@click="handleDislike"
>
<i class="fa-regular fa-thumbs-down"></i> <span class="ms-1">{{ dislikeCount }}</span>
</button> </button>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed } from 'vue';
const props = defineProps({ const props = defineProps({
comment: { comment: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
likeClicked: { likeClicked : {
type: Boolean, type : Boolean,
default: false, default : false,
}, },
dislikeClicked: { dislikeClicked : {
type: Boolean, type : Boolean,
default: false, default : false,
}, },
bigBtn: { bigBtn : {
type: Boolean, type :Boolean,
default: false, default : false,
}, },
isRecommend: { isRecommend: {
type: Boolean, type:Boolean,
default: true, default:true,
}, },
boardId: { boardId: {
type: Number, type: Number,
required: true, required: true,
}, },
commentId: { commentId: {
type: [Number, null], type: [Number, null],
default: null, default: null,
}, },
likeCount: { likeCount: {
type: Number, type: Number,
default: 0, default: 0,
}, },
dislikeCount: { dislikeCount: {
type: Number, type: Number,
default: 0, default: 0,
}, },
}); });
const emit = defineEmits(['updateReaction']); const emit = defineEmits(['updateReaction']);
const likeClicked = ref(props.likeClicked); const likeClicked = ref(props.likeClicked);
const dislikeClicked = ref(props.dislikeClicked); const dislikeClicked = ref(props.dislikeClicked);
const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount); const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount); const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => { const handleLike = () => {
likeClicked.value = likeNewval; const isLike = !likeClicked.value;
dislikeClicked.value = dislikeNewval; const isDislike = false;
});
const handleLike = () => { emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
const isLike = !likeClicked.value; likeClicked.value = isLike;
const isDislike = false; dislikeClicked.value = false;
};
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike }); const handleDislike = () => {
likeClicked.value = isLike; const isDislike = !dislikeClicked.value;
dislikeClicked.value = false; const isLike = false;
};
const handleDislike = () => { emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
const isDislike = !dislikeClicked.value; dislikeClicked.value = isDislike;
const isLike = false; likeClicked.value = false;
};
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike;
likeClicked.value = false;
};
</script> </script>
<style scoped>
.btn + .btn {
margin-left: 5px;
}
.num {
margin-left: 5px;
}
.btn-label-danger.clicked {
background-color: #e6381a;
}
.btn-label-danger.clicked i,
.btn-label-danger.clicked span {
color: #fff;
}
.btn-label-primary.clicked {
background-color: #5f61e6;
}
.btn-label-primary.clicked i,
.btn-label-primary.clicked span {
color : #fff;
}
.btn {
width: 55px;
height: 30px;
}
.btn.big {
width: 70px;
height: 70px;
font-size: 18px;
}
@media screen and (max-width:450px) {
.btn {
width: 50px;
height: 20px;
font-size: 12px;
}
}
</style>

View File

@ -1,14 +1,13 @@
<template> <template>
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }"> <button class="btn btn-label-primary btn-icon">
<i class="bx bx-trash"></i> <i class='bx bx-trash' ></i>
</button> </button>
</template> </template>
<script setup> <script>
const props = defineProps({ export default {
isPushed: { name: 'DeleteButton',
type: Boolean, methods: {
required: false, },
}, };
});
</script> </script>

View File

@ -1,38 +1,30 @@
<template> <template>
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }" @click="toggleText"> <button class="btn btn-label-primary btn-icon me-1" @click="toggleText">
<i :class="buttonClass"></i> <i :class="buttonClass"></i>
</button> </button>
</template> </template>
<script setup> <script setup>
import { ref, watch, defineEmits, watchEffect } from 'vue'; import { ref, defineProps } from 'vue';
const props = defineProps({ const props = defineProps({
isToggleEnabled: { isToggleEnabled: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isActive: {
type: Boolean,
required: false,
},
isPushed: {
type: Boolean,
required: false,
},
}); });
const emit = defineEmits(['click']);
const buttonClass = ref('bx bx-edit-alt'); const buttonClass = ref('bx bx-edit-alt');
watchEffect(() => {
buttonClass.value = props.isActive ? 'bx bx-x' : 'bx bx-edit-alt'; const toggleText = () => {
});
const toggleText = event => {
//
if (props.isToggleEnabled) { if (props.isToggleEnabled) {
buttonClass.value = buttonClass.value === 'bx bx-edit-alt' ? 'bx bx-x' : 'bx bx-edit-alt'; buttonClass.value = buttonClass.value === 'bx bx-edit-alt' ? 'bx bx-x' : 'bx bx-edit-alt';
} }
emit('click', event); //
}; };
const resetButton = () => { const resetButton = () => {
buttonClass.value = 'bx bx-edit-alt'; buttonClass.value = 'bx bx-edit-alt';
}; };
defineExpose({ resetButton }); defineExpose({ resetButton });
</script> </script>

View File

@ -1,72 +1,66 @@
<template> <template>
<div class="row gx-2 mb-10 mt-1"> <div class="menu gap-4 justify-content-center mt-5">
<div class="col-3"> <!-- 오전 반차 버튼 -->
<div class="ratio ratio-1x1"> <button
<!-- 오전 반차 버튼 --> class="vac-btn vac-btn-warning"
<button class="vac-btn vac-btn-warning rounded-circle d-flex align-items-center justify-content-center" :class="{ active: halfDayType === 'AM' }"
:class="{ active: halfDayType === 'AM' }" @click="toggleHalfDay('AM')"
@click="toggleHalfDay('AM')"> >
<i class="bi bi-sun d-flex"></i> <i class="bi bi-sun"></i>
</button> </button>
</div> <!-- 오후 반차 버튼 -->
</div> <button
<div class="col-3"> class="vac-btn vac-btn-info"
<div class="ratio ratio-1x1"> :class="{ active: halfDayType === 'PM' }"
<!-- 오후 반차 버튼 --> @click="toggleHalfDay('PM')"
<button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center" >
:class="{ active: halfDayType === 'PM' }" <i class="bi bi-moon"></i>
@click="toggleHalfDay('PM')"> </button>
<i class="bi bi-moon d-flex"></i> <!-- 저장 버튼 -->
</button> <div class="save-button-container">
</div> <button class="btn-success" @click="addVacationRequests"
</div>
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 연차 버튼 -->
<button class="vac-btn vac-btn-primary rounded-circle d-flex align-items-center justify-content-center"
:class="{ active: halfDayType === 'FULL' }"
@click="toggleHalfDay('FULL')">
<i class="bi bi-calendar d-flex"></i>
</button>
</div>
</div>
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 저장 버튼 -->
<button class="vac-btn-success rounded-circle d-flex align-items-center justify-content-center"
@click="addVacationRequests"
:class="{ active: !isDisabled, disabled: isDisabled }"> :class="{ active: !isDisabled, disabled: isDisabled }">
</button> </button>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineEmits, ref, defineProps } from "vue"; import { defineEmits, ref, defineProps, watch } from "vue";
const props = defineProps({ const props = defineProps({
isDisabled: Boolean isDisabled: Boolean,
selectedDate: String // props
}); });
const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]); const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]);
const halfDayType = ref(null); const halfDayType = ref(null);
const toggleHalfDay = (type) => { const toggleHalfDay = (type) => {
halfDayType.value = halfDayType.value === type ? null : type; halfDayType.value = halfDayType.value === type ? null : type;
emit("toggleHalfDay", halfDayType.value); emit("toggleHalfDay", halfDayType.value);
}; };
// // `selectedDate`
watch(() => props.selectedDate, (newDate) => {
if (newDate) {
resetHalfDay();
}
});
//
const resetHalfDay = () => { const resetHalfDay = () => {
halfDayType.value = null; halfDayType.value = null;
emit("resetHalfDay"); emit("resetHalfDay");
}; };
const addVacationRequests = () => { const addVacationRequests = () => {
emit("addVacationRequests"); emit("addVacationRequests");
}; };
defineExpose({ resetHalfDay }); defineExpose({ resetHalfDay });
</script> </script>
<style scoped>
</style>

View File

@ -1,14 +1,13 @@
<template> <template>
<button class="btn btn-label-primary btn-icon"> <button class="btn btn-label-primary btn-icon">
<slot> <i class="icon-base bx bx-plus"></i>
<i class="icon-base bx bx-plus"></i> </button>
</slot>
</button>
</template> </template>
<script> <script>
export default { export default {
name: 'PlusButton', name: 'PlusButton',
methods: {}, methods: {
}; },
};
</script> </script>

View File

@ -1,18 +1,23 @@
<template> <template>
<button type="button" class="btn btn-primary ms-1" @click="$emit('click')" :disabled="!isEnabled"> <button
type="button"
class="btn btn-primary ms-1"
@click="$emit('click')"
:disabled="!isEnabled"
>
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
</button> </button>
</template> </template>
<script> <script>
export default { export default {
name: 'SaveButton', name: "SaveButton",
props: { props: {
isEnabled: { isEnabled: {
type: Boolean, type: Boolean,
default: true, // default: true, //
},
}, },
emits: ['click'], },
}; emits: ["click"],
};
</script> </script>

View File

@ -1,87 +0,0 @@
<template>
<!-- 뒤로가기 -->
<button
v-if="canGoBack"
@click="goBack"
:disabled="!canGoBack"
:class="{ 'shifted': showButton }"
class="back-btn rounded-pill btn-icon btn-secondary position-fixed shadow z-5 border-0">
<i class='bx bx-chevron-left'></i>
</button>
<!-- 위로 -->
<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, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const showButton = ref(false);
const canGoBack = ref(false);
const route = useRoute();
const router = useRouter();
const loginPagePath = "/login"; //
//
const handleScroll = () => {
showButton.value = window.scrollY > 200;
};
//
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
//
const goBack = () => {
if (canGoBack.value) {
router.back();
}
};
//
const updateCanGoBack = () => {
const historyBack = router.options.history.state.back;
const previousPage = document.referrer;
// URL
const getPath = (url) => {
try {
return new URL(url, window.location.origin).pathname; //
} catch {
return ""; // URL
}
};
const previousPath = getPath(previousPage);
// :
canGoBack.value = !!historyBack
&& getPath(historyBack) !== loginPagePath
&& !previousPath.startsWith(loginPagePath);
};
//
onMounted(() => {
window.addEventListener("scroll", handleScroll);
updateCanGoBack();
});
// `canGoBack`
watch(route, () => {
updateCanGoBack();
});
//
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>

View File

@ -5,28 +5,17 @@
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineExpose, watch } from 'vue'; import { ref, defineProps, defineExpose } from 'vue';
const props = defineProps({ const props = defineProps({
isToggleEnabled: { isToggleEnabled: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isActive: {
type: Boolean,
required: false,
},
}); });
const buttonClass = ref("bx bx-edit"); const buttonClass = ref("bx bx-edit");
watch(
() => props.isActive,
newVal => {
buttonClass.value = newVal ? 'bx bx-x' : 'bx bx-edit';
},
);
const toggleText = () => { const toggleText = () => {
if (props.isToggleEnabled) { if (props.isToggleEnabled) {
buttonClass.value = buttonClass.value === "bx bx-edit" ? "bx bx-x" : "bx bx-edit"; buttonClass.value = buttonClass.value === "bx bx-edit" ? "bx bx-x" : "bx bx-edit";
@ -38,4 +27,4 @@ const resetButton = () => {
}; };
defineExpose({ resetButton }); defineExpose({ resetButton });
</script> </script>

View File

@ -1,65 +1,54 @@
<template> <template>
<ul class="cate-list list-unstyled d-flex flex-wrap mb-0"> <ul class="cate-list list-unstyled d-flex flex-wrap mb-0">
<li v-if="showAll" class="mt-2 me-2">
<button
type="button"
class="btn"
:class="{
'btn-outline-primary': selectedCategory !== 'all',
'btn-primary': selectedCategory === 'all'
}"
@click="selectCategory('all')"
>
All
</button>
</li>
<li v-for="category in lists" :key="category.value" class="mt-2 me-2"> <li v-for="category in lists" :key="category.value" class="mt-2 me-2">
<button <button
type="button" type="button"
class="btn" class="btn"
:class="{ :class="{
'btn-outline-primary': category.value.toString() !== selectedCategory?.toString(), 'btn-outline-primary': category.value !== selectedCategory,
'btn-primary': category.value.toString() === selectedCategory?.toString() 'btn-primary': category.value === selectedCategory
}" }"
@click="selectCategory(category.value)" @click="selectCategory(category.value)"
> >
{{ category.label }} {{ category.label }}
</button> </button>
</li> </li>
</ul> </ul>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref, watch } from 'vue'; import { defineProps, ref } from 'vue';
// lists prop
const props = defineProps({ const props = defineProps({
lists: { lists: {
type: Array, type: Array,
required: true, required: true,
}, },
showAll: {
type: Boolean,
required: false,
},
selectedCategory: {
type: [String,Number],
default: null,
required: false,
},
}); });
// //
const selectedCategory = ref(props.selectedCategory); const selectedCategory = ref(null);
const emit = defineEmits();
const emit = defineEmits(['update:data']);
const selectCategory = (cate) => { const selectCategory = (cate) => {
selectedCategory.value = selectedCategory.value === cate ? null : cate; selectedCategory.value = selectedCategory.value === cate ? null : cate;
emit('update:data', selectedCategory.value); emit('update:data', selectedCategory.value);
}; };
watch(() => props.selectedCategory, (newVal) => {
selectedCategory.value = newVal;
});
</script> </script>
<style scoped>
@media (max-width: 768px) {
.cate-list {
overflow-x: scroll;
flex-wrap: nowrap !important;
li {
flex: 0 0 auto;
}
}
}
</style>

View File

@ -0,0 +1,247 @@
<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

@ -1,223 +0,0 @@
<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 border-3 w-100 py-0 h-px-50"
:class="!workTime ? 'btn-outline-secondary pe-none disabled' : 'btn-outline-secondary'"
@click="setLeaveTime"
:disabled="!workTime"
>
<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
},
pendingProjectChange: {
type: Object,
default: null
}
});
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated', 'projectChangeComplete', 'update:pendingProjectChange']);
const workTime = ref(null);
const leaveTime = ref(null)
const userLocation = ref(null);
//
const { coords, isSupported, error } = useGeolocation({
enableHighAccuracy: true,
});
//
const getAddress = async (lat, lng) => {
return new Promise((resolve, reject) => {
if (typeof kakao === 'undefined' || !kakao.maps) {
reject('Kakao Maps API가 로드되지 않았습니다.');
return;
}
const geocoder = new kakao.maps.services.Geocoder();
geocoder.coord2Address(lng, lat, (result, status) => {
if (status === kakao.maps.services.Status.OK) {
if (result && result.length > 0 && result[0].address) {
const address = result[0].address.address_name;
resolve(address);
} else {
//
reject('주소 정보가 없습니다.');
}
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
// ZERO_RESULT
//
geocoder.coord2RegionCode(lng, lat, (regionResult, regionStatus) => {
if (regionStatus === kakao.maps.services.Status.OK && regionResult.length > 0) {
//
const region = regionResult[0].address_name;
resolve(`[대략적 위치] ${region}`);
} else {
//
resolve(`위도: ${lat}, 경도: ${lng} (주소 정보 없음)`);
}
});
} else {
reject(`주소를 가져올 수 없습니다. 상태: ${status}`);
}
});
});
};
//
const getLocation = async () => {
if (!isSupported.value) {
alert('브라우저가 위치 정보를 지원하지 않습니다.');
return null;
}
if (error.value) {
alert(`위치 정보를 가져오는데 실패했습니다: ${error.value.message}`);
return null;
}
if (!coords.value) {
return null;
}
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;
}
};
//
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();
if (!address) {
//
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 출근 처리하시겠습니까?')) {
return;
}
}
$api.post('commuters/insert', {
memberSeq: props.userId,
projctSeq: props.checkedInProject.PROJCTSEQ,
commutLve: null,
commutArr: address,
commutOut: null,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('workTimeUpdated', true);
}
});
};
//
const setLeaveTime = async () => {
//
const address = await getLocation();
if (!address && !leaveTime.value) {
//
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 퇴근 처리하시겠습니까?')) {
return;
}
}
$api.patch('commuters/updateLve', {
memberSeq: props.userId,
commutLve: leaveTime.value || null,
projctLve: props.pendingProjectChange ? props.pendingProjectChange.projctSeq : props.checkedInProject.PROJCTSEQ,
commutOut: address,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('leaveTimeUpdated');
emit('update:pendingProjectChange', null);
}
});
};
// props
watch(() => props.userId, async () => {
if (props.userId) {
await todayCommuterInfo();
}
});
onMounted(async () => {
await todayCommuterInfo();
});
//
defineExpose({
todayCommuterInfo,
workTime,
leaveTime
});
</script>

View File

@ -1,520 +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" id="app-calendar-sidebar">
<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 object-fit-cover" @error="$event.target.src = '/img/icons/icon.png'"/>
<p class="mt-2 fw-bold">
{{ user.name }}
</p>
<CommuterBtn
:userId="user.id"
:checkedInProject="checkedInProject || {}"
:pendingProjectChange="pendingProjectChange"
@update:pendingProjectChange="pendingProjectChange = $event"
@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>
<input ref="calendarDatepicker" type="text" class="d-none" />
</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="row my-2 d-flex align-items-center">
<div class="col-4">
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
class="me-2 w-px-50 h-px-50 rounded-circle object-fit-cover"
@error="$event.target.src = '/img/icons/icon.png'">
<span class="fw-bold">{{ commuter.memberName }}</span>
</div>
<div class="col-8">
<div class="d-flex gap-1 align-items-center">
출근 :
<MapPopover
:address="commuter.projectAddress"
:is-visible="visiblePopover.type === 'project' && visiblePopover.index === index"
@update-visible="updatePopover('project', index)"
v-if="commuter.projectAddress"
>
<template #trigger>
<div
class="text-white rounded px-2 cursor-pointer"
:style="`background: ${commuter.projctcolor} !important;`"
>
{{ commuter.PROJCTNAM }}
</div>
</template>
</MapPopover>
<span class="ms-auto">
({{ commuter.COMMUTCMT }})
</span>
</div>
<div v-if="commuter.PROJCTLVE" class="d-flex gap-1 mt-1">
퇴근 :
<MapPopover
:address="commuter.leaveProjectAddress"
:is-visible="visiblePopover.type === 'leave' && visiblePopover.index === index"
@update-visible="updatePopover('leave', index)"
v-if="commuter.leaveProjectAddress"
>
<template #trigger>
<div
class="text-white rounded px-2 cursor-pointer"
:style="`background: ${commuter.leaveProjectColor} !important;`"
>
{{ commuter.leaveProjectName }}
</div>
</template>
</MapPopover>
<span class="ms-auto">
({{ commuter.COMMUTLVE || "00:00:00" }})
</span>
</div>
</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, nextTick, 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';
import MapPopover from '@c/map/MapPopover.vue';
import { useDatePicker } from '@/stores/useDatePicker';
const datePickerStore = useDatePicker();
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 visiblePopover = ref({
type: null, // 'project' 'leave'
index: null //
});
const commuters = ref([]);
const monthlyCommuters = ref([]);
const calendarDatepicker = ref(null);
const pendingProjectChange = ref(null);
//
const handleWorkTimeUpdate = () => {
todaysCommuter();
loadCommuters();
};
const handleLeaveTimeUpdate = async () => {
await todaysCommuter(); //
// null
const currentUserCommuter = commuters.value.find(c => c.MEMBERSEQ === user.value.id);
if (currentUserCommuter && !currentUserCommuter.COMMUTLVE) {
await projectStore.getMemberProjects();
if (projectStore.activeMemberProjectList.length > 0) {
const previousProject = projectStore.activeMemberProjectList.find(
p => commuters.value.some(c => c.MEMBERSEQ === user.value.id && c.PROJCTLVE === p.PROJCTSEQ)
) || projectStore.activeMemberProjectList[0]; //
if (previousProject) {
selectedProject.value = previousProject.PROJCTSEQ;
projectStore.setSelectedProject(previousProject);
} else if (projectStore.activeProjectList.length > 0) {
selectedProject.value = projectStore.activeProjectList[0].PROJCTSEQ;
projectStore.setSelectedProject(projectStore.activeProjectList[0]);
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
}
};
// (ProjectList )
const handleProjectDrop = ({ event, targetProject }) => {
const draggedProjectData = JSON.parse(event.dataTransfer.getData('application/json'));
//
if (draggedProjectData.PROJCTSEQ === targetProject.PROJCTSEQ) {
return;
}
pendingProjectChange.value = {
projctSeq: targetProject.PROJCTSEQ,
memberSeq: user.value.id
};
checkedInProject.value = targetProject;
selectedProject.value = targetProject.PROJCTSEQ;
projectStore.setSelectedProject(targetProject);
// UI
commuters.value = commuters.value.map(commuter =>
commuter.MEMBERSEQ === user.value.id
? { ...commuter, PROJCTNAM: targetProject.PROJCTNAM, PROJCTLVE: targetProject.PROJCTSEQ }
: commuter
);
// CommuterBtn (/ )
if (workTimeComponentRef.value && workTimeComponentRef.value.fetchWorkTime) {
workTimeComponentRef.value.fetchWorkTime();
}
};
//
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) {
dateCell.setAttribute('data-has-commuters', 'true');
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 object-fit-cover';
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;
} else {
selectedProject.value = null;
checkedInProject.value = null;
}
});
//
const closeModal = () => {
isModalOpen.value = false;
visiblePopover.value = { type: null, index: null };
};
// MapPopover visible
const updatePopover = (popoverType, index) => {
if (visiblePopover.value.type === popoverType && visiblePopover.value.index === index) {
//
visiblePopover.value = { type: null, index: null };
} else {
//
visiblePopover.value = { type: popoverType, index: index };
}
};
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.activeProjectList;
await todaysCommuter();
//
if (projectStore.activeMemberProjectList.length > 0) {
const initialProject = projectStore.getSelectedProject() || projectStore.activeMemberProjectList[0];
selectedProject.value = initialProject?.PROJCTSEQ || null;
projectStore.setSelectedProject(initialProject);
checkedInProject.value = initialProject;
}
datePickerStore.initDatePicker(
fullCalendarRef,
async (year, month, options) => {
//
await fetchData();
}
);
});
</script>

View File

@ -1,111 +0,0 @@
<template>
<div class="commuter-list mt-3">
<div
v-for="post in sortedProjects"
:key="post.PROJCTSEQ"
class="border border-2 mb-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.PROJCTLVE ? c.PROJCTLVE === post.PROJCTSEQ : c.PROJCTNAM === post.PROJCTNAM)
)"
:key="commuter.MEMBERSEQ"
class="col-4"
>
<div class="ratio ratio-1x1">
<img
:src="`${baseUrl}upload/img/profile/${commuter.profile}`"
alt="User Profile"
class="rounded-circle object-fit-cover"
: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, computed } 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 sortedProjects = computed(() => {
const projectList = Array.isArray(props.project) ? props.project :
Object.values(props.project || {});
return projectList
.filter(item => item && typeof item === 'object')
.sort((a, b) => (b.participant_count || 0) - (a.participant_count || 0));
});
//
const isCurrentUser = (commuter) => {
//
const isCurrentUserCheck = props.user && commuter && commuter.MEMBERSEQ === props.user.id;
//
const hasNoCheckRecord = !commuter.COMMUTLVE;
// true
return isCurrentUserCheck && hasNoCheckRecord;
};
//
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

@ -41,12 +41,11 @@
<button class="ql-link">Link</button> <button class="ql-link">Link</button>
<button class="ql-image">Image</button> <button class="ql-image">Image</button>
<button class="ql-video">Video</button>
<button class="ql-blockquote">Blockquote</button> <button class="ql-blockquote">Blockquote</button>
<button class="ql-code-block">Code Block</button> <button class="ql-code-block">Code Block</button>
</div> </div>
<!-- 에디터가 표시될 div --> <!-- 에디터가 표시될 div -->
<div id="qEditor" ref="editor"></div> <div ref="editor"></div>
<!-- Alert 메시지 표시 --> <!-- Alert 메시지 표시 -->
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div> <div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
</div> </div>
@ -55,11 +54,8 @@
<script setup> <script setup>
import Quill from 'quill'; import Quill from 'quill';
import 'quill/dist/quill.snow.css'; import 'quill/dist/quill.snow.css';
import $api from '@api';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue'; import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import { useToastStore } from '@s/toastStore'; import $api from '@api';
const toastStore = useToastStore();
const props = defineProps({ const props = defineProps({
isAlert: { isAlert: {
@ -75,10 +71,7 @@
const editor = ref(null); // DOM const editor = ref(null); // DOM
const font = ref('nanum-gothic'); // const font = ref('nanum-gothic'); //
const fontSize = ref('16px'); // const fontSize = ref('16px'); //
const emit = defineEmits(['update:data', 'update:uploadedImgList', 'update:deleteImgIndexList']); const emit = defineEmits(['update:data']);
const uploadedImgList = ref([]); //
const initImageIndex = ref([]); //
const deleteImgIndexList = ref([]); //
onMounted(() => { onMounted(() => {
// //
@ -119,54 +112,12 @@
quillInstance.format('size', fontSize.value); quillInstance.format('size', fontSize.value);
}); });
//
watch(uploadedImgList, () => {
emit('update:uploadedImgList', uploadedImgList.value);
});
// ()
watch(deleteImgIndexList, () => {
emit('update:deleteImgIndexList', deleteImgIndexList.value);
});
// , HTML // , HTML
if (props.initialData) { if (props.initialData) {
console.log(props.initialData);
quillInstance.setContents(JSON.parse(props.initialData)); quillInstance.setContents(JSON.parse(props.initialData));
initCheckImageIndex();
} }
//
quillInstance.getModule('toolbar').addHandler('video', () => {
const url = prompt('YouTube 영상 URL을 입력하세요:');
let src = '';
if (!url || url.trim() == '') return;
// youtube url
if (url.indexOf('watch?v=') !== -1) {
src = url.replace('watch?v=', 'embed/');
// youtu.be URL (ex : https://youtu.be/CfiojceAaeQ?si=G7eM56sdDjIEw-Tz)
} else if (url.indexOf('youtu.be/') !== -1) {
const videoId = url.split('youtu.be/')[1].split('?')[0];
src = `https://www.youtube.com/embed/${videoId}`;
// iframe
} else if (url.indexOf('<iframe') !== -1) {
// DOMParser embeded url
const parser = new DOMParser();
const doc = parser.parseFromString(url, 'text/html');
const iframeEL = doc.querySelector('iframe');
src = iframeEL.getAttribute('src');
} else {
toastStore.onToast('지원하는 영상 타입 아님', 'e');
return;
}
const index = quillInstance.getSelection().index;
quillInstance.insertEmbed(index, 'video', src);
quillInstance.setSelection(index + 1);
});
// //
let imageUrls = new Set(); // URL let imageUrls = new Set(); // URL
quillInstance.getModule('toolbar').addHandler('image', () => { quillInstance.getModule('toolbar').addHandler('image', () => {
@ -175,6 +126,7 @@
// //
quillInstance.on('text-change', (delta, oldDelta, source) => { quillInstance.on('text-change', (delta, oldDelta, source) => {
emit('update:data', quillInstance.getContents());
delta.ops.forEach(op => { delta.ops.forEach(op => {
if (op.insert && typeof op.insert === 'object' && op.insert.image) { if (op.insert && typeof op.insert === 'object' && op.insert.image) {
const imageUrl = op.insert.image; // URL const imageUrl = op.insert.image; // URL
@ -183,11 +135,7 @@
checkForDeletedImages(); // checkForDeletedImages(); //
} }
}); });
checkDeletedImages();
emit('update:data', quillInstance.getContents());
}); });
// //
async function selectLocalImage() { async function selectLocalImage() {
const input = document.createElement('input'); const input = document.createElement('input');
@ -202,19 +150,9 @@
// URL // URL
uploadImageToServer(formData) uploadImageToServer(formData)
.then(data => { .then(serverImageUrl => {
const uploadImgIdx = data?.fileIndex; // DB
const serverImageUrl = data?.fileUrl; // url
// ( )
if (uploadImgIdx) {
uploadedImgList.value = [...uploadedImgList.value, uploadImgIdx];
initImageIndex.value = [...initImageIndex.value, uploadImgIdx];
}
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, ''); const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
//const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`; const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}?imgIndex=${uploadImgIdx}`; // index
const range = quillInstance.getSelection(); const range = quillInstance.getSelection();
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); // quillInstance.insertEmbed(range.index, 'image', fullImageUrl); //
@ -227,47 +165,21 @@
} }
}; };
} }
// //
async function uploadImageToServer(formData) { async function uploadImageToServer(formData) {
try { try {
// Make the POST request to upload the image
const response = await $api.post('quilleditor/upload', formData, { isFormData: true }); const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
const imageUrl = response.data.data;
// Check if the response contains the expected data return imageUrl; // URL
if (response.data && response.data.data) {
const imageUrl = response.data.data;
return imageUrl; // Return the image URL received from the server
} else {
throw new Error('Image URL not returned from server');
}
} catch (error) { } catch (error) {
// Log detailed error information for debugging purposes toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
console.error('Image upload failed:', error);
// Handle specific error cases (e.g., network issues, authorization issues)
if (error.response) {
// If the error is from the server (e.g., 4xx or 5xx error)
console.error('Error response:', error.response.data);
toastStore.onToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
} else if (error.request) {
// If no response is received from the server
console.error('No response received:', error.request);
toastStore.onToast('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
} else {
// If the error is due to something else (e.g., invalid request setup)
console.error('Error message:', error.message);
toastStore.onToast('파일 업로드 중 문제가 발생했습니다. 다시 시도해주세요.', 'e');
}
// Throw the error so the caller knows something went wrong
throw error; throw error;
} }
} }
// //
function checkForDeletedImages() { function checkForDeletedImages() {
const editorImages = document.querySelectorAll('#qEditor img'); const editorImages = document.querySelectorAll('#editor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); // const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
imageUrls.forEach(url => { imageUrls.forEach(url => {
@ -276,41 +188,12 @@
} }
}); });
} }
//
function initCheckImageIndex() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
currentImages.forEach(url => {
const index = getImgIndex(url);
if (index) {
initImageIndex.value.push(Number(index));
}
});
}
// index
function getImgIndex(url) {
const params = new URLSearchParams(url.split('?')[1]);
return params.get('imgIndex');
}
//
function checkDeletedImages() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src));
// init
const tempDeleteImgIndex = [...initImageIndex.value];
currentImages.forEach(url => {
const imgIndex = getImgIndex(url);
if (imgIndex) {
const index = tempDeleteImgIndex.indexOf(imgIndex);
tempDeleteImgIndex.splice(index, 1);
}
});
deleteImgIndexList.value = tempDeleteImgIndex;
}
}); });
</script> </script>
<style>
@import 'quill/dist/quill.snow.css';
.ql-editor {
min-height: 300px;
font-family: 'Nanum Gothic', sans-serif;
}
</style>

View File

@ -18,7 +18,6 @@
type="text" type="text"
v-model="postcode" v-model="postcode"
placeholder="우편번호" placeholder="우편번호"
disabled="true"
readonly readonly
/> />
@ -27,7 +26,6 @@
type="text" type="text"
v-model="address" v-model="address"
placeholder="기본주소" placeholder="기본주소"
disabled="true"
readonly readonly
/> />
</div> </div>

View File

@ -2,14 +2,11 @@
<div class="mb-4 row"> <div class="mb-4 row">
<label :for="inputId" class="col-md-2 col-form-label">{{ title }}</label> <label :for="inputId" class="col-md-2 col-form-label">{{ title }}</label>
<div class="col-md-10"> <div class="col-md-10">
<label :for="inputId" class="btn btn-label-primary">파일 선택</label>
<input <input
class="form-control" class="form-control"
type="file" type="file"
style="display: none"
:id="inputId" :id="inputId"
ref="fileInput" ref="fileInput"
:key="autoIncrement"
@change="changeHandler" @change="changeHandler"
multiple multiple
/> />
@ -21,71 +18,69 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref ,computed} from 'vue';
import { fileMsg } from '@/common/msgEnum'; import { fileMsg } from '@/common/msgEnum';
// Props // Props
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
default: '라벨', default: '라벨',
required: true, required: true,
}, },
name: { name: {
type: String, type: String,
default: 'fileInput', default: 'fileInput',
required: true, required: true,
}, },
isAlert: { isAlert: {
type: Boolean, type: Boolean,
default: false, default: false,
required: false, required: false,
}, },
}); });
//:key="autoIncrement" . const inputId = computed(() => props.name || 'defaultFileInput');
const inputId = computed(() => props.name || 'defaultFileInput'); const emits = defineEmits(['update:data', 'update:isValid']);
const emits = defineEmits(['update:data', 'update:isValid']); const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_COUNT = 5; //
const ALLOWED_FILE_TYPES = []; //
const autoIncrement = ref(props.autoIncrement); const showError = ref(false);
const fileMsgKey = ref(''); //
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB const changeHandler = (event) => {
const MAX_FILE_COUNT = 5; // const files = Array.from(event.target.files);
const ALLOWED_FILE_TYPES = []; // const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const showError = ref(false); // ALLOWED_FILE_TYPES
const fileMsgKey = ref(''); // const invalidFiles = ALLOWED_FILE_TYPES.length > 0
? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type))
: [];
const changeHandler = event => { if (totalSize > MAX_TOTAL_SIZE) {
const files = Array.from(event.target.files); showError.value = true;
const totalSize = files.reduce((sum, file) => sum + file.size, 0); 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);
}
};
// ALLOWED_FILE_TYPES const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
const invalidFiles = ALLOWED_FILE_TYPES.length > 0 ? 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);
}
};
const errorMessage = computed(() => fileMsg[fileMsgKey.value] || '');
</script> </script>

View File

@ -5,26 +5,25 @@
<span v-if="isEssential" class="text-danger">*</span> <span v-if="isEssential" class="text-danger">*</span>
</label> </label>
<div class="col-md-10"> <div class="col-md-10">
<div class="d-flex align-items-center"> <input
<input :id="name"
:id="name" class="form-control"
class="form-control" :type="type"
:type="type" v-model="inputValue"
v-model="inputValue" :maxLength="maxlength"
:maxLength="maxlength" :placeholder="title"
:placeholder="title" :disabled="disabled"
:disabled="disabled" :min="min"
:min="min" @focusout="$emit('focusout', modelValue)"
autocomplete="off" @input="handleInput"
@focusout="$emit('focusout', modelValue)" />
@input="handleInput" <div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">
/> {{ title }} 확인해주세요.
<div v-if="isBtn" class="ms-2"> </div>
<slot name="append"></slot> <!-- 카테고리 중복 -->
</div> <div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">
카테고리 중복입니다.
</div> </div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
</div> </div>
</div> </div>
</template> </template>
@ -32,89 +31,84 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
// Props // Props
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
default: '라벨', default: '라벨',
required: true, required: true,
}, },
name: { name: {
type: String, type: String,
default: 'nameplz', default: 'nameplz',
required: true, required: true,
}, },
isEssential: { isEssential: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
type: { type: {
type: String, type: String,
default: 'text', default: 'text',
}, },
modelValue: { modelValue: {
type: String, type: String,
default: '', default: '',
}, },
maxlength: { maxlength: {
type: Number, type: Number,
default: 30, default: 30,
}, },
isAlert: { isAlert: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCateAlert: { isCateAlert : {
type: Boolean, type :Boolean,
default: false, default: false,
}, },
isLabel: { isLabel : {
type: Boolean, type: Boolean,
default: true, default: true,
required: false, required: false,
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
min: { min: {
type: String, type: String,
default: '', default: '',
required: false, required: false,
}, }
isBtn: { });
type: Boolean,
default: false,
required: false,
}
});
// Emits // Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']); const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
// `inputValue`
const inputValue = ref(props.modelValue);
// // `inputValue`
watch(inputValue, newValue => { const inputValue = ref(props.modelValue);
emits('update:modelValue', newValue);
});
// //
watch( watch(inputValue, (newValue) => {
() => props.modelValue, emits('update:modelValue', newValue);
newValue => { });
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
},
);
const handleInput = event => { //
const newValue = event.target.value.slice(0, props.maxlength); watch(() => props.modelValue, (newValue) => {
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
});
const handleInput = (event) => {
const newValue = event.target.value.slice(0, props.maxlength);
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
</script> </script>

View File

@ -2,39 +2,27 @@
<div class="mb-2" :class="isRow ? 'row' : ''"> <div class="mb-2" :class="isRow ? 'row' : ''">
<label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'"> <label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'">
{{ title }} {{ title }}
<span v-if="isEssential" class="link-danger">*</span> <span :class="isEssential ? 'link-danger' : 'none'">*</span>
</label> </label>
<div :class="isRow ? 'col-md-10' : 'col-md-12'"> <div :class="isRow ? 'col-md-10' : 'col-md-12'" class="d-flex gap-2 align-items-center">
<div class="d-flex gap-2 align-items-center"> <select class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">
<select v-if="isColor && (!data || data.length === 0)" class="form-select" disabled> <option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
<option>사용가능한 컬러가 없습니다</option> {{ isCommon ? item.label : item }}
</select> </option>
</select>
<!-- 데이터가 있는 경우 원래 select 표시 --> <div v-if="isColor && selected"
<select v-else class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')"> class="w-px-40 h-px-30"
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}"> :style="{backgroundColor: selected}">
{{ isCommon ? item.label : item }}
</option>
</select>
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
</div>
<div v-if="isColor && selected"
class="w-px-40 h-px-30"
:style="{backgroundColor: selected}">
</div>
<img v-if="isMbti && selected"
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${selected.toLowerCase()}.png`"
alt="MBTI image"/>
</div> </div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div> <img v-if="isMbti && selected"
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${selected.toLowerCase()}.png`"
alt="MBTI image"/>
</div> </div>
<div v-if="isAlert" class="invalid-feedback">{{ title }} 확인해주세요.</div>
</div> </div>
</template> </template>
@ -82,11 +70,6 @@ const props = defineProps({
default: true, default: true,
required: false, required: false,
}, },
isBtn: {
type: Boolean,
default: false,
required: false,
},
isCommon: { isCommon: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -138,11 +121,10 @@ watch(selectData, (newValue) => {
const selected = computed(() => { const selected = computed(() => {
//
const selectedItem = props.data.find(item => const selectedItem = props.data.find(item =>
props.isCommon ? item.value === selectData.value : props.data.indexOf(item) === selectData.value props.isCommon ? item.value === selectData.value : props.data.indexOf(item) === selectData.value
); );
return selectedItem ? selectedItem.label : null; return selectedItem ? selectedItem.label : null;
}); });
</script> </script>

View File

@ -5,7 +5,7 @@
<span :class="isEssential ? 'link-danger' : 'd-none'">*</span> <span :class="isEssential ? 'link-danger' : 'd-none'">*</span>
</label> </label>
<div class="col-md-12"> <div class="col-md-12">
<div v-if="useInputGroup" class="input-group mb-1"> <div v-if="useInputGroup" class="input-group mb-3">
<input <input
:id="name" :id="name"
class="form-control" class="form-control"
@ -14,7 +14,7 @@
:value="computedValue" :value="computedValue"
:disabled="disabled" :disabled="disabled"
:maxLength="maxlength" :maxLength="maxlength"
:placeholder="placeholder ? placeholder : title" :placeholder="title"
@blur="$emit('blur')" @blur="$emit('blur')"
/> />
<span class="input-group-text">@ localhost.co.kr</span> <span class="input-group-text">@ localhost.co.kr</span>
@ -29,10 +29,8 @@
:value="computedValue" :value="computedValue"
:disabled="disabled" :disabled="disabled"
:maxLength="maxlength" :maxLength="maxlength"
:placeholder="placeholder ? placeholder : title" :placeholder="title"
@blur="$emit('blur')" @blur="$emit('blur')"
@click="handleDateClick"
ref="inputElement"
/> />
</div> </div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div> <div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
@ -41,7 +39,7 @@
</template> </template>
<script setup> <script setup>
import { inject, computed, ref } from 'vue'; import { inject, computed } from 'vue';
const props = defineProps({ const props = defineProps({
title: { title: {
@ -89,14 +87,9 @@
default: false, default: false,
required: false, required: false,
}, },
placeholder: {
type: String,
default: ''
},
}); });
const emits = defineEmits(['update:data', 'update:alert', 'blur']); const emits = defineEmits(['update:data', 'update:alert', 'blur']);
const inputElement = ref(null);
// dayjs // dayjs
const dayjs = inject('dayjs'); const dayjs = inject('dayjs');
@ -125,12 +118,4 @@
emits('update:alert', false); emits('update:alert', false);
} }
}; };
// date input
const handleDateClick = (event) => {
if (props.type === 'date' && inputElement.value) {
// : UI
inputElement.value.showPicker();
}
};
</script> </script>

View File

@ -3,71 +3,68 @@
<div class="row g-0"> <div class="row g-0">
<div class="card-body"> <div class="card-body">
<!-- 제목 --> <!-- 제목 -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between ">
<h5 class="card-title fw-bold"> <h5 class="card-title fw-bold">
{{ title }} {{ title }}
</h5> </h5>
<div v-if="!isProjectExpired" class="d-flex gap-1"> <p v-if="isProjectExpired" class="btn-icon btn-danger rounded-2"><i class='bx bx-power-off'></i></p>
<div v-if="!isProjectExpired">
<EditBtn @click.stop="openEditModal" /> <EditBtn @click.stop="openEditModal" />
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/> <DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
</div> </div>
</div> </div>
<!-- 날짜 --> <!-- 날짜 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center"> <i class="bx bx-calendar"></i>
<i class="bx bx-calendar"></i> <div class="ms-2">날짜</div>
<div class="ms-2">날짜</div> <div class="ms-12">{{ strdate }} ~ {{ enddate }}</div>
</div>
<div class="col-9 col-md-10">
{{ strdate }} ~ {{ enddate }}
</div>
</div> </div>
<!-- 참여자 --> <!-- 참여자 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center"> <i class="bx bxs-user"></i>
<i class="bx bxs-user"></i> <div class="ms-2">참여자</div>
<div class="ms-2">참여자</div> <UserList :projctSeq="projctSeq" :showOnlyActive="isProjectExpired" class="ms-8 mb-0" />
</div>
<div class="col-9 col-md-10">
<UserList ref="userListRef" :projctSeq="projctSeq" :showOnlyActive="true" class="mb-0" />
</div>
</div> </div>
<!-- 설명 --> <!-- 설명 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center"> <i class="bx bx-detail"></i>
<i class="bx bx-detail"></i> <div class="ms-2">설명</div>
<div class="ms-2">설명</div> <div class="ms-12">{{ description }}</div>
</div>
<div class="col-9 col-md-10">
{{ description || '-' }}
</div>
</div> </div>
<!-- 주소 --> <!-- 주소 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center"> <div class="d-flex" @click.stop="isPopoverVisible = !isPopoverVisible">
<MapPopover <i class="bx bxs-map cursor-pointer" ref="mapIconRef"></i>
:address="address" <div class="ms-2">주소</div>
:is-visible="isMapVisible" </div>
@update-visible="updatePopover" <div class="ms-12 position-relative">
> {{ address }} {{ addressdtail }}
<template #trigger> <!-- 팝오버 -->
<div class="d-flex align-items-center cursor-pointer"> <div v-if="isPopoverVisible" class="position-absolute map ">
<i class="bx bxs-map"></i> <button type="button" class="btn-close popover-close" @click.stop="isPopoverVisible = !isPopoverVisible"></button>
<div class="ms-2">주소</div> <div class="card">
<div class="card-body p-1">
<KakaoMap
v-if="coordinates"
:lat="coordinates.lat"
:lng="coordinates.lng"
class="w-px-250 h-px-200"
@onLoadKakaoMap="onLoadKakaoMap"
>
<KakaoMapMarker
:lat="coordinates.lat"
:lng="coordinates.lng"
/>
</KakaoMap>
<div class="position-absolute top-50 translate-middle-y end-0 me-3 z-1 d-flex flex-column gap-1">
<button class="btn-secondary border-none" @click="zoomOut">+</button>
<button class="btn-secondary border-none" @click="zoomIn">-</button>
</div>
</div> </div>
</template> </div>
</MapPopover> </div>
</div>
<div class="col-9 col-md-10 d-flex justify-content-between align-items-center">
<div>{{ address }} {{ addressdtail }}</div>
<button type="button" class="btn text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">
<i class='bx bx-child'></i>
</button>
</div> </div>
<button type="button" class="btn ms-auto text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">log</button>
</div> </div>
</div> </div>
</div> </div>
@ -75,7 +72,7 @@
<!-- 로그 모달 --> <!-- 로그 모달 -->
<CenterModal :display="isModalOpen" @close="closeModal"> <CenterModal :display="isModalOpen" @close="closeModal">
<template #title> 등록·수정자 </template> <template #title> Log </template>
<template #body> <template #body>
<div v-if="logData.length > 0"> <div v-if="logData.length > 0">
<div <div
@ -98,7 +95,7 @@
<template #title> 프로젝트 수정 </template> <template #title> 프로젝트 수정 </template>
<template #body> <template #body>
<FormInput <FormInput
title="프로젝트명" title="이름"
name="name" name="name"
:is-essential="true" :is-essential="true"
:is-alert="nameAlert" :is-alert="nameAlert"
@ -113,51 +110,28 @@
:is-essential="true" :is-essential="true"
:is-label="true" :is-label="true"
:is-common="true" :is-common="true"
:is-color="true"
:data="allColors" :data="allColors"
:value="selectedProject.PROJCTCOL" :value="selectedProject.PROJCTCOL"
@update:data="selectedProject.PROJCTCOL = $event" @update:data="selectedProject.PROJCTCOL = $event"
/> />
<div class="mb-2 row"> <FormInput
<label class="col-md-2 col-form-label"> title="시작일"
참여자 type="date"
</label> name="startDay"
<div class="col-md-10"> :is-essential="true"
<UserList class="m-0" :modelValue="selectedProject.PROJCTSTR"
ref="editUserListRef" @update:modelValue="selectedProject.PROJCTSTR = $event"
:projctSeq="projctSeq" />
:showOnlyActive="false"
@user-list-update="handleEditUserListUpdate" <FormInput
/> title="종료일"
</div> type="date"
</div> name="endDay"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
/>
<!-- 시작일 -->
<div @click="openStartDatePicker">
<FormInput
title="시작일"
type="date"
name="startDay"
:is-essential="true"
:is-alert="startDayAlert"
:modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event"
ref="startDateInput"
/>
</div>
<!-- 종료일 -->
<div @click="openEndDatePicker">
<FormInput
title="종료일"
type="date"
name="endDay"
:min="selectedProject.PROJCTSTR"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
ref="endDateInput"
/>
</div>
<FormInput <FormInput
title="설명" title="설명"
name="description" name="description"
@ -190,6 +164,7 @@ import { defineProps, onMounted, ref, computed, watch } from 'vue';
import UserList from '@c/user/UserList.vue'; import UserList from '@c/user/UserList.vue';
import CenterModal from '@c/modal/CenterModal.vue'; import CenterModal from '@c/modal/CenterModal.vue';
import $api from '@api'; import $api from '@api';
import { KakaoMap, KakaoMapMarker } from 'vue3-kakao-maps';
import BackBtn from '@c/button/BackBtn.vue'; import BackBtn from '@c/button/BackBtn.vue';
import BackButton from '@c/button/BackBtn.vue'; import BackButton from '@c/button/BackBtn.vue';
import SaveButton from '@c/button/SaveBtn.vue'; import SaveButton from '@c/button/SaveBtn.vue';
@ -198,16 +173,13 @@ import DeleteBtn from '../button/DeleteBtn.vue';
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import FormSelect from '@c/input/FormSelect.vue'; import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue'; import ArrInput from '@c/input/ArrInput.vue';
import MapPopover from '@c/map/MapPopover.vue';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import commonApi, { refreshColorList } from '@/common/commonApi'; import commonApi from '@/common/commonApi';
import { useProjectStore } from '@/stores/useProjectStore';
// //
const toastStore = useToastStore(); const toastStore = useToastStore();
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const projectStore = useProjectStore();
// Props // Props
const props = defineProps({ const props = defineProps({
@ -227,7 +199,6 @@ const props = defineProps({
description: { description: {
type: String, type: String,
required: false, required: false,
default: "",
}, },
address: { address: {
type: String, type: String,
@ -256,14 +227,6 @@ const props = defineProps({
projctCreatorId: { projctCreatorId: {
type: Number, type: Number,
required: false required: false
},
resetUserSelection: {
type: Boolean,
default: false
},
searchParams: {
type: Object,
default: () => ({ text: '', year: null })
} }
}); });
@ -274,59 +237,22 @@ const emit = defineEmits(['update']);
const isModalOpen = ref(false); const isModalOpen = ref(false);
const logData = ref([]); const logData = ref([]);
const isMapVisible = ref(null); //
const isPopoverVisible = ref(false);
const map = ref();
const mapIconRef = ref(null);
const coordinates = ref(null);
// //
const isEditModalOpen = ref(false); const isEditModalOpen = ref(false);
const originalColor = ref(''); const originalColor = ref('');
const nameAlert = ref(false); const nameAlert = ref(false);
const startDayAlert = ref(false);
const user = ref(null); const user = ref(null);
const editUserListRef = ref(null);
const userListRef = ref(null);
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
const startDateInput = ref(null);
const endDateInput = ref(null);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
}
};
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const updatePopover = (visible) => {
isMapVisible.value = visible;
};
//
const handleEditUserListUpdate = (userLists) => {
selectedUsers.value = userLists;
};
const isProjectCreator = computed(() => { const isProjectCreator = computed(() => {
return user.value?.id === props.projctCreatorId; return user.value?.id === props.projctCreatorId;
}); });
// ( ) // ( )
const isProjectExpired = computed(() => { const isProjectExpired = computed(() => {
if (!props.enddate) return false; if (!props.enddate) return false;
@ -360,23 +286,12 @@ const { colorList } = commonApi({
colorType: 'YNP', colorType: 'YNP',
}); });
// + // +
const allColors = computed(() => { const allColors = computed(() => {
// ( ) const existingColor = { value: selectedProject.value.PROJCTCOL, label: selectedProject.value.projctcolor };
const existingColor = { return [existingColor, ...colorList.value];
value: props.projctCol, //
label: props.projctColor //
};
//
const otherColors = colorList.value.filter(color => color.value !== existingColor.value);
//
return [existingColor, ...otherColors];
}); });
// :: // ::
const updateAddress = addressData => { const updateAddress = addressData => {
selectedProject.value = { selectedProject.value = {
@ -407,23 +322,9 @@ const closeModal = () => {
}; };
// //
const openEditModal = async () => { 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; isEditModalOpen.value = true;
originalColor.value = props.projctCol; originalColor.value = props.projctCol;
}; };
// //
@ -442,24 +343,11 @@ const closeEditModal = () => {
}; };
isEditModalOpen.value = false; isEditModalOpen.value = false;
// UserList resetSelection
if (editUserListRef.value) {
editUserListRef.value.resetSelection();
}
}; };
// selectedUsers
watch(() => selectedUsers.value.activeUsers, (newVal, oldVal) => {
}, { deep: true });
watch(() => selectedUsers.value.disabledUsers, (newVal, oldVal) => {
}, { deep: true });
// //
const hasChanges = computed(() => { const hasChanges = computed(() => {
// return selectedProject.value.PROJCTNAM !== props.title ||
const basicChanges = selectedProject.value.PROJCTNAM !== props.title ||
selectedProject.value.PROJCTSTR !== props.strdate || selectedProject.value.PROJCTSTR !== props.strdate ||
selectedProject.value.PROJCTEND !== props.enddate || selectedProject.value.PROJCTEND !== props.enddate ||
selectedProject.value.PROJCTZIP !== props.addressZip || selectedProject.value.PROJCTZIP !== props.addressZip ||
@ -467,37 +355,26 @@ const hasChanges = computed(() => {
selectedProject.value.PROJCTDTL !== props.addressdtail || selectedProject.value.PROJCTDTL !== props.addressdtail ||
selectedProject.value.PROJCTDES !== props.description || selectedProject.value.PROJCTDES !== props.description ||
selectedProject.value.PROJCTCOL !== props.projctCol; selectedProject.value.PROJCTCOL !== props.projctCol;
//
const userChanges = editUserListRef.value?.hasUserChanges() || false;
return basicChanges || userChanges;
}); });
//
//
watch( watch(
() => selectedProject.value.PROJCTSTR, // (strdate) () => selectedProject.value,
(newStartDate) => { () => {
if (newStartDate && new Date(newStartDate) > new Date(selectedProject.value.PROJCTEND)) { const start = new Date(selectedProject.value.PROJCTSTR);
// const end = new Date(selectedProject.value.PROJCTEND);
selectedProject.value.PROJCTEND = newStartDate;
if (end < start) {
selectedProject.value.PROJCTEND = selectedProject.value.PROJCTSTR;
} }
} },
{ deep: true, flush: 'post' }
); );
// resetUserSelection
watch(() => props.resetUserSelection, () => {
if (editUserListRef.value) {
editUserListRef.value.resetSelection();
}
});
// //
const handleUpdate = async () => { const handleUpdate = () => {
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === ''; nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
startDayAlert.value = selectedProject.value.PROJCTSTR.trim() === '';
if (nameAlert.value || startDayAlert.value) { if (nameAlert.value) {
return; return;
} }
@ -506,9 +383,7 @@ const handleUpdate = async () => {
return; return;
} }
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ); $api.patch('project/update', {
const res = await $api.patch('project/update', {
projctSeq: selectedProject.value.PROJCTSEQ, projctSeq: selectedProject.value.PROJCTSEQ,
projctNam: selectedProject.value.PROJCTNAM, projctNam: selectedProject.value.PROJCTNAM,
projctCol: selectedProject.value.PROJCTCOL, projctCol: selectedProject.value.PROJCTCOL,
@ -520,40 +395,68 @@ const handleUpdate = async () => {
projctDes: selectedProject.value.PROJCTDES || null, projctDes: selectedProject.value.PROJCTDES || null,
projctUmb: user.value?.id, projctUmb: user.value?.id,
originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value, originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value,
disabledMembers: disabledMemberSeqs }).then(res => {
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
closeEditModal();
//
emit('update');
}
}); });
};
if (res.status === 200) { //
toastStore.onToast('수정이 완료 되었습니다.', 's'); const convertAddressToCoordinates = () => {
const geocoder = new window.kakao.maps.services.Geocoder();
// geocoder.addressSearch(props.address, (result, status) => {
await projectStore.getProjectList(props.searchParams.text, props.searchParams.year, 'false'); if (status === window.kakao.maps.services.Status.OK) {
await projectStore.getMemberProjects(); coordinates.value = {
await refreshColorList('YNP'); lat: parseFloat(result[0].y),
lng: parseFloat(result[0].x)
};
} else {
// ()
coordinates.value = {
lat: 37.2108651707078,
lng: 127.089445559923
};
}
});
};
await editUserListRef.value.fetchProjectParticipation(); const onLoadKakaoMap = (mapRef) => {
await userListRef.value.fetchProjectParticipation(); map.value = mapRef;
};
closeEditModal(); //
emit('update', props.searchParams); const zoomIn = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level + 1);
}
};
//
const zoomOut = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level - 1);
} }
}; };
// //
const handleDelete = () => { const handleDelete = () => {
if (confirm('프로젝트를 삭제하시겠습니까?')) { $api.patch('project/delete', {
$api.patch('project/delete', { projctSeq: props.projctSeq,
projctSeq: props.projctSeq, projctCol: props.projctCol,
projctCol: props.projctCol, })
}) .then(res => {
.then(res => { if (res.status === 200) {
if (res.status === 200) { toastStore.onToast('삭제가 완료되었습니다.', 's');
toastStore.onToast('프로젝트가 삭제되었습니다.', 's'); location.reload()
projectStore.getProjectList(); }
projectStore.getMemberProjects(); })
}
})
}
}; };
// //
@ -562,18 +465,8 @@ onMounted(async () => {
await userStore.userInfo(); await userStore.userInfo();
user.value = userStore.user; user.value = userStore.user;
if (startDateInput.value) { convertAddressToCoordinates();
// FormInput input
startInputElement = startDateInput.value.$el.querySelector('input[type="date"]');
}
if (endDateInput.value) {
endInputElement = endDateInput.value.$el.querySelector('input[type="date"]');
}
}); });
</script> </script>

View File

@ -1,233 +0,0 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card text-center h-100">
<!-- 더보기 버튼 -->
<div class="d-flex">
<router-link
:to="{ name: 'BoardList', query: { type: selectedBoard } }"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
>
more
</router-link>
</div>
<div class="card-body">
<!-- 모달 본문 -->
<div class="modal-body">
<!-- 버튼 영역 -->
<div class="btn-group mb-5" role="group">
<button
type="button"
class="btn"
:class="selectedBoard === 'notices' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('notices')"
>
공지
</button>
<button
type="button"
class="btn"
:class="selectedBoard === 'general' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('general')"
>
자유
</button>
<button
type="button"
class="btn"
:class="selectedBoard === 'anonymous' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('anonymous')"
>
익명
</button>
</div>
<!-- 게시글 미리보기 테이블 -->
<table class="table">
<thead>
<tr>
<!-- 익명게시판은 '닉네임', 나머지는 '작성자' -->
<th class="text-start">
<div class="ms-4">
{{ selectedBoard === 'anonymous' ? '닉네임' : '작성자' }}
</div>
</th>
<th class="text-start" style="width: 65%;">
<div class="ms-4">
제목
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="post in currentList"
:key="post.id"
style="cursor: pointer;"
@click="goDetail(post.id, selectedBoard)"
>
<td class="text-start nickname-ellipsis small">
<div class="ms-4">
{{ selectedBoard === 'anonymous' ? post.nickname : post.author }}
</div>
</td>
<td class="text-start fs-6">
<div class="ms-4">
{{ truncateTitle(post.title) }}
<span v-if="post.commentCount" class="text-danger ml-1 small">
[{{ post.commentCount }}]
</span>
<i v-if="post.img" class="bi bi-image mx-1 small"></i>
<i
v-if="post.hasAttachment.length > 0"
class="bi bi-paperclip ml-1 small"
></i>
<div class="text-muted small small">
{{ post.date }}
<span class="ms-1">
<i class="fa-regular fa-eye small me-1"></i>{{post.views}}
</span>
</div>
</div>
</td>
</tr>
<tr v-if="currentList.length === 0">
<td colspan="3" class="text-center text-muted">게시물이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import axios from '@api';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import 'bootstrap-icons/font/bootstrap-icons.css';
dayjs.extend(isToday);
dayjs.extend(isYesterday);
const router = useRouter();
// true
const isModalOpen = ref(true);
// : 'notices', 'general', 'anonymous'
const selectedBoard = ref('notices');
//
const noticeList = ref([]);
const freeList = ref([]);
const anonymousList = ref([]);
// computed
const currentList = computed(() => {
if (selectedBoard.value === 'notices') return noticeList.value;
if (selectedBoard.value === 'general') return freeList.value;
if (selectedBoard.value === 'anonymous') return anonymousList.value;
return [];
});
// : HH:mm, YYYY-MM-DD
const formatDate = dateString => {
const date = dayjs(dateString);
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
};
// 14 ...
const truncateTitle = title => {
return title.length > 7 ? title.slice(0, 7) + '...' : title;
};
// ( 5)
const fetchNoticePosts = async () => {
try {
const { data } = await axios.get('board/notices', { params: { size: 8 } });
if (data?.data) {
noticeList.value = data.data.map(post => ({
id: post.id,
title: post.title,
date: formatDate(post.date),
rawDate: post.date,
views: post.cnt || 0,
commentCount: post.commentCount,
img: post.firstImageUrl,
author: post.author || '관리자',
nickname: post.nickname || '관리자',
hasAttachment: post.hasAttachment, //
}));
}
} catch (error) {
}
};
// board/general ( 10 5)
const fetchGeneralPosts = async () => {
try {
const { data } = await axios.get('board/general', { params: { size: 16 } });
if (data?.data && data.data.list) {
const freePosts = [];
const anonymousPosts = [];
data.data.list.forEach(post => {
if (post.nickname) {
//
anonymousPosts.push({
id: post.id,
title: post.title,
date: formatDate(post.date),
img: post.firstImageUrl,
rawDate: post.date,
views: post.cnt || 0,
commentCount: post.commentCount,
nickname: post.nickname,
hasAttachment: post.hasAttachment, //
});
} else {
//
freePosts.push({
id: post.id,
title: post.title,
date: formatDate(post.date),
rawDate: post.date,
views: post.cnt || 0,
img: post.firstImageUrl,
commentCount: post.commentCount,
author: post.author || '익명',
hasAttachment: post.hasAttachment, //
});
}
});
freeList.value = freePosts.slice(0, 8);
anonymousList.value = anonymousPosts.slice(0, 8);
}
} catch (error) {
console.error(error);
}
};
//
const changeBoard = type => {
selectedBoard.value = type;
};
// ( )
const goDetail = (id, boardType) => {
router.push({ name: 'BoardDetail', params: { id }, query: { type: boardType } });
};
//
fetchNoticePosts();
fetchGeneralPosts();
</script>
<style scoped>
.table > :not(caption) > * > * {
padding: 0 !important;
}
</style>

View File

@ -1,260 +0,0 @@
<template>
<div class="event-modal position-fixed bg-white shadow rounded" :style="modalStyle">
<!-- 이벤트 선택 화면 -->
<div v-if="!selectedEventType" class="d-flex flex-wrap gap-2 p-2">
<div v-for="event in eventTypes" :key="event.code" class="event-icon-wrapper position-relative">
<img
:src="`${baseUrl}img/main-category-img/main-${event.code}.png`"
class="event-icon-select"
style="width: 25px; height: 25px; cursor: pointer"
@click="handleEventClick(event)"
/>
<!-- X 표시 수정 -->
<span v-if="isEventExists(event.type)" class="cancel-mark"> × </span>
</div>
</div>
<!-- 입력 화면 -->
<div v-else class="p-2" style="min-width: 200px">
<div class="d-flex justify-content-between align-items-center mb-2">
<small>{{ getEventTitle(selectedEventType) }}</small>
<button class="btn-close btn-close-sm" style="font-size: 8px" @click="resetForm"></button>
</div>
<div class="mb-2">
<input
type="text"
class="form-control form-control-sm py-1"
style="height: 25px; font-size: 12px"
placeholder="장소"
v-model="eventPlace"
maxlength="20"
@input="handleChangeInput"
/>
<span v-if="noInputAlert" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert }}</span>
</div>
<div class="mb-2">
<div @click="focusPicker">
<input
type="time"
class="form-control form-control-sm py-1"
style="height: 0%; font-size: 12px"
v-model="eventTime"
@input="handleChangeInput2"
/>
</div>
<input
ref="timeInput"
type="time"
class="hidden-time-input"
style="height: 0%; font-size: 12px"
v-model="eventTime"
@input="handleChangeInput2"
/>
<span v-if="noInputAlert2" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert2 }}</span>
</div>
<div class="text-end">
<button class="btn btn-primary btn-sm py-1" style="font-size: 12px; height: 25px; line-height: 1" @click="handleSubmit">
등록
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
position: {
type: Object,
required: true,
default: () => ({ x: 0, y: 0 }),
},
selectedDate: {
type: String,
required: true,
},
baseUrl: {
type: String,
required: true,
},
dateEvents: {
type: Array,
},
});
const emit = defineEmits(['select', 'delete', 'insert']);
//
const selectedEventType = ref(null);
const eventPlace = ref('');
const eventTime = ref('');
const noInputAlert = ref(null);
const noInputAlert2 = ref(null);
const timeInput = ref(null);
const focusPicker = () => {
if (timeInput.value) {
timeInput.value.showPicker(); // ( )
timeInput.value.focus(); //
}
};
const eventTypes = [
{ type: 'birthdayParty', code: '300203', title: '생일파티' },
{ type: 'dinner', code: '300204', title: '회식' },
{ type: 'teaTime', code: '300205', title: '티타임' },
{ type: 'workshop', code: '300206', title: '워크샵' },
];
const getEventTitle = type => {
return eventTypes.find(event => event.code === type)?.title || '';
};
const isEventExists = type => {
return props.dateEvents?.some(event => event.type === type);
};
const handleEventClick = event => {
if (isEventExists(event.type)) {
if (confirm('이벤트를 취소하시겠습니까?')) {
emit('delete', {
date: props.selectedDate,
code: event.code,
title: event.title,
});
}
} else {
selectedEventType.value = event.code;
noInputAlert.value = '';
noInputAlert2.value = '';
}
};
const handleSubmit = () => {
if (isValid()) {
emit('insert', {
date: props.selectedDate,
code: selectedEventType.value,
title: getEventTitle(selectedEventType.value),
place: eventPlace.value.trim(),
time: eventTime.value,
});
}
};
//
const isValid = () => {
let isValid = true;
if (!eventPlace.value.trim()) {
noInputAlert.value = '장소를 입력해 주세요';
isValid = false;
} else {
noInputAlert.value = '';
}
if (!eventTime.value) {
noInputAlert2.value = '시간을 입력해 주세요';
isValid = false;
} else {
noInputAlert2.value = '';
}
return isValid;
};
const handleChangeInput = () => {
noInputAlert.value = null;
};
const handleChangeInput2 = () => {
noInputAlert2.value = null;
};
const resetForm = () => {
selectedEventType.value = null;
eventPlace.value = '';
eventTime.value = '';
};
// computed
const modalStyle = computed(() => {
const modalWidth = 200; //
const modalHeight = 150; //
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = props.position?.x || 0;
let y = props.position?.y || 0;
//
if (x + modalWidth > viewportWidth) {
x = viewportWidth - modalWidth - 10;
}
if (x < 0) {
x = 10;
}
if (y + modalHeight > viewportHeight) {
y = viewportHeight - modalHeight - 10;
}
if (y < 0) {
y = 10;
}
return {
left: `${x}px`,
top: `${y}px`,
zIndex: 1050,
maxWidth: '90vw', // 90%
maxHeight: '90vh', // 90%
};
});
</script>
<style scoped>
.event-icon-wrapper {
position: relative;
display: inline-block;
}
.event-icon-select {
transition: transform 0.2s;
}
.event-icon-select:hover {
transform: scale(1.1);
}
.cancel-mark {
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #dc3545;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
line-height: 1;
}
.event-modal {
min-width: 120px;
max-width: 300px;
overflow: auto;
}
/* 작은 화면에서의 스타일 */
@media (max-width: 576px) {
.event-modal {
min-width: 100px;
font-size: 0.9em;
}
}
</style>

View File

@ -1,30 +0,0 @@
<template>
<div class="ps-2" style="font-size: 13px">
<span class="d-flex align-items-center g-2 font_767"><i class="bx bxs-map pe-1"></i>{{ place }}</span>
<span class="d-flex align-items-center g-2 font_767"
><i class="bx bxs-time-five pe-1"></i>{{ $common.dateFormatter(time, 'T') }}</span
>
</div>
</template>
<script>
export default {
props: {
place: {
type: String,
required: true,
},
time: {
type: String,
required: true,
},
},
};
</script>
<style>
@media (max-width: 767px) {
.font_767 {
font-size: 2rem;
}
}
</style>

View File

@ -1,724 +0,0 @@
<template>
<div class="card app-calendar-wrapper">
<div class="row g-0">
<div class="col-3 border-end text-center" id="app-calendar-sidebar">
<div class="card-body">
<img
v-if="user"
:src="`${profileImgUrl}profile/${user.profile}`"
alt="Profile Image"
class="w-px-50 h-px-50 rounded-circle profile-img"
@error="$event.target.src = '/img/icons/icon.png'"
/>
<p class="mt-2 fw-bold">
{{ user.name }}
</p>
<CommuterBtn
ref="workTimeComponentRef"
:userId="user.id"
:checkedInProject="checkedInProject || {}"
:pendingProjectChange="pendingProjectChange"
@update:pendingProjectChange="pendingProjectChange = $event"
@leaveTimeUpdated="handleLeaveTimeUpdate"
/>
<MainEventList
:categoryList="categoryList"
:baseUrl="baseUrl"
:birthdayList="birthdayList"
:vacationList="vacationList"
:birthdayPartyList="birthdayPartyList"
:dinnerList="dinnerList"
:teaTimeList="teaTimeList"
:workShopList="workShopList"
@handle-click-vacation="handleClickVacation"
/>
</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>
<input ref="calendarDatepicker" type="text" class="d-none" />
</div>
</div>
</div>
</div>
</div>
<EventModal
v-if="showModal"
:position="modalPosition"
:selected-date="selectedDate"
:base-url="baseUrl"
:date-events="currentDateEvents"
@select="handleEventSelect"
@delete="handleEventDelete"
@insert="handleEventInsert"
@close="handleCloseModal"
/>
</template>
<script setup>
import { inject, onMounted, reactive, ref, watch, nextTick } from 'vue';
import { fetchHolidays } from '@c/calendar/holiday';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
import { useToastStore } from '@s/toastStore';
import { useWeatherStore } from '@/stores/useWeatherStore';
import { useDatePicker } from '@/stores/useDatePicker';
import { storeToRefs } from 'pinia';
import router from '@/router';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import CommuterBtn from '@c/commuters/CommuterBtn.vue';
import MainEventList from '@c/main/MainEventList.vue';
import EventModal from '@c/main/EventModal.vue';
import $api from '@api';
import 'flatpickr/dist/flatpickr.min.css';
import '@/assets/css/app-calendar.css';
const baseUrl = import.meta.env.VITE_DOMAIN;
const profileImgUrl = import.meta.env.VITE_SERVER_IMG_URL;
const user = ref({});
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const weatherStore = useWeatherStore();
const datePickerStore = useDatePicker();
const { dailyWeatherList } = storeToRefs(weatherStore);
const dayjs = inject('dayjs');
const fullCalendarRef = ref(null);
const workTimeComponentRef = ref(null);
const calendarEvents = ref([]);
const calendarDatepicker = ref(null);
//const dailyWeatherList = ref([]);
const selectedProject = ref(null);
const checkedInProject = ref(null);
const pendingProjectChange = ref(null);
//
const showModal = ref(false);
const modalPosition = ref({ x: 0, y: 0 });
const selectedDate = ref('');
//
const $common = inject('common');
const toastStore = useToastStore();
//
const pressTimer = ref(null);
const longPressDelay = 500; // 0.5
/************* category ***************/
//
const categoryList = ref([]);
const fetchCategoryList = async () => {
const { data } = await $api.get('main/category');
if (data) categoryList.value = [...data.data.filter(categoryInfo => categoryInfo.CMNCODODR != 0)];
};
/************* init ***************/
const monthBirthdayList = ref([]);
const monthVacationList = ref([]);
const monthBirthdayPartyList = ref([]);
const monthDinnerList = ref([]);
const monthTeaTimeList = ref([]);
const monthWorkShopList = ref([]);
const birthdayList = ref([]);
const vacationList = ref([]);
const birthdayPartyList = ref([]);
const dinnerList = ref([]);
const teaTimeList = ref([]);
const workShopList = ref([]);
const currentDateEvents = ref([]);
// , ,
const fetchEventList = async param => {
const { data } = await $api.get(`main/eventList?${param}`);
const res = data?.data;
//
const holidayEvents = calendarEvents.value.filter(event => event.classNames?.includes('holiday-event'));
calendarEvents.value = [...holidayEvents, ...dailyWeatherList.value];
//
if (res?.memberBirthdayList?.length) {
monthBirthdayList.value = [...res.memberBirthdayList];
res.memberBirthdayList.forEach(member => {
addEvent($common.dateFormatter(member.MEMBERBTH, 'YMD'), 'birthday', `${member.MEMBERNAM}`);
});
}
//
if (res?.memberVacationList?.length) {
monthVacationList.value = [...res.memberVacationList];
res.memberVacationList.forEach(member => {
addEvent($common.dateFormatter(member.LOCVACUDT, 'YMD'), 'vacation', `${member.MEMBERNAM}`);
});
}
//
monthBirthdayPartyList.value = [];
monthDinnerList.value = [];
monthTeaTimeList.value = [];
monthWorkShopList.value = [];
if (res?.eventList?.length) {
res.eventList.forEach(item => {
switch (item.CMNCODVAL) {
case 300203:
monthBirthdayPartyList.value = [...monthBirthdayPartyList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'birthdayParty', '생일파티');
break;
case 300204:
monthDinnerList.value = [...monthDinnerList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'dinner', '회식');
break;
case 300205:
monthTeaTimeList.value = [...monthTeaTimeList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'teaTime', '티타임');
break;
case 300206:
monthWorkShopList.value = [...monthWorkShopList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'workshop', '워크샵');
break;
}
});
}
};
//
const addEvent = (date, type, title) => {
//
if (type === 'birthday') {
const calendarApi = fullCalendarRef.value?.getApi();
if (calendarApi) {
const calendarDate = calendarApi.currentData.currentDate;
const { year } = $common.formatDateTime(new Date(calendarDate));
const birthDate = $common.dateFormatter(date, 'MD');
date = `${year}-${birthDate}`;
}
}
//
const existingEvent = calendarEvents.value.find(
event => $common.dateFormatter(event.start, 'MD') === $common.dateFormatter(date, 'MD') && event.type == type,
);
//
if (!existingEvent) {
calendarEvents.value.push({
start: date,
type: type,
title: title,
classNames: [`${type}-event`],
});
}
};
//
const useFilterEventList = (month, day) => {
//
if (monthBirthdayList.value) {
birthdayList.value = $common.filterTargetByDate(monthBirthdayList.value, 'MEMBERBTH', month, day);
}
//
if (monthVacationList.value) {
vacationList.value = $common.filterTargetByDate(monthVacationList.value, 'LOCVACUDT', month, day);
}
//
if (monthBirthdayPartyList.value) {
birthdayPartyList.value = $common.filterTargetByDate(monthBirthdayPartyList.value, 'LOCEVTTME', month, day);
}
//
if (monthDinnerList.value) {
dinnerList.value = $common.filterTargetByDate(monthDinnerList.value, 'LOCEVTTME', month, day);
}
//
if (monthTeaTimeList.value) {
teaTimeList.value = $common.filterTargetByDate(monthTeaTimeList.value, 'LOCEVTTME', month, day);
}
//
if (monthWorkShopList.value) {
workShopList.value = $common.filterTargetByDate(monthWorkShopList.value, 'LOCEVTTME', month, day);
}
};
//
const fetchData = async () => {
// FullCalendar API
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
const date = calendarApi.currentData.currentDate;
const { year, month } = $common.formatDateTime(new Date(date));
try {
//
const holidayEvents = await fetchHolidays(year, month);
calendarEvents.value = [...holidayEvents]; //
//
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', '1'); //
await fetchEventList(param);
} 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;
};
//
const getCellClassNames = arg => {
const cellDate = dayjs(arg.date);
const classes = [];
// (, , )
if (!isSelectableDate(cellDate)) {
classes.push('fc-day-sat-sun');
} else {
//
classes.push('clickable');
}
return classes;
};
//
let todayEL = null;
const handleDateClick = info => {
if (isSelectableDate(info.date)) {
if ($common.isToday(info.date)) {
//
todayEL = info.dayEl;
todayEL.classList.remove('fc-day-today');
} else if (todayEL) {
//
todayEL.classList.add('fc-day-today');
todayEL = null;
}
}
const { month, day } = $common.formatDateTime(new Date(info.dateStr));
useFilterEventList(month, day);
};
//
const colorToday = e => {
if (todayEL != null && !todayEL.classList.contains('fc-day-today')) todayEL.classList.add('fc-day-today');
};
//
const handleMouseDown = (date, jsEvent) => {
if (showModal.value) showModal.value = false;
//
const dateEvents = calendarEvents.value.filter(
event => $common.dateFormatter(event.start, 'YMD') === $common.dateFormatter(date, 'YMD'),
);
pressTimer.value = setTimeout(() => {
modalPosition.value = {
x: jsEvent.clientX,
y: jsEvent.clientY,
};
selectedDate.value = date;
currentDateEvents.value = dateEvents;
showModal.value = true;
pressTimer.value = null;
}, longPressDelay);
};
//
const handleMouseUp = () => {
if (pressTimer.value) {
clearTimeout(pressTimer.value);
pressTimer.value = null;
}
};
// api
const toggleEvent = async (date, code, title) => {
const { data } = await $api.post('main/toggleEvent', {
date: date,
code: code,
title: title,
});
if (data?.code === 200) toastStore.onToast(data.message);
const { year, month, day } = $common.formatDateTime(new Date(date));
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', day);
await fetchEventList(param);
useFilterEventList(month, day);
};
// api
const insertEvent = async (date, code, title, place, time) => {
const dateTime = $common.dateFormatter(`${date} ${time}`);
const { data } = await $api.post('main/inserEvent', {
date: dateTime,
code: code,
title: title,
place: place,
});
if (data?.code === 200) toastStore.onToast(data.message);
const { year, month, day } = $common.formatDateTime(new Date(date));
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', day);
await fetchEventList(param);
useFilterEventList(month, day);
};
//
const handleEventSelect = data => {
toggleEvent(data.date, data.code, data.title);
showModal.value = false;
};
//
const handleEventInsert = data => {
insertEvent(data.date, data.code, data.title, data.place, data.time);
showModal.value = false;
};
//
const handleEventDelete = data => {
toggleEvent(data.date, data.code, data.title);
showModal.value = false;
};
//
const handleCloseModal = () => {
showModal.value = false;
};
//
const handleEventContent = item => {
if (!item.event) return null;
//
if (item.event.classNames?.includes('holiday-event')) {
return {
html: `<div class="holiday-text" style="color: white;">${item.event.title}</div>`,
};
}
//
const eventType = item.event.extendedProps.type;
if (!eventType) return null;
let iconCode = '';
switch (eventType) {
case 'birthday':
iconCode = '300201';
break;
case 'vacation':
iconCode = '300202';
break;
case 'birthdayParty':
iconCode = '300203';
break;
case 'dinner':
iconCode = '300204';
break;
case 'teaTime':
iconCode = '300205';
break;
case 'workshop':
iconCode = '300206';
break;
default:
return null;
}
return {
html: `<img src="${baseUrl}img/main-category-img/main-${iconCode}.png" class="calendar-event-icon" style="width: 20px; height: 20px; margin: 2px;" />`,
};
};
//
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: handleEventContent,
selectable: true,
selectAllow: selectInfo => isSelectableDate(selectInfo.start),
dateClick: handleDateClick,
dayCellDidMount: arg => {
//
addWeatherInfo(arg);
const dateCell = arg.el;
//
dateCell.addEventListener('mousedown', e => {
if (!isSelectableDate(arg.date)) return; //
const date = $common.dateFormatter(arg.date, 'YMD');
handleMouseDown(date, e);
});
dateCell.addEventListener('mouseup', handleMouseUp);
dateCell.addEventListener('mouseleave', handleMouseUp);
},
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),
},
},
});
//
const addWeatherInfo = arg => {
const dateStr = $common.dateFormatter(arg.date, 'YMD');
//
const theDayWeatherInfo = dailyWeatherList.value.find(weather => weather.date === dateStr);
const dayTopEl = arg.el.querySelector('.fc-daygrid-day-top');
const isWeatherInfoExist = dayTopEl.getElementsByClassName('weather-icon').length > 0; //
if (theDayWeatherInfo && !isWeatherInfoExist) {
let weatherIconUrl = `https://openweathermap.org/img/wn/${theDayWeatherInfo.icon}.png`;
if (theDayWeatherInfo.icon === '01d' || theDayWeatherInfo.icon === '01n') {
weatherIconUrl = '/img/icons/sunny-custom.png';
}
//
const weatherEl = document.createElement('img');
weatherEl.src = weatherIconUrl;
weatherEl.alt = theDayWeatherInfo.description;
weatherEl.className = 'weather-icon';
weatherEl.style.width = '28px';
weatherEl.style.height = '28px';
//
dayTopEl.classList.add('align-items-center');
dayTopEl.prepend(weatherEl); // reverse
}
};
//
watch(dailyWeatherList, async () => {
await nextTick(); // DOM
document.querySelectorAll('.fc-daygrid-day').forEach(dayCell => {
addWeatherInfo({
el: dayCell,
date: dayCell.dataset.date,
});
});
});
const handleWheelEvent = e => {
handleCloseModal();
};
//
const handleClickVacation = () => {
router.push('/vacation');
};
// ( )
watch(
() => fullCalendarRef.value?.getApi().currentData.viewTitle,
async () => {
await fetchData();
},
);
// selectbox
watch(
() => projectStore.selectedProject,
newProject => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
checkedInProject.value = newProject;
} else {
selectedProject.value = null;
checkedInProject.value = null;
}
},
);
const handleLeaveTimeUpdate = async event => {
const memberSeq = user.value.id;
if (!memberSeq) return;
//
const { data } = await $api.post('main/getUserLeaveRecord', {
memberSeq: memberSeq,
});
const res = data?.data;
if (res && !res?.COMMUTLVE) {
await projectStore.getMemberProjects();
if (projectStore.activeMemberProjectList.length > 0) {
const previousProject =
projectStore.activeMemberProjectList.find(p => res.MEMBERSEQ === user.value.id && res.PROJCTLVE === p.PROJCTSEQ) ||
projectStore.activeMemberProjectList[0]; //
if (previousProject) {
selectedProject.value = previousProject.PROJCTSEQ;
projectStore.setSelectedProject(previousProject);
} else if (projectStore.activeProjectList.length > 0) {
selectedProject.value = projectStore.activeProjectList[0].PROJCTSEQ;
projectStore.setSelectedProject(projectStore.activeProjectList[0]);
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
}
};
onMounted(async () => {
await userStore.userInfo();
user.value = userStore.user;
//
const storedProject = projectStore.getSelectedProject();
if (storedProject) {
selectedProject.value = storedProject.PROJCTSEQ;
checkedInProject.value = storedProject;
}
//
const { year, month, day } = $common.getToday();
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', day);
//
await fetchCategoryList();
await fetchEventList(param);
useFilterEventList(month, day);
//
window.addEventListener('wheel', handleWheelEvent);
window.addEventListener('click', colorToday);
datePickerStore.initDatePicker(fullCalendarRef, async (year, month, options) => {
//
await fetchData();
});
});
</script>
<style scoped>
::v-deep(.fc-h-event) {
background-color: transparent;
}
::v-deep(.event-modal) {
padding: 8px;
border: 1px solid #ddd;
}
::v-deep(.event-icon-select:hover) {
transform: scale(1.1);
transition: transform 0.2s;
}
/* 이벤트 모달 노출 시 텍스트 선택 방지 */
::v-deep(.fc-daygrid-day) {
user-select: none;
}
::v-deep(.fc-daygrid-day-events) {
display: flex;
flex-wrap: wrap;
justify-content: center;
/* align-content: flex-start;
align-items: center;
text-align: center !important; */
}
/* 공휴일만 가로로 넗게 나오게 */
::v-deep(.fc-daygrid-event-harness:has(.holiday-event)) {
width: 100% !important;
}
</style>

View File

@ -1,137 +0,0 @@
<template>
<div class="">
<template v-for="category in categoryList" :key="category.CMNCODVAL">
<div
v-if="
(category.CMNCODVAL === 300201 && birthdayList?.length) ||
(category.CMNCODVAL === 300202 && vacationList?.length) ||
(category.CMNCODVAL === 300203 && birthdayPartyList?.length) ||
(category.CMNCODVAL === 300204 && dinnerList?.length) ||
(category.CMNCODVAL === 300205 && teaTimeList?.length) ||
(category.CMNCODVAL === 300206 && workShopList?.length)
"
@click="category.CMNCODVAL == 300202 ? $emit('handleClickVacation') : ''"
:class="category.CMNCODVAL == 300202 ? 'pointer' : ''"
class="border border-2 mt-3 card p-2"
>
<div class="row g-2 position-relative">
<div class="col-3 mx-0 px-0">
<div class="ratio ratio-1x1">
<img
:src="`${baseUrl}img/main-category-img/main-${category.CMNCODVAL}.png`"
:alt="`${category.CMNCODNAM}`"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</div>
</div>
<div class="col-9 mx-0 px-0 d-flex align-items-center">
<template v-if="category.CMNCODVAL === 300201">
<MainMemberProfile :members="birthdayList" :baseUrl="baseUrl" />
</template>
<template v-if="category.CMNCODVAL === 300202">
<MainMemberProfile :members="vacationList" :baseUrl="baseUrl" />
</template>
<template v-if="category.CMNCODVAL === 300203">
<MainEventBoard :place="birthdayPartyList[0].LOCEVTPLC" :time="birthdayPartyList[0].LOCEVTTME" />
</template>
<template v-if="category.CMNCODVAL === 300204">
<MainEventBoard :place="dinnerList[0].LOCEVTPLC" :time="dinnerList[0].LOCEVTTME" />
</template>
<template v-if="category.CMNCODVAL === 300205">
<MainEventBoard :place="teaTimeList[0].LOCEVTPLC" :time="teaTimeList[0].LOCEVTTME" />
</template>
<template v-if="category.CMNCODVAL === 300206">
<MainEventBoard :place="workShopList[0].LOCEVTPLC" :time="workShopList[0].LOCEVTTME" />
</template>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { defineEmits } from 'vue';
import MainMemberProfile from '@c/main/MainMemberProfile.vue';
import MainEventBoard from '@c/main/MainEventBoard.vue';
const props = defineProps({
project: {
type: Object,
required: false,
},
categoryList: {
type: Array,
},
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,
},
birthdayList: {
type: Array,
},
vacationList: {
type: Array,
},
birthdayPartyList: {
type: Array,
},
dinnerList: {
type: Array,
},
teaTimeList: {
type: Array,
},
workShopList: {
type: Array,
},
});
defineEmits(['handleClickVacation']);
</script>
<style scoped>
.event-board {
width: 100%;
padding: 0.5rem;
}
.event-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-weight: 500;
color: #666;
min-width: 3rem;
}
.content {
color: #333;
word-break: break-all;
}
</style>

View File

@ -1,32 +0,0 @@
<template>
<div class="ms-2" style="flex: 1">
<ul class="row gx-1 mb-0 list-inline">
<li class="col-4 me-0" v-for="(member, index) in members" :key="index">
<div class="ratio ratio-1x1 mb-0">
<img
:src="`${profileImgUrl}profile/${member.MEMBERPRF}`"
:style="`border-color: ${member.usercolor} !important;`"
alt="User Profile"
class="rounded-circle border border-2 profile-img"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</div>
</li>
</ul>
</div>
</template>
<script setup>
const props = defineProps({
members: {
type: Array,
required: true,
},
baseUrl: {
type: String,
required: true,
},
});
const profileImgUrl = import.meta.env.VITE_SERVER_IMG_URL;
</script>

View File

@ -1,244 +0,0 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card h-100">
<!-- 더보기 버튼-->
<div class="d-flex">
<router-link
to="/voteboard"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
>
more
</router-link>
</div>
<div class="card-header d-flex justify-content-between">
<div class="card-title mb-0">
<h5 class="mb-1 me-2">투표진행</h5>
</div>
</div>
<div class="card-body" v-if="voteListData.length > 0">
<ul class="p-0 m-0">
<li class="d-flex mb-1" v-for="item in voteListData" :key="item.LOCVOTSEQ">
<div class="d-flex w-100 flex-wrap align-items-center justify-content-between gap-2">
<div class="me-2 mb-3">
<div class="text-muted small">{{ item.localVote.formatted_LOCVOTRDT }}</div>
<div class="d-flex flex-wrap align-items-center">
<div class="avatar flex-shrink-0 me-1">
<img
style="cursor: auto;"
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.localVote.MEMBERPRF)"
alt="최초 작성자"
:style="{ borderColor: item.localVote.usercolor }"
@error="setDefaultImage"
/>
</div>
<div class="timeline-event ps-1" style="cursor: pointer;" @click.stop="openModal(item.localVote.LOCVOTSEQ)" >
<div class="timeline-header ">
<small ><strong>{{ truncateTitle(item.localVote.LOCVOTTTL) }}</strong></small>
</div>
<small class="d-flex align-items-center lh-1 me-4 mb-4 mb-sm-0"
:style="{ color: getDaysAgo(item.localVote.formatted_LOCVOTEDT) == '금일 종료' ? 'red' : '' }">
{{getDaysAgo(item.localVote.formatted_LOCVOTEDT)}}({{item.localVote.total_voted}}/{{ item.localVote.total_votable }})
</small>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
<div class="card-body" v-else>
진행중인 투표가 없습니다.
</div>
</div>
</div>
<!--투표 모달 -->
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> 투표 하기 </template>
<template #body>
<div>
<vote-list
:key="voteListKey"
:data="selectVoteDate"
@checkedNames="checkedNames"
@addContents="addContents"
@endVoteId="endVoteId"
@voteDelete="voteDelete"
/>
</div>
</template>
<template #footer>
<BackButton @click="closeModal" />
</template>
</CenterModal>
</template>
<script setup>
import router from '@/router';
import $api from '@api';
import { onMounted, ref } from 'vue';
import CenterModal from '@c/modal/CenterModal.vue';
import BackButton from '@c/button/BackBtn.vue';
import voteList from '@c/voteboard/voteCardList.vue';
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const currentPage = ref(1);
const voteset = ref(0);
const voteListData= ref([]);
const voteListKey = ref(0); //
//
const isModalOpen = ref(false);
const selectVoteDate = ref([]);
//
const openModal = async (id) => {
isModalOpen.value = true;
if(id){
const selectData = voteListData.value.filter((item) => item.localVote.LOCVOTSEQ === id);
selectVoteDate.value = selectData;
}
};
//
const closeModal = () => {
isModalOpen.value = false;
voteListKey.value++;
};
//
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const defaultProfile = "/img/icons/icon.png";
const getProfileImage = (profilePath) => {
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
};
const setDefaultImage = (event) => {
event.target.src = defaultProfile;
};
onMounted(() => {
getvoteList();
});
//
const getvoteList = () => {
$api.get('vote/getVoteList',{
params:
{
page: 1
,voteset:'2' //
,myVote:'2' //
}
}).then(res => {
voteListData.value = res.data.data.list;
voteListData.value = res.data.data.list.slice(0, 6);
})
};
//
const checkedNames = (numList) => {
$api.post('vote/insertCheckedNums',{
checkedList :numList
,votenum : numList[0].LOCVOTSEQ
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 완료되었습니다.', 's');
isModalOpen.value = false;
getvoteList();
voteListKey.value++;
}
})
}
//
const addContents = (itemList, voteId) => {
$api.post('vote/insertWord',{
itemList :itemList
,voteId :voteId
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('항목이 등록되었습니다.', 's');
getvoteList();
const updatedVote = selectVoteDate.value.find(vote => vote.localVote.LOCVOTSEQ === voteId);
if (updatedVote) {
if (!updatedVote.voteDetails) {
updatedVote.voteDetails = [];
}
const maxSeq = updatedVote.voteDetails.reduce((max, item) => {
return item.VOTDETSEQ > max ? item.VOTDETSEQ : max;
}, 0);
// voteDetails
itemList.forEach(item => {
updatedVote.voteDetails.push({
VOTDETSEQ: maxSeq + 1,
LOCVOTSEQ: voteId,
LOCVOTCON: item.content,
LOCVOTLIK: item.url,
VOTE_COUNT: 0,
yesvote: 0
});
});
}
}
})
}
//
const endVoteId = (endVoteId) => {
if(confirm('투표를 종료하시겠습니까?')){
$api.patch('vote/updateEndData',{
endVoteId :endVoteId
}).then((res)=>{
if(res.data.status === 'OK'){
getvoteList();
isModalOpen.value = false;
}
})
}
}
//
const voteDelete =(id) =>{
if(confirm('투표를 삭제하시겠습니까?')){
$api.patch('vote/updateDeleteData',{
deleteVoteId :id
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 삭제되었습니다.', 's');
getvoteList();
isModalOpen.value = false;
}
})
}
}
// 14 ...
const truncateTitle = title => {
return title.length > 10 ? title.slice(0, 10) + '...' : title;
};
//
const goVoteList = () =>{
router.push({
path: '/voteboard',
query: {
voteset: '2' //
,myVote:'2' //
,id:id
}
});
}
//
const getDaysAgo = (dateString) => {
const inputDate = new Date(dateString); // Date
const today = new Date(); //
const input = new Date(inputDate.getFullYear(), inputDate.getMonth(), inputDate.getDate());
const now = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const timeDiff = now - input;
const dayDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
//
if (dayDiff === 0) return "금일 종료";
return `종료 ${Math.abs(dayDiff)}일 전`;
};
</script>
<style scoped>
.user-avatar {
border: 3px solid;
}
</style>

View File

@ -1,139 +0,0 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card h-100">
<!-- 더보기 버튼 -->
<div class="d-flex ">
<router-link
to="/wordDict"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
>
more
</router-link>
</div>
<div class="card-header d-flex justify-content-between">
<div class="card-title mb-0">
<h5 class="mb-1 me-2">용어집</h5>
</div>
</div>
<div class="card-body" v-if="wordList.length > 0">
<ul class="p-0 m-0" v-for="item in wordList" :key="item.WRDDICSEQ">
<li class="d-flex align-items-center mb-1">
<!-- 프로필 이미지 -->
<div class="avatar flex-shrink-0 me-2 d-flex align-items-center">
<img
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최종 작성자"
:style="{ borderColor: item.lastEditor.color }"
@error="setDefaultImage"
/>
</div>
<!-- 텍스트 영역 -->
<div class="timeline-event ps-1 flex-grow-1" style="cursor: pointer;" @click="goWordList(item.WRDDICCAT,item.WRDDICTTL)">
<div class="timeline-header">
<small class="text-primary text-uppercase">{{ item.category }}</small>
</div>
<h6 class="my-50 d-flex align-items-center">
{{ truncateTitle(item.WRDDICTTL) }}
</h6>
<div class="text-muted small">{{$common.dateFormatter(item.lastEditor.updatedAt)}}</div>
</div>
</li>
</ul>
</div>
<div class="card-body" v-else >
등록된 용어가 없습니다.
</div>
</div>
</div>
</template>
<script setup>
import axios from '@api';
import { getCurrentInstance, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import commonApi from '@/common/commonApi';
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
const router = useRouter();
onMounted(() => {
getwordList();
});
//
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, '');
const defaultProfile = "/img/icons/icon.png";
const getProfileImage = (profilePath) => {
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
};
const setDefaultImage = (event) => {
event.target.src = defaultProfile;
};
const wordList = ref([]);
//
const getwordList = (searchKeyword='', indexKeyword='', category='') => {
axios.get('worddict/getWordList',{
params: {
searchKeyword : searchKeyword,
indexKeyword :indexKeyword,
category : category,
pageNum:6
}
})
.then(res => {
wordList.value = res.data.data.data;
});
};
// /
const getFirstCharacter = (char) => {
const CHOSUNG_LIST = [
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];
if (!char || char.length === 0) return '';
const code = char.charCodeAt(0);
// (~)
if (code >= 0xAC00 && code <= 0xD7A3) {
const index = Math.floor((code - 0xAC00) / (21 * 28));
return CHOSUNG_LIST[index];
}
//
if (char.match(/[a-zA-Z]/)) {
return char.toLowerCase();
}
// (, )
return char;
};
//
const goWordList = (category, indexKeyword) => {
const firstChar = getFirstCharacter(indexKeyword[0]); //
router.push({
path: '/wordDict',
query: {
indexKeyword: firstChar,
category: category,
}
});
};
// 14 ...
const truncateTitle = title => {
return title.length > 25 ? title.slice(0, 25) + '...' : title;
};
</script>
<style scoped>
.user-avatar {
border: 3px solid;
}
</style>

View File

@ -1,120 +0,0 @@
<template>
<div class="row g-4 mt-2" v-if="memberList?.length">
<div>
<div class="card">
<div class="card-body p-3 row">
<div class="d-flex justify-content-between mb-2">
<h5 class="card-title fw-bold">사원 등록 관리</h5>
</div>
<div class="g-4 col-12 col-lg-6" v-for="member in memberList" :key="member.MEMBERSEQ">
<div class="card">
<div class="row card-body">
<div class="col-3 d-flex align-items-center">
<img
:src="`${imgURL}profile/${member.MEMBERPRF}`"
alt="Profile Image"
class="img-thumbnail mx-auto d-block"
style="max-height: 140px"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</div>
<div class="col-7">
<div class="d-flex flex-sm-row align-items-center pb-2">
<div class="font-bold">{{ member.MEMBERNAM }}</div>
<div class="ms-2">
({{ member.MBTI }})
<img
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${member.MBTI.toLowerCase()}.png`"
alt="MBTI image"
/>
</div>
</div>
<div class="d-flex flex-sm-row align-items-center pb-2">
<i class="bx bx-id-card"></i>
<div class="ms-2">{{ member.MEMBERIDS }}</div>
</div>
<div class="d-flex flex-sm-row align-items-center pb-2">
<i class="bx bxs-phone"></i>
<div class="ms-2">{{ $common.addHyphenToPhoneNumber(member.MEMBERTEL) }}</div>
</div>
<div class="d-flex flex-sm-row align-items-center pb-2">
<i class="bx bx-calendar"></i>
<div class="ms-2">{{ $common.dateFormatter(member.MEMBERCDT) }}</div>
</div>
</div>
<div class="col-2 d-flex align-items-center">
<div>
<div>
<label class="switch"
><input
type="checkbox"
:checked="member.checked"
@click="handleRegisterMember($event, member)" />
<span class="slider round"></span
></label>
</div>
<button
class="btn-close btn-close-sm"
style="position: absolute; top: 10px; right: 10px"
@click="handleRejectMember(member)"
></button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useToastStore } from '@s/toastStore';
import $api from '@api';
const memberList = ref([]);
const toast = useToastStore();
const imgURL = import.meta.env.VITE_SERVER_IMG_URL;
// api
const fetchRegisterMemberList = async () => {
const { data } = await $api.get('main/registerMemberList');
if (data?.data) {
memberList.value = data.data.map(member => ({
...member,
checked: false, // checked
}));
}
};
// api
const handleRegisterMember = async (e, member) => {
e.preventDefault();
const { data } = await $api.post('main/registerMember', { memberSeq: member.MEMBERSEQ });
if (data?.data) {
member.checked = true;
toast.onToast(data.data, 's');
fetchRegisterMemberList();
}
};
// api
const handleRejectMember = async member => {
if (!confirm('해당 사원 등록을 거절하시겠습니까?')) return;
const { data } = await $api.post('main/rejectMember', { memberSeq: member.MEMBERSEQ });
if (data?.data) {
toast.onToast(data.data, 's');
fetchRegisterMemberList();
}
};
onMounted(async () => {
await fetchRegisterMemberList();
});
</script>

View File

@ -1,114 +0,0 @@
<template>
<div class="position-relative">
<div @click="togglePopover">
<slot name="trigger">
<i class="bx bxs-map"></i>
</slot>
</div>
<div
v-if="isVisible"
class="position-absolute map z-3"
>
<button
type="button"
class="btn-close popover-close"
@click="togglePopover"
></button>
<div class="card">
<div class="card-body p-1">
<KakaoMap
v-if="coordinates"
:lat="coordinates.lat"
:lng="coordinates.lng"
class="w-px-250 h-px-200"
@onLoadKakaoMap="onLoadKakaoMap"
>
<KakaoMapMarker
:lat="coordinates.lat"
:lng="coordinates.lng"
/>
</KakaoMap>
<div class="position-absolute top-50 translate-middle-y end-0 me-3 z-1 d-flex flex-column gap-1">
<button class="btn-secondary border-none" @click="zoomOut">+</button>
<button class="btn-secondary border-none" @click="zoomIn">-</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { KakaoMap, KakaoMapMarker } from 'vue3-kakao-maps';
const props = defineProps({
address: {
type: String,
required: true
},
isVisible: {
type: Boolean,
required: false
}
});
const emit = defineEmits(['update-visible']);
const coordinates = ref(null);
const map = ref(null);
//
const convertAddressToCoordinates = () => {
return new Promise((resolve, reject) => {
if (!window.kakao || !window.kakao.maps) {
reject(new Error('Kakao Maps not loaded'));
return;
}
const geocoder = new window.kakao.maps.services.Geocoder();
geocoder.addressSearch(props.address, (result, status) => {
if (status === window.kakao.maps.services.Status.OK) {
resolve({
lat: parseFloat(result[0].y),
lng: parseFloat(result[0].x)
});
} else {
reject(new Error('Address conversion failed'));
}
});
});
};
const togglePopover = () => {
emit('update-visible', !props.isVisible);
};
const onLoadKakaoMap = (mapRef) => {
map.value = mapRef;
};
//
const zoomIn = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level + 1);
}
};
//
const zoomOut = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level - 1);
}
};
//
onMounted(async () => {
coordinates.value = await convertAddressToCoordinates();
});
</script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="toastStore.toastModal" v-if="toastStore.toastModal"
:class="['bs-toast toast toast-placement-ex m-2 fade show', toastClass]" :class="['bs-toast toast m-2 fade show', toastClass]"
role="alert" role="alert"
aria-live="assertive" aria-live="assertive"
aria-atomic="true" aria-atomic="true"
@ -9,10 +9,10 @@
<div class="toast-header"> <div class="toast-header">
<i class="bx bx-bell me-2"></i> <i class="bx bx-bell me-2"></i>
<div class="me-auto fw-semibold">알림</div> <div class="me-auto fw-semibold">알림</div>
<button <button
type="button" type="button"
class="btn-close" class="btn-close"
aria-label="Close" aria-label="Close"
@click="offToast" @click="offToast"
></button> ></button>
</div> </div>
@ -35,3 +35,24 @@ const toastClass = computed(() => {
return toastStore.toastType === 'e' ? 'bg-danger' : 'bg-success'; // red, blue return toastStore.toastType === 'e' ? 'bg-danger' : 'bg-success'; // red, blue
}); });
</script> </script>
<style scoped>
.bs-toast {
position: fixed;
bottom: 20px; /* 화면 하단에 위치 */
right: 20px; /* 오른쪽에 위치 */
z-index: 2000; /* 충분히 높은 값으로 설정 */
max-width: 300px; /* 최대 너비 제한 */
opacity: 1;
transition: opacity 0.5s ease-in-out;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* 그림자 추가 */
}
.bg-primary {
background-color: #007bff !important; /* 성공 색상 */
}
.bg-danger {
background-color: #ff3e1d !important; /* 에러 색상 */
}
</style>

View File

@ -6,14 +6,14 @@
<button class="close-btn" @click="closeModal"></button> <button class="close-btn" @click="closeModal"></button>
</div> </div>
<div class="vac-modal-body"> <div class="vac-modal-body">
<p class="vac-modal-text">선물할 연차 개수를 선택세요.</p> <p class="vac-modal-text">선물할 연차 개수를 선택세요.</p>
<div class="count-container"> <div class="count-container">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button> <button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="count-value">{{ grantCount }}</span> <span class="count-value">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button> <button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div> </div>
<div class="custom-button-container"> <div class="custom-button-container">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0 || isGiftButtonDisabled"> <button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0">
<i class="bx bx-gift"></i> <i class="bx bx-gift"></i>
</button> </button>
</div> </div>
@ -23,16 +23,14 @@
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits, watch, onMounted, computed } from "vue"; import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
import axios from "@api"; import axios from "@api";
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from "@s/useUserInfoStore";
const userStore = useUserInfoStore();
const toastStore = useToastStore(); const toastStore = useToastStore();
const props = defineProps({ const props = defineProps({
isOpen: Boolean, isOpen: Boolean,
targetUser: Object, targetUser: Object,
remainingVacationData: Object,
}); });
const emit = defineEmits(["close", "updateVacation"]); const emit = defineEmits(["close", "updateVacation"]);
@ -41,13 +39,6 @@ const maxQuota = 2;
const sentCount = ref(0); const sentCount = ref(0);
const availableQuota = ref(2); const availableQuota = ref(2);
const myUserId = computed(() => userStore.user.id);
const myRemainingQuota = computed(() => {
return props.remainingVacationData?.[myUserId.value] ?? 0;
});
const isGiftButtonDisabled = computed(() => {
return myRemainingQuota.value < 0;
});
// //
const fetchSentVacationCount = async () => { const fetchSentVacationCount = async () => {
try { try {
@ -57,6 +48,7 @@ const fetchSentVacationCount = async () => {
availableQuota.value = Math.max(maxQuota - sentCount.value, 0); availableQuota.value = Math.max(maxQuota - sentCount.value, 0);
grantCount.value = availableQuota.value; grantCount.value = availableQuota.value;
} catch (error) { } catch (error) {
console.error("🚨 연차 전송 기록 조회 실패:", error);
availableQuota.value = maxQuota; availableQuota.value = maxQuota;
grantCount.value = maxQuota; grantCount.value = maxQuota;
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal"> <div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal">
<div class="vac-modal-content p-5 modal-scroll"> <div class="vac-modal-content p-5 modal-scroll">
<h5 class="vac-modal-title">📅 연차 (누적 개수)</h5> <h5 class="vac-modal-title">📅 연차 사용 내역</h5>
<button class="close-btn" @click="closeModal"></button> <button class="close-btn" @click="closeModal"></button>
<!-- 연차 목록 --> <!-- 연차 목록 -->
<div class="vac-modal-body" v-if="mergedVacations.length > 0"> <div class="vac-modal-body" v-if="mergedVacations.length > 0">
@ -11,6 +11,9 @@
:key="vac._expandIndex" :key="vac._expandIndex"
class="vacation-item" class="vacation-item"
> >
<span v-if="vac.category === 'used'" class="fw-bold text-dark me-2">
{{ usedVacationIndexMap[vac._expandIndex] }})
</span>
<span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'"> <span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'">
{{ vac.category === 'used' ? '-' : '+' }} {{ vac.category === 'used' ? '-' : '+' }}
</span> </span>
@ -19,15 +22,12 @@
> >
{{ formatDate(vac.date) }} {{ formatDate(vac.date) }}
</span> </span>
<span v-if="vac.category === 'used'" class="fw-bold text-dark ms-1">
( {{ usedVacationIndexMap[vac._expandIndex] }} )
</span>
</li> </li>
</ol> </ol>
</div> </div>
<!-- 연차 데이터 없음 --> <!-- 연차 데이터 없음 -->
<p v-else class="text-sm-center mt-10 text-gray vac-modal-title"> <p v-else class="text-sm-center mt-10 text-gray">
🚫 연차 내역이 없습니다. 🚫 사용한 연차가 없습니다.
</p> </p>
</div> </div>
</div> </div>
@ -57,34 +57,30 @@ const emit = defineEmits(["close"]);
// (,) // (,)
let globalCounter = 0; let globalCounter = 0;
const usedVacations = computed(() => { const usedVacations = computed(() => {
const data = props.myVacations.flatMap((v) => { const result = [];
props.myVacations.forEach((v) => {
const count = v.used_quota const count = v.used_quota || 1;
return Array.from({ length: Math.ceil(count) }, (_, i) => ({ for (let i = 0; i < count; i++) {
...v, result.push({
category: "used", ...v,
code: v.LOCVACTYP, category: "used",
used_quota: count, // code: v.LOCVACTYP,
_expandIndex: globalCounter++, _expandIndex: globalCounter++,
})); });
}
}); });
return data; return result;
}); });
// //
const receivedVacations = computed(() => { const receivedVacations = computed(() =>
const data = props.receivedVacations.flatMap((v) => { props.receivedVacations.map((v) => ({
const count = v.received_quota ?? 1; ...v,
return Array.from({ length: Math.ceil(count) }, (_, i) => ({ category: "received",
...v, }))
category: "received", );
_expandIndex: globalCounter++,
}));
});
return data;
});
// //
const sortedUsedVacationsAsc = computed(() => { const sortedUsedVacationsAsc = computed(() => {
return [...usedVacations.value].sort((a, b) => { return [...usedVacations.value].sort((a, b) => {
@ -118,7 +114,7 @@ const mergedVacations = computed(() => {
// //
const closeModal = () => { const closeModal = () => {
emit("close"); emit("close");
}; };
</script> </script>
<style scoped> <style scoped>

View File

@ -14,7 +14,7 @@
<div v-for="post in projectStore.projectList" :key="post.PROJCTSEQ"> <div v-for="post in projectStore.projectList" :key="post.PROJCTSEQ">
<ProjectCard <ProjectCard
:title="post.PROJCTNAM" :title="post.PROJCTNAM"
:description="post.PROJCTDES ?? ''" :description="post.PROJCTDES"
:strdate="post.PROJCTSTR" :strdate="post.PROJCTSTR"
:enddate="post.PROJCTEND" :enddate="post.PROJCTEND"
:address="post.PROJCTARR" :address="post.PROJCTARR"
@ -24,9 +24,7 @@
:projctCol="post.PROJCTCOL" :projctCol="post.PROJCTCOL"
:projctColor="post.projctcolor" :projctColor="post.projctcolor"
:projctCreatorId="post.PROJCTCMB" :projctCreatorId="post.PROJCTCMB"
:resetUserSelection="resetUserSelection" @update="getProjectList"
:searchParams="{ text: searchText, year: selectedYear }"
@update="handleProjectUpdate"
/> />
</div> </div>
</div> </div>
@ -37,7 +35,7 @@
<template #title> 프로젝트 등록 </template> <template #title> 프로젝트 등록 </template>
<template #body> <template #body>
<FormInput <FormInput
title="프로젝트명" title="이름"
name="name" name="name"
:is-essential="true" :is-essential="true"
:is-alert="nameAlert" :is-alert="nameAlert"
@ -53,48 +51,27 @@
:is-label="true" :is-label="true"
:is-common="true" :is-common="true"
:is-color="true" :is-color="true"
:value="color"
:data="colorList" :data="colorList"
@update:data="color = $event" @update:data="color = $event"
:is-alert="colorAlert"
/> />
<div class="mb-2 row"> <FormInput
<label class="col-md-2 col-form-label"> title="시작 일"
참여자 name="startDay"
</label> :type="'date'"
<div class="col-md-10"> :is-essential="true"
<UserList :modelValue="startDay"
ref="userListRef" v-model="startDay"
@user-list-update="handleUserListUpdate" />
class="m-0"
/> <FormInput
</div> title="종료 일"
</div> :type="'date'"
name="endDay"
:modelValue="endDay"
@update:modelValue="endDay = $event"
/>
<div @click="openStartDatePicker">
<FormInput
title="시작 일"
name="startDay"
:type="'date'"
:is-alert="startDayAlert"
:is-essential="true"
:modelValue="startDay"
v-model="startDay"
ref="startDateInput"
/>
</div>
<div @click="openEndDatePicker">
<FormInput
title="종료 일"
name="endDay"
:type="'date'"
:modelValue="endDay"
:min="startDay"
@update:modelValue="endDay = $event"
ref="endDateInput"
/>
</div>
<FormInput <FormInput
title="설명" title="설명"
name="description" name="description"
@ -122,7 +99,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch, onMounted, inject, nextTick } from 'vue'; import { computed, ref, watch, onMounted, inject } from 'vue';
import SearchBar from '@c/search/SearchBar.vue'; import SearchBar from '@c/search/SearchBar.vue';
import ProjectCard from '@c/list/ProjectCard.vue'; import ProjectCard from '@c/list/ProjectCard.vue';
import CategoryBtn from '@/components/category/CategoryBtn.vue'; import CategoryBtn from '@/components/category/CategoryBtn.vue';
@ -131,8 +108,7 @@
import FormSelect from '@c/input/FormSelect.vue'; import FormSelect from '@c/input/FormSelect.vue';
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import ArrInput from '@c/input/ArrInput.vue'; import ArrInput from '@c/input/ArrInput.vue';
import UserList from '@c/user/UserList.vue'; import commonApi from '@/common/commonApi';
import commonApi, { refreshColorList } from '@/common/commonApi';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore'; import { useProjectStore } from '@/stores/useProjectStore';
@ -149,9 +125,6 @@
const selectedCategory = ref(null); const selectedCategory = ref(null);
const searchText = ref(''); const searchText = ref('');
const userListRef = ref(null);
const resetUserSelection = ref(false);
// dayjs // dayjs
const dayjs = inject('dayjs'); const dayjs = inject('dayjs');
@ -161,37 +134,13 @@
// //
const isCreateModalOpen = ref(false); const isCreateModalOpen = ref(false);
const name = ref(''); const name = ref('');
const color = ref('0'); const color = ref('');
const startDay = ref(today); const startDay = ref(today);
const endDay = ref(''); const endDay = ref('');
const description = ref(''); const description = ref('');
const nameAlert = ref(false); const nameAlert = ref(false);
const colorAlert = ref(false);
const addressAlert = ref(false); const addressAlert = ref(false);
const startDayAlert = ref(false);
const startDateInput = ref(null);
const endDateInput = ref(null);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
}
};
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const addressData = ref({ const addressData = ref({
postcode: '', postcode: '',
@ -199,17 +148,6 @@
detailAddress: '' detailAddress: ''
}); });
//
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
// UserList
const handleUserListUpdate = (userLists) => {
selectedUsers.value = userLists;
};
// API // API
const { yearCategory, colorList } = commonApi({ const { yearCategory, colorList } = commonApi({
loadColor: true, loadColor: true,
@ -233,7 +171,7 @@
// //
const getProjectList = async () => { const getProjectList = async () => {
await projectStore.getProjectList(searchText.value, selectedYear.value, 'false'); await projectStore.getProjectList(searchText.value, selectedYear.value);
}; };
// //
@ -242,27 +180,17 @@
}); });
// //
const openCreateModal = async () => { const openCreateModal = () => {
const updatedColors = await refreshColorList('YNP');
if (updatedColors && updatedColors.length > 0) {
color.value = updatedColors[0].value;
}
isCreateModalOpen.value = true; isCreateModalOpen.value = true;
}; };
const closeCreateModal = () => { const closeCreateModal = () => {
isCreateModalOpen.value = false; isCreateModalOpen.value = false;
resetUserSelection.value = !resetUserSelection.value;
}; };
const formReset = () => { const formReset = () => {
name.value = ''; name.value = '';
if (colorList.value && colorList.value.length > 0) { color.value = '';
color.value = colorList.value[0].value;
}
addressData.value = { addressData.value = {
postcode: '', postcode: '',
address: '', address: '',
@ -273,67 +201,37 @@
description.value = ''; description.value = '';
nameAlert.value = false; nameAlert.value = false;
addressAlert.value = false; addressAlert.value = false;
startDayAlert.value = false; }
selectedUsers.value = {
activeUsers: [],
disabledUsers: []
};
if (userListRef.value) {
userListRef.value.resetSelection();
}
};
// :: // ::
const handleAddressUpdate = (data) => { const handleAddressUpdate = (data) => {
addressData.value = data; addressData.value = data;
}; };
// //
watch(startDay, (newStartDate) => { watch([startDay, endDay], () => {
if (new Date(newStartDate) > new Date(endDay.value)) { if (startDay.value && endDay.value) {
endDay.value = ''; const start = new Date(startDay.value);
} const end = new Date(endDay.value);
});
const handleProjectUpdate = async (params) => { if (end < start) {
if (params) { endDay.value = startDay.value;
await projectStore.getProjectList(params.text, params.year, 'false'); }
} else {
await projectStore.getProjectList(searchText.value, selectedYear.value, 'false');
} }
await projectStore.getMemberProjects(); }, { flush: 'post' });
//
const updatedColors = await refreshColorList('YNP');
// ()
if (updatedColors && updatedColors.length > 0) {
color.value = updatedColors[0].value;
}
};
// //
const handleCreate = async () => { const handleCreate = async () => {
nameAlert.value = name.value.trim() === ''; nameAlert.value = name.value.trim() === '';
startDayAlert.value = startDay.value.trim() === '';
addressAlert.value = addressData.value.address.trim() === ''; addressAlert.value = addressData.value.address.trim() === '';
if (!colorList.value || colorList.value.length === 0) { if (nameAlert.value || addressAlert.value) {
colorAlert.value = true;
}
if (nameAlert.value || startDayAlert.value || addressAlert.value || colorAlert.value) {
return; return;
} }
// $api.post('project/insert', {
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
const response = await $api.post('project/insert', {
projctNam: name.value, projctNam: name.value,
projctCol: color.value, projctCol: color.value,
projctStr: startDay.value, projctStr: startDay.value,
@ -343,36 +241,18 @@
projctDtl: addressData.value.detailAddress, projctDtl: addressData.value.detailAddress,
projctZip: addressData.value.postcode, projctZip: addressData.value.postcode,
projctCmb: user.value.id, projctCmb: user.value.id,
disabledMembers: disabledMemberSeqs }).then(res => {
if (res.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
closeCreateModal();
getProjectList();
}
}); });
if (response.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
colorList.value = colorList.value.filter(c => c.value !== color.value);
formReset();
await getProjectList();
await projectStore.getMemberProjects();
closeCreateModal();
resetUserSelection.value = !resetUserSelection.value;
}
}; };
onMounted(async () => { onMounted(async () => {
await getProjectList(); await getProjectList();
await userStore.userInfo(); await userStore.userInfo();
user.value = userStore.user; user.value = userStore.user;
});
if (startDateInput.value) {
// FormInput input
startInputElement = startDateInput.value.$el.querySelector('input[type="date"]');
}
if (endDateInput.value) {
endInputElement = endDateInput.value.$el.querySelector('input[type="date"]');
}
});
</script> </script>

View File

@ -1,63 +1,31 @@
<template> <template>
<form @submit.prevent="search"> <div class="input-group mb-3 d-flex">
<div class="input-group mb-3 d-flex"> <input type="text" class="form-control" placeholder="Search" @change="search" @input="preventLeadingSpace" />
<input type="text" class="form-control" placeholder="Search" v-model="searchQuery" @input="preventLeadingSpace" /> <button type="button" class="btn btn-primary"><i class="bx bx-search bx-md"></i></button>
<button type="submit" class="btn btn-primary"> </div>
<i class="bx bx-search bx-md"></i>
</button>
</div>
</form>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue';
const props = defineProps({ const props = defineProps({
maxlength: { maxlength: {
type: Number, type: Number,
default: 30, default: 30,
required: false, required: false,
}, },
initKeyword: {
type: String,
},
}); });
const emits = defineEmits(['update:data']); const emits = defineEmits(['update:data']);
const searchQuery = ref('');
watch( const search = function (event) {
() => props.initKeyword,
(newVal, oldVal) => {
searchQuery.value = newVal;
},
);
// ( or ) //Type Number maxlength
const search = () => { if (event.target.value.length > props.maxlength) {
const trimmedQuery = searchQuery.value.trimStart(); event.target.value = event.target.value.slice(0, props.maxlength);
if (trimmedQuery === '') {
emits('update:data', '');
return;
} }
if (trimmedQuery.length < 2) { emits('update:data', event.target.value);
alert('검색어는 최소 2글자 이상 입력해주세요.');
searchQuery.value = '';
return;
}
//
if (trimmedQuery.length > props.maxlength) {
searchQuery.value = trimmedQuery.slice(0, props.maxlength);
} else {
searchQuery.value = trimmedQuery;
}
emits('update:data', searchQuery.value);
}; };
// const preventLeadingSpace = function (event) {
const preventLeadingSpace = () => { event.target.value = event.target.value.trimStart();
searchQuery.value = searchQuery.value.trimStart(); }
};
</script> </script>

View File

@ -23,7 +23,7 @@
<label class="form-check-label fw-bold" for="rememberCheck">&nbsp;자동로그인</label> <label class="form-check-label fw-bold" for="rememberCheck">&nbsp;자동로그인</label>
</div> </div>
<RouterLink class="text-dark fw-bold" to="/register">등록신청</RouterLink> <RouterLink class="text-dark fw-bold" to="/register">등록신청</RouterLink>
<RouterLink class="text-dark fw-bold" to="/pw">비밀번호 재설정</RouterLink> <RouterLink class="text-dark fw-bold" to="/pw">비밀번호 찾기</RouterLink>
</div> </div>
</div> </div>
</form> </form>
@ -32,7 +32,7 @@
<script setup> <script setup>
import $api from '@api'; import $api from '@api';
import router from '@/router'; import router from '@/router';
import { ref } from 'vue'; import { nextTick, ref } from 'vue';
import UserFormInput from '@c/input/UserFormInput.vue'; import UserFormInput from '@c/input/UserFormInput.vue';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
@ -71,16 +71,17 @@
remember: remember.value, remember: remember.value,
}, { headers: { isLogin: true } }) }, { headers: { isLogin: true } })
.then(async res => { .then(async res => {
// if (res.status === 200) {
if (res.data.code > 10000) { userStore.userInfo();
// await nextTick();
errorMessage.value = res.data.message; router.push('/')
return;
} }
}).catch(error => {
// if (error.response) {
await userStore.userInfo(); error.config.isLoginRequest = true;
router.push('/'); errorMessage.value = error.response.data.message;
}) console.clear();
}
});
}; };
</script> </script>

View File

@ -3,9 +3,9 @@
<div class="text-center"> <div class="text-center">
<label <label
for="profilePic" for="profilePic"
class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer " class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer"
id="profileLabel" id="profileLabel"
style="width: 100px; height: 100px; background-image: url(img/avatars/default-Profile.jpg); background-repeat: no-repeat; background-size: cover;" style="width: 100px; height: 100px; background-image: url(public/img/avatars/default-Profile.jpg); background-repeat: no-repeat;"
> >
</label> </label>
@ -25,7 +25,6 @@
@update:alert="idAlert = $event" @update:alert="idAlert = $event"
@blur="checkIdDuplicate" @blur="checkIdDuplicate"
:value="id" :value="id"
@keypress="noSpace"
/> />
<span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span> <span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span>
@ -38,9 +37,7 @@
@update:data="password = $event" @update:data="password = $event"
@update:alert="passwordAlert = $event" @update:alert="passwordAlert = $event"
:value="password" :value="password"
@keypress="noSpace"
/> />
<span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span>
<UserFormInput <UserFormInput
title="비밀번호 확인" title="비밀번호 확인"
@ -50,20 +47,20 @@
:is-alert="passwordcheckAlert" :is-alert="passwordcheckAlert"
@update:data="passwordcheck = $event" @update:data="passwordcheck = $event"
@update:alert="passwordcheckAlert = $event" @update:alert="passwordcheckAlert = $event"
@blur="checkPw"
:value="passwordcheck" :value="passwordcheck"
@keypress="noSpace"
/> />
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span> <span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
<FormSelect <FormSelect
title="비밀번호 힌트" title="비밀번호 힌트"
name="pwhint" name="pwhint"
:is-essential="true" :is-essential="true"
:is-row="false" :is-row="false"
:is-label="true" :is-label="true"
:is-common="true" :is-common="true"
:data="pwhintList" :data="pwhintList"
@update:data="pwhint = $event" @update:data="pwhint = $event"
/> />
<UserFormInput <UserFormInput
@ -85,7 +82,6 @@
@update:data="name = $event" @update:data="name = $event"
@update:alert="nameAlert = $event" @update:alert="nameAlert = $event"
:value="name" :value="name"
@keypress="noSpace"
class="me-2 w-50" class="me-2 w-50"
/> />
@ -98,8 +94,8 @@
:is-common="true" :is-common="true"
:is-color="true" :is-color="true"
:data="colorList" :data="colorList"
@update:data="handleColorUpdate" @update:data="color = $event"
:is-alert="colorAlert" @blur="checkColorDuplicate"
class="w-50" class="w-50"
/> />
</div> </div>
@ -140,7 +136,6 @@
@update:data="handleAddressUpdate" @update:data="handleAddressUpdate"
@update:alert="addressAlert = $event" @update:alert="addressAlert = $event"
:value="address" :value="address"
:disabled="true"
/> />
<UserFormInput <UserFormInput
@ -148,12 +143,11 @@
name="phone" name="phone"
:isEssential="true" :isEssential="true"
:is-alert="phoneAlert" :is-alert="phoneAlert"
@update:data="phone = $event"
@update:alert="phoneAlert = $event" @update:alert="phoneAlert = $event"
@blur="checkPhoneDuplicate" @blur="checkPhoneDuplicate"
:maxlength="11" :maxlength="11"
:value="phone" :value="phone"
@keypress="onlyNumber"
@input="inputEvent"
/> />
<span v-if="phoneError" class="invalid-feedback d-block">{{ phoneError }}</span> <span v-if="phoneError" class="invalid-feedback d-block">{{ phoneError }}</span>
@ -166,9 +160,9 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref } from 'vue';
import $api from '@api'; import $api from '@api';
import commonApi from '@/common/commonApi'; import commonApi from '@/common/commonApi'
import UserFormInput from '@c/input/UserFormInput.vue'; import UserFormInput from '@c/input/UserFormInput.vue';
import FormSelect from '@c/input/FormSelect.vue'; import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue'; import ArrInput from '@c/input/ArrInput.vue';
@ -184,7 +178,6 @@
const id = ref(''); const id = ref('');
const idError = ref(''); const idError = ref('');
const password = ref(''); const password = ref('');
const passwordError = ref('');
const passwordcheck = ref(''); const passwordcheck = ref('');
const passwordcheckError = ref(''); const passwordcheckError = ref('');
const pwhintRes = ref(''); const pwhintRes = ref('');
@ -192,24 +185,22 @@
const birth = ref(''); const birth = ref('');
const address = ref(''); const address = ref('');
const detailAddress = ref(''); const detailAddress = ref('');
const postcode = ref(''); // const postcode = ref(''); //
const phone = ref(''); const phone = ref('');
const phoneError = ref(''); const phoneError = ref('');
const color = ref(''); // color const color = ref(''); // color
const colorError = ref(''); const colorError = ref('');
const mbti = ref(''); // MBTI const mbti = ref(''); // MBTI
const pwhint = ref(''); // pwhint const pwhint = ref(''); // pwhint
const profilAlert = ref(false); const profilAlert = ref(false);
const idAlert = ref(false); const idAlert = ref(false);
const idErrorAlert = ref(false); const idErrorAlert = ref(false);
const passwordAlert = ref(false); const passwordAlert = ref(false);
const passwordErrorAlert = ref(false);
const passwordcheckAlert = ref(false); const passwordcheckAlert = ref(false);
const passwordcheckErrorAlert = ref(false); // const passwordcheckErrorAlert = ref(false); //
const pwhintResAlert = ref(false); const pwhintResAlert = ref(false);
const nameAlert = ref(false); const nameAlert = ref(false);
const colorAlert = ref(false);
const birthAlert = ref(false); const birthAlert = ref(false);
const addressAlert = ref(false); const addressAlert = ref(false);
const phoneAlert = ref(false); const phoneAlert = ref(false);
@ -218,9 +209,6 @@
const toastStore = useToastStore(); const toastStore = useToastStore();
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
// //
const profileValid = (size, type) => { const profileValid = (size, type) => {
@ -250,7 +238,7 @@
// , // ,
if (!profileValid(file.size, file.type)) { if (!profileValid(file.size, file.type)) {
e.target.value = ''; e.target.value = '';
profileLabel.style.backgroundImage = 'url("img/avatars/default-Profile.jpg")'; profileLabel.style.backgroundImage = 'url("public/img/avatars/default-Profile.jpg")';
return false; return false;
} }
@ -260,15 +248,8 @@
profile.value = file; profile.value = file;
}; };
// //
const checkIdDuplicate = async () => { const checkIdDuplicate = async () => {
//
if (id.value.length < 4) {
idError.value = '아이디는 4자리 이상이어야 합니다.';
idErrorAlert.value = true;
return;
}
const response = await $api.get(`/user/checkId?memberIds=${id.value}`); const response = await $api.get(`/user/checkId?memberIds=${id.value}`);
if (!response.data.data) { if (!response.data.data) {
idErrorAlert.value = true; idErrorAlert.value = true;
@ -294,19 +275,30 @@
// , mbti, // , mbti,
const { colorList, mbtiList, pwhintList } = commonApi({ const { colorList, mbtiList, pwhintList } = commonApi({
loadColor: true, loadColor: true, colorType: 'YON',
colorType: 'YON',
loadMbti: true, loadMbti: true,
loadPwhint: true, loadPwhint: true,
}); });
// //
const handleAddressUpdate = addressData => { const handleAddressUpdate = (addressData) => {
address.value = addressData.address; address.value = addressData.address;
detailAddress.value = addressData.detailAddress; detailAddress.value = addressData.detailAddress;
postcode.value = addressData.postcode; // postcode.value = addressData.postcode; //
}; };
//
const checkPw = async () => {
if (password.value !== passwordcheck.value) {
passwordcheckError.value = '비밀번호가 일치하지 않습니다.';
passwordcheckErrorAlert.value = true;
} else {
passwordcheckError.value = '';
passwordcheckErrorAlert.value = false;
}
};
// //
const checkColorDuplicate = async () => { const checkColorDuplicate = async () => {
const response = await $api.get(`/user/checkColor?memberCol=${color.value}`); const response = await $api.get(`/user/checkColor?memberCol=${color.value}`);
@ -320,63 +312,10 @@
} }
}; };
const handleColorUpdate = async newColor => {
color.value = newColor;
colorError.value = '';
colorErrorAlert.value = false;
await checkColorDuplicate();
}
const onlyNumber = (event) => {
//
if (!/^[0-9]$/.test(event.key)) {
event.preventDefault();
}
};
const inputEvent = (e) => {
const newValue = e.target.value.replace(/\D/g, ''); //
e.target.value = newValue; //
phone.value = newValue; // Vue
};
watch(id, (newValue) => {
if (newValue && newValue.length >= 4) {
idError.value = '';
idErrorAlert.value = false;
} else if (newValue && newValue.length < 4) {
idError.value = '아이디는 4자리 이상이어야 합니다.';
idErrorAlert.value = true;
}
});
watch(password, (newValue) => {
if (newValue && newValue.length >= 4) {
passwordErrorAlert.value = false;
passwordError.value = '';
} else if (newValue && newValue.length < 4) {
passwordErrorAlert.value = true;
passwordError.value = '비밀번호는 4자리 이상이어야 합니다.';
}
});
//
watch([password, passwordcheck], ([newPassword, newPasswordCheck]) => {
if (newPassword && newPasswordCheck) {
if (newPassword !== newPasswordCheck) {
passwordcheckError.value = '비밀번호가 일치하지 않습니다.';
passwordcheckErrorAlert.value = true;
} else {
passwordcheckError.value = '';
passwordcheckErrorAlert.value = false;
}
}
});
// //
const handleSubmit = async () => { const handleSubmit = async () => {
await checkColorDuplicate(); await checkColorDuplicate();
idAlert.value = id.value.trim() === ''; idAlert.value = id.value.trim() === '';
@ -388,32 +327,6 @@
addressAlert.value = address.value.trim() === ''; addressAlert.value = address.value.trim() === '';
phoneAlert.value = phone.value.trim() === ''; phoneAlert.value = phone.value.trim() === '';
if (!colorList.value || colorList.value.length === 0) {
colorAlert.value = true;
}
//
if (id.value && id.value.length < 4) {
idErrorAlert.value = true;
idError.value = '아이디는 4자리 이상이어야 합니다.';
}
//
if (password.value && password.value.length < 4) {
passwordErrorAlert.value = true;
passwordError.value = '비밀번호는 4자리 이상이어야 합니다.';
} else {
passwordError.value = '';
}
const phoneRegex = /^010\d{8}$/;
const isFormatValid = phoneRegex.test(phone.value);
if (!/^\d+$/.test(phone.value) || !isFormatValid) {
phoneAlert.value = true;
} else {
phoneAlert.value = false;
}
// //
if (!profile.value) { if (!profile.value) {
profilerr.value = '프로필 이미지를 선택해주세요.'; profilerr.value = '프로필 이미지를 선택해주세요.';
@ -423,35 +336,20 @@
profilAlert.value = false; profilAlert.value = false;
} }
if (profilAlert.value || idAlert.value || idErrorAlert.value || passwordAlert.value || passwordcheckAlert.value ||
if ( passwordcheckErrorAlert.value || pwhintResAlert.value || nameAlert.value || birthAlert.value ||
profilAlert.value || addressAlert.value || phoneAlert.value || phoneErrorAlert.value || colorErrorAlert.value) {
idAlert.value ||
idErrorAlert.value ||
passwordAlert.value ||
passwordErrorAlert.value ||
passwordcheckAlert.value ||
passwordcheckErrorAlert.value ||
pwhintResAlert.value ||
nameAlert.value ||
birthAlert.value ||
addressAlert.value ||
phoneAlert.value ||
phoneErrorAlert.value ||
colorAlert.value ||
colorErrorAlert.value
) {
return; return;
} }
const formData = new FormData(); const formData = new FormData();
formData.append('memberIds', id.value.trim()); formData.append('memberIds', id.value);
formData.append('memberPwd', password.value.trim()); formData.append('memberPwd', password.value);
formData.append('memberPwh', pwhint.value); formData.append('memberPwh', pwhint.value);
formData.append('memberPwr', pwhintRes.value.trim()); formData.append('memberPwr', pwhintRes.value);
formData.append('memberNam', name.value.trim()); formData.append('memberNam', name.value);
formData.append('memberArr', address.value); formData.append('memberArr', address.value);
formData.append('memberDtl', detailAddress.value.trim()); formData.append('memberDtl', detailAddress.value);
formData.append('memberZip', postcode.value); formData.append('memberZip', postcode.value);
formData.append('memberBth', birth.value); formData.append('memberBth', birth.value);
formData.append('memberTel', phone.value); formData.append('memberTel', phone.value);
@ -459,7 +357,7 @@
formData.append('memberMbt', mbti.value); formData.append('memberMbt', mbti.value);
formData.append('memberPrf', profile.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) { if (response.status === 200) {
toastStore.onToast('등록신청이 완료되었습니다. 관리자 승인 후 이용가능합니다.', 's'); toastStore.onToast('등록신청이 완료되었습니다. 관리자 승인 후 이용가능합니다.', 's');
@ -467,3 +365,5 @@
} }
}; };
</script> </script>
<style></style>

View File

@ -1,9 +1,9 @@
<template> <template>
<ul v-if="displayedUserList && displayedUserList.length > 0" class="list-unstyled users-list d-flex align-items-center gap-1 flex-wrap"> <ul class="list-unstyled users-list d-flex align-items-center gap-1">
<li <li
v-for="(user, index) in displayedUserList" v-for="(user, index) in displayedUserList"
:key="index" :key="index"
class="avatar pull-up " class="avatar pull-up"
:class="{ 'opacity-100': isUserDisabled(user) }" :class="{ 'opacity-100': isUserDisabled(user) }"
@click.stop="showOnlyActive ? null : toggleDisable(index)" @click.stop="showOnlyActive ? null : toggleDisable(index)"
:style="showOnlyActive ? 'cursor: default' : ''" :style="showOnlyActive ? 'cursor: default' : ''"
@ -14,7 +14,7 @@
:data-bs-original-title="getTooltipTitle(user)" :data-bs-original-title="getTooltipTitle(user)"
> >
<img <img
class="user-avatar border border-3 rounded-circle object-fit-cover" class="rounded-circle user-avatar border border-3"
:class="{ 'grayscaleImg': isUserDisabled(user) }" :class="{ 'grayscaleImg': isUserDisabled(user) }"
:src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`"
:style="`border-color: ${user.usercolor} !important;`" :style="`border-color: ${user.usercolor} !important;`"
@ -23,21 +23,17 @@
/> />
</li> </li>
</ul> </ul>
<span v-else >-</span>
</template> </template>
<script setup> <script setup>
import { onMounted, ref, nextTick, computed, watch } from 'vue'; import { onMounted, ref, nextTick, computed } from 'vue';
import { useUserStore } from '@s/userList'; import { useUserStore } from '@s/userList';
import $api from '@api'; import $api from '@api';
import { useToastStore } from "@s/toastStore";
const emit = defineEmits(['user-list-update']); const emit = defineEmits(['user-list-update']);
const userStore = useUserStore(); const userStore = useUserStore();
const userList = ref([]); const userList = ref([]);
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, ''); const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userProjectPeriods = ref([]);
const toastStore = useToastStore();
const props = defineProps({ const props = defineProps({
projctSeq: { projctSeq: {
@ -47,39 +43,9 @@ const props = defineProps({
showOnlyActive: { showOnlyActive: {
type: Boolean, type: Boolean,
default: false default: false
},
role: {
type:String,
required:false
} }
}); });
//
const originalDisabledUsers = ref([]);
const resetSelection = async () => {
//
if (props.projctSeq) {
//
userList.value = userList.value.map(user => ({
...user,
PROJCTYON: '1'
}));
//
await fetchProjectParticipation();
} else {
//
userList.value = userList.value.map(user => ({
...user,
disabled: false //
}));
}
emitUserListUpdate();
};
// computed // computed
const sortedUserList = computed(() => { const sortedUserList = computed(() => {
return [...userList.value].sort((a, b) => { return [...userList.value].sort((a, b) => {
@ -111,37 +77,9 @@ const fetchProjectParticipation = async () => {
...user, ...user,
PROJCTYON: projectMembers.find(pm => pm.MEMBERSEQ === user.MEMBERSEQ)?.PROJCTYON ?? '1' PROJCTYON: projectMembers.find(pm => pm.MEMBERSEQ === user.MEMBERSEQ)?.PROJCTYON ?? '1'
})); }));
//
originalDisabledUsers.value = userList.value
.filter(user => user.PROJCTYON === '0')
.map(user => user.MEMBERSEQ);
emitUserListUpdate();
} }
} }
}; };
//
const fetchUserProjectPeriods = async () => {
if (props.projctSeq) {
try {
const response = await $api.get(`project/period/${props.projctSeq}`);
if (response.status === 200) {
userProjectPeriods.value = response.data.data;
}
} catch (error) {
console.error('프로젝트 참여 기간 조회 실패:', error);
}
}
};
// projctSeq
watch(() => props.projctSeq, async (newVal, oldVal) => {
if (newVal !== oldVal) {
await fetchProjectParticipation();
await fetchUserProjectPeriods();
}
}, { immediate: true });
// //
onMounted(async () => { onMounted(async () => {
@ -150,56 +88,52 @@ onMounted(async () => {
if (props.projctSeq) { if (props.projctSeq) {
await fetchProjectParticipation(); await fetchProjectParticipation();
await fetchUserProjectPeriods();
} else {
// projctSeq , emit
emitUserListUpdate();
} }
nextTick(() => { nextTick(() => {
initTooltips(); const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
new bootstrap.Tooltip(tooltip);
});
}); });
}); });
//
const initTooltips = () => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
if (tooltip._tooltip) {
tooltip._tooltip.dispose();
}
new bootstrap.Tooltip(tooltip);
});
};
// //
const isUserDisabled = (user) => { const isUserDisabled = (user) => {
return props.projctSeq ? user.PROJCTYON === '0' : user.disabled; return props.projctSeq ? user.PROJCTYON === '0' : user.disabled;
}; };
// ( API ) // / DB
const toggleDisable = (index) => { // showOnlyActive true toggleDisable
if (props.showOnlyActive) return; const toggleDisable = async (index) => {
if (props.showOnlyActive) return; // showOnlyActive true
const user = displayedUserList.value[index]; const user = displayedUserList.value[index];
//
if (props.role === 'vote') {
if (user.MEMBERSEQ === userStore.userInfo.id) {
toastStore.onToast('본인은 비활성화할 수 없습니다.', 'e');
return;
}
}
if (user) { if (user) {
// const newParticipationStatus = props.projctSeq
? user.PROJCTYON === '1'
: !user.disabled;
if (props.projctSeq) { if (props.projctSeq) {
user.PROJCTYON = user.PROJCTYON === '1' ? '0' : '1'; const response = await $api.patch('project/updateYon', {
memberSeq: user.MEMBERSEQ,
projctSeq: props.projctSeq,
projctYon: newParticipationStatus ? '0' : '1'
});
if (response.status === 200) {
// userList
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
if (originalIndex !== -1) {
userList.value[originalIndex].PROJCTYON = newParticipationStatus ? '0' : '1';
}
}
} else { } else {
user.disabled = !user.disabled; // userList
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
if (originalIndex !== -1) {
userList.value[originalIndex].disabled = newParticipationStatus;
emitUserListUpdate();
}
} }
emitUserListUpdate();
} }
}; };
@ -210,51 +144,7 @@ const emitUserListUpdate = () => {
emit('user-list-update', { activeUsers, disabledUsers }); emit('user-list-update', { activeUsers, disabledUsers });
}; };
//
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
//
const getTooltipTitle = (user) => { const getTooltipTitle = (user) => {
const userName = user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM; return user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM;
if (props.projctSeq) {
const periodInfo = userProjectPeriods.value.find(p => p.MEMBERSEQ === user.MEMBERSEQ);
if (periodInfo) {
return `${formatDate(periodInfo.userStartDate)} ~ ${formatDate(periodInfo.userEndDate)}`;
}
}
return userName;
}; };
const hasUserChanges = () => {
if (!props.projctSeq) return false;
const currentDisabledUserIds = userList.value
.filter(user => user.PROJCTYON === '0')
.map(user => user.MEMBERSEQ);
//
if (currentDisabledUserIds.length !== originalDisabledUsers.value.length) {
return true;
}
// ID
return currentDisabledUserIds.some(id => !originalDisabledUsers.value.includes(id)) ||
originalDisabledUsers.value.some(id => !currentDisabledUserIds.includes(id));
};
// expose
defineExpose({
resetSelection,
fetchProjectParticipation,
hasUserChanges
});
</script> </script>

View File

@ -1,35 +1,28 @@
<template> <template>
<ul class="row gx-2 mb-0 list-inline "> <div class="card-body d-flex justify-content-center m-n5">
<li <ul class="list-unstyled profile-list">
v-for="(user, index) in sortedUserList" <li
:key="index" v-for="(user, index) in sortedUserList"
class="col-4 mb-3" :key="index"
:class="{ newRow: (index + 1) % 4 === 0 }" :class="{ disabled: user.disabled }"
@click="$emit('profileClick', user)" @click="$emit('profileClick', user)"
data-bs-placement="top" data-bs-placement="top"
:aria-label="user.MEMBERSEQ" :aria-label="user.MEMBERSEQ"
> >
<div class="ratio ratio-1x1 mb-0 profile-list position-relative">
<img <img
v-if="user.MEMBERSEQ === employeeId" class="rounded-circle profile-img"
src="/img/icons/Crown.png"
alt="Crown"
class="start-50 translate-middle crown-icon"
/>
<img
class="rounded-circle object-fit-cover"
:src="getUserProfileImage(user.MEMBERPRF)" :src="getUserProfileImage(user.MEMBERPRF)"
alt="user" alt="user"
:style="getDynamicStyle(user)" :style="getDynamicStyle(user)"
@error="setDefaultImage" @error="setDefaultImage"
@load="showImage" @load="showImage"
/> />
</div> <span class="mt-2 text-sm-center d-block fs-6 remaining-vacation">
<span class="mt-2 text-sm-center d-block fs-6 remaining-vacation"> {{ remainingVacationData[user.MEMBERSEQ] || 0 }}
{{ remainingVacationData[user.MEMBERSEQ] || 0 }} </span>
</span> </li>
</li> </ul>
</ul> </div>
</template> </template>
<script setup> <script setup>
@ -76,13 +69,10 @@ nextTick(() => {
}); });
const sortedUserList = computed(() => { const sortedUserList = computed(() => {
if (!employeeId.value) return []; if (!employeeId.value) return userList.value;
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value);
// ( ) const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value);
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value); return myProfile ? [myProfile, ...otherUsers] : userList.value;
const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value);
return myProfile ? [myProfile, ...otherUsers] : otherUsers;
}); });
const getUserProfileImage = (profilePath) => const getUserProfileImage = (profilePath) =>
@ -91,18 +81,35 @@ profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}
const setDefaultImage = (event) => (event.target.src = defaultProfile); const setDefaultImage = (event) => (event.target.src = defaultProfile);
const showImage = (event) => (event.target.style.visibility = "visible"); const showImage = (event) => (event.target.style.visibility = "visible");
//
const profileSize = computed(() => {
const totalUsers = userList.value.length;
if (windowWidth.value >= 1650) {
if (totalUsers <= 10) return "68px";
if (totalUsers <= 15) return "55px";
return "45px";
} else if (windowWidth.value >= 1300) {
if (totalUsers <= 10) return "45px";
if (totalUsers <= 15) return "40px";
return "30px";
} else if (windowWidth.value >= 1024) {
if (totalUsers <= 10) return "40px";
if (totalUsers <= 15) return "30px";
return "20px";
} else {
return "20px";
}
});
const getDynamicStyle = (user) => ({ const getDynamicStyle = (user) => ({
borderWidth: "3px", width: profileSize.value,
height: profileSize.value,
borderWidth: "4px",
borderColor: user.usercolor || "#ccc", borderColor: user.usercolor || "#ccc",
borderStyle: "solid", borderStyle: "solid",
}); });
</script> </script>
<style scoped> <style scoped>
.crown-icon {
width: 90%;
height: 70%;
z-index: 0;
top: -7%
}
</style> </style>

View File

@ -1,65 +1,63 @@
<template> <template>
<div class="card mb-6" > <div class="card mb-6">
<div class="card-body " :class="{'disabled-class': data.localVote.LOCVOTDDT && (topVoters.length == 1 || data.localVote.LOCVOTRES || voteResult == 0)}" v-if="!data.localVote.LOCVOTDEL" > <div class="card-body" v-if="!data.localVote.LOCVOTDEL" >
<h5 class="card-title mb-1"> <h5 class="card-title mb-1">
<div class="list-unstyled users-list d-flex align-items-center gap-1"> <div class="list-unstyled users-list d-flex align-items-center gap-1">
<img <img
class="object-fit-cover rounded-circle user-avatar border border-3 w-px-40 h-px-40" class="rounded-circle user-avatar border border-3 w-px-40"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`" :style="`border-color: ${data.localVote.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'" alt="user"
alt="user" />
/>
<div class="w-100"> <div class="w-100">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between">
<div class="user-info"> <div class="user-info">
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6> <h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
<!-- 투표완료시 --> </div>
</div> <div class="add-btn d-flex align-items-center">
<div class="add-btn d-flex align-items-center"> <!-- 투표완료시 -->
<!-- 투표작성자만 수정/삭제/종료 가능 --> <i v-if="data.yesVotetotal == '1'" class="bx bxs-check-circle link-success"></i>
<div v-if="userStore.user.id === data.localVote.LOCVOTREG"> <!-- 투표작성자만 수정/삭제/종료 가능 -->
<button <div v-if="userStore.user.id === data.localVote.LOCVOTREG">
v-if="!data.localVote.LOCVOTDDT" <button
type="button" v-if="!data.localVote.LOCVOTDDT"
class="btn btn-label-danger btn-icon m-1" type="button"
@click="endBtn(data.localVote.LOCVOTSEQ)" class="bx btn btn-danger"
><i class="bx bx-power-off"></i> @click="endBtn(data.localVote.LOCVOTSEQ)"
</button> >종료</button>
<DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" /> <DeleteBtn @click="voteDelete(data.localVote.LOCVOTSEQ)" />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</h5> </h5>
<h5 class="mb-0">{{ data.localVote.LOCVOTTTL }} <h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}</h5>
<i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i>
</h5>
<small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small> <small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small>
<!-- 투표안했을시--> <!-- 투표안했을시-->
<div v-if="data.localVote.LOCVOTDDT && voteResult == 0"> <div v-if="data.localVote.LOCVOTDDT && data.voteResult.length == 0">
<small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small> <small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small>
</div> </div>
<div v-else> <div v-else>
<vote-card-check <vote-card-check
v-if="yesVotetotal == 0 && !data.localVote.LOCVOTDDT" v-if="data.yesVotetotal == 0"
@addContents="addContents" @addContents="addContents"
@checkedNames="checkedNames" @checkedNames="checkedNames"
:data="data.voteDetails" :data="data.voteDetails"
:voteInfo="data.localVote" :voteInfo="data.localVote"
:total="data.voteDetails.length "/> :total="data.voteDetails.length "/>
<small v-if="yesVotetotal != 0 && !data.localVote.LOCVOTDDT">투표 완료 : 종료시 투표 결과가 나타납니다.</small>
<!-- 투표 결과 --> <!-- 투표 결과 -->
<div v-if="data.localVote.LOCVOTDDT" class="mt-3"> <div v-if="data.localVote.LOCVOTDDT" class="mt-3">
<vote-result-list :data="topVoters" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES" :locvotreg="data.localVote.LOCVOTREG" /> <vote-result-list :data="data.voteResult" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES"/>
</div> </div>
<!-- 투표완/미완 인원 --> <!-- 투표완/미완 인원 -->
<vote-user-list <vote-user-list
:data="data.voteMembers"/> :data="data.voteMembers"/>
</div> </div>
</div> </div>
<div v-else class="card-body disabled-class"> <div v-else class="card-body">
<h5>{{ data.localVote.LOCVOTTTL }}</h5> <h5>{{ data.localVote.LOCVOTTTL }}</h5>
삭제된 투표입니다. 삭제된 투표입니다.
</div> </div>
@ -67,7 +65,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import EditBtn from '@c/button/EditBtn.vue'; import EditBtn from '@c/button/EditBtn.vue';
import DeleteBtn from '@c/button/DeleteBtn.vue'; import DeleteBtn from '@c/button/DeleteBtn.vue';
import voteUserList from '@c/voteboard/voteUserList.vue'; import voteUserList from '@c/voteboard/voteUserList.vue';
@ -86,50 +84,22 @@ const props = defineProps({
required: false, required: false,
}, },
}); });
const voteResult = computed(() => {
return props.data.voteDetails.reduce((sum, item) => sum + item.VOTE_COUNT, 0);
});
const yesVotetotal = computed(() => {
return props.data.voteDetails.reduce((sum, item) => sum + item.yesvote, 0);
});
// (1)
const topVoters = computed(() => {
// VOTE_COUNT
const maxVoteCount = Math.max(...props.data.voteDetails.map(item => item.VOTE_COUNT));
// VOTE_COUNT
return props.data.voteDetails.filter(item => item.VOTE_COUNT === maxVoteCount);
});
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, ''); const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const offset = new Date().getTimezoneOffset() * 60000 const currentDate = new Date();
const today = new Date(Date.now() - offset); const voteEndDate = new Date(props.data.localVote.formatted_LOCVOTEDT.replace(' ', 'T'));
const currentDate = today.toISOString().substring(0,16); voteEndDate.setDate(voteEndDate.getDate() + 1);
const voteEndDate = props.data.localVote.LOCVOTEDT.substring(0,16);
// //
const isVoteEnded = computed(() => { const isVoteEnded = computed(() => {
return currentDate > voteEndDate; return currentDate > voteEndDate;
}); });
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','randomList','voteDelete','updateVote']); const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','randomList','voteDelete','updateVote']);
onMounted(() => { onMounted(() => {
if (isVoteEnded.value && !props.data.localVote.LOCVOTDDT) { if (isVoteEnded.value && !props.data.localVote.LOCVOTDDT) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ }); emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
} }
checkVoteCompletion();
}); });
//
watch(() => props.data.localVote.total_voted, () => {
checkVoteCompletion();
});
//
const checkVoteCompletion = () => {
if (props.data.localVote.total_votable === props.data.localVote.total_voted && props.data.localVote.LOCVOTDDT == null) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
}
};
const addContents = (itemList, voteId) =>{ const addContents = (itemList, voteId) =>{
emit('addContents',itemList,voteId) emit('addContents',itemList,voteId)
} }
@ -137,6 +107,7 @@ const checkedNames = (numList) =>{
emit('checkedNames',numList); emit('checkedNames',numList);
} }
const endBtn = (voteid) =>{ const endBtn = (voteid) =>{
voteEndDate.setTime(currentDate.getTime()); //
emit('endVoteId',voteid); emit('endVoteId',voteid);
} }
const voteDelete = (voteid) =>{ const voteDelete = (voteid) =>{

View File

@ -1,50 +1,43 @@
<template> <template>
<div class="card-text"> <div class="card-text">
<!-- 투표리스트 --> <div class="demo-inline-spacing mt-4">
<div v-for="(item, index) in data" :key="index" class="mt-3"> <!-- 투표리스트 -->
<vote-card-check-list <div v-for="(item, index) in data"
:data="item" :key="index">
:multiIs = voteInfo.LOCVOTMUL <vote-card-check-list
:selectedValues="checkedNames" :data="item"
@update:selectedValues="updateCheckedNames" :multiIs = voteInfo.LOCVOTMUL
/> :selectedValues="checkedNames"
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1"> @update:selectedValues="updateCheckedNames"
<div v-for="(item, index) in itemList" :key="index" class=" mt-2"> />
<form-input <div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1" class="d-flex align-items-center">
:title="'항목 ' + (index + data.length + 1)" <div class="d-flex flex-column gap-2">
:name="'content' + index" <div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-start">
:is-essential="false" <form-input
:is-alert="contentAlerts[index]" class="flex-grow-1 me-2"
v-model="item.content" :title="'항목 ' + (index + data.length + 1)"
:is-btn="true" :name="'content' + index"
@keyup="ValidHandler('content' + (index + 1))" :is-essential="false"
> :is-alert="contentAlerts[index]"
<template v-slot:append> v-model="item.content"
<delete-btn @click="removeItem(index)" /> />
</template> <link-input v-model="item.url" />
</form-input> <delete-btn @click="removeItem(index)" class="ms-2" />
<form-input
:title="'URL ' + (index + data.length + 1)"
:name="'url' + index"
v-model="item.url"
:is-essential="false"
class="mb-1"
:maxlength="maxLength"
/>
</div>
<div class="d-flex justify-content align-items-center mt-3">
<plus-btn @click="addItem" :disabled=" total >= 10" />
<button class="btn btn-primary btn-icon m-1" @click="addContentSave(item.LOCVOTSEQ ,index)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div> </div>
<div class="d-flex"> <div class="mb-4 d-flex justify-content">
<save-btn class="mt-2 ms-auto" @click="selectVote"/> <plus-btn @click="addItem" :disabled="total >= 10" class="mb-3" />
<button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div> </div>
</div>
</div>
</div> </div>
<save-btn class="btn-sm mt-2" @click="selectVote"/>
</template> </template>
<script setup> <script setup>
@ -54,20 +47,20 @@ import SaveBtn from '@c/button/SaveBtn.vue'
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import voteCardCheckList from '@c/voteboard/voteCardCheckList.vue'; import voteCardCheckList from '@c/voteboard/voteCardCheckList.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
import { voteCommon } from '@s/voteCommon'; import { voteCommon } from '@s/voteCommon';
import DeleteBtn from "@c/button/DeleteBtn.vue"; import DeleteBtn from "@c/button/DeleteBtn.vue";
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import router from '@/router'; import router from '@/router';
const toastStore = useToastStore(); const toastStore = useToastStore();
const contentAlerts = ref([false, false]); const contentAlerts = ref(false);
const titleAlert = ref(false); const titleAlert = ref(false);
const title = ref(''); const title = ref('');
const rink = ref(''); const rink = ref('');
const maxLength = ref(2000);
const { itemList, addItem, removeItem } = voteCommon(true); const { itemList, addItem, removeItem } = voteCommon(true);
const total = computed(() => props.total + itemList.value.length); const total = computed(() => props.total + itemList.value.length);
const isSaveDisabled = computed(() => { const isSaveDisabled = computed(() => {
return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim() && !item.url.trim()); return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim());
}); });
const props = defineProps({ const props = defineProps({
data: { data: {
@ -85,43 +78,18 @@ const props = defineProps({
}); });
const emit = defineEmits(['addContents','checkedNames']); const emit = defineEmits(['addContents','checkedNames']);
// //
const addContentSave = (voteId,index) =>{ const addContentSave = (voteId) =>{
let valid = true;
const filteredItemList = itemList.value.filter(item => item.content && item.content.trim() !== ''); const filteredItemList = itemList.value.filter(item => item.content && item.content.trim() !== '');
emit('addContents',filteredItemList,voteId);
itemList.value.forEach((item, index) => { itemList.value = [{ content: "", url: "" }];
if (!item.content.trim() && item.url.trim()) {
contentAlerts.value[index] = true;
valid = false;
} else {
contentAlerts.value[index] = false;
}
});
if(valid){
emit('addContents',filteredItemList,voteId);
itemList.value = [{ content: "", url: "" }];
removeItem();
}
} }
const ValidHandler = (field) => {
if (field.startsWith('content')) {
const index = parseInt(field.replace('content', '')) - 1;
if (!isNaN(index)) {
contentAlerts.value[index] = false;
}
}
};
const checkedNames = ref([]); // const checkedNames = ref([]); //
const updateCheckedNames = (newValues) => { const updateCheckedNames = (newValues) => {
checkedNames.value = newValues; checkedNames.value = newValues;
}; };
const selectVote = () =>{ const selectVote = () =>{
if(checkedNames.value != ''){ emit('checkedNames',checkedNames.value);
emit('checkedNames',checkedNames.value);
}
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="list-group"> <div class="list-group">
<label class="list-group-item" style="cursor: pointer;"> <label class="list-group-item">
<input <input
class="form-check-input me-1" class="form-check-input me-1"
:name="data.LOCVOTSEQ" :name="data.LOCVOTSEQ"
@ -10,12 +10,7 @@
@change="handleChange" @change="handleChange"
> >
{{ data.LOCVOTCON }} {{ data.LOCVOTCON }}
<a :style="{ maxWidth: (data.LOCVOTLIK.length * 1) + 'ch' }" <a v-if="data.LOCVOTLIK" :href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK" target="_blank">
v-if="data.LOCVOTLIK"
:href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK"
class="text-truncate"
target="_blank"
rel="noopener noreferrer">
{{ data.LOCVOTLIK }} {{ data.LOCVOTLIK }}
</a> </a>
</label> </label>
@ -45,6 +40,7 @@ const emit = defineEmits(["update:selectedValues"]);
const handleChange = (event) => { const handleChange = (event) => {
const value = event.target.value; const value = event.target.value;
let updatedValues = []; let updatedValues = [];
// //
if (props.multiIs === "1") { if (props.multiIs === "1") {
updatedValues = event.target.checked updatedValues = event.target.checked
@ -56,17 +52,8 @@ const handleChange = (event) => {
} }
emit("update:selectedValues", updatedValues); emit("update:selectedValues", updatedValues);
}; };
const preventLinkMove = (event) =>{
event.preventDefault();
}
</script> </script>
<style> <style>
a {
display: block; /* 링크 텍스트에만 영역 적용 */
max-width: 500px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style> </style>

View File

@ -1,18 +1,18 @@
<template> <template>
<div> <div>
<card <card
@addContents="addContents" @addContents="addContents"
@checkedNames="checkedNames" @checkedNames="checkedNames"
@endVoteId="endVoteId" @endVoteId="endVoteId"
@voteEnded="voteEnded" @voteEnded="voteEnded"
@voteDelete="voteDelete" @voteDelete="voteDelete"
@randomList="randomList" @randomList="randomList"
@updateVote="updateVote" @updateVote="updateVote"
v-for="(item, index) in data" v-for="(item, index) in data"
:key="index" :key="index"
:data="item" :data="item"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
@ -24,7 +24,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete','randomList']); const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete']);
const addContents = (itemList ,voteId) =>{ const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId); emit('addContents',itemList ,voteId);
} }

View File

@ -3,7 +3,7 @@
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0"> <li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center"> <div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group"> <ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<vote-complete-user-list-card :data="data"/> <vote-complete-user-list-card :data="data"/>
</ul> </ul>
</div> </div>

View File

@ -7,9 +7,9 @@
:aria-label="data.MEMBERSEQ" :aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)"> :data-bs-original-title="getTooltipTitle(data)">
<img <img
class="rounded-circle user-avatar border border-3 object-fit-cover" class="rounded-circle user-avatar border border-3"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`" :style="`border-color: ${data.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'" @error="$event.target.src = '/img/icons/icon.png'"
alt="user" alt="user"
/> />

View File

@ -2,11 +2,11 @@
<div class="d-flex align-items-center "> <div class="d-flex align-items-center ">
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0"> <li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center"> <div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group "> <ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<vote-in-complete-user-list-card :data="data" /> <vote-in-complete-user-list-card :data="data" />
</ul> </ul>
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -6,13 +6,13 @@
class="avatar pull-up" class="avatar pull-up"
:aria-label="data.MEMBERSEQ" :aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)"> :data-bs-original-title="getTooltipTitle(data)">
<img <img
class="rounded-circle user-avatar border border-3 object-fit-cover" class="rounded-circle user-avatar border border-3"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`" :style="`border-color: ${data.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'" @error="$event.target.src = '/img/icons/icon.png'"
alt="user" alt="user"
/> />
</li> </li>
</template> </template>

View File

@ -1,34 +1,33 @@
<template> <template>
<div class="mb-2 row"> <div class="d-flex align-items-center">
<!-- 링크 아이콘 --> <!-- 링크 아이콘 -->
<label for="name" class="col-md-2 col-form-label"> <i class="bx bx-link-alt me-2" @click="togglePopover"></i>
<div class="d-flex align-items-center">
<!-- 링크 아이콘 -->
<i class="bx bx-link-alt me-2" @click="togglePopover"></i>
<!-- 등록된 링크, 입력창이 보이지 않고 등록된 링크만 보일 -->
<span v-if="isLinkSaved && !isPopoverVisible" class="ms-2">
<a :href="formattedLink" class="d-inline-block text-truncate" target="_blank" rel="noopener noreferrer">
{{ link }}
</a>
</span>
</div>
</label>
<!-- 링크 입력창 (옆으로 나오게) --> <!-- 링크 입력창 (옆으로 나오게) -->
<div v-if="isPopoverVisible" class="col-md-10"> <div
<div class="d-flex align-items-center"> v-if="isPopoverVisible"
<input class="popover-container d-flex align-items-center"
v-model="link" >
placeholder="URL을 입력해주세요" <input
class="form-control me-2" v-model="link"
/> placeholder="URL을 입력해주세요"
<save-btn class="btn-icon" @click="saveLink"/> class="form-control me-2"
</div> style="min-width: 200px;"
/>
<save-btn class="btn-sm" @click="saveLink"/>
</div> </div>
<!-- 등록된 링크, 입력창이 보이지 않고 등록된 링크만 보일 -->
<span v-if="isLinkSaved && !isPopoverVisible" class="ms-2">
<a :href="formattedLink" target="_blank" rel="noopener noreferrer">{{ link }}</a>
</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import SaveBtn from '@c/button/SaveBtn.vue' import SaveBtn from '@c/button/SaveBtn.vue'
import { ref, computed } from "vue"; import { ref, computed } from "vue";
const props = defineProps({ const props = defineProps({
@ -61,10 +60,4 @@ display: flex;
align-items: center; align-items: center;
gap: 8px; /* 아이콘과 입력창 간격 조정 */ gap: 8px; /* 아이콘과 입력창 간격 조정 */
} }
a {
max-width: 500px; /* 원하는 너비로 조정 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style> </style>

View File

@ -1,19 +1,19 @@
<template> <template>
<div> <div>
<ul class="timeline mb-0"> <ul class="timeline mb-0">
<li class="timeline-item ps-6 "> <li class="timeline-item ps-6 ">
<span class="timeline-indicator-advanced timeline-indicator-primary border-0 shadow-none"> <span class="timeline-indicator-advanced timeline-indicator-primary border-0 shadow-none">
<i class="icon-base bx bx-check-circle"></i> <i class="icon-base bx bx-check-circle"></i>
</span> </span>
<div class="timeline-event ps-1"> <div class="timeline-event ps-1">
<div class="timeline-header"> <div class="timeline-header">
<small class="text-primary text-uppercase">투표결과</small> <small class="text-primary text-uppercase">투표결과</small>
</div> </div>
<h6 v-if="data" class="my-50">{{ data[0].LOCVOTCON }}</h6> <h6 v-if="data" class="my-50">{{ data[0].LOCVOTCON }}</h6>
<h6 v-if="randomResultNum" class="my-50">{{randomResultNum }}</h6> <h6 v-if="randomResultNum" class="my-50">{{randomResultNum }}</h6>
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="card mb-6 border border-2 border-primary rounded primary-shadow"> <div class="card mb-6 border border-2 border-primary rounded primary-shadow">
<div class="card-body"> <div class="card-body">
<!-- 1위가 여러개일때 --> <!-- 1위가 여러개일때 -->
<vote-result-random v-if="topVoters.length > 1" :data="topVoters" :randomResultNum="randomResultNum" :locvotreg="locvotreg" <vote-result-random v-if="topVoters.length > 1" :data="topVoters" :randomResultNum="randomResultNum"
@randomList="randomList"/> @randomList="randomList"/>
<!-- 1위가 하나일때--> <!-- 1위가 하나일때-->
<vote-result-card v-if="topVoters.length == 1" :data="topVoters"/> <vote-result-card v-if="topVoters.length == 1" :data="topVoters"/>
</div> </div>
</div> </div>
</template> </template>
@ -24,10 +24,6 @@ const props = defineProps({
type: String, type: String,
required: false, required: false,
}, },
locvotreg: {
type: Number,
required: false,
},
}); });
// (1) // (1)
const topVoters = computed(() => { const topVoters = computed(() => {

View File

@ -1,22 +1,24 @@
<template> <template>
<div v-if="!isRandom && !randomResultNum"> <div v-if="!isRandom && !randomResultNum">
<h6><i class="icon-base bx bx-error icon-lg link-warning"></i> 1위가 중복 되었습니다</h6> <h6><i class="icon-base bx bx-error icon-lg link-warning"></i> 1위가 중복 되었습니다</h6>
<!-- 중복된 1 리스트 --> <!-- 중복된 1 리스트 -->
<vote-result-random-list <vote-result-random-list
v-for="(item,index) in data" v-for="(item,index) in data"
:key="index" :key="index"
:data="item" :data="item"
:randomResultNum="randomResultNum"/> :randomResultNum="randomResultNum"/>
</div> </div>
<div v-if="isRandom === false && randomResultNum"> <div v-if="isRandom === false && randomResultNum">
<vote-result-card :randomResultNum="randomResultNum"/> <vote-result-card :randomResultNum="randomResultNum"/>
</div> </div>
<button v-if="isRandom" class="btn btn-primary" type="button" disabled=""> <button v-if="isRandom" class="btn btn-primary" type="button" disabled="">
<span class="spinner-grow me-1" role="status" aria-hidden="true"></span> <span class="spinner-grow me-1" role="status" aria-hidden="true"></span>
random.. random..
</button> </button>
<div class="d-grid w-100 mt-6" v-if="userStore.user.id === locvotreg"> <div class="d-grid w-100 mt-6">
<button v-if="!isRandom && !randomResultNum" @click="randomList" class="btn btn-primary"><i class='bx bx-sync'></i></button> <button v-if="!isRandom && !randomResultNum" @click="randomList" class="btn btn-primary"><i class='bx bx-sync'></i></button>
</div> </div>
</template> </template>
@ -24,7 +26,6 @@
import voteResultRandomList from "@c/voteboard/voteResultRandomList.vue" import voteResultRandomList from "@c/voteboard/voteResultRandomList.vue"
import voteResultCard from '@c/voteboard/voteResultCard.vue'; import voteResultCard from '@c/voteboard/voteResultCard.vue';
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { useUserInfoStore } from "@s/useUserInfoStore";
const emit = defineEmits(['randomList']); const emit = defineEmits(['randomList']);
const props = defineProps({ const props = defineProps({
data: { data: {
@ -35,12 +36,7 @@ const props = defineProps({
type: String, type: String,
required: false, required: false,
}, },
locvotreg: {
type: Number,
required: false,
},
}); });
const userStore = useUserInfoStore();
const isRandom = ref(false); const isRandom = ref(false);
const randomList = () =>{ const randomList = () =>{
isRandom.value = true; isRandom.value = true;

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="d-flex align-items-start mt-3"> <div class="d-flex align-items-center">
<!--투표한 사람 목록 --> <!--투표한 사람 목록 -->
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2">
<i class='bx bxs-user-check link-info fa-2x'></i> <i class='bx bxs-user-check link-info'></i>
<vote-complete-user-list <vote-complete-user-list
v-for="(item, index) in voetedUsers" v-for="(item, index) in voetedUsers"
:key="index" :key="index"
@ -10,8 +10,8 @@
/> />
</div> </div>
<!-- 투표안한 사람 목록 --> <!-- 투표안한 사람 목록 -->
<div class="d-flex align-items-center gap-2 ms-auto flex-wrap"> <div class="d-flex align-items-center gap-2 ms-auto">
<i class='bx bxs-user-x link-danger fa-2x'></i> <i class='bx bxs-user-x link-danger'></i>
<vote-in-complete-user-list <vote-in-complete-user-list
v-for="(item, index) in noVoetedUsers" v-for="(item, index) in noVoetedUsers"
:key="index" :key="index"

View File

@ -1,121 +1,56 @@
<template> <template>
<!-- <ul class="d-flex p-0 mb-0 flex-wrap"> <div>
<li class="d-flex"> <ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<button <li v-for="char in koreanChars" :key="char" class="mt-2 me-2">
type="button"
class="alphabet-btn"
:class="{ active: selectedAl === 'all' }"
@click="selectAlphabet('all')"
> 전체 ({{ totalCount }})
</button>
</li>
</ul> -->
<div v-for="(group, groupIndex) in chunkedKoreanChars" :key="'ko-group-' + groupIndex">
<ul class="d-flex p-0 mb-0">
<li v-for="(char, index) in group" :key="char.CHARACTER_" class="d-flex">
<button <button
type="button" type="button"
class="alphabet-btn" class="btn"
:class="{ active: selectedAl === char.CHARACTER_ }" :class="selectedAlphabet === char ? 'btn-primary' : 'btn-outline-primary'"
@click="selectAlphabet(char.CHARACTER_)" @click="selectAlphabet(char)"
> >
{{ char.CHARACTER_ }} ({{ char.COUNT }}) {{ char }}
</button> </button>
<span v-if="index !== group.length - 1" class="divider">|</span>
</li> </li>
</ul> </ul>
</div> <ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<div v-for="(group, groupIndex) in chunkedEnglishChars" :key="'en-group-' + groupIndex"> <li v-for="char in englishChars" :key="char" class="mt-2 me-2">
<ul class="d-flex p-0 mb-0">
<li v-for="(char, index) in group" :key="char.CHARACTER_" class="d-flex">
<button <button
type="button" type="button"
class="alphabet-btn" class="btn"
:class="{ active: selectedAl === char.CHARACTER_ }" :class="selectedAlphabet === char ? 'btn-primary' : 'btn-outline-primary'"
@click="selectAlphabet(char.CHARACTER_)" @click="selectAlphabet(char)"
> >
{{ char.CHARACTER_ }} ({{ char.COUNT }}) {{ char }}
</button> </button>
<span v-if="index !== group.length - 1" class="divider">|</span>
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref } from 'vue';
const props = defineProps({ const koreanChars = ['ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
indexCategory: { const englishChars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
type: Array,
required: true,
},
selectedAl: {
type: String,
default: '',
required: false,
},
});
const selectedAlphabet = ref(props.selectedAl);
const totalCount = computed(() => {
return props.indexCategory.reduce((sum, item) => sum + item.COUNT, 0);
});
const chunkArray = (arr, size) => {
return arr.reduce((acc, _, i) => {
if (i % size === 0) acc.push(arr.slice(i, i + size));
return acc;
}, []);
};
const koreanChars = computed(() => {
return props.indexCategory.filter(char => /[-ㅎ가-]/.test(char.CHARACTER_));
});
const englishChars = computed(() => {
return props.indexCategory.filter(char => /^[a-zA-Z]$/.test(char.CHARACTER_));
});
const chunkedKoreanChars = computed(() => chunkArray(koreanChars.value, 5));
const chunkedEnglishChars = computed(() => chunkArray(englishChars.value, 5));
const selectedAlphabet = ref(null);
//emit
const emit = defineEmits(); const emit = defineEmits();
const selectAlphabet = (alphabet) => { const selectAlphabet = (alphabet) => {
selectedAlphabet.value = selectedAlphabet.value === alphabet ? null : alphabet; selectedAlphabet.value = selectedAlphabet.value === alphabet ? null : alphabet;
emit('update:data', selectedAlphabet.value); emit('update:data',selectedAlphabet.value);
}; };
</script> </script>
<style scoped> <style scoped>
.alphabet-btn { .btn {
background: none; min-width: 56px;
border: none;
font-size: 13px;
font-weight: bold;
color: #6c757d;
cursor: pointer;
width: 70%;
height: 40px;
transition: color 0.3s ease, font-size 0.3s ease; /* Smooth transition for color */
} }
.alphabet-btn:hover { @media (max-width: 768px) {
color: #0d6efd; .alphabet-list {
} overflow-x: scroll;
flex-wrap: nowrap !important;
.alphabet-btn.active { }
color: #0d6efd;
text-decoration: underline;
font-size: 13px; /* Keep font size fixed in active state */
}
.divider {
color: #bbb;
font-size: 14px;
font-weight: bold;
}
.flex-wrap {
flex-wrap: wrap;
} }
</style> </style>

View File

@ -1,68 +1,71 @@
<template> <template>
<li class="card p-4 mb-2"> <li class="mt-5 card p-5">
<DictWrite <DictWrite
v-if="writeStore.isItemActive(item.WRDDICSEQ)" v-if="writeStore.isItemActive(item.WRDDICSEQ)"
@close="writeStore.closeAll();" @close="writeStore.closeAll();"
:dataList="cateList" :dataList="cateList"
@addWord="editWord" @addWord="editWord"
:NumValue="item.WRDDICSEQ" :NumValue="item.WRDDICSEQ"
:formValue="item.WRDDICCAT" :formValue="item.WRDDICCAT"
:titleValue="item.WRDDICTTL" :titleValue="item.WRDDICTTL"
:contentValue="item.WRDDICCON" :contentValue="item.WRDDICCON"
:isDisabled="true" :isDisabled="userStore.user.role !== 'ROLE_ADMIN'"
:showEditBtn="true" />
@toggleEdit="toggleEdit"
/>
<div v-else> <div v-else>
<div class="d-flex align-items-center justify-content-between"> <input
<div class="d-flex align-items-center"> v-if="userStore.user.role == 'ROLE_ADMIN'"
<span class="btn btn-primary pe-none m-2" type="checkbox"
style="writing-mode: horizontal-tb;">{{ item.category }}</span> class="form-check-input admin-chk"
{{ item.WRDDICTTL }} :name="item.WRDDICSEQ"
@change="toggleCheck($event)"
>
<div class="d-flex align-ite-center">
<div class="w-100 d-flex align-items-center">
<span class="btn btn-primary pe-none">{{ item.category }}</span>
<strong class="mx-2 w-75">{{ item.WRDDICTTL }}</strong>
</div> </div>
<EditBtn
@click="toggleEdit"
:isToggleEnabled="true"
:isActive="writeStore.isItemActive(item.WRDDICSEQ)"
/>
</div> </div>
<p class="mt-5 dict-content-wrap" v-html="$common.contentToHtml(item.WRDDICCON)"></p> <p class="mt-5 dict-content-wrap" v-html="$common.contentToHtml(item.WRDDICCON)"></p>
<div class="d-flex align-items-start"> <div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
<!-- 최초 작성자 --> <div class="d-flex flex-wrap align-items-center mb-50">
<div class="d-flex flex-wrap align-items-center me-4"> <div class="avatar avatar-sm me-2">
<div class="avatar me-2"> <img
<img class="rounded-circle user-avatar"
class="rounded-circle user-avatar object-fit-cover" :src="getProfileImage(item.author.profileImage)"
:src="getProfileImage(item.author.profileImage)" alt="최초 작성자"
alt="최초 작성자" :style="{ borderColor: item.author.color}"
:style="{ borderColor: item.author.color }" @error="setDefaultImage"
@error="setDefaultImage" />
/>
</div> </div>
<div> <div>
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.author.createdAt) }}</p> <p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.author.createdAt) }}</p>
</div>
</div>
<!-- 최근 작성자 (조건부) -->
<div
v-if="item.author.createdAt !== item.lastEditor.updatedAt"
class="d-flex flex-wrap align-items-center"
>
<div class="avatar me-2">
<img
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color }"
@error="setDefaultImage"
/>
</div>
<div>
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.lastEditor.updatedAt) }}</p>
</div> </div>
</div> </div>
</div> </div>
<div
v-if="item.author.createdAt !== item.lastEditor.updatedAt"
class="d-flex justify-content-between flex-wrap gap-2 mb-2"
>
<div class="d-flex flex-wrap align-items-center mb-50">
<div class="avatar avatar-sm me-2">
<img
class="rounded-circle user-avatar"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color}"
@error="setDefaultImage"
/>
</div>
<div>
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.lastEditor.updatedAt) }}</p>
</div>
</div>
</div>
</div>
<div class="edit-btn" v-if="userStore.user.role !== 'ROLE_ADMIN'">
<EditBtn ref="writeButton" @click="writeStore.toggleItem(item.WRDDICSEQ)" :isToggleEnabled="true"/>
</div> </div>
</li> </li>
</template> </template>
@ -70,7 +73,7 @@
<script setup> <script setup>
import axios from "@api"; import axios from "@api";
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { getCurrentInstance, nextTick, ref } from 'vue'; import { getCurrentInstance, ref } from 'vue';
import EditBtn from '@/components/button/EditBtn.vue'; import EditBtn from '@/components/button/EditBtn.vue';
import $api from '@api'; import $api from '@api';
import DictWrite from './DictWrite.vue'; import DictWrite from './DictWrite.vue';
@ -121,12 +124,12 @@ const editWord = (data) => {
}) })
.then((res) => { .then((res) => {
if (res.data.data === 1) { if (res.data.data === 1) {
toastStore.onToast('용어가 수정되었습니다.', 's'); toastStore.onToast('용어가 수정되었습니다.', 's');
writeStore.closeAll(); writeStore.closeAll();
if (writeButton.value) { if (writeButton.value) {
writeButton.value.resetButton(); writeButton.value.resetButton();
} }
emit('refreshWordList',data.category); emit('refreshWordList');
} else { } else {
console.warn('⚠️ 서버 응답이 예상과 다릅니다:', res.data); console.warn('⚠️ 서버 응답이 예상과 다릅니다:', res.data);
toastStore.onToast('용어 수정이 정상적으로 처리되지 않았습니다.', 'e'); toastStore.onToast('용어 수정이 정상적으로 처리되지 않았습니다.', 'e');
@ -151,10 +154,11 @@ const setDefaultImage = (event) => {
event.target.src = defaultProfile; event.target.src = defaultProfile;
}; };
const toggleEdit = async () => {
writeStore.toggleItem(props.item.WRDDICSEQ);
};
//
const toggleCheck = (event) => {
emit('updateChecked', event.target.checked, props.item.WRDDICSEQ, event.target.name);
};
</script> </script>
<style scoped> <style scoped>
@ -164,6 +168,7 @@ const toggleEdit = async () => {
.user-avatar { .user-avatar {
border: 3px solid; border: 3px solid;
padding: 0.1px;
} }
.edit-btn { .edit-btn {
@ -178,21 +183,4 @@ const toggleEdit = async () => {
top: -0.5rem; top: -0.5rem;
--bs-form-check-bg: #fff; --bs-form-check-bg: #fff;
} }
.btn.btn-primary { </style>
writing-mode: horizontal-tb;
}
.dict-content-wrap {
max-width: 100%;
overflow-x: auto;
word-break: break-word;
word-wrap: break-word;
white-space: normal;
box-sizing: border-box;
}
.dict-content-wrap * {
max-width: 100% !important;
box-sizing: border-box !important;
word-break: break-word;
white-space: normal !important;
}
</style>

View File

@ -1,42 +1,37 @@
<template> <template>
<div v-if="dataList.length > 0" > <div>
<FormSelect <div class="row">
name="cate" <div class="col-10">
title="카테고리" <FormSelect
:data="dataList" name="cate"
:is-common="true" title="카테고리 선택"
@update:data="selectCategory = $event" :data="dataList"
@change="onChange" :is-common="true"
:value="formValue" @update:data="selectCategory = $event"
:is-essential="false" @change="onChange"
:is-btn="true" :value="formValue"
> :disabled="isDisabled"
<template v-slot:append> />
<div>
<PlusBtn v-if="!showInput && !isDisabled" @click="toggleInput"/>
<EditBtn
v-if="showEditBtn"
@click="$emit('toggleEdit')"
:isToggleEnabled="true"
:isActive="writeStore.isItemActive(NumValue)"
/>
</div> </div>
</template> <div class="col-2 btn-margin" v-if="!isDisabled">
</FormSelect> <PlusBtn @click="toggleInput" />
</div> </div>
<div v-if="dataList.length === 0 || showInput"> </div>
<FormInput
class="justify-content-end"
ref="categoryInputRef"
title="새 카테고리"
:isLabel="dataList.length === 0 ?true : false"
name="새 카테고리"
@update:modelValue="addCategory = $event"
:is-cate-alert="addCategoryAlert"
@focusout="handleCategoryFocusout(addCategory)"
/> <div class="row" v-if="showInput">
<div class="col-10">
<FormInput
ref="categoryInputRef"
title="카테고리 입력"
name="카테고리"
@update:modelValue="addCategory = $event"
:is-cate-alert="addCategoryAlert"
@focusout="handleCategoryFocusout(addCategory)"
/>
</div>
</div>
</div> </div>
<div class="dict-w">
<FormInput <FormInput
title="용어" title="용어"
type="text" type="text"
@ -46,197 +41,171 @@
:modelValue="titleValue" :modelValue="titleValue"
@update:modelValue="wordTitle = $event" @update:modelValue="wordTitle = $event"
:disabled="isDisabled" :disabled="isDisabled"
@keyup="ValidHandler('title')"
/> />
<div> </div>
<QEditor class="q-editor-container" @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/> <div>
<div class="text-end mt-5"> <QEditor
<button class="btn btn-primary" @click="saveWord" :disabled="titleValue ? !changed : false"> @update:data="content = $event"
<i class="bx bx-check"></i> @update:imageUrls="imageUrls = $event"
</button> :is-alert="wordContentAlert"
</div> :initialData="contentValue"
/>
<div class="text-end mt-5">
<button class="btn btn-primary" @click="saveWord">
<i class="bx bx-check"></i>
</button>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { defineProps, computed, ref, defineEmits } from 'vue'; import { defineProps, computed, ref, defineEmits } from 'vue';
import QEditor from '@/components/editor/QEditor.vue'; import QEditor from '@/components/editor/QEditor.vue';
import FormInput from '@/components/input/FormInput.vue'; import FormInput from '@/components/input/FormInput.vue';
import FormSelect from '@/components/input/FormSelect.vue'; import FormSelect from '@/components/input/FormSelect.vue';
import PlusBtn from '../button/PlusBtn.vue'; import PlusBtn from '../button/PlusBtn.vue';
import EditBtn from '../button/EditBtn.vue';
import { useWriteVisibleStore } from '@s/writeVisible';
const props = defineProps({
dataList: {
type: Array,
default: () => []
},
NumValue : {
type: Number
},
formValue : {
type:[String, Number]
},
titleValue : {
type:String,
},contentValue : {
type:String,
},
isDisabled: {
type: Boolean,
default: false
},
showEditBtn: {
type: Boolean,
default: false
}
});
const writeStore = useWriteVisibleStore(); const emit = defineEmits(['close', 'addCategory', 'addWord']);
const emit = defineEmits(['close','addCategory','addWord', 'toggleEdit']); //
const wordTitle = ref('');
const addCategory = ref('');
const content = ref('');
const imageUrls = ref([]);
// // Vaildation
const wordTitle = ref(''); const wordTitleAlert = ref(false);
const addCategory = ref(''); const wordContentAlert = ref(false);
const content = ref(''); const addCategoryAlert = ref(false);
const imageUrls = ref([]);
// Vaildation
const wordTitleAlert = ref(false);
const wordContentAlert = ref(false);
const addCategoryAlert = ref(false);
const changed = ref(false);
//
const selectCategory = ref('');
// //
const computedTitle = computed(() => const selectCategory = ref('');
wordTitle.value === '' ? props.titleValue : wordTitle.value
);
// //
const selectedCategory = computed(() => const computedTitle = computed(() => (wordTitle.value === '' ? props.titleValue : wordTitle.value));
selectCategory.value === '' ? props.formValue : selectCategory.value
); //
const selectedCategory = computed(() => (selectCategory.value === '' ? props.formValue : selectCategory.value));
// ref // ref
const categoryInputRef = ref(null); const categoryInputRef = ref(null);
const props = defineProps({
dataList: {
type: Array,
default: () => [],
},
NumValue: {
type: Number,
},
formValue: {
type: [String, Number],
},
titleValue: {
type: String,
},
contentValue: {
type: String,
},
isDisabled: {
type: Boolean,
default: false,
},
});
// //
const showInput = ref(false); const showInput = ref(false);
// //
const toggleInput = () => { const toggleInput = () => {
showInput.value = !showInput.value; showInput.value = !showInput.value;
};
const onChange = (newValue) => {
selectCategory.value = newValue.target.value;
changed.value = true;
};
const ValidHandler = (field) => {
if(field == 'title'){
wordTitleAlert.value = false;
}
if(field == 'content'){
wordContentAlert.value = false;
}
}
const handleContentUpdate = (newContent) => {
content.value = newContent;
const oldContent = typeof props.contentValue === 'string'? JSON.parse(props.contentValue) : props.contentValue;
const newContentOps = newContent?.ops || [];
const oldContentJson = JSON.stringify(oldContent);
const newContentJson = JSON.stringify(newContentOps);
// changed = true;
changed.value = oldContentJson !== newContentJson;
ValidHandler("content"); //
};
//
const saveWord = () => {
let valid = true;
//validation
let computedTitleTrim;
if(computedTitle.value != undefined){
computedTitleTrim = computedTitle.value.trim()
}
//
if(computedTitleTrim == undefined || computedTitleTrim == ''){
wordTitleAlert.value = true;
valid = false;
} else {
wordTitleAlert.value = false;
}
//
let inserts = [];
if (inserts.length === 0 && content.value?.ops?.length > 0) {
inserts = content.value.ops.map(op =>
typeof op.insert === 'string' ? op.insert.trim() : op.insert
);
}
//
if(content.value == '' || inserts.join('') === ''){
wordContentAlert.value = true;
valid = false;
}else{
wordContentAlert.value = false;
}
const wordData = {
id: props.NumValue || null,
title: computedTitle.value.trim(),
category: selectedCategory.value,
content: content.value,
}; };
if(valid){
emit('addWord', wordData, addCategory.value.trim() === ''
? (isNaN(selectedCategory.value) ? selectedCategory.value : Number(selectedCategory.value))
: addCategory.value);
}
}
// focusout const onChange = newValue => {
const handleCategoryFocusout = (value) => { selectCategory.value = newValue.target.value;
if (!value || value.trim() === '') { };
return;
}
const valueTrim = value.trim();
const existingCategory = props.dataList.find(item => item.label === valueTrim);
if (existingCategory) { //
addCategoryAlert.value = true; const saveWord = () => {
//validation
let computedTitleTrim;
// focus if (computedTitle.value != undefined) {
setTimeout(() => { computedTitleTrim = computedTitle.value.trim();
const inputElement = categoryInputRef.value?.$el?.querySelector('input'); }
if (inputElement) {
inputElement.focus();
}
}, 0);
} else {
addCategoryAlert.value = false;
}
};
//
if (computedTitleTrim == undefined || computedTitleTrim == '') {
wordTitleAlert.value = true;
return;
} else {
wordTitleAlert.value = false;
}
//
let inserts = [];
if (inserts.length === 0 && content.value?.ops?.length > 0) {
inserts = content.value.ops.map(op => (typeof op.insert === 'string' ? op.insert.trim() : op.insert));
}
//
if (content.value == '' || inserts.join('') === '') {
wordContentAlert.value = true;
return;
}
const wordData = {
id: props.NumValue || null,
title: computedTitle.value,
category: selectedCategory.value,
content: content.value,
};
emit('addWord', wordData, addCategory.value);
};
// focusout
const handleCategoryFocusout = value => {
const valueTrim = value.trim();
const existingCategory = props.dataList.find(item => item.label === valueTrim);
//
if (valueTrim == '') {
addCategoryAlert.value = true;
// focus
setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
} else if (existingCategory) {
addCategoryAlert.value = true;
// focus
setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
} else {
addCategoryAlert.value = false;
}
};
</script> </script>
<style>
.q-editor-container * { <style scoped>
max-width: 100% !important; .dict-w {
word-break: break-all !important; width: 83%;
box-sizing: border-box; }
white-space: normal !important;
} @media (max-width: 768px) {
.btn-margin {
margin-top: 2.5rem;
}
}
</style> </style>

View File

@ -23,8 +23,6 @@
<!-- Drag Target Area To SlideIn Menu On Small Screens --> <!-- Drag Target Area To SlideIn Menu On Small Screens -->
<div class="drag-target"></div> <div class="drag-target"></div>
<ScrollTopButton />
</div> </div>
</template> </template>
<script setup> <script setup>
@ -34,7 +32,6 @@
import TheChat from './TheChat.vue'; import TheChat from './TheChat.vue';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { wait } from '@/common/utils'; import { wait } from '@/common/utils';
import ScrollTopButton from '@c/button/ScrollTopButton.vue';
window.isDarkStyle = window.Helpers.isDarkStyle(); window.isDarkStyle = window.Helpers.isDarkStyle();
@ -51,3 +48,26 @@
loadScript('/js/main.js'); loadScript('/js/main.js');
}); });
</script> </script>
<style>
/* 중앙 콘텐츠 자동 조정 */
.layout-page {
flex-grow: 1;
min-width: 0; /* flexbox 내에서 올바른 크기 계산 */
margin-right: 20%; /* 채팅 사이드바의 너비만큼 밀리도록 설정 */
}
/* top bar 고정1 */
.layout-navbar-fixed .layout-navbar {
position: relative;
top: 0;
right: auto;
left: 0 !important;
width: 96% !important;
padding-top: 0 !important;
}
/* top bar 고정2 */
.layout-navbar-fixed .layout-wrapper:not(.layout-horizontal):not(.layout-without-menu) .layout-page {
padding-top: 0 !important;
}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<!-- Chat Sidebar --> <!-- Chat Sidebar -->
<aside id="chat-sidebar" class="chat-sidebar bg-white position-fixed top-0 end-0 z-4 vh-100 menu border shadow"> <aside id="chat-sidebar" class="chat-sidebar">
</aside> </aside>
</template> </template>
@ -25,8 +25,19 @@ const sendMessage = () => {
</script> </script>
<style scoped> <style scoped>
/* 채팅 사이드바 고정 */
.chat-sidebar { .chat-sidebar {
width: 20%; width: 20%;
height: 100vh;
position: fixed;
right: 0;
top: 0;
background: #fff;
border-left: 1px solid #ddd;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
z-index: 1000;
} }
</style> </style>

View File

@ -1,7 +1,17 @@
<template> <template>
<footer class="content-footer footer bg-footer-theme"> <footer class="content-footer footer bg-footer-theme">
<div class="container-xxl"> <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> </div>
</footer> </footer>
</template> </template>

View File

@ -74,32 +74,19 @@
<div class="text-truncate">Commuters</div> <div class="text-truncate">Commuters</div>
</RouterLink> </RouterLink>
</li> </li>
<li v-if="userId === allowedUserId" class="menu-item" :class="$route.path.includes('/authorization') ? 'active' : ''"> <!-- <li class="menu-item" :class="$route.path.includes('/sample') ? 'active' : ''">
<RouterLink class="menu-link" to="/authorization"> <RouterLink class="menu-link" to="/sample"> <i class="bi "></i>
<i class="menu-icon icon-base bx bx-user-check"></i> <i class="menu-icon tf-icons bx bx-calendar"></i>
<div class="text-truncate">Authorization</div> <div class="text-truncate">Sample</div>
</RouterLink> </RouterLink>
</li> </li> -->
<li class="menu-item" :class="$route.path.includes('/people') ? 'active' : ''">
<RouterLink class="menu-link" to="/people"> <i class="bi "></i>
<i class="menu-icon icon-base bi bi-people-fill"></i>
<div class="text-truncate">people</div>
</RouterLink>
</li>
</ul> </ul>
</aside> </aside>
<!-- / Menu --> <!-- / Menu -->
</template> </template>
<script setup> <script setup>
import { computed } from "vue"; import router from '@/router';
import { useUserInfoStore } from '@s/useUserInfoStore';
import "bootstrap-icons/font/bootstrap-icons.css";
const userStore = useUserInfoStore();
const allowedUserId = 1; // ID (!!)
const userId = computed(() => userStore.user?.id ?? null);
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@ -6,87 +6,181 @@
</a> </a>
</div> </div>
<!-- 날씨 정보 영역 --> <div class="navbar-nav-right d-flex align-items-center" id="navbar-collapse">
<div class="navbar-nav align-items-center">
<div class="d-flex align-items-center weather-box">
<img v-if="weather.icon" :src="customIconUrl" :alt="weather.description" :class="customIconClass" />
<div class="d-flex flex-column">
<span class="weather-desc">{{ weather.description }}</span>
<span class="weather-temp" v-if="weatherReady">
최저 {{ weather.tempMin }}° / 최고 {{ weather.tempMax }}°
</span>
</div>
</div>
</div>
<div class="d-flex align-items-center ms-auto" id="navbar-collapse">
<ul class="navbar-nav flex-row align-items-center ms-auto"> <ul class="navbar-nav flex-row align-items-center ms-auto">
<select class="form-select py-1 cursor-pointer" id="name" v-model="selectedProject" @change="updateSelectedProject"> <!-- <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> -->
<option v-for="item in myActiveProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
{{ item.PROJCTNAM }}
</option>
<!-- 내가 참여하지 않는 프로젝트 그룹 -->
<option v-if="otherActiveProjects.length > 0" disabled>-----------</option>
<option v-for="item in otherActiveProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
{{ item.PROJCTNAM }}
</option>
</select>
<i class="cursor-pointer p-2"></i> <i class="bx bx-bell bx-md bx-log-out cursor-pointer p-1" @click="handleLogout"></i>
<!-- Notification --> <!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-2 p-0"> <li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-2 p-1">
<a <a
class="nav-link dropdown-toggle hide-arrow p-0" class="nav-link dropdown-toggle hide-arrow p-0"
href="javascript:void(0);" href="javascript:void(0);"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
data-bs-auto-close="outside" data-bs-auto-close="outside"
aria-expanded="false" aria-expanded="false"
> >
<span class="position-relative"> <span class="position-relative">
<i class="bx bx-bell bx-md"></i> <i class="bx bx-bell bx-md"></i>
<!-- 알림이 있을 경우에만 뱃지를 표시 --> <span class="badge rounded-pill bg-danger badge-dot badge-notifications border"></span>
<span </span>
v-if="notificationCount > 0"
class="badge rounded-pill bg-danger badge-dot badge-notifications border"
></span>
</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end p-0"> <ul class="dropdown-menu dropdown-menu-end p-0">
<li class="dropdown-notifications-list scrollable-container p-3"> <li class="dropdown-menu-header border-bottom">
<!-- 알림이 없으면 "알림이 없습니다." 메시지 표시 --> <div class="dropdown-header d-flex align-items-center py-3">
<div v-if="notificationCount === 0"> <h6 class="mb-0 me-auto">Notification</h6>
알림이 없습니다. <div class="d-flex align-items-center h6 mb-0">
</div> <span class="badge bg-label-primary me-2">8 New</span>
<!-- 알림이 있을 목록 렌더링--> <a
<div v-else> href="javascript:void(0)"
<ul> class="dropdown-notifications-all p-2"
<li v-for="notification in notifications" :key="notification.id"> data-bs-toggle="tooltip"
{{ notification.text }} data-bs-placement="top"
</li> title="Mark all as read"
</ul> ><i class="bx bx-envelope-open text-heading"></i
</div> ></a>
</li> </div>
</div>
</li>
<li class="dropdown-notifications-list scrollable-container">
<ul class="list-group list-group-flush">
<li class="list-group-item list-group-item-action dropdown-notifications-item">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<img src="/img/avatars/1.png" class="rounded-circle" />
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">Congratulation Lettie 🎉</h6>
<small class="mb-1 d-block text-body">Won the monthly best seller gold badge</small>
<small class="text-muted">1h ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
<li class="list-group-item list-group-item-action dropdown-notifications-item">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<span class="avatar-initial rounded-circle bg-label-danger">CF</span>
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">Charles Franklin</h6>
<small class="mb-1 d-block text-body">Accepted your connection</small>
<small class="text-muted">12hr ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
<li class="list-group-item list-group-item-action dropdown-notifications-item marked-as-read">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<img src="/img/avatars/2.png" class="rounded-circle" />
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">New Message </h6>
<small class="mb-1 d-block text-body">You have new message from Natalie</small>
<small class="text-muted">1h ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
<li class="list-group-item list-group-item-action dropdown-notifications-item">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<span class="avatar-initial rounded-circle bg-label-success"
><i class="bx bx-cart"></i
></span>
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">Whoo! You have new order 🛒</h6>
<small class="mb-1 d-block text-body">ACME Inc. made new order $1,154</small>
<small class="text-muted">1 day ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
</ul>
</li>
<li class="border-top">
<div class="d-grid p-4">
<a class="btn btn-primary btn-sm d-flex" href="javascript:void(0);">
<small class="align-middle">View all notifications</small>
</a>
</div>
</li>
</ul> </ul>
</li> </li>
<!--/ Notification --> <!--/ Notification -->
<!-- User --> <!-- User -->
<li class="nav-item navbar-dropdown dropdown-user dropdown"> <li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow p-1" href="javascript:void(0);" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle hide-arrow p-0" href="javascript:void(0);" data-bs-toggle="dropdown">
<img <img
v-if="user" v-if="user"
:src="`${baseUrl}upload/img/profile/${user.profile}`" :src="`${baseUrl}upload/img/profile/${user.profile}`"
alt="Profile Image" alt="Profile Image"
class="w-px-40 h-px-40 rounded-circle border border-3 object-fit-cover" class="w-px-40 h-px-40 rounded-circle"
:style="`border-color: ${user.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'" @error="$event.target.src = '/img/icons/icon.png'"
/> />
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" @click="goToMyPage"> <a class="dropdown-item" href="pages-account-settings-account.html">
<i class="bx bx-user bx-md me-3"></i><span>My Page</span> <div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar avatar-online">
<img src="/img/avatars/1.png" class="w-px-40 h-auto rounded-circle" />
</div>
</div>
<div class="flex-grow-1">
<h6 class="mb-0">John Doe</h6>
<small class="text-muted">Admin</small>
</div>
</div>
</a>
</li>
<li>
<div class="dropdown-divider my-1"></div>
</li>
<li>
<a class="dropdown-item" href="pages-profile-user.html">
<i class="bx bx-user bx-md me-3"></i><span>My Profile</span>
</a> </a>
</li> </li>
<li> <li>
@ -94,192 +188,82 @@
<i class="bx bx-cog bx-md me-3"></i><span>Settings</span> <i class="bx bx-cog bx-md me-3"></i><span>Settings</span>
</a> </a>
</li> </li>
<li>
<a class="dropdown-item" href="pages-account-settings-billing.html">
<span class="d-flex align-items-center align-middle">
<i class="flex-shrink-0 bx bx-credit-card bx-md me-3"></i
><span class="flex-grow-1 align-middle">Billing Plan</span>
<span class="flex-shrink-0 badge rounded-pill bg-danger">4</span>
</span>
</a>
</li>
<li> <li>
<div class="dropdown-divider my-1"></div> <div class="dropdown-divider my-1"></div>
</li> </li>
<li> <li>
<a class="dropdown-item" @click="handleLogout" target="_blank"> <a class="dropdown-item" href="pages-pricing.html">
<i class="bx bx-dollar bx-md me-3"></i><span>Pricing</span>
</a>
</li>
<li>
<a class="dropdown-item" href="pages-faq.html">
<i class="bx bx-help-circle bx-md me-3"></i><span>FAQ</span>
</a>
</li>
<li>
<div class="dropdown-divider my-1"></div>
</li>
<li>
<a class="dropdown-item" href="auth-login-cover.html" target="_blank">
<i class="bx bx-power-off bx-md me-3"></i><span>Log Out</span> <i class="bx bx-power-off bx-md me-3"></i><span>Log Out</span>
</a> </a>
</li> </li>
</ul> </ul>
</li> </li>
<!--/ User -->
</ul> </ul>
</div> </div>
<!-- Search Small Screens -->
<div class="navbar-search-wrapper search-input-wrapper d-none">
<input type="text" class="form-control search-input container-xxl border-0" placeholder="Search..." aria-label="Search..." />
<i class="bx bx-x bx-md search-toggler cursor-pointer"></i>
</div>
</nav> </nav>
</template> </template>
<script setup> <script setup>
import { useAuthStore } from '@s/useAuthStore'; import { useAuthStore } from '@s/useAuthStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore'; import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useThemeStore } from '@s/darkmode'; import { useThemeStore } from '@s/darkmode';
import { useWeatherStore } from '@/stores/useWeatherStore'; import { onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue'; import $api from '@api';
import axios from '@api';
const user = ref(null);
//const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const baseUrl = import.meta.env.VITE_SERVER; const baseUrl = import.meta.env.VITE_SERVER;
const authStore = useAuthStore(); const authStore = useAuthStore();
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const weatherStore = useWeatherStore();
const user = ref(null); const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();
const selectedProject = ref(null);
const weather = ref({});
const dailyWeatherList = ref([]);
const notifications = ref([]);
const notificationCount = ref(0);
const weatherReady = computed(() => { onMounted(async () => {
return ( if (isDarkMode) {
weather.value && switchToDarkMode();
weather.value.tempMin !== null && } else {
weather.value.tempMax !== null && switchToLightMode();
!!weather.value.description
);
});
//
const myActiveProjects = computed(() => {
return projectStore.activeMemberProjectList || [];
});
//
const otherActiveProjects = computed(() => {
if (!projectStore.activeProjectList) return [];
// ID
const myProjectIds = myActiveProjects.value.map(p => p.PROJCTSEQ);
//
return projectStore.activeProjectList.filter(p => !myProjectIds.includes(p.PROJCTSEQ));
});
//
const updateSelectedProject = () => {
if (!selectedProject.value) return;
//
let selected = projectStore.activeProjectList.find(project => project.PROJCTSEQ === selectedProject.value);
if (selected) {
projectStore.setSelectedProject(selected);
} }
};
// await userStore.userInfo();
watch( user.value = userStore.user;
() => projectStore.selectedProject,
newProject => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
}
},
);
const customIconUrl = computed(() => {
if (weather.value.icon === '01d' || weather.value.icon === '01n') {
return '/img/icons/sunny-custom.png';
}
return `https://openweathermap.org/img/wn/${weather.value.icon}@2x.png`;
}); });
const customIconClass = computed(() => {
if (weather.value.icon === '01d' || weather.value.icon === '01n') {
return 'custom-sunny-icon';
}
return 'weather-icon';
});
// const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();
const handleLogout = async () => { const handleLogout = async () => {
await authStore.logout(); await authStore.logout();
router.push('/login'); router.push('/login');
}; };
const goToMyPage = () => {
router.push('/mypage');
};
onMounted(async () => {
// if (isDarkMode) {
// switchToDarkMode();
// } else {
// switchToLightMode();
// }
await userStore.userInfo();
user.value = userStore.user;
await projectStore.loadAllProjectLists();
//
await projectStore.getMemberProjects();
if (myActiveProjects.value.length > 0) {
const firstProject = myActiveProjects.value[0];
selectedProject.value = firstProject.PROJCTSEQ;
projectStore.setSelectedProject(firstProject);
}
//
if (route.name !== 'login' && route.name !== undefined) {
//
await weatherStore.getWeatherInfoWithCache();
weather.value = weatherStore.weather; //
dailyWeatherList.value = weatherStore.dailyWeatherList; //
}
});
</script> </script>
<style scoped> <style scoped>
.weather-icon {
width: 40%;
height: 40%;
}
.weather-desc {
font-size: 14px;
font-weight: 500;
line-height: 1.6;
}
.weather-temp {
font-size: 13px;
color: #888;
line-height: 1.2;
}
/* .weather-box {
display: flex;
align-items: center;
flex-shrink: 0;
max-width: 3000px;
white-space: nowrap;
overflow: hidden;
} */
.custom-sunny-icon {
width: 50px;
height: 50px;
object-fit: contain;
flex-shrink: 0;
}
.weather-box {
display: flex;
align-items: center;
white-space: nowrap;
gap: 10px;
min-width: 160px; /* 필요시 */
}
@media (max-width: 1200px) {
.custom-sunny-icon {
width: 40px;
}
}
@media (max-width: 1100px) {
.custom-sunny-icon {
width: 30px;
}
}
</style> </style>

View File

@ -1,31 +1,31 @@
import { createApp } from 'vue'; import { createApp } from 'vue'
import { createPinia } from 'pinia'; import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persist'; import piniaPersist from 'pinia-plugin-persist'
import App from './App.vue'; import App from './App.vue'
import router from '@/router'; import router from '@/router'
import dayjs from '@p/dayjs'; import dayjs from '@p/dayjs'
import ToastModal from '@c/modal/ToastModal.vue'; import ToastModal from '@c/modal/ToastModal.vue';
import common from '@/common/common.js'; import common from '@/common/common.js'
import { useKakao } from 'vue3-kakao-maps/@utils'; import { useKakao } from 'vue3-kakao-maps/@utils'
const pinia = createPinia(); const pinia = createPinia()
pinia.use(piniaPersist); pinia.use(piniaPersist)
const kakaoApiKey = import.meta.env.VITE_KAKAO_MAP_KEY; const kakaoApiKey = import.meta.env.VITE_KAKAO_MAP_KEY;
useKakao(kakaoApiKey, ['services']); useKakao(kakaoApiKey, ['services'])
const app = createApp(App); const app = createApp(App)
app.use(router).use(pinia).use(common).use(dayjs).component('ToastModal', ToastModal); app.use(router)
.use(pinia)
.use(common)
.use(dayjs)
.component('ToastModal',ToastModal)
.mount('#app')
// 라우트 로딩 후 앱 마우트 if (import.meta.env.MODE === "prod") {
router.isReady().then(() => {
app.mount('#app');
});
if (import.meta.env.MODE === 'prod') {
const console = window.console || {}; const console = window.console || {};
console.log = function no_console() {}; // console log 막기 console.log = function no_console() { }; // console log 막기
console.warn = function no_console() {}; // console warning 막기 console.warn = function no_console() { }; // console warning 막기
console.error = function () {}; // console error 막기 console.error = function () { }; // console error 막기
} }

View File

@ -1,53 +1,43 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@s/useAuthStore'; import { useAuthStore } from '@s/useAuthStore';
import { useUserInfoStore } from '@s/useUserInfoStore';
// 초기 렌더링 속도를 위해 지연 로딩 사용 // 초기 렌더링 속도를 위해 지연 로딩 사용
const routes = [ const routes = [
{ {
path: '/', path: '/',
name: 'Home', name: "Home",
component: () => import('@v/MainView.vue'), component: () => import('@v/MainView.vue'),
meta: { requiresAuth: true }, // meta: { requiresAuth: true }
}, },
{ {
path: '/board', path: '/board',
component: () => import('@v/board/TheBoard.vue'), component: () => import('@v/board/TheBoard.vue'),
meta: { requiresAuth: true },
children: [ children: [
{ {
path: '', path: '',
name: 'BoardList', name: 'BoardList',
component: () => import('@v/board/BoardList.vue'), component: () => import('@v/board/BoardList.vue')
}, },
{ {
path: 'write', path: 'write',
name: 'BoardWrite', component: () => import('@v/board/BoardWrite.vue')
component: () => import('@v/board/BoardWrite.vue'),
}, },
{ {
path: ':id', path: ':id',
name: 'BoardDetail', name: 'BoardDetail',
component: () => import('@v/board/BoardView.vue'), component: () => import('@v/board/BoardView.vue')
}, },
{ {
path: 'edit/:id', path: 'edit/:id',
name: 'BoardEdit', name: 'BoardEdit',
component: () => import('@v/board/BoardEdit.vue'), component: () => import('@v/board/BoardEdit.vue')
}, }
], ]
},
{
path: '/mypage',
name: 'MyPage',
component: () => import('@v/mypage/MyPage.vue'),
meta: { requiresAuth: true },
}, },
{ {
path: '/wordDict', path: '/wordDict',
name: 'WordDict',
component: () => import('@v/wordDict/wordDict.vue'), component: () => import('@v/wordDict/wordDict.vue'),
meta: { requiresAuth: true },
}, },
{ {
path: '/login', path: '/login',
@ -69,118 +59,81 @@ const routes = [
}, },
{ {
path: '/vacation', path: '/vacation',
name: 'Vacation',
component: () => import('@v/vacation/VacationManagement.vue'), component: () => import('@v/vacation/VacationManagement.vue'),
meta: { requiresAuth: true },
}, },
{ {
path: '/voteboard', path: '/voteboard',
name: 'VoteBoard',
component: () => import('@v/voteboard/TheVoteBoard.vue'), component: () => import('@v/voteboard/TheVoteBoard.vue'),
meta: { requiresAuth: true },
children: [ children: [
{ {
path: '', path: '',
name: 'VoteBoardList', component: () => import('@v/voteboard/voteBoardList.vue')
component: () => import('@v/voteboard/voteBoardList.vue'),
}, },
{ {
path: 'write', path: 'write',
name: 'VoteboardWrite', component: () => import('@v/voteboard/voteboardWrite.vue')
component: () => import('@v/voteboard/voteboardWrite.vue'),
}, },
],
]
}, },
{ {
path: '/projectlist', path: '/projectlist',
name: 'Projectlist',
component: () => import('@v/projectlist/TheProjectList.vue'), component: () => import('@v/projectlist/TheProjectList.vue'),
meta: { requiresAuth: true },
}, },
{ {
path: '/commuters', path: '/commuters',
name: 'Commuters',
component: () => import('@v/commuters/TheCommuters.vue'), component: () => import('@v/commuters/TheCommuters.vue'),
meta: { requiresAuth: true },
}, },
{ {
path: '/authorization', path: '/sample',
name: 'Authorization', component: () => import('@c/calendar/SampleCalendar.vue'),
component: () => import('@v/admin/TheAuthorization.vue'),
meta: { requiresAuth: true },
}, },
{ {
path: '/people', path: "/:anything(.*)",
name: 'people', component: () => import('@v/ErrorPage.vue'),
component: () => import('@v/people/PeopleList.vue'), }
meta: { requiresAuth: true },
},
{
path: '/error/400',
name: 'Error400',
component: () => import('@v/error/Error400.vue'),
meta: { layout: 'NoLayout' },
},
{
path: '/error/500',
name: 'Error500',
component: () => import('@v/error/Error500.vue'),
meta: { layout: 'NoLayout' },
},
{
path: '/:anything(.*)',
name: 'Error404',
component: () => import('@v/error/Error404.vue'),
meta: { layout: 'NoLayout' },
},
]; ];
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: routes, routes: routes,
}); })
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore(); const authStore = useAuthStore();
await authStore.checkAuthStatus(); // 로그인 상태 확인 await authStore.checkAuthStatus(); // 로그인 상태 확인
const allowedUserId = 1; // 특정 ID (변경필요!!)
const userStore = useUserInfoStore();
const userId = userStore.user?.id ?? null;
// 로그인이 필요한 페이지인데 로그인되지 않은 경우 → 로그인 페이지로 이동
if (to.meta.requiresAuth && !authStore.isAuthenticated) { if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return next({ name: 'Login', query: { redirect: to.fullPath } }); // 로그인이 필요한 페이지인데 로그인되지 않은 경우 → 로그인 페이지로 이동
next({ name: 'Login' });
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
next({ name: 'Home' });
} else {
next();
} }
// Authorization 페이지는 ID가 26이 아니면 접근 차단
if (to.path === '/authorization' && userId !== allowedUserId) {
return next();
}
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
if (to.meta.requiresGuest && authStore.isAuthenticated) {
return next({ name: 'Home' });
}
// 모든 조건을 통과하면 정상적으로 이동
next();
}); });
import axios from 'axios';
axios.interceptors.response.use( router.beforeEach(async (to, from, next) => {
response => response, const authStore = useAuthStore()
error => {
const status = error.response?.status;
if (status === 400) { // 최초 앱 로드 시 인증 상태 체크
router.push({ name: 'Error400' }); await authStore.checkAuthStatus()
} else if (status === 500) {
router.push({ name: 'Error500' });
}
return Promise.reject(error); // 현재 라우트에 인증이 필요한지 확인
}, const requiresAuth = to.meta.requiresAuth === true
);
export default router; if (requiresAuth && !authStore.isAuthenticated) {
// 인증되지 않은 사용자를 로그인 페이지로 리다이렉트
// 원래 가려던 페이지를 쿼리 파라미터로 전달
next({
name: 'Login',
query: { redirect: to.fullPath }
})
} else {
next()
}
})
export default router

View File

@ -1,22 +0,0 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useLoadingStore = defineStore("loading", () => {
const loadingCount = ref(0); // 요청 개수를 추적
const startLoading = () => {
loadingCount.value++;
};
const stopLoading = () => {
if (loadingCount.value > 0) {
setTimeout(() => {
loadingCount.value--;
}, 200); // 약간의 지연을 추가하여 응답이 동시에 도착해도 안정적으로 감소
}
};
const isLoading = computed(() => loadingCount.value > 0); // 하나라도 요청이 있으면 로딩 활성화
return { isLoading, startLoading, stopLoading };
});

View File

@ -1,45 +0,0 @@
/*
작성자 : 박성용
작성일 : 2025-03-14
수정자 :
수정일 :
설명 : 게시글 수정 비밀번호 적재용.
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
export const useBoardAccessStore = defineStore(
'access',
() => {
const password = ref('');
// watch(password, newValue => {
// localStorage.setItem('tempPassword', JSON.stringify(newValue.value));
// });
if (localStorage.getItem('tempPassword')) {
// 저장된 값을 불러와 상태에 할당
const tempPassword = localStorage.getItem('tempPassword');
if (typeof tempPassword === 'string') password.value = JSON.parse(tempPassword);
}
function setBoardPassword(input) {
password.value = input;
if (typeof input === 'string') JSON.stringify(localStorage.setItem('tempPassword', input));
}
function $reset() {
password.value = '';
localStorage.removeItem('tempPassword');
}
return {
password,
setBoardPassword,
$reset,
};
},
{
persist: true,
},
);

View File

@ -1,95 +0,0 @@
/*
작성자 : 박지윤
작성일 : 2025-03-25
수정자 :
수정일 :
설명 : 달력 데이트 피커
*/
import { ref } from 'vue';
import flatpickr from 'flatpickr';
import monthSelectPlugin from 'flatpickr/dist/plugins/monthSelect/index';
import 'flatpickr/dist/flatpickr.min.css';
import 'flatpickr/dist/plugins/monthSelect/style.css';
export function useDatePicker() {
let fpInstance = null;
const calendarDatepicker = ref(null);
const initDatePicker = (fullCalendarRef, onDateChange, options = {}) => {
// input 요소 동적 생성
const datePickerInput = document.createElement('input');
datePickerInput.type = 'text';
datePickerInput.style.display = 'none';
document.body.appendChild(datePickerInput);
calendarDatepicker.value = datePickerInput;
// Flatpickr 초기화
fpInstance = flatpickr(calendarDatepicker.value, {
dateFormat: "Y-m",
plugins: [
new monthSelectPlugin({
shorthand: true,
dateFormat: "Y-m",
altFormat: "F Y"
})
],
onOpen: function() {
document.querySelector('.flatpickr-input').style.visibility = 'hidden';
},
onChange: function(selectedDatesArr, dateStr) {
// 선택한 달의 첫날로 달력을 이동
if (fullCalendarRef.value) {
fullCalendarRef.value.getApi().gotoDate(dateStr + "-01");
}
const [year, month] = dateStr.split("-");
// onDateChange가 함수인 경우에만 호출
if (typeof onDateChange === 'function') {
onDateChange(parseInt(year, 10), month, options);
}
},
onClose: function() {
if (calendarDatepicker.value) {
calendarDatepicker.value.style.display = "none";
}
},
...options
});
// FullCalendar 년월월(.fc-toolbar-title) 클릭 시 데이트피커 열기
const titleEl = document.querySelector('.fc-toolbar-title');
if (titleEl) {
titleEl.style.cursor = 'pointer';
titleEl.addEventListener('click', () => {
const rect = titleEl.getBoundingClientRect();
const dpEl = calendarDatepicker.value;
dpEl.style.display = 'block';
dpEl.style.position = 'fixed';
dpEl.style.top = `${rect.bottom + window.scrollY}px`;
dpEl.style.left = `${rect.left + window.scrollX}px`;
dpEl.style.transform = 'translate(-50%, -50%)';
dpEl.style.zIndex = '9999';
dpEl.style.border = 'none';
dpEl.style.outline = 'none';
dpEl.style.backgroundColor = 'transparent';
fpInstance.open();
});
}
};
const closeDatePicker = () => {
if (fpInstance) {
fpInstance.close();
}
};
return {
initDatePicker,
closeDatePicker,
calendarDatepicker
};
}

View File

@ -6,122 +6,22 @@
설명 : 프로젝트 목록 설명 : 프로젝트 목록
*/ */
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, watch } from 'vue'; import { ref } from 'vue';
import $api from '@api'; import $api from '@api';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
export const useProjectStore = defineStore('project', () => { export const useProjectStore = defineStore('project', () => {
const projectList = ref([]); // 모든 프로젝트 (종료된 프로젝트 포함) const projectList = ref([]);
const activeProjectList = ref([]); // 진행 중인 프로젝트만 (종료된 프로젝트 제외)
const memberProjectList = ref([]); // 사용자가 속한 프로젝트
const activeMemberProjectList = ref([]); // 사용자가 속한 진행 중인 프로젝트
const selectedProject = ref(null);
const userStore = useUserInfoStore();
// 전체 프로젝트 가져오기 (종료된 프로젝트 포함 여부에 따라 다른 배열에 저장) const getProjectList = async (searchText = '', selectedYear = '') => {
const getProjectList = async (searchText = '', selectedYear = '', excludeEnded = 'false') => {
if (!userStore.user) return;
const res = await $api.get('project/select', { const res = await $api.get('project/select', {
params: { params: {
searchKeyword: searchText || '', searchKeyword: searchText || '',
category: selectedYear || '', category: selectedYear || '',
excludeEnded: excludeEnded
}, },
}); });
projectList.value = res.data.data.projectList;
if (excludeEnded === 'true') {
// 종료되지 않은 프로젝트만 저장
activeProjectList.value = res.data.data.projectList;
} else {
// 모든 프로젝트 저장 (종료된 프로젝트 포함)
projectList.value = res.data.data.projectList;
}
}; };
// 모든 프로젝트 목록 로드 (종료 여부 구분하여 모두 로드)
const loadAllProjectLists = async (searchText = '', selectedYear = '') => {
// 진행 중인 프로젝트 로드
await getProjectList(searchText, selectedYear, 'true');
// 모든 프로젝트 로드 (종료된 프로젝트 포함)
await getProjectList(searchText, selectedYear, 'false');
};
// 사용자가 속한 프로젝트 목록 가져오기 return { projectList, getProjectList };
const getMemberProjects = async () => {
if (!userStore.user) return; // 로그인한 사용자 확인
const res = await $api.get(`project/${userStore.user.id}`);
const allMemberProjects = res.data.data;
memberProjectList.value = allMemberProjects;
// 사용자가 속한 프로젝트 중 진행 중인 프로젝트만 필터링
activeMemberProjectList.value = allMemberProjects.filter(project =>
!project.ENDYN || project.ENDYN === 'N'
);
if (activeMemberProjectList.value.length > 0 && !selectedProject.value) {
setSelectedProject(activeMemberProjectList.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(activeProjectList, (newList) => {
// 선택된 프로젝트가 없고 목록이 있는 경우
if (!selectedProject.value && newList.length > 0) {
// 사용자가 속한 프로젝트가 있는지 먼저 확인
if (activeMemberProjectList.value.length > 0) {
setSelectedProject(activeMemberProjectList.value[0]);
} else {
setSelectedProject(newList[0]);
}
}
});
watch(activeMemberProjectList, (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, // 종료된 프로젝트 포함한 모든 프로젝트
activeProjectList, // 진행 중인 프로젝트만
memberProjectList, // 사용자가 속한 모든 프로젝트
activeMemberProjectList, // 사용자가 속한 진행 중인 프로젝트
selectedProject,
getProjectList,
loadAllProjectLists, // 모든 프로젝트 목록 한번에 로드
getMemberProjects,
setSelectedProject,
getSelectedProject,
};
}); });

View File

@ -1,122 +0,0 @@
/*
작성자 : 서지희
작성일 : 2025-04-04
수정일 : 2025-04-07
설명 : 위치 기반으로 날씨를 조회하고, 10 단위 캐시로 저장합니다.
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import $api from '@api';
export const useWeatherStore = defineStore('weather', () => {
const weather = ref({
icon: '',
description: '',
tempMin: null,
tempMax: null,
});
const dailyWeatherList = ref([]);
const getWeatherInfo = async () => {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(async position => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
try {
const res = await $api.get(`/weather`, {
params: { lat, lon },
withCredentials: true,
});
if (!res?.data?.data) return;
const resData = res.data.data;
const raw = resData.weatherInfo;
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.list)) {
console.error('날씨 데이터 형식 오류');
return;
}
// 검은색 태양 아이콘 변경
dailyWeatherList.value = resData.dailyWeatherList.map(w => {
return {
...w,
icon: w.icon.replace(/n$/, 'd'),
};
});
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const nowTime = now.getTime();
const todayList = data.list.filter(item => item.dt_txt.startsWith(todayStr));
if (todayList.length > 0) {
const minTemp = Math.min(...todayList.map(i => i.main.temp_min));
const maxTemp = Math.max(...todayList.map(i => i.main.temp_max));
weather.value.tempMin = Math.round(minTemp);
weather.value.tempMax = Math.round(maxTemp);
}
const closest = data.list.reduce((prev, curr) => {
const prevDiff = Math.abs(new Date(prev.dt_txt).getTime() - nowTime);
const currDiff = Math.abs(new Date(curr.dt_txt).getTime() - nowTime);
return currDiff < prevDiff ? curr : prev;
});
weather.value.icon = closest.weather[0].icon.replace(/n$/, 'd');
weather.value.description = closest.weather[0].description;
resolve({ weather: weather.value, dailyWeatherList: dailyWeatherList.value });
} catch (e) {
console.error('날씨 정보 가져오기 실패:', e);
reject(e);
}
}, reject);
});
};
const getWeatherInfoWithCache = async () => {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const key = `weather_${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(Math.floor(now.getMinutes() / 10) * 10)}`;
const cached = localStorage.getItem(key);
if (cached) {
const parsed = JSON.parse(cached);
weather.value = parsed.weather;
dailyWeatherList.value = parsed.dailyWeatherList;
return;
}
try {
const { weather: w, dailyWeatherList: d } = await getWeatherInfo();
// 기존 캐시 삭제
Object.keys(localStorage).forEach(k => {
if (k.startsWith('weather_')) localStorage.removeItem(k);
});
localStorage.setItem(key, JSON.stringify({ weather: w, dailyWeatherList: d }));
} catch (e) {
console.error('날씨 API 호출 실패, 캐시 fallback 시도 중...');
const oldKey = Object.keys(localStorage)
.filter(k => k.startsWith('weather_'))
.sort()
.pop();
if (oldKey) {
const fallback = JSON.parse(localStorage.getItem(oldKey));
weather.value = fallback.weather;
dailyWeatherList.value = fallback.dailyWeatherList;
}
}
};
return {
weather,
dailyWeatherList,
getWeatherInfo,
getWeatherInfoWithCache,
};
});

13
src/views/ErrorPage.vue Normal file
View File

@ -0,0 +1,13 @@
<template>
Error
</template>
<script>
export default {
}
</script>
<style>
</style>

View File

@ -1,71 +0,0 @@
<template>
<div v-if="isLoading" class="loading-overlay">
<div class="loading-container">
<div class="spinner">
<img src="/img/icons/loading.png" class="loading-img" />
</div>
<p class="loading-text">LOADING...</p>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
isLoading: Boolean, //
});
</script>
<style scoped>
/* 회색 배경으로 클릭 방지 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4); /* 회색 반투명 */
display: flex;
align-items: center;
justify-content: center;
z-index: 9999; /* 가장 위에 위치 */
pointer-events: auto; /* 모든 클릭 방지 */
}
/* 로딩 컨테이너 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
background: none;
border-radius: 10px;
}
.loading-img {
width: 80px; /* 원하는 크기로 조정 */
height: 80px;
}
/* 빙글빙글 돌아가는 스피너 */
.spinner {
font-size: 50px;
animation: spin 2.2s linear infinite;
}
/* 로딩 텍스트 */
.loading-text {
margin-top: 10px;
font-size: 18px;
font-weight: bold;
color: #ffffff;
}
/* 회전 애니메이션 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,120 +0,0 @@
<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="user-avatar2"
alt="프로필 사진"
@error="setDefaultImage"
/>
<!-- 사용자 정보 -->
<div class="user-info">
<h5>{{ user.name }}</h5>
</div>
<!-- 권한 토글 버튼 (기본 동작 막고 클릭시 직접 토글 처리) -->
<label class="switch me-0">
<input
type="checkbox"
:checked="user.isAdmin"
@click="handleToggle($event, 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";
const allowedUserId = 1; // ID ( )
//
async function fetchUsers() {
try {
const response = await axios.get('admin/users'); // API
if (!response.data || !Array.isArray(response.data.data)) {
throw new Error("올바른 데이터 형식이 아닙니다.");
}
users.value = response.data.data
.filter(user => user.MEMBERSEQ !== allowedUserId)
.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) {
toastStore.onToast('사용자 목록을 불러오지 못했습니다.', 'e');
}
}
//
function getProfileImage(photo) {
return photo || defaultProfile;
}
//
function setDefaultImage(event) {
event.target.src = defaultProfile;
}
//
async function handleToggle(event, user) {
// Prevent the default checkbox toggle behavior
event.preventDefault();
// : ( )
const originalState = user.isAdmin;
const newState = !originalState;
const requestData = {
id: user.id,
role: originalState ? 'MEMBER' : 'ADMIN'
};
try {
const response = await axios.put('admin/role', requestData);
if (response.status === 200) {
//
user.isAdmin = newState;
toastStore.onToast(`'${user.name}'의 권한이 '${requestData.role}'(으)로 변경되었습니다.`, 's');
} else {
throw new Error('권한 변경 실패');
}
} catch (error) {
//
toastStore.onToast('권한 변경에 실패했습니다.', 'e');
}
}
onMounted(fetchUsers);
</script>
<style scoped>
.user-avatar2 {
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 1rem auto 0 auto;
margin-top: 0px;
margin-bottom: 10px;
}
</style>

View File

@ -10,22 +10,13 @@
<div class="col-xl-12"> <div class="col-xl-12">
<div class="card-body"> <div class="card-body">
<!-- 제목 입력 --> <!-- 제목 입력 -->
<FormInput <FormInput title="제목" name="title" :is-essential="true" :is-alert="titleAlert" v-model="title" />
title="제목"
name="title"
:is-essential="true"
:is-alert="titleAlert"
v-model="title"
@update:alert="titleAlert = $event"
@input.once="validateTitle"
/>
<!-- 첨부파일 업로드 --> <!-- 첨부파일 업로드 -->
<FormFile <FormFile
title="첨부파일" title="첨부파일"
name="files" name="files"
:is-alert="attachFilesAlert" :is-alert="attachFilesAlert"
:key="autoIncrement"
@update:data="handleFileUpload" @update:data="handleFileUpload"
@update:isValid="isFileValid = $event" @update:isValid="isFileValid = $event"
/> />
@ -51,27 +42,25 @@
<div class="mb-4"> <div class="mb-4">
<label for="html5-tel-input" class="col-md-2 col-form-label"> <label for="html5-tel-input" class="col-md-2 col-form-label">
내용 내용
<span class="text-danger">*</span> <span class="text-red">*</span>
<div class="invalid-feedback" :class="contentAlert ? 'display-block' : ''">내용을 확인해주세요.</div>
</label> </label>
<div class="col-md-12"> <div class="col-md-12">
<QEditor <QEditor
v-if="contentLoaded" v-if="contentLoaded"
@update:data="handleEditorDataUpdate" @update:data="content = $event"
@update:imageUrls="imageUrls = $event" @update:imageUrls="imageUrls = $event"
@update:uploadedImgList="handleUpdateEditorImg"
@update:deleteImgIndexList="handleDeleteEditorImg"
:initialData="content" :initialData="content"
/> />
</div> </div>
<div v-if="contentAlert" class="invalid-feedback d-block">내용을 확인해주세요.</div>
</div> </div>
<!-- 버튼 --> <!-- 버튼 -->
<div class="mb-4 d-flex justify-content-end"> <div class="mb-4 d-flex justify-content-end">
<button type="button" class="btn btn-info right" @click="goBack"> <button type="button" class="btn btn-info right" @click="goList">
<i class="bx bx-left-arrow-alt"></i> <i class="bx bx-left-arrow-alt"></i>
</button> </button>
<button type="button" class="btn btn-primary ms-1" :disabled="!isChanged" @click="updateBoard"> <button type="button" class="btn btn-primary ms-1" @click="updateBoard">
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
</button> </button>
</div> </div>
@ -85,22 +74,13 @@
import QEditor from '@c/editor/QEditor.vue'; import QEditor from '@c/editor/QEditor.vue';
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import FormFile from '@c/input/FormFile.vue'; import FormFile from '@c/input/FormFile.vue';
import { ref, onMounted, computed, watch, inject } from 'vue'; import { ref, onMounted, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useToastStore } from '@s/toastStore';
import { useBoardAccessStore } from '@s/useBoardAccessStore';
import axios from '@api'; import axios from '@api';
import Quill from 'quill';
//
const $common = inject('common');
const toastStore = useToastStore();
const accessStore = useBoardAccessStore();
// //
const title = ref(''); const title = ref('');
const content = ref({ ops: [] }); const content = ref('');
const autoIncrement = ref(0);
// //
const titleAlert = ref(false); const titleAlert = ref(false);
@ -120,163 +100,85 @@
const attachFilesAlert = ref(false); const attachFilesAlert = ref(false);
const isFileValid = ref(true); const isFileValid = ref(true);
const delFileIdx = ref([]); // ID const delFileIdx = ref([]); // ID
const editorUploadedImgList = ref([]); const additionalFiles = ref([]); //
const editorDeleteImgList = ref([]);
const originalTitle = ref('');
const originalContent = ref({});
const originalFiles = ref([]);
const contentInitialized = ref(false);
//
const isFirstContentUpdate = ref(true);
// ( )
const handleEditorDataUpdate = data => {
content.value = data;
if (isFirstContentUpdate.value) {
originalContent.value = structuredClone(data);
isFirstContentUpdate.value = false;
contentInitialized.value = true;
}
};
// isDeltaChanged ( diff , , )
function isDeltaChanged(current, original) {
const Delta = Quill.import('delta');
const currentDelta = new Delta(current || []);
const originalDelta = new Delta(original || []);
//
const getPlainText = delta =>
(delta.ops || [])
.filter(op => typeof op.insert === 'string')
.map(op => op.insert)
.join('');
// URL
const getImages = delta =>
(delta.ops || [])
.filter(op => typeof op.insert === 'object' && op.insert.image)
.map(op => op.insert.image);
// URL
const getVideos = delta =>
(delta.ops || [])
.filter(op => typeof op.insert === 'object' && op.insert.video)
.map(op => op.insert.video);
const textCurrent = getPlainText(currentDelta);
const textOriginal = getPlainText(originalDelta);
const imgsCurrent = getImages(currentDelta);
const imgsOriginal = getImages(originalDelta);
const vidsCurrent = getVideos(currentDelta);
const vidsOriginal = getVideos(originalDelta);
const textEqual = textCurrent === textOriginal;
const imageEqual = imgsCurrent.length === imgsOriginal.length && imgsCurrent.every((val, idx) => val === imgsOriginal[idx]);
const videoEqual = vidsCurrent.length === vidsOriginal.length && vidsCurrent.every((val, idx) => val === vidsOriginal[idx]);
return !(textEqual && imageEqual && videoEqual);
}
//
const isChanged = computed(() => {
if (!contentInitialized.value) return false;
const isTitleChanged = title.value !== originalTitle.value;
const isContentChanged = isDeltaChanged(content.value, originalContent.value);
const isFilesChanged =
attachFiles.value.some(f => !f.id) || //
delFileIdx.value.length > 0 || //
!isSameFiles(
attachFiles.value.filter(f => f.id), //
originalFiles.value
);
return isTitleChanged || isContentChanged || isFilesChanged;
});
//
function isSameFiles(current, original) {
if (current.length !== original.length) return false;
const sortedCurrent = [...current].sort((a, b) => a.id - b.id);
const sortedOriginal = [...original].sort((a, b) => a.id - b.id);
return sortedCurrent.every((file, idx) => {
return file.id === sortedOriginal[idx].id && file.name === sortedOriginal[idx].name;
});
}
// //
const fetchBoardDetails = async () => { const fetchBoardDetails = async () => {
let password = accessStore.password; try {
const params = { const response = await axios.get(`board/${currentBoardId.value}`);
password: `${password}` || '', const data = response.data.data;
};
const { data } = await axios.post(`board/${currentBoardId.value}`, params);
if (data.code !== 200) {
alert(data.message, 'e');
router.back();
return;
}
const boardData = data.data;
if (boardData.hasAttachment && boardData.attachments.length > 0) {
const formatted = addDisplayFileName([...boardData.attachments]);
attachFiles.value = formatted;
originalFiles.value = formatted;
}
title.value = boardData.title || '제목 없음';
content.value = boardData.content || { ops: [] };
originalTitle.value = title.value;
originalContent.value = structuredClone(boardData.content);
contentInitialized.value = true;
contentLoaded.value = true;
};
const handleUpdateEditorImg = item => { //
editorUploadedImgList.value = item; if (data.hasAttachment && data.attachments.length > 0) {
}; attachFiles.value = addDisplayFileName([...data.attachments]);
}
const handleDeleteEditorImg = item => { //
editorDeleteImgList.value = item; title.value = data.title || '제목 없음';
content.value = data.content || '내용 없음';
contentLoaded.value = true;
} catch (error) {
console.error('게시물 가져오기 오류:', error.response || error.message);
}
}; };
// //
const addDisplayFileName = fileInfos => const addDisplayFileName = fileInfos =>
fileInfos.map(file => ({ fileInfos.map(file => ({
...file, ...file,
name: `${file.originalName}.${file.extension}` name: `${file.originalName}.${file.extension}`,
})); }));
// //
const goList = () => { const goList = () => {
accessStore.$reset(); router.push('/board');
router.back();
}; };
// //
const goBack = () => { const updateBoard = async () => {
accessStore.$reset(); //
router.back(); if (!title.value) {
}; titleAlert.value = true;
return;
}
titleAlert.value = false;
// ( : , , ) if (!content.value) {
const isNotValidContent = delta => { contentAlert.value = true;
if (!delta?.ops?.length) return true; return;
const hasText = delta.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0); }
const hasImage = delta.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image); contentAlert.value = false;
const hasVideo = delta.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.video);
return !(hasText || hasImage || hasVideo);
};
// try {
const checkValidation = () => { //
contentAlert.value = isNotValidContent(content.value); const boardData = {
titleAlert.value = $common.isNotValidInput(title.value); LOCBRDTTL: title.value,
if (titleAlert.value || contentAlert.value || !isFileValid.value) { LOCBRDCON: JSON.stringify(content.value),
if (titleAlert.value) { LOCBRDSEQ: currentBoardId.value,
title.value = ''; };
if (delFileIdx.value && delFileIdx.value.length > 0) {
boardData.delFileIdx = [...delFileIdx.value];
} }
return true;
} else { const fileArray = newFileFilter(attachFiles);
return false; const formData = new FormData();
Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value);
});
fileArray.forEach((file, idx) => {
formData.append('files', file);
});
await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
alert('게시물이 수정되었습니다.');
goList();
} catch (error) {
console.error('게시물 수정 중 오류 발생:', error);
alert('게시물 수정에 실패했습니다.');
} }
}; };
@ -285,6 +187,7 @@
const handleFileUpload = files => { const handleFileUpload = files => {
const validFiles = files.filter(file => file.size <= maxSize); const validFiles = files.filter(file => file.size <= maxSize);
if (files.some(file => file.size > maxSize)) { if (files.some(file => file.size > maxSize)) {
fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.'; fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.';
return; return;
@ -295,16 +198,15 @@
} }
fileError.value = ''; fileError.value = '';
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles); attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
autoIncrement.value++;
}; };
const removeFile = (index, file) => { const removeFile = (index, file) => {
if (file.id) delFileIdx.value.push(file.id); if (file.id) delFileIdx.value.push(file.id);
attachFiles.value.splice(index, 1); attachFiles.value.splice(index, 1);
if (attachFiles.value.length <= maxFiles) { if (attachFiles.value.length <= maxFiles) {
fileError.value = ''; fileError.value = '';
} }
autoIncrement.value++;
}; };
watch(attachFiles, () => { watch(attachFiles, () => {
@ -317,52 +219,8 @@
}; };
////////////////// fileSection[E] //////////////////// ////////////////// fileSection[E] ////////////////////
/** content 변경 감지 (deep 옵션 추가) */
watch(content, () => {
contentAlert.value = isNotValidContent(content.value);
}, { deep: true });
//
const validateTitle = () => {
titleAlert.value = title.value.trim().length === 0;
};
//
const updateBoard = async () => {
if (checkValidation()) return;
const boardData = {
LOCBRDTTL: title.value.trim(),
LOCBRDCON: JSON.stringify(content.value),
LOCBRDSEQ: currentBoardId.value
};
if (delFileIdx.value && delFileIdx.value.length > 0) {
boardData.delFileIdx = [...delFileIdx.value];
}
if (editorUploadedImgList.value && editorUploadedImgList.value.length > 0) {
boardData.editorUploadedImgList = [...editorUploadedImgList.value];
}
if (editorDeleteImgList.value && editorDeleteImgList.value.length > 0) {
boardData.editorDeleteImgList = [...editorDeleteImgList.value];
}
const fileArray = newFileFilter(attachFiles);
const formData = new FormData();
Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value);
});
fileArray.forEach((file, idx) => {
formData.append('files', file);
});
const { data } = await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
if (data.code === 200) {
toastStore.onToast('게시물이 수정되었습니다.', 's');
goList();
} else {
toastStore.onToast('게시물 수정에 실패했습니다.', 'e');
}
};
// //
onMounted(async () => { onMounted(() => {
if (currentBoardId.value) { if (currentBoardId.value) {
fetchBoardDetails(); fetchBoardDetails();
} else { } else {
@ -370,3 +228,10 @@
} }
}); });
</script> </script>
<style>
.text-red {
color: red;
text-align: center;
}
</style>

Some files were not shown because too many files have changed in this diff Show More