Compare commits

..

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

106 changed files with 8224 additions and 13213 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

45
package-lock.json generated
View File

@ -15,7 +15,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",
@ -939,12 +938,6 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.1", "version": "5.2.1",
"dev": true, "dev": true,
@ -1136,44 +1129,6 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/@vueuse/core": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.0.0.tgz",
"integrity": "sha512-rkgb4a8/0b234lMGCT29WkCjPfsX0oxrIRR7FDndRoW3FsaC9NBzefXg/9TLhAgwM11f49XnutshM4LzJBrQ5g==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.0.0",
"@vueuse/shared": "13.0.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.0.0.tgz",
"integrity": "sha512-TRNksqmvtvqsuHf7bbgH9OSXEV2b6+M3BSN4LR5oxWKykOFT9gV78+C2/0++Pq9KCp9KQ1OQDPvGlWNQpOb2Mw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.0.0.tgz",
"integrity": "sha512-9MiHhAPw+sqCF/RLo8V6HsjRqEdNEWVpDLm2WBRW2G/kSQjb8X901sozXpSCaeLG0f7TEfMrT4XNaA5m1ez7Dg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.14.0",
"dev": true, "dev": true,

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",

View File

@ -1,120 +1,6 @@
/* 여기에 light css 작성 */ /* 여기에 light css 작성 */
.opacity-50 {
opacity: 0.5;
}
/* board */
.board-content img {
max-width: 100% !important;
height: auto !important;
display: block;
object-fit: contain;
}
.board-content table {
max-width: 100%;
overflow-x: auto;
display: block;
}
.btn.big {
width: 70px;
height: 70px;
font-size: 18px;
}
@keyframes new {
0% {
background-color: #ffcc00;
}
50% {
background-color: red;
}
100% {
background-color: #ffcc00;
}
}
.box-new {
animation: new 1s infinite; /* 1초마다 반복 */
}
/* board end */
/* Qeditor */
.ql-editor {
min-height: 300px;
font-family: 'Nanum Gothic', sans-serif;
}
/* Qeditor end */
/* 에러페이지 */
/* 전체 화면을 덮는 스타일 */
.error-page {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #000;
font-family: 'Poppins', sans-serif;
z-index: 9999 !important;
}
/* 오류 메시지 컨텐츠 */
.error-content {
text-align: center;
animation: fadeIn 0.8s ease-in-out;
}
/* 에러 이미지 */
.error-image {
width: 280px; /* 이미지 크기 */
margin-bottom: 20px;
}
/* 에러 코드 스타일 */
.error-content h1 {
font-size: 6rem;
font-weight: bold;
color: #ff8c00; /* 오렌지 */
text-shadow: 2px 2px 8px rgba(255, 140, 0, 0.3);
margin-bottom: 60px;
}
/* 홈으로 돌아가기 버튼 */
.home-btn {
display: inline-block;
padding: 10px 28px;
font-size: 1rem;
font-weight: bold;
text-decoration: none;
color: #fff;
background: rgba(105, 108, 255, 0.9);
border-radius: 30px;
transition: 0.3s ease-in-out;
box-shadow: 0 4px 15px rgba(105, 108, 255, 0.5);
}
/* 버튼 호버 효과 */
.home-btn:hover {
background: linear-gradient(90deg, orange, #ff8c00);
box-shadow: 0 0 20px rgba(255, 140, 0, 1);
transform: scale(1.05);
color: #fff;
}
/* 페이드 인 애니메이션 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 휴가 */ /* 휴가 */
.fc-daygrid-event { .fc-daygrid-event {
@ -130,7 +16,6 @@
height: 8px !important; height: 8px !important;
border-radius: 2px !important; border-radius: 2px !important;
font-size: 0px !important; font-size: 0px !important;
margin-left: -0.5% !important;
} }
/* 오후 반차 그래프 (오른쪽 절반) */ /* 오후 반차 그래프 (오른쪽 절반) */
.fc-daygrid-event.half-day-pm { .fc-daygrid-event.half-day-pm {
@ -139,7 +24,6 @@
margin-left: auto !important; margin-left: auto !important;
border-radius: 2px !important; border-radius: 2px !important;
font-size: 0px !important; font-size: 0px !important;
margin-right: -0.5% !important;
} }
/* 연차 그래프 (풀) */ /* 연차 그래프 (풀) */
.fc-daygrid-event.full-day { .fc-daygrid-event.full-day {
@ -172,10 +56,7 @@
.fc-toolbar-title { .fc-toolbar-title {
cursor: pointer; cursor: pointer;
} }
.fc-today-button { /* 클릭 가능한 날짜 (오늘 + 미래) */
cursor: pointer !important;
}
/* 클릭 가능한 날짜 */
.fc-daygrid-day.clickable { .fc-daygrid-day.clickable {
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out;
@ -188,7 +69,7 @@
.fc-day-sat-sun { .fc-day-sat-sun {
cursor: not-allowed !important; cursor: not-allowed !important;
} }
/* 과거 날짜 (오늘 -7일일) */ /* 과거 날짜 (오늘 이전) */
.fc-daygrid-day.past { .fc-daygrid-day.past {
cursor: not-allowed !important; cursor: not-allowed !important;
} }
@ -212,6 +93,7 @@
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
/* 휴가 모달 */ /* 휴가 모달 */
.vac-modal-dialog { .vac-modal-dialog {
background: none !important; background: none !important;
@ -226,15 +108,14 @@
.vac-modal-content { .vac-modal-content {
background: #fff; background: #fff;
padding: 20px; padding: 20px;
box-shadow: box-shadow: 0px -4px 5px rgba(0, 0, 0, 0.1),
0px -4px 5px rgba(0, 0, 0, 0.1),
0px 4px 0px rgba(0, 0, 0, 0); 0px 4px 0px rgba(0, 0, 0, 0);
max-width: 500px; max-width: 500px;
width: 100%; width: 100%;
position: relative; position: relative;
} }
.vac-modal-body { .vac-modal-body {
max-height: 180px; max-height: 140px;
overflow-y: auto; overflow-y: auto;
} }
.vac-modal-text { .vac-modal-text {
@ -292,10 +173,10 @@
color: #525252; color: #525252;
} }
.count-btn { .count-btn {
font-size: 17px; font-size: 18px;
padding: 2px 10px; padding: 2px 10px;
border: none; border: none;
background: #2c3e50; background: #2C3E50;
color: white; color: white;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
@ -327,6 +208,7 @@
} }
.custom-button:disabled { .custom-button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
/* 휴가 사원프로필 */ /* 휴가 사원프로필 */
@ -366,7 +248,7 @@
/* AM 버튼 (선택된 상태) */ /* AM 버튼 (선택된 상태) */
.vac-btn-warning.active { .vac-btn-warning.active {
background-color: #ff7300 !important; background-color: #ff7300 !important;
color: #fff; color: #fff;;
} }
.vac-btn-info { .vac-btn-info {
color: #fff; color: #fff;
@ -380,28 +262,6 @@
background-color: #0b5ed7 !important; background-color: #0b5ed7 !important;
color: white; color: white;
} }
/* 풀 연차 버튼 스타일 */
.vac-btn-primary {
color: #fff;
background-color: #49d46a; /* 녹색 */
border-color: #49d46a;
box-shadow: 0 0.125rem 0.25rem 0 rgba(40, 167, 69, 0.4);
font-size: 28px;
transition: all 0.2s ease-in-out;
}
/* 풀 연차 버튼 활성화 스타일 */
.vac-btn-primary.active {
background-color: #009124 !important;
color: #fff;
border: 3px solid #91d091 !important;
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
/* 풀 연차 버튼이 눌렸을 때 효과 */
.vac-btn-primary:active {
transform: scale(0.9);
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
}
/* 버튼 기본 */ /* 버튼 기본 */
.vac-btn-success { .vac-btn-success {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
@ -431,8 +291,8 @@
/* 작은 화면에서 버튼 크기 조정 */ /* 작은 화면에서 버튼 크기 조정 */
@media (max-width: 1700px) { @media (max-width: 1700px) {
.count-btn { .count-btn {
width: 26px; width: 28px;
height: 26px; height: 28px;
font-size: 15px; font-size: 15px;
} }
.count-container { .count-container {
@ -443,7 +303,7 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.count-value { .count-value {
font-size: 15px; font-size: 20px;
} }
.custom-button { .custom-button {
width: 45px; width: 45px;
@ -454,10 +314,10 @@
font-size: 18px; font-size: 18px;
} }
.vac-modal-text { .vac-modal-text {
font-size: 12px; font-size: 13px;
} }
.vac-modal-title { .vac-modal-title {
font-size: 15px; font-size: 17px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.vacation-item { .vacation-item {
@ -466,8 +326,8 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
.vac-btn { .vac-btn {
width: 40px; width: 50px;
height: 40px; height: 50px;
font-size: 18px; font-size: 18px;
} }
.vac-btn-success { .vac-btn-success {
@ -498,17 +358,18 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
.vac-btn { .vac-btn {
width: 10px; width: 40px;
height: 10px; height: 40px;
font-size: 12px; font-size: 18px;
} }
.vac-btn-success { .vac-btn-success {
font-size: 15px; font-size: 20px;
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
} }
.grayscaleImg { .grayscaleImg {
filter: grayscale(100%); filter: grayscale(100%);
} }
@ -520,27 +381,9 @@
/* project list */ /* project list */
.hidden-start-input {
position: absolute;
top: 103%;
left: 20%;
width: 100%;
height: 40px;
opacity: 0;
}
.hidden-end-input {
position: absolute;
top: 113.3%;
left: 20%;
width: 100%;
height: 40px;
opacity: 0;
}
.map { .map {
top: -160px; top: -160px;
left: 90px; left: -5px;
} }
@keyframes sparkle { @keyframes sparkle {
@ -580,255 +423,3 @@
background-color: #ddd !important; background-color: #ddd !important;
} }
/* project list end */ /* project list end */
/* commuters */
.commuter-list {
max-height: 450px;
overflow-y: auto;
scrollbar-width: none;
}
.fc-daygrid-day[data-has-commuters='true'] {
cursor: pointer;
}
/* commuters end */
/* Scroll Button */
.scroll-top-btn {
bottom: 40px;
right: 21.7%;
transition:
opacity 0.4s ease,
visibility 0.4s ease,
transform 0.4s ease;
}
.scroll-top-btn.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.scroll-top-btn.hidden {
opacity: 0;
visibility: hidden;
transform: translateY(10px);
}
.back-btn {
bottom: 40px;
right: 21.7%;
transition: transform 0.4s ease;
}
.back-btn.shifted {
transform: translateY(-50px);
}
/* Scroll Button end */
/* 중앙 콘텐츠 자동 조정 */
.layout-page {
flex-grow: 1;
min-width: 0; /* flexbox 내에서 올바른 크기 계산 */
margin-right: 20%; /* 채팅 사이드바의 너비만큼 밀리도록 설정 */
}
/* 중앙 콘텐츠 자동 조정 end */
/* media */
/* 탑바 범위조정 */
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 1.625rem * 2 - 16.25rem - 20%) !important;
}
@media (min-width: 1200px) {
nav#layout-navbar {
left: calc(16.25rem - 20%) !important;
}
.layout-navbar.navbar-detached.container-xxl {
max-width: calc(1440px - 1.625rem * 2) !important;
}
}
/* 탑바 범위조정(1200px 이하) */
@media (max-width: 1200px) {
nav#layout-navbar {
left: -20% !important;
}
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 1.625rem * 2 - 20%) !important;
}
}
/* 탑바 범위조정(992px 이하) */
@media (max-width: 992px) {
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 2rem - 20%) !important;
}
}
/* Mobile */
/* 가로모드 모바일 디바이스 (가로 해상도가 768px 보다 작은 화면에 적용) */
@media (max-width: 767px) {
.chat-sidebar {
display: none !important;
}
nav#layout-navbar {
left: 0 !important;
}
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 2rem) !important;
}
.layout-page {
margin-right: 0 !important;
}
#app-calendar-sidebar {
width: 100%;
}
}
/* 세로모드 모바일 디바이스 (가로 해상도가 576px 보다 작은 화면에 적용) */
@media (max-width: 575px) {
}
/* Mobile end */
/* media end */
/* BoardComment */
.beforeRotate {
transition: transform 0.3s ease-in-out;
}
.rotate {
transform: rotate(45deg);
transition: transform 0.3s ease-in-out;
}
/* BoardComment end */
/* vote */
.hidden-date-input {
position: absolute;
top: 31.5%;
left: 17%;
width: 100%;
opacity: 0;
pointer-events: none;
}
.hidden-time-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* 권한부여 */
.user-card-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
margin-top: 20px;
}
.user-card {
width: 200px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 10px;
background-color: #fff;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 10px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
margin-top: 5px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: '';
height: 18px;
width: 18px;
left: 4px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4caf50;
}
input:checked + .slider:before {
transform: translateX(24px);
}
/* 권한부여 끝 */
/* toast */
.bs-toast {
bottom: 20px;
right: 20px;
}
/* toast end */
.cursor-none {
cursor: none !important;
}
.ml-1 {
margin-left: 0.25rem !important;
}
.mr-1 {
margin-right: 0.25rem !important;
}
.nickname-ellipsis {
white-space: nowrap;
max-width: 100px;
vertical-align: middle;
}
/* font css */
.font-bold {
font-weight: bold;
}
.pointer {
cursor: pointer;
}

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: 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

View File

@ -1,5 +1,4 @@
<template> <template>
<link rel="stylesheet" href="/css/font.css">
<component :is="layout"> <component :is="layout">
<template #content> <template #content>
<LoadingSpinner :isLoading="loadingStore.isLoading" /> <LoadingSpinner :isLoading="loadingStore.isLoading" />

View File

@ -40,33 +40,7 @@ $api.interceptors.response.use(
function (response) { function (response) {
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
loadingStore.stopLoading(); loadingStore.stopLoading();
// 2xx 범위의 응답 처리
// 로그인 요청일 경우 (헤더에 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) {
@ -74,25 +48,13 @@ $api.interceptors.response.use(
loadingStore.stopLoading(); 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) {
// toastStore.onToast('인증이 필요합니다.', 'e');
}
router.push('/login'); router.push('/login');
break; break;
case 403: case 403:
@ -100,18 +62,18 @@ $api.interceptors.response.use(
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 +81,4 @@ $api.interceptors.response.use(
}, },
); );
export default $api; export default $api;

View File

@ -47,11 +47,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 +59,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,44 +76,6 @@ 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;
});
},
/** /**
* 빈값 확인 * 빈값 확인
* *
@ -136,17 +83,9 @@ const common = {
* @returns * @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;
}, },
@ -185,32 +124,12 @@ const common = {
* @param { String } profileImg * @param { String } profileImg
* @returns * @returns
*/ */
getProfileImage(profileImg, isAnonymous = false) { getProfileImage(profileImg) {
const defaultProfileImg = '/img/icons/icon.png'; // 기본 프로필 이미지 경로 let profileImgUrl = '/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}`; const UserProfile = `${import.meta.env.VITE_SERVER}upload/img/profile/${profileImg}`;
return !profileImg || profileImg === '' ? profileImgUrl : UserProfile; 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,28 +8,14 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import $api from '@api'; import $api from '@api';
const commonApi = (options = {}) => {
const colorList = ref([]); const colorList = ref([]);
const mbtiList = ref([]); const mbtiList = ref([]);
const pwhintList = ref([]); const pwhintList = ref([]);
const yearCategory = ref([]); const yearCategory = ref([]);
const cateList = ref([]); const cateList = ref([]);
const refreshColorList = async (type = 'YNP') => { // type 파라미터를 추가로 받도록 수정
const response = await $api.get(`user/color`, {
params: { type }
});
if (response.data && response.data.data) {
colorList.value = response.data.data.map(item => ({
label: item.CMNCODNAM,
value: item.CMNCODVAL,
}));
}
return colorList.value;
};
// CommonCode 함수를 외부에서도 접근할 수 있게 변경
const CommonCode = async (path, endpoint, targetList, type = null) => { const CommonCode = async (path, endpoint, targetList, type = null) => {
const params = type ? { type } : {}; const params = type ? { type } : {};
const response = await $api.get(`${path}/${endpoint}`, { const response = await $api.get(`${path}/${endpoint}`, {
@ -41,9 +27,9 @@ const CommonCode = async (path, endpoint, targetList, type = null) => {
})); }));
}; };
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,7 +4,7 @@
: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" :profileImg="comment.profileImg"
@ -12,23 +12,19 @@
: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 && comment.author == '익명'" 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,15 +33,14 @@
<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> -->
@ -64,7 +59,7 @@
</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';
@ -79,15 +74,10 @@
type: Boolean, type: Boolean,
default: false, default: false,
}, },
nickname: {
type: String,
default: '',
},
isCommentAuthor: { isCommentAuthor: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isPlusButton: { isPlusButton: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -118,22 +108,6 @@
password: { password: {
type: String, 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;
}); });
// emits // emits
@ -146,17 +120,9 @@
'submitEdit', 'submitEdit',
'cancelEdit', 'cancelEdit',
'update:password', 'update:password',
'inputDetector',
]); ]);
const filterInput = event => {
event.target.value = event.target.value.replace(/\s/g, ''); //
emit('update:password', event.target.value);
};
const localEditedContent = ref(props.comment.content); const localEditedContent = ref(props.comment.content);
const isModifyContent = ref(props.comment.content);
const disabled = ref(false);
// //
const isComment = ref(false); const isComment = ref(false);
@ -184,24 +150,6 @@
emit('submitPassword', props.comment, props.password); 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( watch(
() => props.comment.isEditTextarea, () => props.comment.isEditTextarea,
newVal => { newVal => {
@ -211,19 +159,6 @@
}, },
); );
// 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, () => { // watch(() => props.comment.isDeleted, () => {
// console.log("BoardComment - isDeleted :", newVal); // console.log("BoardComment - isDeleted :", newVal);
@ -242,3 +177,13 @@
emit('editClick', props.comment); emit('editClick', props.comment);
}; };
</script> </script>
<style>
.beforeRotate {
transition: transform 0.3s ease-in-out;
}
.rotate {
transform: rotate(45deg);
transition: transform 0.3s ease-in-out;
}
</style>

View File

@ -3,79 +3,58 @@
<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 mb-1" class="form-control"
placeholder="댓글 달기" placeholder="댓글 달기"
rows="3" rows="3"
:maxlength="maxLength" :maxlength="maxLength"
v-model="comment" v-model="comment"
@input="clearAlert('comment')" @input="alertTextHandler"
></textarea> ></textarea>
<span v-if="commentAlert" class="invalid-feedback d-inline text-start ms-2 mb-2">{{ commentAlert }}</span> <span v-if="commentAlert" class="invalid-feedback d-block text-start ms-2">{{ commentAlert }}</span>
<span v-else class="invalid-feedback d-inline">{{ textAlert }}</span> <span v-else class="invalid-feedback d-block text-start ms-2">{{ 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="`checkboxAnnonymous${commnetId}`" v-model="isCheck" />
type="checkbox" <label class="form-check-label" :for="`checkboxAnnonymous${commnetId}`">익명</label>
:id="`checkboxAnnonymous${commnetId}`"
v-model="isCheck"
@change="pwd2AlertHandler"
/>
<label class="form-check-label text-nowrap" :for="`checkboxAnnonymous${commnetId}`">익명</label>
</div> </div>
<!-- 중앙: 닉네임 & 비밀번호 입력 필드 (가로 정렬) --> <!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) -->
<div v-if="isCheck" class="d-flex flex-grow-1 gap-2"> <template v-if="isCheck">
<!-- 닉네임 입력 영역 --> <div class="d-flex align-items-center flex-grow-1">
<div class="position-relative"> <label class="form-label mb-0 me-3" for="basic-default-password">비밀번호</label>
<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 class="position-relative">
<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="passwordAlertTextHandler"
@input="
password = password.replace(/\s/g, '');
clearAlert('password');
"
/> />
<!-- 비밀번호 경고 메시지 -->
<div v-if="passwordAlert2" class="position-absolute text-danger small top-100 start-0">
{{ passwordAlert2 }}
</div>
</div> </div>
</template>
</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>
<span v-if="passwordAlert" class="invalid-feedback d-block text-start ms-2">{{ passwordAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ passwordAlert2 }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -111,64 +90,41 @@
}, },
}); });
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
const $common = inject('common'); const $common = inject('common');
const comment = ref(''); const comment = ref('');
const password = ref(''); const password = ref('');
const isCheck = ref(false); const isCheck = ref(false);
const textAlert = ref(''); const textAlert = ref('');
const nicknameAlert = ref('');
const passwordAlert2 = ref(''); const passwordAlert2 = ref('');
const nickname = ref('');
const emit = defineEmits(['submitComment']); const emit = defineEmits(['submitComment']);
// const alertTextHandler = () => {
const clearAlert = field => { textAlert.value = '';
if (field === 'comment') textAlert.value = ''; };
if (field === 'nickname') nicknameAlert.value = '';
if (field === 'password') passwordAlert2.value = ''; const passwordAlertTextHandler = () => {
passwordAlert2.value = '';
}; };
const handleCommentSubmit = () => { const handleCommentSubmit = () => {
let isValid = true;
//
if (!$common.isNotEmpty(comment.value)) { if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력해주세요.'; textAlert.value = '댓글을 입력하세요';
isValid = false; return false;
} else { } else {
textAlert.value = ''; textAlert.value = '';
} }
// & if (isCheck.value && !$common.isNotEmpty(password.value)) {
if (isCheck.value) { passwordAlert2.value = '비밀번호를 입력하세요';
if (!$common.isNotEmpty(nickname.value)) { return false;
nicknameAlert.value = '닉네임을 입력해주세요.';
isValid = false;
} else {
nicknameAlert.value = '';
}
if (!$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력해주세요.';
password.value = '';
isValid = false;
} else { } else {
passwordAlert2.value = ''; passwordAlert2.value = '';
} }
}
//
if (!isValid) return;
// //
emit('submitComment', { emit('submitComment', {
comment: comment.value, comment: comment.value,
nickname: isCheck.value ? nickname.value : '',
password: isCheck.value ? password.value : '', password: isCheck.value ? password.value : '',
isCheck: isCheck.value, isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102' LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
@ -178,19 +134,10 @@
resetCommentForm(); resetCommentForm();
}; };
// &
const pwd2AlertHandler = () => {
if (!isCheck.value) {
passwordAlert2.value = '';
nicknameAlert.value = '';
}
};
// //
const resetCommentForm = () => { const resetCommentForm = () => {
comment.value = ''; comment.value = '';
password.value = ''; password.value = '';
nickname.value = '';
isCheck.value = false; isCheck.value = false;
}; };

View File

@ -7,23 +7,18 @@
: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> <template #reply>
@ -35,24 +30,19 @@
:isPlusButton="false" :isPlusButton="false"
:isLike="true" :isLike="true"
:isCommentProfile="true" :isCommentProfile="true"
:nickname="child.nickname"
:isCommentAuthor="child.isCommentAuthor" :isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword" :isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId" :currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert" :passwordCommentAlert="passwordCommentAlert"
:password="password" :password="password"
:editCommentAlert="editCommentAlert[child.commentId]"
:is-edit-pushed="child.isEditPushed"
:is-delete-pushed="child.isDeletePushed"
@editClick="handleReplyEditClick" @editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)" @deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent, child.commentId)" @submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="$emit('cancelEdit', child)" @cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment" @submitComment="submitComment"
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)" @submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)" @update:password="$emit('update:password', $event)"
@inputDetector="$emit('inputDetector')"
/> />
</li> </li>
</ul> </ul>
@ -63,7 +53,7 @@
</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({
@ -105,7 +95,6 @@
index: { index: {
type: Number, type: Number,
}, },
editCommentAlert: Object,
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -117,7 +106,6 @@
'clearPassword', 'clearPassword',
'submitEdit', 'submitEdit',
'update:password', 'update:password',
'inputDetector',
]); ]);
const submitComment = replyData => { const submitComment = replyData => {

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 class="avatar me-2">
<img <img :src="getProfileImage(profileImg)" 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,33 +20,18 @@
<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, inject } 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';
@ -75,10 +54,6 @@
type: String, type: String,
default: '', default: '',
}, },
nickname: {
type: String,
default: '',
},
unknown: { unknown: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -113,14 +88,6 @@
type: String, type: String,
default: false, default: false,
}, },
isEditPushed: {
type: Boolean,
require: false,
},
isDeletePushed: {
type: Boolean,
require: false,
},
}); });
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']); const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
@ -151,14 +118,6 @@
// //
const getProfileImage = profileImg => { const getProfileImage = profileImg => {
return $common.getProfileImage(profileImg, props.unknown); return $common.getProfileImage(profileImg);
};
const setDefaultImage = e => {
return $common.setDefaultImage(e);
};
const showImage = e => {
return $common.showImage(e);
}; };
</script> </script>

View File

@ -1,22 +1,14 @@
<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: {
@ -64,11 +56,6 @@
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]) => {
likeClicked.value = likeNewval;
dislikeClicked.value = dislikeNewval;
});
const handleLike = () => { const handleLike = () => {
const isLike = !likeClicked.value; const isLike = !likeClicked.value;
const isDislike = false; const isDislike = false;
@ -87,3 +74,50 @@
likeClicked.value = false; 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,10 +1,12 @@
<template> <template>
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }" @click="toggleText"> <button class="btn btn-label-primary btn-icon float-end" @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, watch, defineProps } from 'vue';
const props = defineProps({ const props = defineProps({
isToggleEnabled: { isToggleEnabled: {
type: Boolean, type: Boolean,
@ -14,25 +16,24 @@
type: Boolean, type: Boolean,
required: false, 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'; watch(() => props.isActive, (newVal) => {
buttonClass.value = newVal ? "bx bx-x" : "bx bx-edit-alt";
}); });
const toggleText = event => {
// const toggleText = () => {
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,40 +1,26 @@
<template> <template>
<div class="row gx-2 mb-10 mt-1"> <div class="row gx-2 mb-4">
<div class="col-3"> <div class="col-4">
<div class="ratio ratio-1x1"> <div class="ratio ratio-1x1">
<!-- 오전 반차 버튼 --> <!-- 오전 반차 버튼 -->
<button class="vac-btn vac-btn-warning rounded-circle d-flex align-items-center justify-content-center" <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>
</div> </div>
<div class="col-3"> <div class="col-4">
<div class="ratio ratio-1x1"> <div class="ratio ratio-1x1">
<!-- 오후 반차 버튼 --> <!-- 오후 반차 버튼 -->
<button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center" <button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center" :class="{ active: halfDayType === 'PM' }"
:class="{ active: halfDayType === 'PM' }"
@click="toggleHalfDay('PM')"> @click="toggleHalfDay('PM')">
<i class="bi bi-moon d-flex"></i> <i class="bi bi-moon"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="col-3"> <div class="col-4">
<div class="ratio ratio-1x1"> <div class="ratio ratio-1x1">
<!-- 연차 버튼 --> <button class="vac-btn-success rounded-circle d-flex align-items-center justify-content-center" @click="addVacationRequests"
<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>
@ -44,10 +30,11 @@
</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"]);
@ -58,7 +45,14 @@ const toggleHalfDay = (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");
@ -70,3 +64,6 @@ const addVacationRequests = () => {
defineExpose({ resetHalfDay }); defineExpose({ resetHalfDay });
</script> </script>
<style scoped>
</style>

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";

View File

@ -18,8 +18,8 @@
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)"
> >
@ -27,11 +27,13 @@
</button> </button>
</li> </li>
</ul> </ul>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref, watch } from 'vue'; import { defineProps, ref, watch } from 'vue';
// lists prop
const props = defineProps({ const props = defineProps({
lists: { lists: {
type: Array, type: Array,
@ -61,5 +63,14 @@ watch(() => props.selectedCategory, (newVal) => {
selectedCategory.value = newVal; selectedCategory.value = newVal;
}); });
</script> </script>
<style scoped>
@media (max-width: 768px) {
.cate-list {
overflow-x: scroll;
flex-wrap: nowrap !important;
}
}
</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,11 @@
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) {
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', () => {
@ -183,8 +133,6 @@
checkForDeletedImages(); // checkForDeletedImages(); //
} }
}); });
checkDeletedImages();
emit('update:data', quillInstance.getContents()); emit('update:data', quillInstance.getContents());
}); });
@ -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); //
@ -267,7 +205,7 @@
// //
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 +214,13 @@
} }
}); });
} }
//
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

@ -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
/> />
@ -42,14 +39,10 @@
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 autoIncrement = ref(props.autoIncrement);
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_COUNT = 5; // const MAX_FILE_COUNT = 5; //
const ALLOWED_FILE_TYPES = []; // const ALLOWED_FILE_TYPES = []; //
@ -57,12 +50,14 @@
const showError = ref(false); const showError = ref(false);
const fileMsgKey = ref(''); // const fileMsgKey = ref(''); //
const changeHandler = event => { const changeHandler = (event) => {
const files = Array.from(event.target.files); const files = Array.from(event.target.files);
const totalSize = files.reduce((sum, file) => sum + file.size, 0); const totalSize = files.reduce((sum, file) => sum + file.size, 0);
// ALLOWED_FILE_TYPES // ALLOWED_FILE_TYPES
const invalidFiles = ALLOWED_FILE_TYPES.length > 0 ? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type)) : []; const invalidFiles = ALLOWED_FILE_TYPES.length > 0
? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type))
: [];
if (totalSize > MAX_TOTAL_SIZE) { if (totalSize > MAX_TOTAL_SIZE) {
showError.value = true; showError.value = true;
@ -87,5 +82,5 @@
} }
}; };
const errorMessage = computed(() => fileMsg[fileMsgKey.value] || ''); const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
</script> </script>

View File

@ -5,7 +5,6 @@
<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"
@ -19,11 +18,8 @@
@focusout="$emit('focusout', modelValue)" @focusout="$emit('focusout', modelValue)"
@input="handleInput" @input="handleInput"
/> />
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
</div>
</div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div> <div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
<!-- 카테고리 중복 -->
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div> <div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
</div> </div>
</div> </div>
@ -82,11 +78,6 @@ import { ref, watch } from 'vue';
default: '', default: '',
required: false, required: false,
}, },
isBtn: {
type: Boolean,
default: false,
required: false,
}
}); });
// Emits // Emits

View File

@ -4,23 +4,13 @@
{{ title }} {{ title }}
<span v-if="isEssential" class="link-danger">*</span> <span v-if="isEssential" class="link-danger">*</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>사용가능한 컬러가 없습니다</option>
</select>
<!-- 데이터가 있는 경우 원래 select 표시 -->
<select v-else class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}"> <option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
{{ isCommon ? item.label : item }} {{ isCommon ? item.label : item }}
</option> </option>
</select> </select>
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
</div>
<div v-if="isColor && selected" <div v-if="isColor && selected"
class="w-px-40 h-px-30" class="w-px-40 h-px-30"
:style="{backgroundColor: selected}"> :style="{backgroundColor: selected}">
@ -32,9 +22,7 @@
:src="`/img/mbti/${selected.toLowerCase()}.png`" :src="`/img/mbti/${selected.toLowerCase()}.png`"
alt="MBTI image"/> alt="MBTI image"/>
</div> </div>
<div v-if="isAlert" class="invalid-feedback">{{ title }} 확인해주세요.</div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
</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

@ -7,75 +7,72 @@
<h5 class="card-title fw-bold"> <h5 class="card-title fw-bold">
{{ title }} {{ title }}
</h5> </h5>
<p v-if="isProjectExpired" class="btn-icon btn-danger rounded-2"><i class='bx bx-power-off'></i></p>
<div v-if="!isProjectExpired" class="d-flex gap-1"> <div v-if="!isProjectExpired" class="d-flex gap-1">
<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>
<div class="col-9 col-md-10">
{{ strdate }} ~ {{ enddate }}
</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>
<div class="col-9 col-md-10">
<UserList ref="userListRef" :projctSeq="projctSeq" :showOnlyActive="true" class="mb-0" />
</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>
<div class="col-9 col-md-10">
{{ description || '-' }}
</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"
:is-visible="isMapVisible"
@update-visible="updatePopover"
>
<template #trigger>
<div class="d-flex align-items-center cursor-pointer">
<i class="bx bxs-map"></i>
<div class="ms-2">주소</div> <div class="ms-2">주소</div>
</div> </div>
</template> <div class="ms-12 position-relative">
</MapPopover> {{ address }} {{ addressdtail }}
<!-- 팝오버 -->
<div v-if="isPopoverVisible" class="position-absolute map ">
<button type="button" class="btn-close popover-close" @click.stop="isPopoverVisible = !isPopoverVisible"></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 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>
</div> </div>
</div> </div>
</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>
<!-- 로그 모달 --> <!-- 로그 모달 -->
<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,29 @@
: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">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList class="m-0"
ref="editUserListRef"
:projctSeq="projctSeq"
:showOnlyActive="false"
@user-list-update="handleEditUserListUpdate"
/>
</div>
</div>
<!-- 시작일 -->
<div @click="openStartDatePicker">
<FormInput <FormInput
title="시작일" title="시작일"
type="date" type="date"
name="startDay" name="startDay"
:is-essential="true" :is-essential="true"
:is-alert="startDayAlert"
:modelValue="selectedProject.PROJCTSTR" :modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event" @update:modelValue="selectedProject.PROJCTSTR = $event"
ref="startDateInput"
/> />
</div>
<!-- 종료일 -->
<div @click="openEndDatePicker">
<FormInput <FormInput
title="종료일" title="종료일"
type="date" type="date"
name="endDay" name="endDay"
:min="selectedProject.PROJCTSTR" :min="todays"
:modelValue="selectedProject.PROJCTEND" :modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event" @update:modelValue="selectedProject.PROJCTEND = $event"
ref="endDateInput"
/> />
</div>
<FormInput <FormInput
title="설명" title="설명"
name="description" name="description"
@ -186,10 +161,11 @@
</template> </template>
<script setup> <script setup>
import { defineProps, onMounted, ref, computed, watch } from 'vue'; import { defineProps, onMounted, ref, computed, watch, inject } 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 +174,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 +200,6 @@ const props = defineProps({
description: { description: {
type: String, type: String,
required: false, required: false,
default: "",
}, },
address: { address: {
type: String, type: String,
@ -256,14 +228,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,58 +238,27 @@ 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;
}); });
// dayjs
const dayjs = inject('dayjs');
// YYYY-MM-DD
const todays = dayjs().format('YYYY-MM-DD');
// ( ) // ( )
const isProjectExpired = computed(() => { const isProjectExpired = computed(() => {
@ -360,23 +293,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 +329,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 +350,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 +362,13 @@ 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(
() => selectedProject.value.PROJCTSTR, // (strdate)
(newStartDate) => {
if (newStartDate && new Date(newStartDate) > new Date(selectedProject.value.PROJCTEND)) {
//
selectedProject.value.PROJCTEND = newStartDate;
}
}
);
// 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 +377,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 +389,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) { if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's'); toastStore.onToast('수정이 완료 되었습니다.', 's');
//
await projectStore.getProjectList(props.searchParams.text, props.searchParams.year, 'false');
await projectStore.getMemberProjects();
await refreshColorList('YNP');
await editUserListRef.value.fetchProjectParticipation();
await userListRef.value.fetchProjectParticipation();
closeEditModal(); closeEditModal();
emit('update', props.searchParams); //
emit('update');
}
});
};
//
const convertAddressToCoordinates = () => {
const geocoder = new window.kakao.maps.services.Geocoder();
geocoder.addressSearch(props.address, (result, status) => {
if (status === window.kakao.maps.services.Status.OK) {
coordinates.value = {
lat: parseFloat(result[0].y),
lng: parseFloat(result[0].x)
};
} else {
// ()
coordinates.value = {
lat: 37.2108651707078,
lng: 127.089445559923
};
}
});
};
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);
} }
}; };
// //
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');
projectStore.getProjectList(); location.reload()
projectStore.getMemberProjects();
} }
}) })
}
}; };
// //
@ -562,18 +459,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"
@ -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,9 +22,6 @@
> >
{{ 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>

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,28 @@
: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">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList
ref="userListRef"
@user-list-update="handleUserListUpdate"
class="m-0"
/>
</div>
</div>
<div @click="openStartDatePicker">
<FormInput <FormInput
title="시작 일" title="시작 일"
name="startDay" name="startDay"
:type="'date'" :type="'date'"
:is-alert="startDayAlert"
:is-essential="true" :is-essential="true"
:modelValue="startDay" :modelValue="startDay"
v-model="startDay" v-model="startDay"
ref="startDateInput"
/> />
</div>
<div @click="openEndDatePicker">
<FormInput <FormInput
title="종료 일" title="종료 일"
name="endDay"
:type="'date'" :type="'date'"
name="endDay"
:modelValue="endDay" :modelValue="endDay"
:min="startDay" :min = "today"
@update:modelValue="endDay = $event" @update:modelValue="endDay = $event"
ref="endDateInput"
/> />
</div>
<FormInput <FormInput
title="설명" title="설명"
name="description" name="description"
@ -122,7 +100,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 +109,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 +126,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 +135,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 +149,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 +172,7 @@
// //
const getProjectList = async () => { const getProjectList = async () => {
await projectStore.getProjectList(searchText.value, selectedYear.value, 'false'); await projectStore.getProjectList(searchText.value, selectedYear.value);
}; };
// //
@ -242,27 +181,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 +202,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();
//
const updatedColors = await refreshColorList('YNP');
// ()
if (updatedColors && updatedColors.length > 0) {
color.value = updatedColors[0].value;
} }
}; }, { flush: 'post' });
// //
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 +242,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) {
if (response.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's'); toastStore.onToast('프로젝트가 등록되었습니다.', 's');
colorList.value = colorList.value.filter(c => c.value !== color.value);
formReset();
await getProjectList();
await projectStore.getMemberProjects();
closeCreateModal(); closeCreateModal();
resetUserSelection.value = !resetUserSelection.value; getProjectList();
} }
});
}; };
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" v-model="searchQuery" @input="preventLeadingSpace" /> <input type="text" class="form-control" placeholder="Search" @change="search" @input="preventLeadingSpace" />
<button type="submit" class="btn btn-primary"> <button type="button" class="btn btn-primary"><i class="bx bx-search bx-md"></i></button>
<i class="bx bx-search bx-md"></i>
</button>
</div> </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

@ -5,7 +5,7 @@
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,8 +47,8 @@
: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>
@ -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"
/> />
@ -99,7 +95,6 @@
:is-color="true" :is-color="true"
:data="colorList" :data="colorList"
@update:data="handleColorUpdate" @update:data="handleColorUpdate"
:is-alert="colorAlert"
class="w-50" class="w-50"
/> />
</div> </div>
@ -148,12 +143,11 @@
name="phone" name="phone"
:isEssential="true" :isEssential="true"
:is-alert="phoneAlert" :is-alert="phoneAlert"
@update:data="phone = $event.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1')"
@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('');
@ -204,12 +197,10 @@
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,29 @@
// , 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,7 +311,7 @@
} }
}; };
const handleColorUpdate = async newColor => { const handleColorUpdate = async (newColor) => {
color.value = newColor; color.value = newColor;
colorError.value = ''; colorError.value = '';
colorErrorAlert.value = false; colorErrorAlert.value = false;
@ -328,55 +319,10 @@
await checkColorDuplicate(); 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 +334,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 +343,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);
@ -467,3 +372,4 @@
} }
}; };
</script> </script>

View File

@ -1,5 +1,5 @@
<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"
@ -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,57 +88,53 @@ onMounted(async () => {
if (props.projctSeq) { if (props.projctSeq) {
await fetchProjectParticipation(); await fetchProjectParticipation();
await fetchUserProjectPeriods();
} else {
// projctSeq , emit
emitUserListUpdate();
} }
nextTick(() => { nextTick(() => {
initTooltips();
});
});
//
const initTooltips = () => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => { tooltips.forEach((tooltip) => {
if (tooltip._tooltip) {
tooltip._tooltip.dispose();
}
new bootstrap.Tooltip(tooltip); 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', {
} else { memberSeq: user.MEMBERSEQ,
user.disabled = !user.disabled; 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 {
// userList
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
if (originalIndex !== -1) {
userList.value[originalIndex].disabled = newParticipationStatus;
emitUserListUpdate(); emitUserListUpdate();
} }
}
}
}; };
// emit // emit
@ -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,5 +1,6 @@
<template> <template>
<ul class="row gx-2 mb-0 list-inline "> <div class="">
<ul class="row gx-2 mb-2 list-inline">
<li <li
v-for="(user, index) in sortedUserList" v-for="(user, index) in sortedUserList"
:key="index" :key="index"
@ -9,15 +10,9 @@
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"> <div class="ratio ratio-1x1 mb-2 profile-list">
<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)"
@ -30,6 +25,7 @@
</span> </span>
</li> </li>
</ul> </ul>
</div>
</template> </template>
<script setup> <script setup>
@ -78,19 +74,43 @@ nextTick(() => {
const sortedUserList = computed(() => { const sortedUserList = computed(() => {
if (!employeeId.value) return []; if (!employeeId.value) return [];
// ( ) //
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value); const nonAdminUsers = userList.value.filter(user => user.MEMBERROL !== "ROLE_ADMIN");
const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value);
const myProfile = nonAdminUsers.find(user => user.MEMBERSEQ === employeeId.value);
const otherUsers = nonAdminUsers.filter(user => user.MEMBERSEQ !== employeeId.value);
return myProfile ? [myProfile, ...otherUsers] : otherUsers; return myProfile ? [myProfile, ...otherUsers] : otherUsers;
}); });
const getUserProfileImage = (profilePath) => const getUserProfileImage = (profilePath) =>
profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile; profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
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 >= 1850) {
if (totalUsers <= 10) return "80px";
if (totalUsers <= 15) return "60px";
return "45px";
} else if (windowWidth.value >= 1500) {
if (totalUsers <= 10) return "60px";
if (totalUsers <= 15) return "40px";
return "30px";
} else if (windowWidth.value >= 900) {
if (totalUsers <= 10) return "48px";
if (totalUsers <= 15) return "30px";
return "20px";
} else {
return "35px";
}
});
const getDynamicStyle = (user) => ({ const getDynamicStyle = (user) => ({
borderWidth: "3px", borderWidth: "3px",
borderColor: user.usercolor || "#ccc", borderColor: user.usercolor || "#ccc",
@ -99,10 +119,4 @@ borderStyle: "solid",
</script> </script>
<style scoped> <style scoped>
.crown-icon {
width: 90%;
height: 70%;
z-index: 0;
top: -7%
}
</style> </style>

View File

@ -1,17 +1,18 @@
<template> <template>
<div class="card mb-6" > <div class="card mb-6" :class="{ 'disabled-class': data.localVote.LOCVOTDDT && (topVoters.length == 1 || data.localVote.LOCVOTRES || voteResult == 0)}">
<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'" @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>
<!-- 투표완료시 --> <!-- 투표완료시 -->
@ -22,20 +23,20 @@
<button <button
v-if="!data.localVote.LOCVOTDDT" v-if="!data.localVote.LOCVOTDDT"
type="button" type="button"
class="btn btn-label-danger btn-icon m-1" class="btn btn-label-danger btn-icon"
@click="endBtn(data.localVote.LOCVOTSEQ)" @click="endBtn(data.localVote.LOCVOTSEQ)"
><i class="bx bx-power-off"></i> ><i class="bx bx-power-off"></i>
</button> </button>
<DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" /> <DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" />
</div> </div>
</div> <p v-if="data.localVote.LOCVOTDDT" class="btn-icon btn-danger rounded-2"><i class="bx bx-power-off"></i></p>
</div>
</div>
</div>
</h5>
<h5 class="mb-0">{{ data.localVote.LOCVOTTTL }}
<i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i> <i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i>
</div>
</div>
</div>
</div>
</h5> </h5>
<h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}</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 && voteResult == 0">
@ -49,7 +50,9 @@
: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> <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="topVoters" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES" :locvotreg="data.localVote.LOCVOTREG"/>
@ -59,7 +62,7 @@
: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 +70,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';
@ -93,7 +96,6 @@ const yesVotetotal = computed(() => {
return props.data.voteDetails.reduce((sum, item) => sum + item.yesvote, 0); return props.data.voteDetails.reduce((sum, item) => sum + item.yesvote, 0);
}); });
// (1) // (1)
const topVoters = computed(() => { const topVoters = computed(() => {
// VOTE_COUNT // VOTE_COUNT
@ -104,10 +106,9 @@ const topVoters = computed(() => {
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;
@ -117,19 +118,7 @@ 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 +126,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,7 +1,9 @@
<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"> <div v-for="(item, index) in data"
:key="index">
<vote-card-check-list <vote-card-check-list
:data="item" :data="item"
:multiIs = voteInfo.LOCVOTMUL :multiIs = voteInfo.LOCVOTMUL
@ -9,42 +11,32 @@
@update:selectedValues="updateCheckedNames" @update:selectedValues="updateCheckedNames"
/> />
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1"> <div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1">
<div v-for="(item, index) in itemList" :key="index" class=" mt-2"> <div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-start mt-2">
<div class="flex-grow-1 me-2 ">
<form-input <form-input
:title="'항목 ' + (index + data.length + 1)" :title="'항목 ' + (index + data.length + 1)"
:name="'content' + index" :name="'content' + index"
:is-essential="false" :is-essential="false"
:is-alert="contentAlerts[index]" :is-alert="contentAlerts[index]"
v-model="item.content" v-model="item.content"
:is-btn="true"
@keyup="ValidHandler('content' + (index + 1))"
>
<template v-slot:append>
<delete-btn @click="removeItem(index)" />
</template>
</form-input>
<form-input
:title="'URL ' + (index + data.length + 1)"
:name="'url' + index"
v-model="item.url"
:is-essential="false"
class="mb-1"
:maxlength="maxLength"
/> />
<link-input v-model="item.url" />
</div> </div>
<div class="d-flex justify-content align-items-center mt-3"> <delete-btn @click="removeItem(index)" />
<plus-btn @click="addItem" :disabled=" total >= 10" /> </div>
<button class="btn btn-primary btn-icon m-1" @click="addContentSave(item.LOCVOTSEQ ,index)" :disabled="isSaveDisabled"> <div class="mb-4 d-flex justify-content mt-3">
<plus-btn @click="addItem" :disabled="total >= 10" class="mb-2" />
<button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex">
<save-btn class="mt-2 ms-auto" @click="selectVote"/>
</div> </div>
</div> </div>
<save-btn class="btn-sm mt-2" @click="selectVote"/>
</template> </template>
<script setup> <script setup>
@ -54,20 +46,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,34 +77,11 @@ 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() !== '');
itemList.value.forEach((item, index) => {
if (!item.content.trim() && item.url.trim()) {
contentAlerts.value[index] = true;
valid = false;
} else {
contentAlerts.value[index] = false;
}
});
if(valid){
emit('addContents',filteredItemList,voteId); emit('addContents',filteredItemList,voteId);
itemList.value = [{ content: "", url: "" }]; 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) => {

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>
@ -56,17 +51,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

@ -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

@ -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-in-complete-user-list-card :data="data" /> <vote-in-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

@ -1,34 +1,33 @@
<template> <template>
<div class="mb-2 row">
<!-- 링크 아이콘 -->
<label for="name" class="col-md-2 col-form-label">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<!-- 링크 아이콘 --> <!-- 링크 아이콘 -->
<i class="bx bx-link-alt me-2" @click="togglePopover"></i> <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"
class="popover-container d-flex align-items-center"
>
<input <input
v-model="link" v-model="link"
placeholder="URL을 입력해주세요" placeholder="URL을 입력해주세요"
class="form-control me-2" class="form-control me-2"
style="min-width: 200px;"
/> />
<save-btn class="btn-icon" @click="saveLink"/> <save-btn class="btn-sm" @click="saveLink"/>
</div>
</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

@ -11,6 +11,7 @@
<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..

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,5 +1,5 @@
<template> <template>
<!-- <ul class="d-flex p-0 mb-0 flex-wrap"> <ul class="d-flex p-0 mb-0">
<li class="d-flex"> <li class="d-flex">
<button <button
type="button" type="button"
@ -8,11 +8,9 @@
@click="selectAlphabet('all')" @click="selectAlphabet('all')"
> 전체 ({{ totalCount}}) > 전체 ({{ totalCount}})
</button> </button>
<span class="divider">|</span>
</li> </li>
</ul> --> <li v-for="(char, index) in koreanChars" :key="char.CHARACTER_" class="d-flex">
<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="alphabet-btn"
@ -21,13 +19,11 @@
> >
{{ char.CHARACTER_ }} ({{ char.COUNT }}) {{ char.CHARACTER_ }} ({{ char.COUNT }})
</button> </button>
<span v-if="index !== group.length - 1" class="divider">|</span> <span v-if="index !== koreanChars.length - 1" class="divider">|</span>
</li> </li>
</ul> </ul>
</div>
<div v-for="(group, groupIndex) in chunkedEnglishChars" :key="'en-group-' + groupIndex">
<ul class="d-flex p-0 mb-0"> <ul class="d-flex p-0 mb-0">
<li v-for="(char, index) in group" :key="char.CHARACTER_" class="d-flex"> <li v-for="(char, index) in englishChars" :key="char.CHARACTER_" class="d-flex">
<button <button
type="button" type="button"
class="alphabet-btn" class="alphabet-btn"
@ -36,10 +32,9 @@
> >
{{ char.CHARACTER_ }} ({{ char.COUNT }}) {{ char.CHARACTER_ }} ({{ char.COUNT }})
</button> </button>
<span v-if="index !== group.length - 1" class="divider">|</span> <span v-if="index !== englishChars.length - 1" class="divider">|</span>
</li> </li>
</ul> </ul>
</div>
</template> </template>
<script setup> <script setup>
@ -61,24 +56,14 @@ const selectedAlphabet = ref(props.selectedAl);
const totalCount = computed(() => { const totalCount = computed(() => {
return props.indexCategory.reduce((sum, item) => sum + item.COUNT, 0); 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(() => { const koreanChars = computed(() => {
return props.indexCategory.filter(char => /[-ㅎ가-]/.test(char.CHARACTER_)); return props.indexCategory.filter(char => /[-ㅎ가-]/.test(char.CHARACTER_));
}); });
const englishChars = computed(() => { const englishChars = computed(() => {
return props.indexCategory.filter(char => /^[a-zA-Z]$/.test(char.CHARACTER_)); 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 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;
@ -96,7 +81,6 @@ const selectAlphabet = (alphabet) => {
cursor: pointer; cursor: pointer;
width: 70%; width: 70%;
height: 40px; height: 40px;
transition: color 0.3s ease, font-size 0.3s ease; /* Smooth transition for color */
} }
.alphabet-btn:hover { .alphabet-btn:hover {
@ -106,16 +90,10 @@ const selectAlphabet = (alphabet) => {
.alphabet-btn.active { .alphabet-btn.active {
color: #0d6efd; color: #0d6efd;
text-decoration: underline; text-decoration: underline;
font-size: 13px; /* Keep font size fixed in active state */
} }
.divider { .divider {
color: #bbb; color: #bbb;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
} }
.flex-wrap {
flex-wrap: wrap;
}
</style> </style>

View File

@ -1,5 +1,5 @@
<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();"
@ -9,30 +9,29 @@
: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 object-fit-cover" class="rounded-circle user-avatar"
:src="getProfileImage(item.author.profileImage)" :src="getProfileImage(item.author.profileImage)"
alt="최초 작성자" alt="최초 작성자"
:style="{ borderColor: item.author.color}" :style="{ borderColor: item.author.color}"
@ -43,15 +42,15 @@
<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> </div>
</div>
<!-- 최근 작성자 (조건부) -->
<div <div
v-if="item.author.createdAt !== item.lastEditor.updatedAt" v-if="item.author.createdAt !== item.lastEditor.updatedAt"
class="d-flex flex-wrap align-items-center" class="d-flex justify-content-between flex-wrap gap-2 mb-2"
> >
<div class="avatar me-2"> <div class="d-flex flex-wrap align-items-center mb-50">
<div class="avatar avatar-sm me-2">
<img <img
class="rounded-circle user-avatar object-fit-cover" class="rounded-circle user-avatar"
:src="getProfileImage(item.lastEditor.profileImage)" :src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자" alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color}" :style="{ borderColor: item.lastEditor.color}"
@ -64,13 +63,18 @@
</div> </div>
</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"
:isActive="writeStore.activeItemId === item.WRDDICSEQ"/>
</div>
</li> </li>
</template> </template>
<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';
@ -126,7 +130,7 @@ const editWord = (data) => {
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 +155,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 +169,7 @@ const toggleEdit = async () => {
.user-avatar { .user-avatar {
border: 3px solid; border: 3px solid;
padding: 0.1px;
} }
.edit-btn { .edit-btn {
@ -178,21 +184,4 @@ const toggleEdit = async () => {
top: -0.5rem; top: -0.5rem;
--bs-form-check-bg: #fff; --bs-form-check-bg: #fff;
} }
.btn.btn-primary {
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> </style>

View File

@ -1,42 +1,38 @@
<template> <template>
<div v-if="dataList.length > 0" > <div>
<div class="row">
<div class="col-10">
<FormSelect <FormSelect
name="cate" name="cate"
title="카테고리" title="카테고리 선택"
:data="dataList" :data="dataList"
:is-common="true" :is-common="true"
@update:data="selectCategory = $event" @update:data="selectCategory = $event"
@change="onChange" @change="onChange"
:value="formValue" :value="formValue"
:disabled="isDisabled"
:is-essential="false" :is-essential="false"
:is-btn="true"
>
<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>
<div class="row" v-if="showInput">
<div class="col-10">
<FormInput <FormInput
class="justify-content-end"
ref="categoryInputRef" ref="categoryInputRef"
title="새 카테고리" title="카테고리 입력"
:isLabel="dataList.length === 0 ?true : false" name="카테고리"
name="새 카테고리"
@update:modelValue="addCategory = $event" @update:modelValue="addCategory = $event"
:is-cate-alert="addCategoryAlert" :is-cate-alert="addCategoryAlert"
@focusout="handleCategoryFocusout(addCategory)" @focusout="handleCategoryFocusout(addCategory)"
/> />
</div> </div>
</div>
</div>
<div class="dict-w">
<FormInput <FormInput
title="용어" title="용어"
type="text" type="text"
@ -48,10 +44,11 @@
:disabled="isDisabled" :disabled="isDisabled"
@keyup="ValidHandler('title')" @keyup="ValidHandler('title')"
/> />
</div>
<div> <div>
<QEditor class="q-editor-container" @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/> <QEditor @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/>
<div class="text-end mt-5"> <div class="text-end mt-5">
<button class="btn btn-primary" @click="saveWord" :disabled="titleValue ? !changed : false"> <button class="btn btn-primary" @click="saveWord">
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
</button> </button>
</div> </div>
@ -65,8 +62,37 @@ 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 emit = defineEmits(['close','addCategory','addWord']);
//
const wordTitle = ref('');
const addCategory = ref('');
const content = ref('');
const imageUrls = ref([]);
// Vaildation
const wordTitleAlert = ref(false);
const wordContentAlert = ref(false);
const addCategoryAlert = ref(false);
//
const selectCategory = ref('');
//
const computedTitle = computed(() =>
wordTitle.value === '' ? props.titleValue : wordTitle.value
);
//
const selectedCategory = computed(() =>
selectCategory.value === '' ? props.formValue : selectCategory.value
);
// ref
const categoryInputRef = ref(null);
const props = defineProps({ const props = defineProps({
dataList: { dataList: {
type: Array, type: Array,
@ -86,45 +112,9 @@ const props = defineProps({
isDisabled: { isDisabled: {
type: Boolean, type: Boolean,
default: false default: false
},
showEditBtn: {
type: Boolean,
default: false
} }
}); });
const writeStore = useWriteVisibleStore();
const emit = defineEmits(['close','addCategory','addWord', 'toggleEdit']);
//
const wordTitle = ref('');
const addCategory = ref('');
const content = ref('');
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(() =>
wordTitle.value === '' ? props.titleValue : wordTitle.value
);
//
const selectedCategory = computed(() =>
selectCategory.value === '' ? props.formValue : selectCategory.value
);
// ref
const categoryInputRef = ref(null);
// //
const showInput = ref(false); const showInput = ref(false);
@ -135,7 +125,6 @@ const toggleInput = () => {
const onChange = (newValue) => { const onChange = (newValue) => {
selectCategory.value = newValue.target.value; selectCategory.value = newValue.target.value;
changed.value = true;
}; };
const ValidHandler = (field) => { const ValidHandler = (field) => {
@ -144,22 +133,11 @@ const ValidHandler = (field) => {
} }
if(field == 'content'){ if(field == 'content'){
wordContentAlert.value = false; wordContentAlert.value = false;
} }
} }
const handleContentUpdate = (newContent) => { const handleContentUpdate = (newContent) => {
content.value = 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"); // ValidHandler("content"); //
}; };
@ -197,26 +175,38 @@ const saveWord = () => {
} }
const wordData = { const wordData = {
id: props.NumValue || null, id: props.NumValue || null,
title: computedTitle.value.trim(), title: computedTitle.value,
category: selectedCategory.value, category: selectedCategory.value,
content: content.value, content: content.value,
}; };
if(valid){ if(valid){
emit('addWord', wordData, addCategory.value.trim() === '' emit('addWord', wordData, addCategory.value === ''
? (isNaN(selectedCategory.value) ? selectedCategory.value : Number(selectedCategory.value)) ? (isNaN(selectedCategory.value) ? selectedCategory.value : Number(selectedCategory.value))
: addCategory.value); : addCategory.value);
} }
} }
// focusout // focusout
const handleCategoryFocusout = (value) => { const handleCategoryFocusout = (value) => {
if (!value || value.trim() === '') {
return;
}
const valueTrim = value.trim(); const valueTrim = value.trim();
const existingCategory = props.dataList.find(item => item.label === valueTrim); const existingCategory = props.dataList.find(item => item.label === valueTrim);
if (existingCategory) { //
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; addCategoryAlert.value = true;
// focus // focus
@ -226,17 +216,23 @@ const handleCategoryFocusout = (value) => {
inputElement.focus(); inputElement.focus();
} }
}, 0); }, 0);
} else { } else {
addCategoryAlert.value = false; 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,48 @@
loadScript('/js/main.js'); loadScript('/js/main.js');
}); });
</script> </script>
<style>
/* 중앙 콘텐츠 자동 조정 */
.layout-page {
flex-grow: 1;
min-width: 0; /* flexbox 내에서 올바른 크기 계산 */
margin-right: 20%; /* 채팅 사이드바의 너비만큼 밀리도록 설정 */
}
/* 탑바 범위조정 */
nav#layout-navbar {
left: 0 !important;
margin-right: 20%;
margin-left: 260px;
width: auto !important;
min-width: auto !important;
right: 26px !important;
left: 26px !important;
}
/* 탑바 범위조정(1200px 이하) */
@media (max-width: 1200px) {
nav#layout-navbar {
left: 0 !important;
margin-right: 20%;
margin-left: 0px;
width: auto !important;
min-width: auto !important;
right: 26px !important;
left: 26px !important;
}
}
/* 탑바 범위조정(992px 이하) */
@media (max-width: 992px) {
nav#layout-navbar {
left: 0 !important;
margin-right: 20%;
margin-left: 0px;
width: auto !important;
min-width: auto !important;
right: 26px !important;
left: 26px !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,37 +6,15 @@
</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);"
@ -46,26 +24,125 @@
> >
<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
v-if="notificationCount > 0"
class="badge rounded-pill bg-danger badge-dot badge-notifications border"
></span>
</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">
<span class="badge bg-label-primary me-2">8 New</span>
<a
href="javascript:void(0)"
class="dropdown-notifications-all p-2"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Mark all as read"
><i class="bx bx-envelope-open text-heading"></i
></a>
</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> </div>
<!-- 알림이 있을 목록 렌더링-->
<div v-else>
<ul>
<li v-for="notification in notifications" :key="notification.id">
{{ notification.text }}
</li> </li>
</ul> </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> </div>
</li> </li>
</ul> </ul>
@ -73,20 +150,37 @@
<!--/ 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,29 +1,29 @@
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 막기

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) {
// Authorization 페이지는 ID가 26이 아니면 접근 차단
if (to.path === '/authorization' && userId !== allowedUserId) {
return next();
}
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동 // 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
if (to.meta.requiresGuest && authStore.isAuthenticated) { next({ name: 'Home' });
return next({ name: 'Home' }); } else {
}
// 모든 조건을 통과하면 정상적으로 이동
next(); 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' }); // 현재 라우트에 인증이 필요한지 확인
const requiresAuth = to.meta.requiresAuth === true
if (requiresAuth && !authStore.isAuthenticated) {
// 인증되지 않은 사용자를 로그인 페이지로 리다이렉트
// 원래 가려던 페이지를 쿼리 파라미터로 전달
next({
name: 'Login',
query: { redirect: to.fullPath }
})
} else {
next()
} }
})
return Promise.reject(error); export default router
},
);
export default router;

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
}, },
}); });
if (excludeEnded === 'true') {
// 종료되지 않은 프로젝트만 저장
activeProjectList.value = res.data.data.projectList;
} else {
// 모든 프로젝트 저장 (종료된 프로젝트 포함)
projectList.value = res.data.data.projectList; 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>

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

@ -25,7 +25,6 @@
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,15 +50,13 @@
<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>
</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>
@ -68,10 +65,10 @@
<!-- 버튼 --> <!-- 버튼 -->
<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>
@ -88,19 +85,15 @@
import { ref, onMounted, computed, watch, inject } from 'vue'; import { ref, onMounted, computed, watch, inject } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useToastStore } from '@s/toastStore'; 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 $common = inject('common');
const toastStore = useToastStore(); 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,156 +113,45 @@
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) { if (data.hasAttachment && data.attachments.length > 0) {
alert(data.message, 'e'); attachFiles.value = addDisplayFileName([...data.attachments]);
router.back();
return;
} }
const boardData = data.data;
if (boardData.hasAttachment && boardData.attachments.length > 0) { //
const formatted = addDisplayFileName([...boardData.attachments]); title.value = data.title || '제목 없음';
attachFiles.value = formatted; content.value = data.content || '내용 없음';
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; contentLoaded.value = true;
}; } catch (error) {
console.error('게시물 가져오기 오류:', error.response || error.message);
const handleUpdateEditorImg = item => { }
editorUploadedImgList.value = item;
};
const handleDeleteEditorImg = item => {
editorDeleteImgList.value = item;
}; };
// //
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 = () => {
accessStore.$reset();
router.back();
};
// ( : , , )
const isNotValidContent = delta => {
if (!delta?.ops?.length) return true;
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);
const hasVideo = delta.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.video);
return !(hasText || hasImage || hasVideo);
};
//
const checkValidation = () => { const checkValidation = () => {
contentAlert.value = isNotValidContent(content.value); contentAlert.value = $common.isNotValidContent(content);
titleAlert.value = $common.isNotValidInput(title.value); titleAlert.value = $common.isNotValidInput(title.value);
if (titleAlert.value || contentAlert.value || !isFileValid.value) { if (titleAlert.value || contentAlert.value || !isFileValid.value) {
if (titleAlert.value) { if (titleAlert.value) {
title.value = ''; title.value = '';
@ -285,6 +167,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 +178,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 +199,62 @@
}; };
////////////////// fileSection[E] //////////////////// ////////////////// fileSection[E] ////////////////////
/** content 변경 감지 (deep 옵션 추가) */ /** `content` 변경 감지하여 자동 유효성 검사 실행 */
watch(content, () => { watch(content, () => {
contentAlert.value = isNotValidContent(content.value); contentAlert.value = $common.isNotValidContent(content);
}, { deep: true }); });
// //
const validateTitle = () => { const validateTitle = () => {
titleAlert.value = title.value.trim().length === 0; titleAlert.value = title.value.trim().length === 0;
}; };
// /** `content` 변경 감지하여 자동 유효성 검사 실행 */
// watch(content, () => {
// contentAlert.value = $common.isNotValidContent(content);
// });
//
const updateBoard = async () => { const updateBoard = async () => {
if (checkValidation()) return; if (checkValidation()) return;
try {
//
const boardData = { const boardData = {
LOCBRDTTL: title.value.trim(), LOCBRDTTL: title.value.trim(),
LOCBRDCON: JSON.stringify(content.value), LOCBRDCON: JSON.stringify(content.value),
LOCBRDSEQ: currentBoardId.value LOCBRDSEQ: currentBoardId.value,
}; };
//
if (delFileIdx.value && delFileIdx.value.length > 0) { if (delFileIdx.value && delFileIdx.value.length > 0) {
boardData.delFileIdx = [...delFileIdx.value]; 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 fileArray = newFileFilter(attachFiles);
const formData = new FormData(); const formData = new FormData();
// formData boardData
Object.entries(boardData).forEach(([key, value]) => { Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value); formData.append(key, value);
}); });
// formData
fileArray.forEach((file, idx) => { fileArray.forEach((file, idx) => {
formData.append('files', file); formData.append('files', file);
}); });
const { data } = await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
if (data.code === 200) { await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
toastStore.onToast('게시물이 수정되었습니다.', 's'); toastStore.onToast('게시물이 수정되었습니다.', 's');
goList(); goList();
} else { } catch (error) {
toastStore.onToast('게시물 수정에 실패했습니다.', 'e'); console.error('게시물 수정 중 오류 발생:', error);
toastStore.onToast('게시물 수정에 실패했습니다.');
} }
}; };
// //
onMounted(async () => { onMounted(() => {
if (currentBoardId.value) { if (currentBoardId.value) {
fetchBoardDetails(); fetchBoardDetails();
} else { } else {
@ -370,3 +262,10 @@
} }
}); });
</script> </script>
<style>
.text-red {
color: red;
text-align: center;
}
</style>

View File

@ -1,119 +1,98 @@
<template> <template>
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container flex-grow-1 container-p-y">
<div class="card"> <div class="card">
<div class="card-header d-flex flex-column"> <div class="card-header">
<!-- 검색창 --> <!-- 검색창 -->
<div class="mb-3 w-100"> <div class="container col-6 mt-12 mb-8">
<search-bar @update:data="search" @keyup.enter="searchOnEnter" :initKeyword="searchText" class="flex-grow-1" /> <search-bar @update:data="search" @keyup.enter="searchOnEnter"/>
</div> </div>
<div class="d-flex align-items-center" style="gap: 15px"> </div>
<div class="card-datatable">
<div class="row mx-6 my-6 justify-content-between g-3 align-items-center">
<div class="col-md-6 d-flex flex-column flex-md-row align-items-md-center gap-2 mt-0">
<!-- 리스트 갯수 선택 --> <!-- 리스트 갯수 선택 -->
<select class="form-select w-auto" v-model="selectedSize" @change="handleSizeChange($event)" style="margin-left: 0"> <select class="form-select w-25 w-md-100" v-model="selectedSize" @change="handleSizeChange">
<option value="10">10개씩</option> <option value="10">10개씩</option>
<option value="20">20개씩</option> <option value="20">20개씩</option>
<option value="30">30개씩</option> <option value="30">30개씩</option>
<option value="50">50개씩</option> <option value="50">50개씩</option>
<option value="100">100개씩</option>
</select> </select>
<!-- 셀렉트 박스 --> <!-- 셀렉트 박스 -->
<select class="form-select w-auto" v-model="selectedOrder" @change="handleSortChange($event)"> <select class="form-select w-25 w-md-100" v-model="selectedOrder" @change="handleSortChange">
<option value="date">날짜</option> <option value="date">최신날짜</option>
<option value="views">조회수</option> <option value="views">조회수</option>
</select> </select>
<!-- 공지 접기 기능 --> <!-- 공지 접기 기능 -->
<div class="form-check mb-0"> <div class="form-check mb-0 ms-2">
<input <input class="form-check-input" type="checkbox" v-model="showNotices" id="hideNotices" />
class="form-check-input mt-1"
type="checkbox"
v-model="showNotices"
id="hideNotices"
@change="handlePageFilter()"
/>
<label class="form-check-label" for="hideNotices">공지 숨기기</label> <label class="form-check-label" for="hideNotices">공지 숨기기</label>
</div> </div>
</div>
<!-- 글쓰기 버튼 --> <div class="col-md-6 d-flex flex-column flex-md-row align-items-md-center justify-content-md-end gap-2">
<router-link to="/board/write" class="ms-auto"> <!-- 글쓰기 -->
<WriteButton /> <router-link to="/board/write" class="ms-2">
<WriteButton class="btn add-new btn-primary"/>
</router-link> </router-link>
</div> </div>
</div> </div>
<div class="card-datatable m"> <!-- 게시판 -->
<div class="table-responsive"> <div class="table-responsive">
<table class="datatables-users table border-top dataTable dtr-column"> <table class="datatables-users table border-top dataTable dtr-column">
<thead> <thead>
<tr> <tr>
<th style="width: 11%" class="text-center fw-bold">번호</th> <th style="width: 11%;" class="text-center fw-bold">번호</th>
<th style="width: 45%" class="text-center fw-bold">제목</th> <th style="width: 45%;" class="text-center fw-bold">제목</th>
<th style="width: 10%" class="text-strat fw-bold">작성자</th> <th style="width: 10%;" class="text-center fw-bold">작성자</th>
<th style="width: 15%" class="text-center fw-bold">작성일</th> <th style="width: 15%;" class="text-center fw-bold">작성일</th>
<th style="width: 9%" class="text-center fw-bold">조회수</th> <th style="width: 9%;" class="text-center fw-bold">조회수</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- 공지사항 --> <!-- 공지사항 -->
<template v-if="pagination.currentPage === 1 && !showNotices"> <template v-if="pagination.currentPage === 1 && !showNotices">
<tr <tr v-for="(notice, index) in noticeList"
v-for="(notice, index) in noticeList"
:key="'notice-' + index" :key="'notice-' + index"
class="bg-label-gray fw-bold" class="bg-label-gray fw-bold"
@click="goDetail(notice.id)" @click="goDetail(notice.id)">
>
<td class="text-center">공지</td> <td class="text-center">공지</td>
<td class="cursor-pointer"> <td class="cursor-pointer">
<div class="d-flex flex-wrap align-items-center"> 📌 {{ notice.title }}
<span class="me-1">📌</span> <span v-if="notice.commentCount" class="comment-count">[ {{ notice.commentCount }} ]</span>
<span>{{ truncateTitle(notice.title) }}</span> <i v-if="notice.img" class="bi bi-image me-1"></i>
<i v-if="Array.isArray(notice.hasAttachment) && notice.hasAttachment.length > 0" class="bi bi-paperclip"></i>
<span v-if="notice.commentCount" class="text-danger fw-bold mx-1"> <span v-if="isNewPost(notice.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
[ {{ notice.commentCount }} ]
</span>
<i v-if="notice.img" class="bi bi-image mx-1"></i>
<i
v-if="Array.isArray(notice.hasAttachment) && notice.hasAttachment.length > 0"
class="bi bi-paperclip"
></i>
<span v-if="isNewPost(notice.rawDate)" class="box-new badge text-white ms-2 fs-tiny"> N </span>
</div>
</td> </td>
<td class="text-start">{{ notice.author }}</td> <td class="text-center">{{ notice.author }}</td>
<td class="text-center">{{ notice.date }}</td> <td class="text-center">{{ notice.date }}</td>
<td class="text-center">{{ notice.views }}</td> <td class="text-center">{{ notice.views }}</td>
</tr> </tr>
</template> </template>
<!-- 일반 게시물 --> <!-- 일반 게시물 -->
<tr <tr v-for="(post, index) in generalList"
v-for="(post, index) in generalList"
:key="'post-' + index" :key="'post-' + index"
class="invert-bg-white" class="invert-bg-white"
@click="goDetail(post.realId)" @click="goDetail(post.realId)">
>
<td class="text-center">{{ post.id }}</td> <td class="text-center">{{ post.id }}</td>
<td class="cursor-pointer"> <td class="cursor-pointer">
<div class="d-flex flex-wrap align-items-center"> {{ post.title }}
{{ truncateTitle(post.title) }} <span v-if="post.commentCount" class="comment-count">[ {{ post.commentCount }} ]</span>
<span v-if="post.commentCount" class="comment-count text-danger">[ {{ post.commentCount }} ]</span> <i v-if="post.img" class="bi bi-image me-1"></i>
<i v-if="post.img" class="bi bi-image mx-1"></i> <i v-if="Array.isArray(post.hasAttachment) && post.hasAttachment.length > 0" class="bi bi-paperclip"></i>
<i <span v-if="isNewPost(post.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
v-if="Array.isArray(post.hasAttachment) && post.hasAttachment.length > 0"
class="bi bi-paperclip"
></i>
<span v-if="isNewPost(post.rawDate)" class="box-new badge text-white ms-2 fs-tiny">N</span>
</div>
</td> </td>
<td class="text-start nickname-ellipsis">{{ post.nickname ? post.nickname : post.author }}</td> <td class="text-center">{{ post.author }}</td>
<td class="text-center">{{ post.date }}</td> <td class="text-center">{{ post.date }}</td>
<td class="text-center">{{ post.views }}</td> <td class="text-center">{{ post.views }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- 게시물이 없을 --> <!-- 검색 결과가 없을 -->
<div v-if="generalList.length === 0"> <div v-if="generalList.length === 0">
<p class="text-center pt-10 mt-2 mb-0 text-muted">게시물이 없습니다.</p> <p class="text-center pt-10 mt-2 mb-0 text-muted">
검색 결과가 없습니다.
</p>
</div> </div>
</div> </div>
</div> </div>
@ -121,7 +100,11 @@
<!-- 페이지네이션 --> <!-- 페이지네이션 -->
<div class="row g-3"> <div class="row g-3">
<div class="mt-8"> <div class="mt-8">
<Pagination v-if="pagination.pages" v-bind="pagination" @update:currentPage="handlePageChange" /> <Pagination
v-if="pagination.pages"
v-bind="pagination"
@update:currentPage="handlePageChange"
/>
</div> </div>
</div> </div>
</div> </div>
@ -130,11 +113,10 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue'; import { ref, onMounted } from 'vue';
import Pagination from '@c/pagination/Pagination.vue'; import Pagination from '@c/pagination/Pagination.vue';
import SearchBar from '@c/search/SearchBar.vue'; import SearchBar from '@c/search/SearchBar.vue';
//import { route, router } from '@/router'; import router from '@/router';
import { useRoute, useRouter } from 'vue-router';
import WriteButton from '@c/button/WriteBtn.vue'; import WriteButton from '@c/button/WriteBtn.vue';
import axios from '@api'; import axios from '@api';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -142,6 +124,7 @@
import isYesterday from 'dayjs/plugin/isYesterday'; import isYesterday from 'dayjs/plugin/isYesterday';
import 'bootstrap-icons/font/bootstrap-icons.css'; import 'bootstrap-icons/font/bootstrap-icons.css';
dayjs.extend(isToday); dayjs.extend(isToday);
dayjs.extend(isYesterday); dayjs.extend(isYesterday);
@ -153,9 +136,6 @@
const selectedSize = ref(10); const selectedSize = ref(10);
const showNotices = ref(false); const showNotices = ref(false);
const router = useRouter();
const route = useRoute();
const pagination = ref({ const pagination = ref({
currentPage: 1, currentPage: 1,
pages: 1, pages: 1,
@ -168,98 +148,67 @@
navigatePages: 10, navigatePages: 10,
navigatepageNums: [1], navigatepageNums: [1],
navigateFirstPage: 1, navigateFirstPage: 1,
navigateLastPage: 1, navigateLastPage: 1
}); });
// //
const goDetail = id => { const goDetail = (id) => {
saveFilterToStorage(id);
router.push({ name: 'BoardDetail', params: { id: id } }); router.push({ name: 'BoardDetail', params: { id: id } });
}; };
const truncateTitle = title => {
return title.length > 19 ? title.slice(0, 19) + '...' : title;
};
//
const saveFilterToStorage = seq => {
const query = {
page: 1,
size: selectedSize.value,
orderBy: selectedOrder.value,
searchText: searchText.value,
showNotice: showNotices.value,
};
//localStorage.removeItem
//
localStorage.setItem(`boardList_${seq}`, JSON.stringify(query));
};
//
const clearFliterStorage = () => {
Object.keys(localStorage).forEach(key => {
if (key.startsWith('boardList_')) {
localStorage.removeItem(key);
}
});
};
// ( HH:mm, YYYY-MM-DD) // ( HH:mm, YYYY-MM-DD)
const formatDate = dateString => { const formatDate = (dateString) => {
const date = dayjs(dateString); const date = dayjs(dateString);
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD'); return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
}; };
// ( ) // ( )
const isNewPost = dateString => { const isNewPost = (dateString) => {
const date = dayjs(dateString); const date = dayjs(dateString);
return date.isToday() || date.isYesterday(); return date.isToday() || date.isYesterday();
}; };
// //
const search = e => { const search = (e) => {
searchText.value = e.trim(); searchText.value = e.trim();
handlePageFilter(); fetchGeneralPosts(1);
}; };
// //
const handleSortChange = e => { const handleSortChange = () => {
selectedOrder.value = e.target.value; fetchGeneralPosts(1);
handlePageFilter();
}; };
// //
const handleSizeChange = e => { const handleSizeChange = () => {
selectedSize.value = e.target.value; fetchGeneralPosts(1);
handlePageFilter();
}; };
// //
const fetchGeneralPosts = async (page = 1, keyword = '', orderBy = 'date', size = 10) => { const fetchGeneralPosts = async (page = 1) => {
try { try {
const { data } = await axios.get('board/general', { const { data } = await axios.get("board/general", {
params: { params: {
page, page,
size: size, size: selectedSize.value,
orderBy: orderBy, orderBy: selectedOrder.value,
searchKeyword: keyword, searchKeyword: searchText.value
}, }
}); });
if (data?.data) { if (data?.data) {
const totalPosts = data.data.total; const totalPosts = data.data.total;
generalList.value = data.data.list.map((post, index) => ({ generalList.value = data.data.list.map((post, index) => ({
realId: post.id, realId: post.id,
id: totalPosts - (page - 1) * selectedSize.value - index, id: totalPosts - ((page - 1) * selectedSize.value) - index,
title: post.title, title: post.title,
author: post.author || '익명', author: post.author || '익명',
rawDate: post.date, rawDate: post.date,
date: formatDate(post.date), // date: formatDate(post.date), //
views: post.cnt || 0, views: post.cnt || 0,
hasAttachment: post.hasAttachment, hasAttachment: post.hasAttachment,
nickname: post.nickname || null,
img: post.firstImageUrl || null, img: post.firstImageUrl || null,
commentCount: post.commentCount, commentCount : post.commentCount
})); }));
pagination.value = { pagination.value = {
@ -275,17 +224,19 @@
navigatePages: data.data.navigatePages, navigatePages: data.data.navigatePages,
navigatepageNums: data.data.navigatepageNums, navigatepageNums: data.data.navigatepageNums,
navigateFirstPage: data.data.navigateFirstPage, navigateFirstPage: data.data.navigateFirstPage,
navigateLastPage: data.data.navigateLastPage, navigateLastPage: data.data.navigateLastPage
}; };
} }
} catch (error) {} } catch (error) {
console.error("데이터 오류:", error);
}
}; };
// //
const fetchNoticePosts = async () => { const fetchNoticePosts = async () => {
try { try {
const { data } = await axios.get('board/notices', { const { data } = await axios.get("board/notices", {
params: { searchKeyword: searchText.value }, params: { searchKeyword: searchText.value }
}); });
if (data?.data) { if (data?.data) {
@ -298,14 +249,16 @@
views: post.cnt || 0, views: post.cnt || 0,
hasAttachment: post.hasAttachment, hasAttachment: post.hasAttachment,
img: post.firstImageUrl || null, img: post.firstImageUrl || null,
commentCount: post.commentCount, commentCount : post.commentCount
})); }));
} }
} catch (error) {} } catch (error) {
console.error("데이터 오류:", error);
}
}; };
// Enter // Enter
const searchOnEnter = event => { const searchOnEnter = (event) => {
const searchTextValue = event.target.value.trim(); const searchTextValue = event.target.value.trim();
if (!searchTextValue || searchTextValue[0] === ' ') { if (!searchTextValue || searchTextValue[0] === ' ') {
@ -313,60 +266,20 @@
} }
searchText.value = searchTextValue; searchText.value = searchTextValue;
handlePageFilter(); fetchGeneralPosts(1);
}; };
// //
const handlePageChange = page => { const handlePageChange = (page) => {
if (page !== pagination.value.currentPage) { if (page !== pagination.value.currentPage) {
handlePageFilter(page); fetchGeneralPosts(page);
} }
}; };
// ( )
const handlePageFilter = (page = 1) => {
router.push({
path: '/board',
query: {
page: page,
size: selectedSize.value,
orderBy: selectedOrder.value,
searchText: searchText.value,
showNotice: showNotices.value,
},
});
};
// ,
watch(
() => route.query,
newQuery => {
pagination.currentPage = newQuery.page || 1;
const keyword = newQuery?.searchText;
const orderBy = newQuery?.orderBy;
const size = newQuery?.size;
fetchGeneralPosts(pagination.currentPage, keyword, orderBy, size);
},
);
// //
onMounted(() => { onMounted(() => {
//
const page = route.query?.page;
const keyword = route.query?.searchText;
const orderBy = route.query?.orderBy;
const size = route.query?.size;
const showNotice = route.query?.showNotice;
//
if (keyword) searchText.value = keyword;
if (orderBy) selectedOrder.value = orderBy;
if (size) selectedSize.value = size;
if (showNotice) showNotices.value = showNotice == 'false' ? false : true;
clearFliterStorage();
fetchNoticePosts(); fetchNoticePosts();
fetchGeneralPosts(page, keyword, orderBy, size); fetchGeneralPosts();
}); });
</script> </script>
@ -376,12 +289,12 @@
width: 100% !important; width: 100% !important;
} }
} }
/* 댓글 개수 스타일 */
.comment-count { .comment-count {
font-size: 0.9rem; font-size: 0.9rem; /* 글씨 크기 증가 */
font-weight: bold; font-weight: bold;
border-radius: 4px; color: #ff5733; /* 강조 색상 (붉은 계열) */
padding: 2px 6px; border-radius: 4px; /* 둥근 모서리 */
position: relative; padding: 2px 6px; /* 내부 패딩 */
top: -1px;
} }
</style> </style>

View File

@ -8,38 +8,27 @@
<div class="pb-5 border-bottom"> <div class="pb-5 border-bottom">
<BoardProfile <BoardProfile
:boardId="currentBoardId" :boardId="currentBoardId"
:profileName="displayName" :profileName="profileName"
:unknown="unknown" :unknown="unknown"
:profileImg="profileImg" :profileImg="profileImg"
:views="views" :views="views"
:nickname="nickname"
:commentNum="commentNum" :commentNum="commentNum"
:date="formattedBoardDate" :date="formattedBoardDate"
:isLike="false" :isLike="false"
:isAuthor="isAuthor" :isAuthor="isAuthor"
:is-edit-pushed="isEditPushed"
:is-delete-pushed="isDeletePushed"
@editClick="editClick" @editClick="editClick"
@deleteClick="deleteClick" @deleteClick="deleteClick"
> />
<!-- 목록으로 버튼 -->
<template #gobackBtn>
<button class="btn btn-label-primary btn-icon me-1" @click="goList">
<i class="bx bx-left-arrow-alt"></i>
</button>
</template>
</BoardProfile>
<!-- 비밀번호 입력창 (익명일 경우) --> <!-- 비밀번호 입력창 (익명일 경우) -->
<div v-if="isPassword && unknown" class="mt-3 w-px-200 ms-auto"> <div v-if="isPassword && 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"
autocomplete="new-password" autocomplete="off"
v-model="password" v-model="password"
placeholder="비밀번호 입력" placeholder="비밀번호 입력"
maxlength="8"
@input=" @input="
password = password.replace(/\s/g, ''); password = password.replace(/\s/g, '');
inputCheck(); inputCheck();
@ -82,14 +71,10 @@
</div> </div>
<!-- HTML 콘텐츠 렌더링 --> <!-- HTML 콘텐츠 렌더링 -->
<div <div class="board-content text-body" style="line-height: 1.6" v-html="$common.contentToHtml(boardContent)"></div>
class="board-content text-body mw-100 overflow-hidden text-break"
style="line-height: 1.6"
v-html="$common.contentToHtml(boardContent)"
></div>
<!-- 좋아요 버튼 --> <!-- 좋아요 버튼 -->
<div v-if="!type" class="row justify-content-center my-10"> <div class="row justify-content-center my-10">
<BoardRecommendBtn <BoardRecommendBtn
:bigBtn="true" :bigBtn="true"
:boardId="currentBoardId" :boardId="currentBoardId"
@ -101,7 +86,7 @@
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
/> />
</div> </div>
<div v-if="!type">
<!-- 댓글 입력 영역 --> <!-- 댓글 입력 영역 -->
<BoardCommentArea <BoardCommentArea
:profileName="profileName" :profileName="profileName"
@ -112,10 +97,9 @@
@submitComment="handleCommentSubmit" @submitComment="handleCommentSubmit"
/> />
</div> </div>
</div>
<!-- 댓글 목록 --> <!-- 댓글 목록 -->
<div v-if="!type" class="card-footer"> <div class="card-footer">
<BoardCommentList <BoardCommentList
:unknown="unknown" :unknown="unknown"
:comments="commentsWithAuthStatus" :comments="commentsWithAuthStatus"
@ -125,7 +109,6 @@
:passwordCommentAlert="passwordCommentAlert" :passwordCommentAlert="passwordCommentAlert"
:currentPasswordCommentId="currentPasswordCommentId" :currentPasswordCommentId="currentPasswordCommentId"
:password="password" :password="password"
:editCommentAlert="editCommentAlert"
@editClick="editComment" @editClick="editComment"
@deleteClick="deleteComment" @deleteClick="deleteComment"
@updateReaction="handleCommentReaction" @updateReaction="handleCommentReaction"
@ -135,7 +118,6 @@
@cancelEdit="handleCancelEdit" @cancelEdit="handleCancelEdit"
@submitEdit="handleSubmitEdit" @submitEdit="handleSubmitEdit"
@update:password="updatePassword" @update:password="updatePassword"
@inputDetector="inputDetector"
/> />
<Pagination v-if="pagination.pages" v-bind="pagination" @update:currentPage="handlePageChange" /> <Pagination v-if="pagination.pages" v-bind="pagination" @update:currentPage="handlePageChange" />
</div> </div>
@ -151,19 +133,15 @@
import BoardCommentList from '@c/board/BoardCommentList.vue'; import BoardCommentList from '@c/board/BoardCommentList.vue';
import BoardRecommendBtn from '@c/button/BoardRecommendBtn.vue'; import BoardRecommendBtn from '@c/button/BoardRecommendBtn.vue';
import Pagination from '@c/pagination/Pagination.vue'; import Pagination from '@c/pagination/Pagination.vue';
import { ref, onMounted, computed, inject, provide } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useBoardAccessStore } from '@s/useBoardAccessStore';
import axios from '@api'; import axios from '@api';
const $common = inject('common');
// //
const profileName = ref(''); const profileName = ref('');
const boardTitle = ref('제목 없음'); const boardTitle = ref('제목 없음');
const boardContent = ref(''); const boardContent = ref('');
const nickname = ref('');
const date = ref(''); const date = ref('');
const views = ref(0); const views = ref(0);
const likes = ref(0); const likes = ref(0);
@ -174,24 +152,15 @@
const attachment = ref(false); const attachment = ref(false);
const comments = ref([]); const comments = ref([]);
const profileImg = ref(''); const profileImg = ref('');
const isEditPushed = ref(false);
const isDeletePushed = ref(false);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const toastStore = useToastStore(); const toastStore = useToastStore();
const accessStore = useBoardAccessStore();
const currentBoardId = ref(Number(route.params.id)); const currentBoardId = ref(Number(route.params.id));
const unknown = computed(() => profileName.value === '익명'); const unknown = computed(() => profileName.value === '익명');
const currentUserId = computed(() => userStore?.user?.id); // id const currentUserId = computed(() => userStore?.user?.id); // id
const authorId = ref(''); // id const authorId = ref(''); // id
const editCommentAlert = ref({}); //,
const displayName = computed(() => {
return nickname.value && unknown.value ? nickname.value : profileName.value;
});
const isAuthor = computed(() => currentUserId.value === authorId.value); const isAuthor = computed(() => currentUserId.value === authorId.value);
const commentsWithAuthStatus = computed(() => { const commentsWithAuthStatus = computed(() => {
@ -225,6 +194,7 @@
link.remove(); link.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error('파일 다운로드 오류:', error);
alert('파일 다운로드 중 오류가 발생했습니다.'); alert('파일 다운로드 중 오류가 발생했습니다.');
} }
}; };
@ -241,7 +211,6 @@
const isDeleted = ref(true); const isDeleted = ref(true);
const commentAlert = ref(''); const commentAlert = ref('');
const boardPasswordAlert = ref(''); const boardPasswordAlert = ref('');
const type = ref('');
const updatePassword = newPassword => { const updatePassword = newPassword => {
password.value = newPassword; password.value = newPassword;
@ -265,42 +234,28 @@
const inputCheck = () => { const inputCheck = () => {
passwordAlert.value = ''; passwordAlert.value = '';
}; };
// //
const fetchBoardDetails = async () => { const fetchBoardDetails = async () => {
const { data } = await axios.get(`board/${currentBoardId.value}`); const response = await axios.get(`board/${currentBoardId.value}`);
if (data?.data) { const data = response.data.data;
const boardData = data.data;
profileName.value = boardData.author || '익명';
authorId.value = boardData.authorId;
type.value = boardData.type === '300103';
boardTitle.value = boardData.title || '제목 없음';
boardContent.value = boardData.content || '';
profileImg.value = boardData.profileImg || '';
date.value = boardData.date || '';
nickname.value = boardData.nickname || '';
views.value = boardData.cnt || 0;
likes.value = boardData.likeCount || 0;
dislikes.value = boardData.dislikeCount || 0;
attachment.value = boardData.hasAttachment || null;
commentNum.value = boardData.commentCount || 0;
attachments.value = boardData.attachments || [];
if (boardData?.myReaction == 1) { profileName.value = data.author || '익명';
likeClicked.value = true; authorId.value = data.authorId;
dislikeClicked.value = false; boardTitle.value = data.title || '제목 없음';
} else if (boardData?.myReaction == 2) { boardContent.value = data.content || '';
likeClicked.value = false; profileImg.value = data.profileImg || '';
dislikeClicked.value = true; date.value = data.date || '';
} views.value = data.cnt || 0;
} else { likes.value = data.likeCount || 0;
toastStore.onToast(data.message, 'e'); dislikes.value = data.dislikeCount || 0;
router.back(); attachment.value = data.hasAttachment || null;
} commentNum.value = data.commentCount || 0;
attachments.value = data.attachments || [];
}; };
// , // ,
const handleUpdateReaction = async ({ boardId, commentId, isLike, isDislike }) => { const handleUpdateReaction = async ({ boardId, commentId, isLike, isDislike }) => {
try {
await axios.post(`/board/${boardId}/${commentId}/reaction`, { await axios.post(`/board/${boardId}/${commentId}/reaction`, {
LOCBRDSEQ: boardId, // id LOCBRDSEQ: boardId, // id
LOCCMTSEQ: commentId, // id LOCCMTSEQ: commentId, // id
@ -316,6 +271,9 @@
likeClicked.value = isLike; likeClicked.value = isLike;
dislikeClicked.value = isDislike; dislikeClicked.value = isDislike;
} catch (error) {
alert('오류가 발생했습니다.');
}
}; };
// //
@ -329,10 +287,7 @@
LOCGOBBAD: isDislike ? 'T' : 'F', LOCGOBBAD: isDislike ? 'T' : 'F',
}); });
fetchComments(pagination.value.currentPage); await fetchComments();
closeAllEditTextareas();
closeAllPasswordAreas();
activeCommentBtnClass();
}; };
// //
@ -344,8 +299,7 @@
page, page,
}, },
}); });
const commentsList = response.data.data.list const commentsList = response.data.data.list.map(comment => ({
.map(comment => ({
commentId: comment.LOCCMTSEQ, // ID commentId: comment.LOCCMTSEQ, // ID
boardId: comment.LOCBRDSEQ, boardId: comment.LOCBRDSEQ,
parentId: comment.LOCCMTPNT, // ID parentId: comment.LOCCMTPNT, // ID
@ -355,19 +309,15 @@
likeCount: comment.likeCount || 0, likeCount: comment.likeCount || 0,
dislikeCount: comment.dislikeCount || 0, dislikeCount: comment.dislikeCount || 0,
profileImg: comment.profileImg || '', profileImg: comment.profileImg || '',
nickname: comment.LOCCMTNIC || '', likeClicked: comment.likeClicked || false,
createdAtRaw: comment.LOCCMTRDT, // dislikeClicked: comment.dislikeClicked || false,
// createdAt: formattedDate(comment.LOCCMTRDT), // () createdAtRaw: new Date(comment.LOCCMTRDT), //
// createdAtRaw: new Date(comment.LOCCMTUDT), // createdAt: formattedDate(comment.LOCCMTRDT), //
createdAt:
formattedDate(comment.LOCCMTUDT) +
(comment.content === '삭제된 댓글입니다' && comment.LOCCMTUDT !== comment.LOCCMTRDT ? ' (수정됨)' : ''), // ()
children: [], // children: [], //
updateAtRaw: comment.LOCCMTUDT, updateAtRaw: comment.LOCCMTUDT,
likeClicked: comment.myReaction == 1, }));
dislikeClicked: comment.myReaction == 2,
})) commentsList.sort((a, b) => b.createdAtRaw - a.createdAtRaw);
.sort((a, b) => b.createdAtRaw - a.createdAtRaw);
for (const comment of commentsList) { for (const comment of commentsList) {
if (!comment.commentId) continue; if (!comment.commentId) continue;
@ -377,8 +327,7 @@
}); });
if (replyResponse.data.data) { if (replyResponse.data.data) {
comment.children = replyResponse.data.data comment.children = replyResponse.data.data.map(reply => ({
.map(reply => ({
author: reply.author || '익명', author: reply.author || '익명',
authorId: reply.authorId, authorId: reply.authorId,
profileImg: reply.profileImg || '', profileImg: reply.profileImg || '',
@ -386,17 +335,13 @@
boardId: reply.LOCBRDSEQ, boardId: reply.LOCBRDSEQ,
parentId: reply.LOCCMTPNT, // ID parentId: reply.LOCCMTPNT, // ID
content: reply.LOCCMTRPY || '내용 없음', content: reply.LOCCMTRPY || '내용 없음',
createdAtRaw: reply.LOCCMTRDT, createdAtRaw: new Date(reply.LOCCMTRDT),
nickname: reply.LOCCMTNIC || '', createdAt: formattedDate(reply.LOCCMTRDT),
// createdAt: formattedDate(reply.LOCCMTRDT),
//createdAtRaw: new Date(reply.LOCCMTUDT),
createdAt: formattedDate(reply.LOCCMTUDT) + (reply.LOCCMTUDT !== reply.LOCCMTRDT ? ' (수정됨)' : ''),
likeCount: reply.likeCount || 0, likeCount: reply.likeCount || 0,
dislikeCount: reply.dislikeCount || 0, dislikeCount: reply.dislikeCount || 0,
likeClicked: false, likeClicked: false,
dislikeClicked: false, dislikeClicked: false,
})) }));
.sort((a, b) => b.createdAtRaw - a.createdAtRaw);
} else { } else {
comment.children = []; // comment.children = []; //
} }
@ -445,12 +390,12 @@
return; return;
} }
try {
const response = await axios.post(`board/${currentBoardId.value}/comment`, { const response = await axios.post(`board/${currentBoardId.value}/comment`, {
LOCBRDSEQ: currentBoardId.value, LOCBRDSEQ: currentBoardId.value,
LOCCMTRPY: comment, LOCCMTRPY: comment,
LOCCMTPWD: isCheck ? password : '', LOCCMTPWD: isCheck ? password : '',
LOCCMTPNT: 1, LOCCMTPNT: 1,
LOCCMTNIC: data.isCheck ? data.nickname : null,
LOCBRDTYP: isCheck ? '300102' : null, LOCBRDTYP: isCheck ? '300102' : null,
}); });
@ -458,31 +403,38 @@
passwordAlert.value = ''; passwordAlert.value = '';
commentAlert.value = ''; commentAlert.value = '';
await fetchComments(); await fetchComments();
activeCommentBtnClass();
} else { } else {
alert('댓글 작성을 실패했습니다.'); alert('댓글 작성을 실패했습니다.');
} }
} catch (error) {
alert('오류가 발생했습니다.');
}
}; };
// //
const handleCommentReply = async reply => { const handleCommentReply = async reply => {
try {
const response = await axios.post(`board/${currentBoardId.value}/comment`, { const response = await axios.post(`board/${currentBoardId.value}/comment`, {
LOCBRDSEQ: currentBoardId.value, LOCBRDSEQ: currentBoardId.value,
LOCCMTRPY: reply.comment, LOCCMTRPY: reply.comment,
LOCCMTPWD: reply.password || null, LOCCMTPWD: reply.password || null,
LOCCMTPNT: reply.parentId, LOCCMTPNT: reply.parentId,
LOCCMTNIC: reply.isCheck ? reply.nickname : null,
LOCBRDTYP: reply.isCheck ? '300102' : null, LOCBRDTYP: reply.isCheck ? '300102' : null,
}); });
if (response.status === 200) { if (response.status === 200) {
fetchComments(pagination.value.currentPage); if (response.data.code === 200) {
await fetchComments();
} else {
alert('대댓글 작성을 실패했습니다.');
}
}
} catch (error) {
if (error.response) {
alert('오류가 발생했습니다.');
}
alert('오류가 발생했습니다.');
} }
};
// ,
const inputDetector = () => {
editCommentAlert.value = {};
}; };
// //
@ -490,9 +442,6 @@
const isUnknown = unknown?.unknown ?? false; const isUnknown = unknown?.unknown ?? false;
if (isUnknown) { if (isUnknown) {
closeAllEditTextareas();
closeAllPasswordAreas();
activeCommentBtnClass();
togglePassword('edit'); togglePassword('edit');
} else { } else {
router.push({ name: 'BoardEdit', params: { id: currentBoardId.value } }); router.push({ name: 'BoardEdit', params: { id: currentBoardId.value } });
@ -504,9 +453,6 @@
const isUnknown = unknown?.unknown ?? false; const isUnknown = unknown?.unknown ?? false;
if (isUnknown) { if (isUnknown) {
closeAllEditTextareas();
closeAllPasswordAreas();
activeCommentBtnClass();
togglePassword('delete'); togglePassword('delete');
} else { } else {
deletePost(); deletePost();
@ -526,36 +472,11 @@
return null; return null;
}; };
const btnState = ref({}); // ( )
provide('isBtnPushed', btnState);
const activeCommentBtnClass = (targetComment = null, type = 3) => {
const target = targetComment?.commentId;
let editPush = false;
let deletePush = false;
if (targetComment) {
if (type == 1) {
editPush = true;
deletePush = false;
} else if (type == 2) {
editPush = false;
deletePush = true;
}
}
btnState.value = {
target: target,
isEditPushed: editPush,
isDeletePushed: deletePush,
};
};
// ( )
const editComment = comment => { const editComment = comment => {
acitveButtonType(); //
password.value = ''; password.value = '';
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
//currentPasswordCommentId.value = null;
isPassword.value = false; // isPassword.value = false; //
const targetComment = findCommentById(comment.commentId, comments.value); const targetComment = findCommentById(comment.commentId, comments.value);
@ -568,20 +489,27 @@
if (isMyComment) { if (isMyComment) {
if (targetComment.isEditTextarea) { if (targetComment.isEditTextarea) {
// //
targetComment.isEditTextarea = false; targetComment.isEditTextarea = false;
currentPasswordCommentId.value = comment.commentId; currentPasswordCommentId.value = comment.commentId;
activeCommentBtnClass(targetComment, 3);
} else { } else {
closeAllEditTextareas(); // //
currentPasswordCommentId.value = null; // closeAllEditTextareas();
targetComment.isEditTextarea = true; // currentPasswordCommentId.value = null;
activeCommentBtnClass(targetComment, 1); //
targetComment.isEditTextarea = true;
} }
} else if (isAnonymous) { } else if (isAnonymous) {
if (currentPasswordCommentId.value === comment.commentId) { if (currentPasswordCommentId.value === comment.commentId) {
toggleCommentPassword(comment, 'edit'); // //
toggleCommentPassword(comment, 'edit');
return;
} else { } else {
closeAllEditTextareas(); // //
closeAllEditTextareas();
//
targetComment.isEditTextarea = false; targetComment.isEditTextarea = false;
toggleCommentPassword(comment, 'edit'); toggleCommentPassword(comment, 'edit');
} }
@ -590,28 +518,6 @@
} }
}; };
//
const deleteComment = async comment => {
acitveButtonType(); //
closeAllEditTextareas();
const isMyComment = comment.authorId === currentUserId.value;
//
if (unknown.value && !isMyComment) {
//
if (comment.isEditTextarea) {
comment.isEditTextarea = false;
comment.isCommentPassword = true;
toggleCommentPassword(comment, 'delete');
} else {
activeCommentBtnClass(comment, 3);
toggleCommentPassword(comment, 'delete');
}
} else {
deleteReplyComment(comment);
}
};
// //
const closeAllEditTextareas = () => { const closeAllEditTextareas = () => {
comments.value.forEach(comment => { comments.value.forEach(comment => {
@ -629,20 +535,29 @@
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
}; };
//
const deleteComment = async comment => {
const isMyComment = comment.authorId === currentUserId.value;
if (unknown.value && !isMyComment) {
if (comment.isEditTextarea) {
comment.isEditTextarea = false;
comment.isCommentPassword = true;
} else {
toggleCommentPassword(comment, 'delete');
}
} else {
deleteReplyComment(comment);
}
};
// //
const toggleCommentPassword = (comment, button) => { const toggleCommentPassword = (comment, button) => {
if (lastCommentClickedButton.value === button && currentPasswordCommentId.value === comment.commentId) { if (lastCommentClickedButton.value === button && currentPasswordCommentId.value === comment.commentId) {
currentPasswordCommentId.value = null; // currentPasswordCommentId.value = null; //
password.value = ''; password.value = '';
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
activeCommentBtnClass(comment, 3);
} else { } else {
if (button == 'edit') {
activeCommentBtnClass(comment, 1);
} else if (button == 'delete') {
activeCommentBtnClass(comment, 2);
}
currentPasswordCommentId.value = comment.commentId; // currentPasswordCommentId.value = comment.commentId; //
password.value = ''; password.value = '';
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
@ -651,56 +566,22 @@
lastCommentClickedButton.value = button; lastCommentClickedButton.value = button;
}; };
//
const togglePassword = button => { const togglePassword = button => {
// close: . // close: .
boardPasswordAlert.value = '';
if (button === 'close') { if (button === 'close') {
isPassword.value = false; isPassword.value = false;
boardPasswordAlert.value = ''; boardPasswordAlert.value = '';
password.value = '';
acitveButtonType();
return; return;
} }
closeAllPasswordAreas();
if (lastClickedButton.value === button) { if (lastClickedButton.value === button) {
isPassword.value = !isPassword.value; isPassword.value = !isPassword.value;
boardPasswordAlert.value = ''; boardPasswordAlert.value = '';
acitveButtonType();
} else { } else {
isPassword.value = true; isPassword.value = true;
if (button == 'edit') {
acitveButtonType(1);
} else if (button == 'delete') {
acitveButtonType(2);
} else {
acitveButtonType();
} }
}
};
// (, ) lastClickedButton.value = button;
const acitveButtonType = type => {
//
if (type == 1) {
isEditPushed.value = true;
isDeletePushed.value = false;
lastClickedButton.value = 'edit';
//
} else if (type == 2) {
isEditPushed.value = false;
isDeletePushed.value = true;
lastClickedButton.value = 'delete';
//
} else {
isEditPushed.value = false;
isDeletePushed.value = false;
lastClickedButton.value = '';
isPassword.value = false;
}
}; };
// //
@ -711,23 +592,20 @@
} }
try { try {
const { data } = await axios.post(`board/${currentBoardId.value}/password`, { const response = await axios.post(`board/${currentBoardId.value}/password`, {
LOCBRDPWD: password.value, LOCBRDPWD: password.value,
LOCBRDSEQ: currentBoardId.value, LOCBRDSEQ: currentBoardId.value,
}); });
if (data.code === 200 && data.data === true) { if (response.data.code === 200 && response.data.data === true) {
accessStore.setBoardPassword(password.value);
boardPasswordAlert.value = ''; boardPasswordAlert.value = '';
isPassword.value = false; isPassword.value = false;
if (lastClickedButton.value === 'edit') { if (lastClickedButton.value === 'edit') {
router.push({ name: 'BoardEdit', params: { id: currentBoardId.value } }); router.push({ name: 'BoardEdit', params: { id: currentBoardId.value } });
return;
} else if (lastClickedButton.value === 'delete') { } else if (lastClickedButton.value === 'delete') {
await deletePost(); await deletePost();
} }
accessStore.$reset();
lastClickedButton.value = null; lastClickedButton.value = null;
} else { } else {
boardPasswordAlert.value = '비밀번호가 일치하지 않습니다.'; boardPasswordAlert.value = '비밀번호가 일치하지 않습니다.';
@ -759,7 +637,9 @@
// //
if (lastCommentClickedButton.value === 'edit') { if (lastCommentClickedButton.value === 'edit') {
if (targetComment) { if (targetComment) {
closeAllEditTextareas(); // //
closeAllEditTextareas();
targetComment.isEditTextarea = true; targetComment.isEditTextarea = true;
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
currentPasswordCommentId.value = null; currentPasswordCommentId.value = null;
@ -777,6 +657,9 @@
passwordCommentAlert.value = '비밀번호가 일치하지 않습니다.'; passwordCommentAlert.value = '비밀번호가 일치하지 않습니다.';
} }
} catch (error) { } catch (error) {
if (error.response?.status === 401) {
passwordCommentAlert.value = '비밀번호가 일치하지 않습니다';
}
passwordCommentAlert.value = '비밀번호가 일치하지 않습니다'; passwordCommentAlert.value = '비밀번호가 일치하지 않습니다';
} }
}; };
@ -818,15 +701,15 @@
}); });
if (response.data.code === 200) { if (response.data.code === 200) {
await fetchComments(pagination.value.currentPage); await fetchComments();
closeAllPasswordAreas(); closeAllPasswordAreas();
activeCommentBtnClass();
if (targetComment) { if (targetComment) {
// " ." , // console.log('',targetComment)
// " ." ,
targetComment.content = '댓글이 삭제되었습니다.'; targetComment.content = '댓글이 삭제되었습니다.';
targetComment.author = '알 수 없음'; // targetComment.author = '알 수 없음'; //
targetComment.isDeleted = true; // targetComment.isDeleted = true; //
} }
} else { } else {
toastStore.onToast('댓글 삭제에 실패했습니다.', 'e'); toastStore.onToast('댓글 삭제에 실패했습니다.', 'e');
@ -836,40 +719,29 @@
} }
}; };
// //
const handleSubmitEdit = async (comment, editedContent) => { const handleSubmitEdit = async (comment, editedContent) => {
if (!checkValidation(comment, editedContent)) return; //
togglePassword(); togglePassword();
try {
const response = await axios.put(`board/comment/${comment.commentId}`, { const response = await axios.put(`board/comment/${comment.commentId}`, {
LOCCMTSEQ: comment.commentId, LOCCMTSEQ: comment.commentId,
LOCCMTRPY: editedContent.trim(), LOCCMTRPY: editedContent,
}); });
if (response.status === 200) { if (response.status === 200) {
togglePassword('close'); const targetComment = findCommentById(comment.commentId, comments.value);
fetchComments(pagination.value.currentPage); if (targetComment) {
activeCommentBtnClass(); targetComment.content = editedContent; //
return; targetComment.isEditTextarea = false; //
// const targetComment = findCommentById(comment.commentId, comments.value);
// if (targetComment) {
// targetComment.content = editedContent.trim(); //
// targetComment.isEditTextarea = false; //
// togglePassword('close');
// }
} else { } else {
toastStore.onToast('댓글 수정을 실패하였습니다', 'e'); toastStore.onToast('수정할 댓글을 찾을 수 없습니다.', 'e');
} }
}; } else {
toastStore.onToast('댓글 수정 실패했습니다.', 'e');
// , . }
const checkValidation = (comment, content) => { } catch (error) {
if (!$common.isNotEmpty(content)) { toastStore.onToast('댓글 수정 중 오류가 발생하였습니다.', 'e');
editCommentAlert.value[comment.commentId] = '내용을 입력하세요';
return false;
} }
return true;
}; };
// ( ) // ( )
@ -891,16 +763,6 @@
} }
}; };
//
const goList = () => {
//
const getFilter = localStorage.getItem(`boardList_${currentBoardId.value}`);
router.push({
name: 'BoardList',
query: getFilter ? JSON.parse(getFilter) : '',
});
};
// ( ) // ( )
const handleCommentDeleted = deletedCommentId => { const handleCommentDeleted = deletedCommentId => {
// //
@ -930,14 +792,9 @@
const formattedBoardDate = computed(() => formattedDate(date.value)); const formattedBoardDate = computed(() => formattedDate(date.value));
const scrollToTop = () => {
window.scrollTo({ top: 0 });
};
// //
onMounted(() => { onMounted(() => {
fetchBoardDetails(); fetchBoardDetails();
fetchComments(); fetchComments();
scrollToTop();
}); });
</script> </script>

View File

@ -37,24 +37,11 @@
</label> </label>
</div> </div>
</div> </div>
<div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'"> <div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'">카테고리를 선택해주세요.</div>
카테고리를 선택해주세요.
</div>
</div> </div>
<!-- 비밀번호 필드 (익명게시판 선택 활성화) --> <!-- 비밀번호 필드 (익명게시판 선택 활성화) -->
<div v-if="categoryValue === 300102" class="mb-4"> <div v-if="categoryValue === 300102" class="mb-4">
<FormInput
title="닉네임"
name="nickname"
:is-essential="true"
:is-alert="nicknameAlert"
v-model="nickname"
@update:alert="nicknameAlert = $event"
@input="validateNickname"
@keypress="noSpace"
:maxlength="6"
/>
<FormInput <FormInput
title="비밀번호" title="비밀번호"
name="pw" name="pw"
@ -73,7 +60,6 @@
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"
/> />
@ -97,20 +83,13 @@
<div class="mb-4"> <div class="mb-4">
<label class="col-md-2 col-form-label"> 내용 <span class="text-danger">*</span> </label> <label class="col-md-2 col-form-label"> 내용 <span class="text-danger">*</span> </label>
<div class="col-md-12"> <div class="col-md-12">
<QEditor <QEditor @update:data="content = $event" />
@update:data="content = $event"
@update:uploadedImgList="handleUpdateEditorImg"
@update:deleteImgIndexList="handleDeleteEditorImg"
/>
</div>
<div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">
내용을 입력해주세요.
</div> </div>
<div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">내용을 입력해주세요.</div>
</div> </div>
<div class="mb-4 d-flex justify-content-end"> <div class="mb-4 d-flex justify-content-end">
<BackButton @click="goList" /> <BackButton @click="goList" />
<!-- 저장 버튼은 항상 활성화 -->
<SaveButton @click="write" :isEnabled="isFileValid" /> <SaveButton @click="write" :isEnabled="isFileValid" />
</div> </div>
</div> </div>
@ -120,7 +99,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, computed } from 'vue'; import { ref, onMounted, getCurrentInstance, watch, computed } from 'vue';
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';
@ -133,14 +112,12 @@
const toastStore = useToastStore(); const toastStore = useToastStore();
const categoryList = ref([]); const categoryList = ref([]);
const title = ref(''); const title = ref('');
const nickname = ref('');
const password = ref(''); const password = ref('');
const categoryValue = ref(null); const categoryValue = ref(null);
const content = ref({ ops: [] }); const content = ref({ ops: [] });
const isFileValid = ref(true); const isFileValid = ref(true);
const titleAlert = ref(false); const titleAlert = ref(false);
const nicknameAlert = ref(false);
const passwordAlert = ref(false); const passwordAlert = ref(false);
const contentAlert = ref(false); const contentAlert = ref(false);
const categoryAlert = ref(false); const categoryAlert = ref(false);
@ -150,40 +127,26 @@
const maxFiles = 5; const maxFiles = 5;
const maxSize = 10 * 1024 * 1024; const maxSize = 10 * 1024 * 1024;
const fileError = ref(''); const fileError = ref('');
const editorUploadedImgList = ref([]);
const editorDeleteImgList = ref([]);
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
const fetchCategories = async () => { const fetchCategories = async () => {
try {
const response = await axios.get('board/categories'); const response = await axios.get('board/categories');
categoryList.value = response.data.data; categoryList.value = response.data.data;
const freeCategory = categoryList.value.find(category => category.CMNCODNAM === '자유'); const freeCategory = categoryList.value.find(category => category.CMNCODNAM === '자유');
if (freeCategory) { if (freeCategory) {
categoryValue.value = freeCategory.CMNCODVAL; categoryValue.value = freeCategory.CMNCODVAL;
} }
} catch (error) {
console.error('카테고리 불러오기 오류:', error);
}
}; };
const autoIncrement = ref(0);
onMounted(() => { onMounted(() => {
fetchCategories(); fetchCategories();
}); });
const fileCount = computed(() => attachFiles.value.length); const fileCount = computed(() => attachFiles.value.length);
//
const handleUpdateEditorImg = item => {
editorUploadedImgList.value = item;
};
//
const handleDeleteEditorImg = item => {
editorDeleteImgList.value = item;
};
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)) {
@ -196,7 +159,6 @@
} }
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 => { const removeFile = index => {
@ -204,7 +166,6 @@
if (attachFiles.value.length <= maxFiles) { if (attachFiles.value.length <= maxFiles) {
fileError.value = ''; fileError.value = '';
} }
autoIncrement.value++;
}; };
watch(attachFiles, () => { watch(attachFiles, () => {
@ -215,15 +176,6 @@
titleAlert.value = title.value.trim().length === 0; titleAlert.value = title.value.trim().length === 0;
}; };
const validateNickname = () => {
if (categoryValue.value === 300102) {
nickname.value = nickname.value.replace(/\s/g, ''); //
nicknameAlert.value = nickname.value.length === 0;
} else {
nicknameAlert.value = false;
}
};
const validatePassword = () => { const validatePassword = () => {
if (categoryValue.value === 300102) { if (categoryValue.value === 300102) {
password.value = password.value.replace(/\s/g, ''); // password.value = password.value.replace(/\s/g, ''); //
@ -233,74 +185,47 @@
} }
}; };
/**
* validateContent:
* - 내용이 없으면 contentAlert를 true로 설정
* - 텍스트, 이미지, 비디오 하나라도 존재하면 유효한 콘텐츠로 판단
*/
const validateContent = () => { const validateContent = () => {
if (!content.value?.ops?.length) { if (!content.value?.ops?.length) {
contentAlert.value = true; contentAlert.value = true;
return; return;
} }
const hasText = content.value.ops.some( //
op => typeof op.insert === 'string' && op.insert.trim().length > 0 const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
); //
const hasImage = content.value.ops.some( const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
op => op.insert && typeof op.insert === 'object' && op.insert.image
);
const hasVideo = content.value.ops.some(
op => op.insert && typeof op.insert === 'object' && op.insert.video
);
contentAlert.value = !(hasText || hasImage || hasVideo); //
contentAlert.value = !(hasText || hasImage);
}; };
/** 글쓰기 */ /** 글쓰기 */
const write = async () => { const write = async () => {
validateTitle(); validateTitle();
validateNickname();
validatePassword(); validatePassword();
validateContent(); validateContent();
categoryAlert.value = categoryValue.value == null; categoryAlert.value = categoryValue.value == null;
if ( if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
titleAlert.value ||
nicknameAlert.value ||
passwordAlert.value ||
contentAlert.value ||
categoryAlert.value ||
!isFileValid.value
) {
return; return;
} }
try { try {
const boardData = { const boardData = {
LOCBRDTTL: title.value.trim(), LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value), // Delta JSON LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDNIC: categoryValue.value === 300102 ? nickname.value : null,
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null, LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
LOCBRDTYP: categoryValue.value, LOCBRDTYP: categoryValue.value,
}; };
//
if (editorUploadedImgList.value && editorUploadedImgList.value.length > 0) {
boardData.editorUploadedImgList = [...editorUploadedImgList.value];
}
//
if (editorDeleteImgList.value && editorDeleteImgList.value.length > 0) {
boardData.editorDeleteImgList = [...editorDeleteImgList.value];
}
const { data: boardResponse } = await axios.post('board', boardData); const { data: boardResponse } = await axios.post('board', boardData);
const boardId = boardResponse.data; const boardId = boardResponse.data;
// ( ) // ( )
if (attachFiles.value && attachFiles.value.length > 0) { if (attachFiles.value && attachFiles.value.length > 0) {
await Promise.all( await Promise.all(
attachFiles.value.map(async file => { attachFiles.value.map(async file => {
console.log(file);
const formData = new FormData(); const formData = new FormData();
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, ''); const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
@ -308,16 +233,17 @@
formData.append('CMNFLEORG', fileNameWithoutExt); formData.append('CMNFLEORG', fileNameWithoutExt);
formData.append('CMNFLEEXT', file.name.split('.').pop()); formData.append('CMNFLEEXT', file.name.split('.').pop());
formData.append('CMNFLESIZ', file.size); formData.append('CMNFLESIZ', file.size);
formData.append('file', file); formData.append('file', file); // 📌
await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true }); await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true });
}) }),
); );
} }
toastStore.onToast('게시물이 작성되었습니다.', 's'); toastStore.onToast('게시물이 작성되었습니다.', 's');
goList(); goList();
} catch (error) { } catch (error) {
console.error(error);
toastStore.onToast('게시물 작성 중 오류가 발생했습니다.', 'e'); toastStore.onToast('게시물 작성 중 오류가 발생했습니다.', 'e');
} }
}; };
@ -327,8 +253,8 @@
router.push('/board'); router.push('/board');
}; };
/** content 변경 감지 (deep 옵션 추가) */ /** `content` 변경 감지하여 자동 유효성 검사 실행 */
watch(content, () => { watch(content, () => {
validateContent(); validateContent();
}, { deep: true }); });
</script> </script>

View File

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

View File

@ -1,12 +0,0 @@
<template>
<div class="error-page">
<div class="error-content">
<img src="/img/illustrations/page-misc-error-dark.png" alt="Error Illustration" class="error-image" />
<h1>400</h1>
<RouterLink to="/" class="home-btn">홈으로 돌아가기</RouterLink>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,13 +0,0 @@
<template>
<div class="error-page">
<div class="error-content">
<img src="/img/illustrations/page-misc-error-light.png" alt="Error Illustration" class="error-image" />
<h1>404</h1>
<RouterLink to="/" class="home-btn">홈으로 돌아가기</RouterLink>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,16 +0,0 @@
<template>
<div class="error-page">
<div class="error-content">
<img src="/img/illustrations/page-misc-error-dark.png" alt="Error Illustration" class="error-image" />
<h1>500</h1>
<RouterLink to="/" class="home-btn">HOME</RouterLink>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
</style>

View File

@ -1,331 +0,0 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card shadow-sm rounded-lg p-6 max-w-2xl mx-auto">
<h3 class="text-2xl font-semibold mb-3 text-center">마이 페이지</h3>
<form @submit.prevent="handleSubmit">
<div class="text-center">
<label
for="profilePic"
class="rounded-circle cursor-pointer"
id="profileLabel"
:style="profilePreviewStyle"
></label>
<input type="file" id="profilePic" class="d-none object-fit-cover" @change="profileUpload" />
<span v-if="profilerr" class="invalid-feedback d-block">{{ profilerr }}</span>
</div>
<div class="col-xl-12">
<div class="d-flex">
<div class="w-50 me-2">
<UserFormInput
title="입사일"
name="entryDate"
type="date"
:value="form.entryDate"
@update:data="form.entryDate = $event"
/>
</div>
<div class="d-flex flex-column w-50">
<FormSelect
title="컬러"
name="color"
:is-row="false"
:is-label="true"
:is-common="true"
:is-color="true"
:data="colorList"
:value="form.color"
@update:data="handleColorUpdate"
/>
<span v-if="colorDuplicated" class="text-danger invalid-feedback mt-1 d-block">
이미 사용 중인 컬러입니다.
</span>
</div>
</div>
<div class="d-flex">
<UserFormInput title="생년월일" name="birth" type="date"
:value="form.birth" @update:data="form.birth = $event" class="me-2 w-50" />
<FormSelect title="MBTI" name="mbti" :is-row="false" :is-label="true"
:is-common="true" :is-mbti="true" :data="mbtiList"
:value="form.mbti" @update:data="form.mbti = $event" class="w-50" />
</div>
<ArrInput title="주소" name="address" v-model="form.address" :disabled="true" />
<UserFormInput title="전화번호" name="phone" :value="form.phone"
@update:data="form.phone = $event" @blur="checkPhoneDuplicateAndFormat"
:maxlength="11" @keypress="onlyNumber" />
<span v-if="phoneFormatError" class="text-danger invalid-feedback mt-1 d-block">
전화번호 형식이 올바르지 않습니다.
</span>
<span v-if="phoneDuplicated" class="text-danger invalid-feedback mt-1 d-block">
이미 사용 중인 전화번호입니다.
</span>
<!-- 기존 비밀번호 입력 -->
<UserFormInput title="비밀번호 재설정" placeholder="기존 비밀번호를 입력하세요" name="currentPw" type="password"
:value="password.current" @update:data="password.current = $event"
@blur="checkCurrentPassword" @keypress="noSpace" />
<span v-if="passwordError" class="text-danger invalid-feedback mt-1 d-block">
비밀번호가 일치하지 않습니다.
</span>
<!-- 비밀번호 재설정 -->
<div v-if="showResetPw">
<UserFormInput title="새 비밀번호" name="newPw" type="password"
:value="password.new" @update:data="password.new = $event" @keypress="noSpace" />
<span v-if="password.new && password.new.length < 4"
class="text-danger invalid-feedback mt-1 d-block">
비밀번호는 최소 4자리 이상이어야 합니다.
</span>
<span v-if="password.new === password.current"
class="text-danger invalid-feedback mt-1 d-block">
기존 비밀번호와 다르게 설정해주세요.
</span>
<UserFormInput title="비밀번호 확인" name="confirmPw" type="password"
:value="password.confirm" @update:data="password.confirm = $event" @keypress="noSpace" />
<span v-if="password.confirm && password.confirm !== password.new"
class="text-danger invalid-feedback mt-1 d-block">
비밀번호와 일치하지 않습니다.
</span>
<div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-sm btn-outline-primary"
:disabled="!canResetPassword"
@click="handlePasswordReset">
비밀번호 변경
</button>
</div>
</div>
<div class="d-flex mt-5">
<button type="submit" class="btn btn-primary w-100"
:disabled="!isChanged || phoneDuplicated || phoneFormatError || colorDuplicated">
정보 수정
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch} from 'vue';
import $api from '@api';
import UserFormInput from '@c/input/UserFormInput.vue';
import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue';
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
const form = ref({
entryDate: '', birth: '', phone: '', color: '', mbti: '',
address: { address: '', detailAddress: '', postcode: '' },
id: ''
});
const originalData = ref({});
const profile = ref('');
const uploadedFile = ref(null);
const profileChanged = ref(false);
const profilerr = ref('');
const currentBlobUrl = ref('');
const colorDuplicated = ref(false);
const phoneDuplicated = ref(false);
const mbtiList = ref([]);
const colorList = ref([]);
const password = ref({ current: '', new: '', confirm: '' });
const passwordError = ref(false);
const phoneFormatError = ref(false);
const showResetPw = ref(false);
const canResetPassword = computed(() => {
return (
password.value.new.length >= 4 &&
password.value.new !== password.value.current &&
password.value.new === password.value.confirm
);
});
watch(
() => form.value.address.detailAddress,
(newVal) => {
if (newVal !== newVal.trim()) {
form.value.address.detailAddress = newVal.trim();
}
}
);
const isChanged = computed(() => {
const f = form.value;
const o = originalData.value;
return (
f.entryDate !== o.entryDate || f.birth !== o.birth || f.phone !== o.phone ||
f.color !== o.color || f.mbti !== o.mbti || profileChanged.value ||
f.address.address !== o.address.address ||
f.address.detailAddress !== o.address.detailAddress ||
f.address.postcode !== o.address.postcode
);
});
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const defaultProfile = "/img/icons/icon.png";
const getProfileImageUrl = (profilePath) =>
profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
const profilePreviewStyle = computed(() => ({
width: '100px',
height: '100px',
backgroundImage: `url(${profile.value})`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
backgroundPosition: 'center'
}));
const profileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024 || !['image/jpeg', 'image/png'].includes(file.type)) {
profilerr.value = '5MB 이하의 JPG/PNG 파일만 업로드 가능합니다.';
return;
}
profilerr.value = '';
if (currentBlobUrl.value) URL.revokeObjectURL(currentBlobUrl.value);
uploadedFile.value = file;
const newBlobUrl = URL.createObjectURL(file);
profile.value = newBlobUrl;
currentBlobUrl.value = newBlobUrl;
profileChanged.value = true;
};
const onlyNumber = (e) => {
if (!/[0-9]/.test(e.key)) e.preventDefault();
};
const checkPhoneDuplicateAndFormat = async () => {
const currentPhone = form.value.phone.trim();
// (010 + 8)
const phoneRegex = /^010\d{8}$/;
const isFormatValid = phoneRegex.test(currentPhone);
phoneFormatError.value = !isFormatValid;
//
if (isFormatValid) {
const response = await $api.get('/user/checkPhone', {
params: { memberTel: currentPhone },
});
// true
// false
phoneDuplicated.value = currentPhone !== originalData.value.phone && !response.data.data;
} else {
// (false )
phoneDuplicated.value = false;
}
};
const handleColorUpdate = async (colorVal) => {
form.value.color = colorVal;
colorDuplicated.value = colorVal !== originalData.value.color &&
(await $api.get('/user/checkColor', { params: { memberCol: colorVal } })).data.data;
};
const checkCurrentPassword = async () => {
if (!password.value.current) return;
const res = await $api.post('/user/checkPassword', {
id: form.value.id,
password: password.value.current
});
passwordError.value = res.data.data;
showResetPw.value = !res.data.data;
};
const handlePasswordReset = async () => {
const res = await $api.patch('/user/pwNew', {
id: form.value.id,
password: password.value.new
});
if (res.data.data) {
toastStore.onToast('비밀번호가 변경되었습니다.', 's');
password.value = { current: '', new: '', confirm: '' };
showResetPw.value = false;
passwordError.value = false;
} else {
toastStore.onToast('비밀번호 변경 실패', 'e');
}
};
const formatDate = (isoDate) => isoDate?.split('T')[0] || '';
const loadInitialData = async () => {
const user = (await $api.get('/user/userInfo')).data.data;
const serverColors = (await $api.get('/user/color', { params: { type: 'YON' } })).data.data.map(c => ({
value: c.CMNCODVAL, label: c.CMNCODNAM
}));
const matchedColor = serverColors.find(c => c.label === user.usercolor);
const colorCode = matchedColor ? matchedColor.value : user.color;
colorList.value = serverColors.some(c => c.value === colorCode)
? serverColors
: [{ value: colorCode, label: user.usercolor }, ...serverColors];
const initData = {
id: user.loginId,
entryDate: formatDate(user.isCdt),
birth: formatDate(user.birth),
phone: user.phone || '',
color: colorCode,
mbti: user.mbit || '',
address: {
address: user.address || '',
detailAddress: user.addressDetail || '',
postcode: user.zipcode || ''
}
};
form.value = { ...initData };
originalData.value = { ...initData };
profile.value = getProfileImageUrl(user.profile);
profileChanged.value = false;
const mbtiRes = await $api.get('/user/mbti');
mbtiList.value = mbtiRes.data.data.map(m => ({ value: m.CMNCODVAL, label: m.CMNCODNAM }));
};
const handleSubmit = async () => {
const formData = new FormData();
Object.entries(form.value).forEach(([k, v]) => {
if (typeof v === 'object') {
formData.append('address', v.address);
formData.append('detailAddress', v.detailAddress);
formData.append('postcode', v.postcode);
} else {
formData.append(k, v);
}
});
if (uploadedFile.value) formData.append('profileFile', uploadedFile.value);
if (form.value.color !== originalData.value.color) {
if (form.value.color) await $api.patch('/user/updateColorYon', { color: form.value.color, type: 'YON' });
if (originalData.value.color) await $api.patch('/user/updateColorChange', { color: originalData.value.color, type: 'YON' });
}
await $api.patch('/user/updateInfo', formData, { isFormData: true });
originalData.value = { ...form.value };
profileChanged.value = false;
location.reload();
toastStore.onToast('정보가 수정되었습니다.', 's');
};
onMounted(() => loadInitialData());
</script>
<style scoped>
</style>

View File

@ -1,318 +0,0 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card">
<!-- 사원 목록이 없을 경우 표시 -->
<div v-if="allUserList.length === 0" class="text-center my-4">
<p class="text-muted">등록된 사원이 없습니다.</p>
</div>
<!-- 사원 카드 리스트 영역 -->
<div class="card-body">
<div class="card-list">
<div
v-for="(person, index) in allUserList"
:key="index"
class="person-card"
@click="openModal(person)"
>
<div>
<img
class="rounded-circle user-avatar pointer"
:src="getProfileImage(person.MEMBERPRF)"
:style="{ borderColor: person.usercolor }"
@error="setDefaultImage"
/>
</div>
<div class="card-body">
<h3 class="person-name">{{ person.MEMBERNAM }}</h3>
<p class="person-email">{{ person.MEMBERIDS }}@local-host.co.kr</p>
<p class="person-phone">{{ person.MEMBERTEL }}</p>
<small>
{{ person.MEMBERARR }} {{ person.MEMBERDTL }}
</small>
</div>
</div>
</div>
</div>
<!-- 상세보기 Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<button class="close-btn" @click="closeModal">×</button>
<div class="modal-body">
<img
class="user-avatar2"
:src="getProfileImage(selectedPerson.MEMBERPRF)"
:style="{ borderColor: selectedPerson.usercolor }"
@error="setDefaultImage"
/>
<h4>{{ selectedPerson.MEMBERNAM }}</h4>
<p>{{ selectedPerson.MEMBERIDS }}@local-host.co.kr</p>
<p>{{ selectedPerson.MEMBERTEL }}</p>
<p>{{ selectedPerson.MEMBERARR }} {{ selectedPerson.MEMBERDTL }}</p>
<hr />
<!-- 추가 정보: 사용자가 속한 프로젝트 목록 -->
<h5>참여 프로젝트</h5>
<div v-if="memberProjects.length > 0" class="project-list-container">
<ul>
<li
v-for="(project, idx) in memberProjects"
:key="idx"
class="project-item"
>
<span class="project-name">{{ project.PROJCTNAM }}</span>
<span class="project-period">
<!-- projectEndDate가 있는 경우 -->
<!-- <template v-if="project.projectEndDate"> -->
{{ project.userStartDate ? project.userStartDate : project.projectStartDate }} ~
{{ project.userEndDate ? project.userEndDate : project.projectEndDate }}
<!-- </template> -->
<!-- 없으면 종료일 표시 안함 -->
<!-- <template v-else>
{{ project.userStartDate ? project.userStartDate : project.projectStartDate }} ~
</template> -->
</span>
</li>
</ul>
</div>
<div v-else>
<p>참여중인 프로젝트가 없습니다.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from '@api' // API Axios
import { ref, onMounted } from 'vue'
import SearchBar from '@c/search/SearchBar.vue'
export default {
name: 'PeopleList',
components: { SearchBar },
setup() {
const allUserList = ref([]) //
const user = ref({}) // ( )
const showModal = ref(false) //
const selectedPerson = ref({})//
const memberProjects = ref([])//
onMounted(async () => {
try {
const response = await axios.get('user/allUserList')
allUserList.value = response.data.data.allUserList
user.value = response.data.data.user
} catch (error) {
console.error('사원 목록 조회 실패:', error)
}
})
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
}
// API
const fetchMemberProjects = async (memberSeq) => {
try {
const res = await axios.get(`project/people/${memberSeq}`)
memberProjects.value = res.data.data
} catch (error) {
console.error('프로젝트 조회 실패:', error)
memberProjects.value = []
}
}
const openModal = (person) => {
selectedPerson.value = person
fetchMemberProjects(person.MEMBERSEQ)
showModal.value = true
}
const closeModal = () => {
showModal.value = false
}
return {
allUserList,
user,
showModal,
selectedPerson,
memberProjects,
openModal,
closeModal,
getProfileImage,
defaultProfile,
setDefaultImage
}
}
}
</script>
<style scoped>
.container-xxl {
padding: 1rem;
}
.card-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.person-card {
width: 280px;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
transition: box-shadow 0.2s ease-in-out;
}
.person-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.person-card .card-header {
width: 100%;
height: 120px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
}
.user-avatar {
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
border: 2px solid #ddd;
display: block;
margin: 1rem auto 0 auto;
}
.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;
}
.person-card .card-body {
padding: 0.75rem;
}
.person-name {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.person-email,
.person-phone {
margin: 0;
font-size: 0.9rem;
color: #555;
}
/* 모달 스타일 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 111%;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.modal-content {
position: relative;
width: 400px;
background: #fff;
padding: 1.5rem;
border-radius: 8px;
animation: slideDown 0.3s ease forwards;
}
.close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
}
.modal-body {
text-align: center;
}
.modal-img {
width: 50%;
height: 50%;
border-radius: 50%;
margin-bottom: 1rem;
object-fit: cover;
}
/* 프로젝트 리스트 스타일 */
.project-list-container {
max-height: 200px; /* 필요에 따라 높이 조절 */
overflow-y: auto;
margin-top: 1rem;
}
.project-item {
display: flex;
align-items: center;
list-style: none;
font-size: 0.9rem;
padding: 0.25rem 0;
}
.project-name {
font-weight: 600;
}
.project-period {
font-size: 1rem;
color: #888;
margin-left: 10px;
}
@keyframes slideDown {
0% {
transform: translateY(-15%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@ -32,7 +32,6 @@
:isOpen="isGrantModalOpen" :isOpen="isGrantModalOpen"
:targetUser="selectedUser" :targetUser="selectedUser"
:remainingQuota="remainingVacationData[selectedUser?.MEMBERSEQ] || 0" :remainingQuota="remainingVacationData[selectedUser?.MEMBERSEQ] || 0"
:remainingVacationData="remainingVacationData"
@close="isGrantModalOpen = false" @close="isGrantModalOpen = false"
@updateVacation="fetchRemainingVacation" @updateVacation="fetchRemainingVacation"
/> />
@ -42,7 +41,7 @@
<!-- Main Content: 캘린더 영역 --> <!-- Main Content: 캘린더 영역 -->
<div class="col app-calendar-content"> <div class="col app-calendar-content">
<div class="card shadow-none border-0"> <div class="card shadow-none border-0">
<div class="card-body" style="position: relative;"> <div class="card-body pb-0" style="position: relative;">
<full-calendar <full-calendar
ref="fullCalendarRef" ref="fullCalendarRef"
:options="calendarOptions" :options="calendarOptions"
@ -107,7 +106,6 @@ const isGrantModalOpen = ref(false);
const fullCalendarRef = ref(null); const fullCalendarRef = ref(null);
const calendarEvents = ref([]); const calendarEvents = ref([]);
const selectedDates = ref(new Map()); const selectedDates = ref(new Map());
const halfDayType = ref(null); const halfDayType = ref(null);
const vacationCodeMap = ref({}); const vacationCodeMap = ref({});
const holidayDates = ref(new Set()); const holidayDates = ref(new Set());
@ -120,6 +118,7 @@ const lastRemainingMonth = ref(String(new Date().getMonth() + 1).padStart(2, "0"
// ref // ref
const calendarDatepicker = ref(null); const calendarDatepicker = ref(null);
let fpInstance = null; let fpInstance = null;
/* 변경사항 여부 확인 */ /* 변경사항 여부 확인 */
const hasChanges = computed(() => { const hasChanges = computed(() => {
return ( return (
@ -128,19 +127,6 @@ const hasChanges = computed(() => {
); );
}); });
// (, , )
const moveCalendar = async (value = 0) => {
const calendarApi = fullCalendarRef.value?.getApi();
if (value === 1) {
calendarApi.prev(); //
} else if (value === 2) {
calendarApi.next(); //
} else if (value === 3) {
calendarApi.today(); //
}
};
/* 캘린더 설정 */ /* 캘린더 설정 */
// , // ,
const calendarOptions = reactive({ const calendarOptions = reactive({
@ -157,20 +143,6 @@ const calendarOptions = reactive({
dateClick: handleDateClick, dateClick: handleDateClick,
datesSet: handleMonthChange, datesSet: handleMonthChange,
events: calendarEvents, events: calendarEvents,
customButtons: {
prev: {
text: 'PREV',
click: () => moveCalendar(1),
},
today: {
text: 'TODAY',
click: () => moveCalendar(3),
},
next: {
text: 'NEXT',
click: () => moveCalendar(2),
},
},
}); });
// //
function handleMonthChange(viewInfo) { function handleMonthChange(viewInfo) {
@ -179,175 +151,72 @@ function handleMonthChange(viewInfo) {
const month = String(currentDate.getMonth() + 1).padStart(2, "0"); const month = String(currentDate.getMonth() + 1).padStart(2, "0");
loadCalendarData(year, month); loadCalendarData(year, month);
} }
// /
const getVacationWeight = (type) => {
if (type === "700101" || type === "700102") return 0.5;
if (type === "700103") return 1;
return 0;
};
//
const vacationUsageInfo = computed(() => {
const myId = userStore.user.id;
const myRemaining = remainingVacationData.value[myId] ?? 0;
let addAmount = 0;
let deleteAmount = 0;
for (const [date, type] of selectedDates.value.entries()) {
if (type === "delete") {
//
const matched = myVacations.value.find(
(v) => v.date.split("T")[0] === date && !v.receiverId
);
if (matched) {
deleteAmount += getVacationWeight(matched.type);
}
} else {
addAmount += getVacationWeight(type);
}
}
const netQuota = myRemaining - addAmount + deleteAmount;
return {
myRemaining,
addAmount,
deleteAmount,
netQuota
};
});
// //
function handleDateClick(info) { function handleDateClick(info) {
if (!info.date || !info.dateStr) {
return;
}
const clickedDateStr = info.dateStr; const clickedDateStr = info.dateStr;
const clickedDate = info.date; const clickedDate = info.date;
const todayStr = new Date().toISOString().split("T")[0]; const todayStr = new Date().toISOString().split("T")[0];
const todayObj = new Date(todayStr);
const oneWeekAgoObj = new Date(todayObj);
oneWeekAgoObj.setDate(todayObj.getDate() - 8); // 7
// (, ) -7
if ( if (
clickedDate.getDay() === 0 || // clickedDate.getDay() === 0 ||
clickedDate.getDay() === 6 || // clickedDate.getDay() === 6 ||
holidayDates.value.has(clickedDateStr) || // holidayDates.value.has(clickedDateStr) ||
clickedDateStr <= oneWeekAgoObj.toISOString().split("T")[0] // -7 clickedDateStr < todayStr
) { ) {
return; return;
} }
const isMyVacation = myVacations.value.some(vac => {
// const vacDate = vac.date ? vac.date.substring(0, 10) : "";
const currentValue = selectedDates.value.get(clickedDateStr); return vacDate === clickedDateStr && !vac.receiverId;
});
const isMyVacation = myVacations.value.some(vac => vac.date.substring(0, 10) === clickedDateStr && !vac.receiverId); if (isMyVacation) {
if (selectedDates.value.get(clickedDateStr) === "delete") {
if (!selectedDates.value.has(clickedDateStr) && isMyVacation && halfDayType.value) { selectedDates.value.delete(clickedDateStr);
const existingVacation = myVacations.value.find(vac => vac.date.substring(0, 10) === clickedDateStr && !vac.receiverId); } else {
const selectedType = selectedDates.value.set(clickedDateStr, "delete");
halfDayType.value === "AM" ? "700101" :
halfDayType.value === "PM" ? "700102" : "700103";
if (existingVacation.type === selectedType) {
toastStore.onToast("이미 사용한 연차입니다.", "e");
if (halfDayButtonsRef.value) {
halfDayButtonsRef.value.resetHalfDay();
} }
halfDayType.value = null; updateCalendarEvents();
return; return;
} }
if (selectedDates.value.has(clickedDateStr)) {
selectedDates.value.delete(clickedDateStr);
updateCalendarEvents();
return;
} }
const type = halfDayType.value
// ? (halfDayType.value === "AM" ? "700101" : "700102")
const upcomingType = halfDayType.value === "AM" ? "700101"
: halfDayType.value === "PM" ? "700102"
: "700103"; : "700103";
const upcomingWeight = getVacationWeight(upcomingType);
const isAddingNew = !selectedDates.value.has(clickedDateStr) && !isMyVacation;
if (isAddingNew) {
const projectedQuota = vacationUsageInfo.value.netQuota - upcomingWeight;
if (projectedQuota < -3) {
toastStore.onToast("연차를 더 이상 선택할 수 없습니다.", "e");
return;
}
}
//
if (currentValue && currentValue !== "delete") {
selectedDates.value.delete(clickedDateStr);
updateCalendarEvents();
return;
}
// -
if (!halfDayType.value) {
if (isMyVacation) {
if (currentValue === "delete") {
selectedDates.value.delete(clickedDateStr);
} else {
selectedDates.value.set(clickedDateStr, "delete");
}
} else {
selectedDates.value.set(clickedDateStr, "700103");
}
updateCalendarEvents();
return;
}
// -
if (isMyVacation) {
selectedDates.value.set(clickedDateStr, "delete");
}
const type = halfDayType.value === "AM" ? "700101" :
halfDayType.value === "PM" ? "700102" :
"700103"; //
selectedDates.value.set(clickedDateStr, type); selectedDates.value.set(clickedDateStr, type);
// ()
halfDayType.value = null; halfDayType.value = null;
updateCalendarEvents();
if (halfDayButtonsRef.value) { if (halfDayButtonsRef.value) {
halfDayButtonsRef.value.resetHalfDay(); halfDayButtonsRef.value.resetHalfDay();
} }
updateCalendarEvents();
} }
//
function markClickableDates() { function markClickableDates() {
nextTick(() => { nextTick(() => {
const todayStr = new Date().toISOString().split("T")[0]; // (YYYY-MM-DD) const todayStr = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const todayObj = new Date(todayStr); const todayObj = new Date(todayStr);
const oneWeekAgoObj = new Date(todayObj);
oneWeekAgoObj.setDate(todayObj.getDate() - 8); // 7
document.querySelectorAll(".fc-daygrid-day").forEach((cell) => { document.querySelectorAll(".fc-daygrid-day").forEach((cell) => {
const dateStr = cell.getAttribute("data-date"); const dateStr = cell.getAttribute("data-date");
if (!dateStr) return; // if (!dateStr) return; //
const dateObj = new Date(dateStr); const dateObj = new Date(dateStr);
// (, )
// (, ) -7 if (dateObj.getDay() === 0 || dateObj.getDay() === 6 || holidayDates.value.has(dateStr)) {
if (
dateObj.getDay() === 0 || //
dateObj.getDay() === 6 || //
holidayDates.value.has(dateStr) || //
dateObj.getTime() <= oneWeekAgoObj.getTime() // -7
) {
cell.classList.remove("clickable"); cell.classList.remove("clickable");
cell.classList.add("fc-day-sat-sun"); cell.classList.add("fc-day-sat-sun");
cell.removeEventListener("click", handleDateClick); //
} }
// -6 // ( )
else if (dateObj < todayObj) {
cell.classList.remove("clickable");
cell.classList.add("past"); //
}
// & ( )
else { else {
cell.classList.add("clickable"); cell.classList.add("clickable");
cell.classList.remove("past", "fc-day-sat-sun"); cell.classList.remove("past", "fc-day-sat-sun");
cell.addEventListener("click", handleDateClick); //
} }
}); });
}); });
@ -392,6 +261,7 @@ const handleProfileClick = async (user) => {
isGrantModalOpen.value = true; isGrantModalOpen.value = true;
} }
} catch (error) { } catch (error) {
console.error("🚨 연차 데이터 불러오기 실패:", error);
} }
}; };
// //
@ -399,7 +269,11 @@ const fetchUserList = async () => {
try { try {
await userListStore.fetchUserList(); await userListStore.fetchUserList();
userList.value = [...userListStore.userList]; // "ROLE_ADMIN"
const filteredUsers = userListStore.userList.filter(user => user.MEMBERROL !== "ROLE_ADMIN");
// userList
userList.value = [...filteredUsers];
if (!userList.value.length) { if (!userList.value.length) {
console.warn("📌 사용자 목록이 비어 있음!"); console.warn("📌 사용자 목록이 비어 있음!");
@ -413,6 +287,7 @@ const fetchUserList = async () => {
}); });
} catch (error) { } catch (error) {
console.error("📌 사용자 목록 불러오기 오류:", error);
} }
}; };
// //
@ -426,6 +301,7 @@ const fetchRemainingVacation = async () => {
}, {}); }, {});
} }
} catch (error) { } catch (error) {
console.error("🚨 남은 연차 데이터를 불러오지 못했습니다:", error);
} }
}; };
// //
@ -449,72 +325,59 @@ const filteredReceivedVacations = computed(() => {
// //
async function saveVacationChanges() { async function saveVacationChanges() {
if (!hasChanges.value) return; if (!hasChanges.value) return;
const selectedDatesArray = Array.from(selectedDates.value); const selectedDatesArray = Array.from(selectedDates.value);
const vacationChangesByYear = selectedDatesArray.reduce((acc, [date, type]) => {
// const year = date.split("-")[0]; // YYYY-MM-DD YYYY
const vacationChanges = selectedDatesArray.reduce((acc, [date, type]) => { if (!acc[year]) acc[year] = { add: [], delete: [] };
if (type !== "delete") { if (type !== "delete") {
acc.add.push({ date, type }); acc[year].add.push({ date, type });
} else { } else {
acc.delete.push(date); acc[year].delete.push(date);
} }
return acc; return acc;
}, { add: [], delete: [] }); }, {});
try { try {
// ID for (const year of Object.keys(vacationChangesByYear)) {
const allYears = new Set(vacationChanges.delete.map(date => date.split("-")[0])); const vacationsToAdd = vacationChangesByYear[year].add;
let vacationIdsToDelete = []; // id
for (const year of allYears) { const vacationsToDeleteForYear = myVacations.value
await fetchVacationHistory(year); // .filter(vac => {
const vacationsToDelete = myVacations.value.filter(vac => {
if (!vac.date) return false; if (!vac.date) return false;
const vacDate = vac.date.split("T")[0]; const vacDate = vac.date.split("T")[0];
return vacationChanges.delete.includes(vacDate) && !vac.receiverId; return vacationChangesByYear[year].delete.includes(vacDate);
}); });
vacationIdsToDelete.push(...vacationsToDelete.map(vac => vac.id)); const vacationIdsToDelete = vacationsToDeleteForYear.map(vac => vac.id);
} if (vacationsToAdd.length > 0 || vacationIdsToDelete.length > 0) {
if (vacationChanges.add.length > 0 || vacationIdsToDelete.length > 0) {
const response = await axios.post("vacation/batchUpdate", { const response = await axios.post("vacation/batchUpdate", {
add: vacationChanges.add, add: vacationsToAdd,
delete: vacationIdsToDelete, delete: vacationIdsToDelete,
}); });
if (response.data && response.data.status === "OK") { if (response.data && response.data.status === "OK") {
toastStore.onToast(`휴가 변경 사항이 저장되었습니다.`, 's'); toastStore.onToast(`휴가 변경 사항이 저장되었습니다.`, 's');
// : myVacations ID
// ID `myVacations.value`
myVacations.value = myVacations.value.filter(vac => !vacationIdsToDelete.includes(vac.id)); myVacations.value = myVacations.value.filter(vac => !vacationIdsToDelete.includes(vac.id));
//
// ( )
const yearsToUpdate = new Set(
[...vacationChanges.add.map(v => v.date.split("-")[0]),
...vacationChanges.delete.map(v => v.split("-")[0])]
);
for (const year of yearsToUpdate) {
const updatedVacations = await fetchVacationHistory(year); const updatedVacations = await fetchVacationHistory(year);
if (updatedVacations) { if (updatedVacations) {
myVacations.value = [...myVacations.value, ...updatedVacations.filter(newVac => myVacations.value = updatedVacations; //
!myVacations.value.some(oldVac => oldVac.id === newVac.id)
)];
}
} }
} else { } else {
toastStore.onToast(`휴가 변경 중 오류가 발생했습니다.`, 'e'); toastStore.onToast(`휴가 변경 중 오류가 발생했습니다.`, 'e');
} }
} }
}
await fetchRemainingVacation(); await fetchRemainingVacation();
selectedDates.value.clear(); selectedDates.value.clear();
updateCalendarEvents(); updateCalendarEvents();
// //
const currentDate = fullCalendarRef.value.getApi().getDate(); const currentDate = fullCalendarRef.value.getApi().getDate();
await loadCalendarData(currentDate.getFullYear(), currentDate.getMonth() + 1); await loadCalendarData(currentDate.getFullYear(), currentDate.getMonth() + 1);
} catch (error) { } catch (error) {
console.error("🚨 휴가 변경 저장 실패:", error);
toastStore.onToast('휴가 저장 요청에 실패했습니다.', 'e'); toastStore.onToast('휴가 저장 요청에 실패했습니다.', 'e');
} }
} }
/* 휴가 조회 */ /* 휴가 조회 */
// //
async function fetchVacationHistory(year) { async function fetchVacationHistory(year) {
@ -525,6 +388,7 @@ async function fetchVacationHistory(year) {
receivedVacations.value = response.data.data.receivedVacations || [] receivedVacations.value = response.data.data.receivedVacations || []
} }
} catch (error) { } catch (error) {
console.error(`🚨 휴가 데이터 불러오기 실패:`, error);
} }
} }
// //
@ -555,6 +419,7 @@ async function fetchVacationData(year, month) {
return []; return [];
} }
} catch (error) { } catch (error) {
console.error("Error fetching vacation data:", error);
return []; return [];
} }
} }
@ -564,12 +429,11 @@ function updateCalendarEvents() {
.filter(([date, type]) => type !== "delete") .filter(([date, type]) => type !== "delete")
.map(([date, type]) => ({ .map(([date, type]) => ({
start: date, start: date,
backgroundColor: "rgb(113 212 243 / 76%)", // backgroundColor: "rgb(113 212 243 / 76%)",
textColor: "#fff", textColor: "#fff",
display: "background", display: "background",
classNames: [getVacationTypeClass(type), "selected-event"] // classNames: [getVacationTypeClass(type), "selected-event"]
})); }));
const filteredFetchedEvents = fetchedEvents.value.filter(event => { const filteredFetchedEvents = fetchedEvents.value.filter(event => {
if (event.saved && selectedDates.value.get(event.start) === "delete") { if (event.saved && selectedDates.value.get(event.start) === "delete") {
if (event.memberSeq === userStore.user.id) { if (event.memberSeq === userStore.user.id) {
@ -578,53 +442,8 @@ function updateCalendarEvents() {
} }
return true; return true;
}); });
calendarEvents.value = [...filteredFetchedEvents, ...selectedEvents]; calendarEvents.value = [...filteredFetchedEvents, ...selectedEvents];
nextTick(() => {
const todayStr = new Date().toISOString().split("T")[0];
const todayElement = document.querySelector(`.fc-daygrid-day[data-date="${todayStr}"]`);
if (todayElement) {
if (selectedDates.value.has(todayStr)) {
todayElement.style.backgroundColor = 'transparent'; //
todayElement.classList.add("selected-event");
//
const existingOverlay = todayElement.querySelector('.half-day-overlay');
if (existingOverlay) {
todayElement.removeChild(existingOverlay);
} }
const overlay = document.createElement('div');
overlay.classList.add('half-day-overlay');
const type = selectedDates.value.get(todayStr);
if (type === "700101") {
overlay.classList.add('am');
} else if (type === "700102") {
overlay.classList.add('pm');
} else {
//
overlay.style.width = '100%';
overlay.style.borderRadius = '4px';
}
todayElement.appendChild(overlay);
} else {
//
todayElement.classList.remove("selected-event", "half-day-am", "half-day-pm");
todayElement.style.backgroundColor = ''; //
const existingOverlay = todayElement.querySelector('.half-day-overlay');
if (existingOverlay) {
todayElement.removeChild(existingOverlay);
}
}
}
});
}
// //
const getVacationTypeClass = (type) => { const getVacationTypeClass = (type) => {
if (type === "700101") return "half-day-am"; if (type === "700101") return "half-day-am";
@ -644,6 +463,7 @@ const fetchVacationCodes = async () => {
console.warn("❌ 공통 코드 데이터를 불러오지 못했습니다."); console.warn("❌ 공통 코드 데이터를 불러오지 못했습니다.");
} }
} catch (error) { } catch (error) {
console.error("🚨 공통 코드 API 호출 실패:", error);
} }
}; };
const getVacationType = (typeCode) => { const getVacationType = (typeCode) => {
@ -685,11 +505,11 @@ watch(() => lastRemainingYear.value, async (newYear, oldYear) => {
await fetchVacationHistory(newYear); await fetchVacationHistory(newYear);
} }
}); });
// // `selectedDates`
watch( watch(
() => Array.from(selectedDates.value.keys()), // () => Array.from(selectedDates.value.keys()), //
(newKeys) => { (newKeys) => {
if (halfDayButtonsRef.value && !halfDayType.value) { if (halfDayButtonsRef.value) {
halfDayButtonsRef.value.resetHalfDay(); halfDayButtonsRef.value.resetHalfDay();
} }
}, },
@ -759,7 +579,7 @@ onMounted(async () => {
const dpEl = calendarDatepicker.value; const dpEl = calendarDatepicker.value;
dpEl.style.display = 'block'; dpEl.style.display = 'block';
dpEl.style.position = 'fixed'; dpEl.style.position = 'fixed';
dpEl.style.top = '18%'; dpEl.style.top = '25%';
dpEl.style.left = '50%'; dpEl.style.left = '50%';
dpEl.style.transform = 'translate(-50%, -50%)'; dpEl.style.transform = 'translate(-50%, -50%)';
dpEl.style.zIndex = '9999'; dpEl.style.zIndex = '9999';

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