Compare commits
No commits in common. "main" and "login" have entirely different histories.
3
.env.dev
@ -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_SERVER = https://192.168.0.251:10300/
|
||||
VITE_API_URL = https://192.168.0.251:10300/api/
|
||||
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
|
||||
@ -3,5 +3,4 @@ VITE_DOMAIN = http://localhost:5173/
|
||||
VITE_SERVER = http://localhost:10325/
|
||||
VITE_API_URL = http://localhost:10325/api/
|
||||
VITE_TEST_URL = http://localhost:10325/test/
|
||||
VITE_SERVER_IMG_URL = http://localhost:10325/upload/img/
|
||||
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492
|
||||
@ -1,7 +1,7 @@
|
||||
<!doctype html>
|
||||
<html
|
||||
lang=""
|
||||
class="light-style layout-navbar-fixed layout-menu-fixed layout-compact"
|
||||
class="light-style layout-navbar-fixed layout-menu-fixed layout-compact scrollbar-none"
|
||||
dir="ltr"
|
||||
data-theme="theme-default"
|
||||
data-assets-path="/"
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
/* board */
|
||||
.board-content img {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
@ -23,25 +24,9 @@
|
||||
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;
|
||||
@ -172,10 +157,7 @@
|
||||
.fc-toolbar-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-today-button {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
/* 클릭 가능한 날짜 */
|
||||
/* 클릭 가능한 날짜 (오늘 + 미래) */
|
||||
.fc-daygrid-day.clickable {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
@ -234,7 +216,7 @@
|
||||
position: relative;
|
||||
}
|
||||
.vac-modal-body {
|
||||
max-height: 180px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vac-modal-text {
|
||||
@ -292,7 +274,7 @@
|
||||
color: #525252;
|
||||
}
|
||||
.count-btn {
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
padding: 2px 10px;
|
||||
border: none;
|
||||
background: #2c3e50;
|
||||
@ -380,28 +362,6 @@
|
||||
background-color: #0b5ed7 !important;
|
||||
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 {
|
||||
transition: all 0.2s ease-in-out;
|
||||
@ -431,8 +391,8 @@
|
||||
/* 작은 화면에서 버튼 크기 조정 */
|
||||
@media (max-width: 1700px) {
|
||||
.count-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.count-container {
|
||||
@ -443,7 +403,7 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count-value {
|
||||
font-size: 15px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.custom-button {
|
||||
width: 45px;
|
||||
@ -454,10 +414,10 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
.vac-modal-text {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vac-modal-title {
|
||||
font-size: 15px;
|
||||
font-size: 17px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vacation-item {
|
||||
@ -466,8 +426,8 @@
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.vac-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.vac-btn-success {
|
||||
@ -498,12 +458,12 @@
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.vac-btn {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
font-size: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.vac-btn-success {
|
||||
font-size: 15px;
|
||||
font-size: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
@ -520,27 +480,9 @@
|
||||
|
||||
/* 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 {
|
||||
top: -160px;
|
||||
left: 90px;
|
||||
left: -5px;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
@ -581,47 +523,36 @@
|
||||
}
|
||||
/* project list end */
|
||||
|
||||
/* commuters */
|
||||
/* commuters project list */
|
||||
.commuter-list {
|
||||
max-height: 450px;
|
||||
max-height: 358px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.fc-daygrid-day[data-has-commuters='true'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* commuters end */
|
||||
/* commuters project list end */
|
||||
|
||||
/* Scroll Button */
|
||||
|
||||
.scroll-top-btn {
|
||||
bottom: 40px;
|
||||
bottom: 20px;
|
||||
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 */
|
||||
|
||||
@ -699,6 +630,7 @@
|
||||
/* Mobile end */
|
||||
/* media end */
|
||||
|
||||
|
||||
/* BoardComment */
|
||||
|
||||
.beforeRotate {
|
||||
@ -712,92 +644,77 @@
|
||||
|
||||
/* 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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
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;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 4px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 4px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: #4caf50;
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(24px);
|
||||
transform: translateX(24px);
|
||||
}
|
||||
/* 권한부여 끝 */
|
||||
|
||||
|
||||
/* toast */
|
||||
|
||||
.bs-toast {
|
||||
@ -805,30 +722,4 @@ input:checked + .slider:before {
|
||||
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;
|
||||
}
|
||||
/* toast end */
|
||||
@ -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');
|
||||
|
||||
/* D2Coding 폰트 */
|
||||
@font-face {
|
||||
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-style: normal;
|
||||
}
|
||||
|
||||
/* Consolas 폰트 */
|
||||
@font-face {
|
||||
font-family: 'Consolas';
|
||||
src: url('/font/Consolas.woff') format('font-woff');
|
||||
src: url('/font/Consolas.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* 툴바에서 선택 가능한 폰트 */
|
||||
/* 툴바에서 선택 가능한 폰트 및 크기 스타일 */
|
||||
.ql-font-nanum-gothic {
|
||||
font-family: 'Nanum Gothic', sans-serif;
|
||||
}
|
||||
@ -175,10 +33,24 @@ body {
|
||||
}
|
||||
|
||||
/* 폰트 크기 스타일 */
|
||||
.ql-size-12px { font-size: 12px; }
|
||||
.ql-size-14px { font-size: 14px; }
|
||||
.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; }
|
||||
.ql-size-12px {
|
||||
font-size: 12px;
|
||||
}
|
||||
.ql-size-14px {
|
||||
font-size: 14px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<link rel="stylesheet" href="/css/font.css">
|
||||
<component :is="layout">
|
||||
<template #content>
|
||||
<LoadingSpinner :isLoading="loadingStore.isLoading" />
|
||||
|
||||
@ -41,24 +41,22 @@ $api.interceptors.response.use(
|
||||
const loadingStore = useLoadingStore();
|
||||
loadingStore.stopLoading();
|
||||
|
||||
|
||||
// 로그인 요청일 경우 (헤더에 isLogin이 true로 설정된 경우)
|
||||
if (response.config.headers && response.config.headers.isLogin) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 테스트 부탁
|
||||
// 로그인 실패, 커스텀 에러 응답 처리
|
||||
if (response.data.code > 10000) {
|
||||
// 로그인 실패, 커스텀 에러 응답 처리 (status는 200 success가 false인 경우)
|
||||
if (response.data && response.data.success === false) {
|
||||
const toastStore = useToastStore();
|
||||
const errorCode = response.data.code;
|
||||
const errorMessage = response.data.message || '알 수 없는 오류가 발생했습니다.';
|
||||
|
||||
// 로그인 요청일 경우 (헤더에 isLogin이 true로 설정된 경우)
|
||||
if (response.config.headers && response.config.headers.isLogin) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 서버에서 보낸 메시지 사용
|
||||
toastStore.onToast(errorMessage, 'e');
|
||||
|
||||
// 특정 에러 코드에 대한 추가 처리만 수행
|
||||
if (errorCode === 10001) {
|
||||
if (errorCode === 'USER_NOT_FOUND') {
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
|
||||
@ -47,11 +47,11 @@ const common = {
|
||||
*
|
||||
* @param {string} dateStr
|
||||
* @return
|
||||
* 1. Date type 인 경우 예시 '2025-02-24 12:02'
|
||||
* 1. Date type 인 경우 예시 '25-02-24 12:02'
|
||||
* 2. Date type 이 아닌 경우 입력값 리턴
|
||||
*
|
||||
*/
|
||||
dateFormatter(dateStr, type = null) {
|
||||
dateFormatter(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const dateCheck = date.getTime();
|
||||
|
||||
@ -59,28 +59,13 @@ const common = {
|
||||
return dateStr;
|
||||
} else {
|
||||
const { year, month, day, hours, minutes } = this.formatDateTime(date);
|
||||
let callback = '';
|
||||
|
||||
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;
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
},
|
||||
|
||||
formatDateTime(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const dateCheck = date.getTime();
|
||||
if (isNaN(dateCheck)) return dateStr;
|
||||
|
||||
formatDateTime(date) {
|
||||
const zeroFormat = num => (num < 10 ? `0${num}` : num);
|
||||
|
||||
return {
|
||||
year: date.getFullYear(),
|
||||
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
|
||||
*/
|
||||
isNotEmpty(obj) {
|
||||
if (obj === null || obj === undefined) {
|
||||
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 (obj === null || obj === undefined) 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;
|
||||
},
|
||||
@ -200,17 +139,6 @@ const common = {
|
||||
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 {
|
||||
|
||||
@ -8,42 +8,28 @@
|
||||
import { ref, onMounted } from "vue";
|
||||
import $api from '@api';
|
||||
|
||||
const colorList = ref([]);
|
||||
const mbtiList = ref([]);
|
||||
const pwhintList = ref([]);
|
||||
const yearCategory = ref([]);
|
||||
const cateList = ref([]);
|
||||
const commonApi = (options = {}) => {
|
||||
const colorList = ref([]);
|
||||
const mbtiList = ref([]);
|
||||
const pwhintList = ref([]);
|
||||
const yearCategory = ref([]);
|
||||
const cateList = ref([]);
|
||||
|
||||
const refreshColorList = async (type = 'YNP') => {
|
||||
const response = await $api.get(`user/color`, {
|
||||
params: { type }
|
||||
});
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
colorList.value = response.data.data.map(item => ({
|
||||
// type 파라미터를 추가로 받도록 수정
|
||||
const CommonCode = async (path, endpoint, targetList, type = null) => {
|
||||
const params = type ? { type } : {};
|
||||
const response = await $api.get(`${path}/${endpoint}`, {
|
||||
params
|
||||
});
|
||||
targetList.value = response.data.data.map(item => ({
|
||||
label: item.CMNCODNAM,
|
||||
value: item.CMNCODVAL,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return colorList.value;
|
||||
};
|
||||
|
||||
// CommonCode 함수를 외부에서도 접근할 수 있게 변경
|
||||
const CommonCode = async (path, endpoint, targetList, type = null) => {
|
||||
const params = type ? { type } : {};
|
||||
const response = await $api.get(`${path}/${endpoint}`, {
|
||||
params
|
||||
});
|
||||
targetList.value = response.data.data.map(item => ({
|
||||
label: item.CMNCODNAM,
|
||||
value: item.CMNCODVAL,
|
||||
}));
|
||||
};
|
||||
|
||||
const commonApi = (options = {}) => {
|
||||
onMounted(async () => {
|
||||
// 요청할 데이터가 옵션으로 전달 -> 그에 맞게 호출
|
||||
// color 옵션에 type 포함
|
||||
if (options.loadColor) {
|
||||
await CommonCode("user", "color", colorList, options.colorType);
|
||||
}
|
||||
@ -53,15 +39,7 @@ const commonApi = (options = {}) => {
|
||||
if (options.loadCateList) await CommonCode("worddict", "getWordCategory", cateList);
|
||||
});
|
||||
|
||||
return {
|
||||
colorList,
|
||||
mbtiList,
|
||||
pwhintList,
|
||||
yearCategory,
|
||||
cateList,
|
||||
refreshColorList
|
||||
};
|
||||
return { colorList, mbtiList, pwhintList, yearCategory, cateList };
|
||||
};
|
||||
|
||||
export { refreshColorList };
|
||||
export default commonApi;
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
:unknown="comment.author === '익명'"
|
||||
:isCommentAuthor="isCommentAuthor"
|
||||
:boardId="comment.boardId"
|
||||
:profileName="displayName"
|
||||
:profileName="comment.author"
|
||||
:date="comment.createdAt"
|
||||
:comment="comment"
|
||||
:profileImg="comment.profileImg"
|
||||
@ -12,21 +12,18 @@
|
||||
:isLike="!isLike"
|
||||
:isCommentPassword="isCommentPassword"
|
||||
:isCommentProfile="true"
|
||||
:is-edit-pushed="isEditPushed"
|
||||
:is-delete-pushed="isDeletePushed"
|
||||
@editClick="handleEditClick"
|
||||
@deleteClick="$emit('deleteClick', comment)"
|
||||
@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-20 ms-auto">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
:value="password"
|
||||
autocomplete="new-password"
|
||||
maxlength="8"
|
||||
placeholder="비밀번호 입력"
|
||||
@input="filterInput"
|
||||
/>
|
||||
@ -37,15 +34,15 @@
|
||||
|
||||
<div class="mt-6">
|
||||
<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">
|
||||
<SaveBtn class="btn btn-primary" @click="submitEdit" :isEnabled="disabled"></SaveBtn>
|
||||
<SaveBtn class="btn btn-primary" @click="submitEdit"></SaveBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="m-0" style="white-space: pre-wrap">{{ comment.content }}</div>
|
||||
<p class="m-0">{{ comment.content }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<!-- <p>현재 isDeleted 값: {{ isDeleted }}</p> -->
|
||||
@ -79,15 +76,10 @@
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nickname: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isCommentAuthor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isPlusButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@ -118,24 +110,9 @@
|
||||
password: {
|
||||
type: String,
|
||||
},
|
||||
// isEditPushed: {
|
||||
// type: Boolean,
|
||||
// required: false,
|
||||
// },
|
||||
// isDeletePushed: {
|
||||
// type: Boolean,
|
||||
// required: false,
|
||||
// },
|
||||
editCommentAlert: String,
|
||||
});
|
||||
|
||||
const isEditPushed = ref(false);
|
||||
const isDeletePushed = ref(false);
|
||||
|
||||
const displayName = computed(() => {
|
||||
return props.nickname ? props.nickname : props.comment.author;
|
||||
});
|
||||
|
||||
// emits 정의
|
||||
const emit = defineEmits([
|
||||
'submitComment',
|
||||
@ -155,8 +132,6 @@
|
||||
};
|
||||
|
||||
const localEditedContent = ref(props.comment.content);
|
||||
const isModifyContent = ref(props.comment.content);
|
||||
const disabled = ref(false);
|
||||
|
||||
// 댓글 입력 창 토글
|
||||
const isComment = ref(false);
|
||||
@ -184,24 +159,6 @@
|
||||
emit('submitPassword', props.comment, props.password);
|
||||
};
|
||||
|
||||
const handleInject = inject('isBtnPushed');
|
||||
|
||||
// 수정, 삭제 버튼 활성화 상태값
|
||||
watch(
|
||||
() => handleInject.value,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue) {
|
||||
if (newValue.target == props.comment.commentId) {
|
||||
isEditPushed.value = newValue.isEditPushed;
|
||||
isDeletePushed.value = newValue.isDeletePushed;
|
||||
} else {
|
||||
isEditPushed.value = false;
|
||||
isDeletePushed.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.comment.isEditTextarea,
|
||||
newVal => {
|
||||
@ -215,11 +172,6 @@
|
||||
watch(
|
||||
() => localEditedContent.value,
|
||||
newVal => {
|
||||
if (JSON.stringify(isModifyContent.value) == JSON.stringify(newVal)) {
|
||||
disabled.value = false;
|
||||
return;
|
||||
}
|
||||
disabled.value = true;
|
||||
emit('inputDetector');
|
||||
},
|
||||
);
|
||||
|
||||
@ -3,77 +3,69 @@
|
||||
<div class="card-body">
|
||||
<!-- 댓글 입력 섹션 -->
|
||||
<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">
|
||||
<textarea
|
||||
class="form-control mb-1"
|
||||
class="form-control mb-2"
|
||||
placeholder="댓글 달기"
|
||||
rows="3"
|
||||
:maxlength="maxLength"
|
||||
v-model="comment"
|
||||
@input="clearAlert('comment')"
|
||||
@input="alertTextHandler"
|
||||
></textarea>
|
||||
<span v-if="commentAlert" class="invalid-feedback d-inline text-start ms-2 mb-2">{{ commentAlert }}</span>
|
||||
<span v-else class="invalid-feedback d-inline">{{ textAlert }}</span>
|
||||
<span v-else class="invalid-feedback d-inline text-start ms-2">{{ textAlert }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 옵션 및 버튼 섹션 -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-1 pb-4">
|
||||
<!-- 왼쪽: 익명 체크박스 -->
|
||||
<div v-if="unknown" class="form-check form-check-inline mb-0 me-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:id="`checkboxAnnonymous${commnetId}`"
|
||||
v-model="isCheck"
|
||||
@change="pwd2AlertHandler"
|
||||
/>
|
||||
<label class="form-check-label text-nowrap" :for="`checkboxAnnonymous${commnetId}`">익명</label>
|
||||
</div>
|
||||
|
||||
<!-- 중앙: 닉네임 & 비밀번호 입력 필드 (가로 정렬) -->
|
||||
<div v-if="isCheck" class="d-flex flex-grow-1 gap-2">
|
||||
<!-- 닉네임 입력 영역 -->
|
||||
<div class="position-relative">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control mb-1"
|
||||
v-model="nickname"
|
||||
placeholder="닉네임"
|
||||
@input="clearAlert('nickname')"
|
||||
@keypress="noSpace"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<!-- 닉네임 경고 메시지 -->
|
||||
<div v-if="nicknameAlert" class="position-absolute text-danger small top-100 start-0">
|
||||
{{ nicknameAlert }}
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<div class="row g-2">
|
||||
<div class="d-flex flex-wrap align-items-center mb-2">
|
||||
<!-- 익명 체크박스 (익명게시판일 경우에만)-->
|
||||
<div v-if="unknown" class="form-check form-check-inline mb-0 me-4 d-flex align-items-center">
|
||||
<input
|
||||
class="form-check-input me-2"
|
||||
type="checkbox"
|
||||
:id="`checkboxAnnonymous${commnetId}`"
|
||||
v-model="isCheck"
|
||||
@change="pwd2AlertHandler"
|
||||
/>
|
||||
<label class="form-check-label" :for="`checkboxAnnonymous${commnetId}`">익명</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 입력 영역 -->
|
||||
<div class="position-relative">
|
||||
<input
|
||||
type="password"
|
||||
id="basic-default-password"
|
||||
class="form-control mb-1"
|
||||
autocomplete="new-password"
|
||||
v-model="password"
|
||||
placeholder="비밀번호"
|
||||
maxlength="8"
|
||||
@input="
|
||||
password = password.replace(/\s/g, '');
|
||||
clearAlert('password');
|
||||
"
|
||||
/>
|
||||
<!-- 비밀번호 경고 메시지 -->
|
||||
<div v-if="passwordAlert2" class="position-absolute text-danger small top-100 start-0">
|
||||
{{ passwordAlert2 }}
|
||||
<!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) -->
|
||||
<template v-if="isCheck">
|
||||
<div class="d-flex align-items-center col">
|
||||
<input
|
||||
type="password"
|
||||
id="basic-default-password"
|
||||
class="form-control w-80"
|
||||
autocomplete="new-password"
|
||||
v-model="password"
|
||||
placeholder="비밀번호 입력"
|
||||
@input="passwordAlertTextHandler"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div style="width: 70px"></div>
|
||||
<div class="col">
|
||||
<span v-if="passwordAlert" class="invalid-feedback d-inline">{{ passwordAlert }}</span>
|
||||
<span v-else class="invalid-feedback d-inline">{{ passwordAlert2 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 답변 쓰기 버튼 -->
|
||||
<div class="ms-auto">
|
||||
<!-- 답변 쓰기 버튼 -->
|
||||
<div class="ms-auto mt-3 mt-md-0">
|
||||
<SaveBtn class="btn btn-primary" @click="handleCommentSubmit"></SaveBtn>
|
||||
</div>
|
||||
</div>
|
||||
@ -111,64 +103,42 @@
|
||||
},
|
||||
});
|
||||
|
||||
const noSpace = (e) => {
|
||||
if (e.key === ' ') e.preventDefault();
|
||||
};
|
||||
|
||||
const $common = inject('common');
|
||||
const comment = ref('');
|
||||
const password = ref('');
|
||||
const isCheck = ref(false);
|
||||
const textAlert = ref('');
|
||||
const nicknameAlert = ref('');
|
||||
const passwordAlert2 = ref('');
|
||||
const nickname = ref('');
|
||||
|
||||
const emit = defineEmits(['submitComment']);
|
||||
|
||||
// 입력 필드별 경고 메시지 초기화
|
||||
const clearAlert = field => {
|
||||
if (field === 'comment') textAlert.value = '';
|
||||
if (field === 'nickname') nicknameAlert.value = '';
|
||||
if (field === 'password') passwordAlert2.value = '';
|
||||
const alertTextHandler = () => {
|
||||
textAlert.value = '';
|
||||
};
|
||||
|
||||
const passwordAlertTextHandler = event => {
|
||||
event.target.value = event.target.value.replace(/\s/g, '');
|
||||
passwordAlert2.value = '';
|
||||
};
|
||||
|
||||
const handleCommentSubmit = () => {
|
||||
let isValid = true;
|
||||
|
||||
// 댓글 공백 체크
|
||||
if (!$common.isNotEmpty(comment.value)) {
|
||||
textAlert.value = '댓글을 입력해주세요.';
|
||||
isValid = false;
|
||||
textAlert.value = '댓글을 입력하세요';
|
||||
return false;
|
||||
} else {
|
||||
textAlert.value = '';
|
||||
}
|
||||
|
||||
// 익명 선택 시 닉네임 & 비밀번호 체크
|
||||
if (isCheck.value) {
|
||||
if (!$common.isNotEmpty(nickname.value)) {
|
||||
nicknameAlert.value = '닉네임을 입력해주세요.';
|
||||
isValid = false;
|
||||
} else {
|
||||
nicknameAlert.value = '';
|
||||
}
|
||||
|
||||
if (!$common.isNotEmpty(password.value)) {
|
||||
passwordAlert2.value = '비밀번호를 입력해주세요.';
|
||||
password.value = '';
|
||||
isValid = false;
|
||||
} else {
|
||||
passwordAlert2.value = '';
|
||||
}
|
||||
if (isCheck.value && !$common.isNotEmpty(password.value)) {
|
||||
passwordAlert2.value = '비밀번호를 입력하세요';
|
||||
return false;
|
||||
} else {
|
||||
passwordAlert2.value = '';
|
||||
}
|
||||
|
||||
// 모든 입력값이 유효할 경우만 제출
|
||||
if (!isValid) return;
|
||||
|
||||
// 댓글 제출
|
||||
emit('submitComment', {
|
||||
comment: comment.value,
|
||||
nickname: isCheck.value ? nickname.value : '',
|
||||
password: isCheck.value ? password.value : '',
|
||||
isCheck: isCheck.value,
|
||||
LOCBRDTYP: isCheck.value ? '300102' : null, // 익명일 경우 '300102' 설정
|
||||
@ -178,19 +148,15 @@
|
||||
resetCommentForm();
|
||||
};
|
||||
|
||||
// 비밀번호 & 닉네임 경고 초기화
|
||||
// 비밀번호 경고 초기화
|
||||
const pwd2AlertHandler = () => {
|
||||
if (!isCheck.value) {
|
||||
passwordAlert2.value = '';
|
||||
nicknameAlert.value = '';
|
||||
}
|
||||
if (isCheck.value === false) passwordAlert2.value = '';
|
||||
};
|
||||
|
||||
// 입력 필드 리셋 함수 추가
|
||||
const resetCommentForm = () => {
|
||||
comment.value = '';
|
||||
password.value = '';
|
||||
nickname.value = '';
|
||||
isCheck.value = false;
|
||||
};
|
||||
|
||||
|
||||
@ -7,14 +7,11 @@
|
||||
:isCommentAuthor="comment.isCommentAuthor"
|
||||
:isEditTextarea="comment.isEditTextarea"
|
||||
:isDeleted="isDeleted"
|
||||
:nickname="comment.nickname"
|
||||
:isCommentPassword="isCommentPassword"
|
||||
:passwordCommentAlert="passwordCommentAlert || ''"
|
||||
:currentPasswordCommentId="currentPasswordCommentId"
|
||||
:password="password"
|
||||
:editCommentAlert="editCommentAlert[comment.commentId]"
|
||||
:is-edit-pushed="comment.isEditPushed"
|
||||
:is-delete-pushed="comment.isDeletePushed"
|
||||
@editClick="handleEditClick"
|
||||
@deleteClick="handleDeleteClick"
|
||||
@submitPassword="submitPassword"
|
||||
@ -35,15 +32,12 @@
|
||||
:isPlusButton="false"
|
||||
:isLike="true"
|
||||
:isCommentProfile="true"
|
||||
:nickname="child.nickname"
|
||||
:isCommentAuthor="child.isCommentAuthor"
|
||||
:isCommentPassword="isCommentPassword"
|
||||
:currentPasswordCommentId="currentPasswordCommentId"
|
||||
:passwordCommentAlert="passwordCommentAlert"
|
||||
:password="password"
|
||||
:editCommentAlert="editCommentAlert[child.commentId]"
|
||||
:is-edit-pushed="child.isEditPushed"
|
||||
:is-delete-pushed="child.isDeletePushed"
|
||||
@editClick="handleReplyEditClick"
|
||||
@deleteClick="$emit('deleteClick', child)"
|
||||
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent, child.commentId)"
|
||||
@ -63,7 +57,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, watch } from 'vue';
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import BoardComment from './BoardComment.vue';
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
<template>
|
||||
<div class="d-flex align-items-center flex-wrap">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar me-2 cursor-none">
|
||||
<div class="avatar me-2">
|
||||
<img
|
||||
:src="getProfileImage(profileImg)"
|
||||
alt="user"
|
||||
class="rounded-circle profile-img"
|
||||
class="rounded-circle"
|
||||
@error="setDefaultImage($event)"
|
||||
@load="showImage($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="me-2">
|
||||
<h6 class="mb-0">{{ profileName ? profileName : nickname }}</h6>
|
||||
<h6 class="mb-0">{{ profileName }}</h6>
|
||||
<div class="profile-detail">
|
||||
<span>{{ date }}</span>
|
||||
<template v-if="showDetail">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -27,32 +27,19 @@
|
||||
<!-- 수정, 삭제 버튼 -->
|
||||
<template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)">
|
||||
<div class="float-end ms-1">
|
||||
<slot name="gobackBtn"></slot>
|
||||
<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>
|
||||
<EditButton @click.stop="editClick" />
|
||||
<DeleteButton :class="'ms-1'" @click.stop="deleteClick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) -->
|
||||
<BoardRecommendBtn
|
||||
v-if="isLike && !isDeletedComment"
|
||||
:boardId="boardId"
|
||||
:comment="comment"
|
||||
:likeClicked="comment.likeClicked"
|
||||
:dislikeClicked="comment.dislikeClicked"
|
||||
@updateReaction="handleUpdateReaction"
|
||||
/>
|
||||
<BoardRecommendBtn v-if="isLike" :boardId="boardId" :comment="comment" @updateReaction="handleUpdateReaction" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps, defineEmits, inject, onMounted } from 'vue';
|
||||
import { computed, defineProps, defineEmits, inject } from 'vue';
|
||||
import DeleteButton from '../button/DeleteBtn.vue';
|
||||
import EditButton from '../button/EditBtn.vue';
|
||||
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
|
||||
@ -75,10 +62,6 @@
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
nickname: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
unknown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@ -113,14 +96,6 @@
|
||||
type: String,
|
||||
default: false,
|
||||
},
|
||||
isEditPushed: {
|
||||
type: Boolean,
|
||||
require: false,
|
||||
},
|
||||
isDeletePushed: {
|
||||
type: Boolean,
|
||||
require: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
|
||||
|
||||
@ -1,22 +1,14 @@
|
||||
<template v-if="isRecommend">
|
||||
<button
|
||||
class="btn btn-label-primary btn-icon me-1"
|
||||
:class="{ clicked: likeClicked, big: bigBtn, active: props.likeClicked }"
|
||||
@click="handleLike"
|
||||
>
|
||||
<button class="btn btn-label-primary btn-icon me-1" :class="{ clicked: likeClicked, big: bigBtn }" @click="handleLike">
|
||||
<i class="fa-regular fa-thumbs-up"></i> <span class="ms-1">{{ likeCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-label-danger btn-icon"
|
||||
:class="{ clicked: dislikeClicked, big: bigBtn, active: props.dislikeClicked }"
|
||||
@click="handleDislike"
|
||||
>
|
||||
<button class="btn btn-label-danger btn-icon" :class="{ clicked: dislikeClicked, big: bigBtn }" @click="handleDislike">
|
||||
<i class="fa-regular fa-thumbs-down"></i> <span class="ms-1">{{ dislikeCount }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
@ -64,11 +56,6 @@
|
||||
const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount);
|
||||
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
|
||||
|
||||
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => {
|
||||
likeClicked.value = likeNewval;
|
||||
dislikeClicked.value = dislikeNewval;
|
||||
});
|
||||
|
||||
const handleLike = () => {
|
||||
const isLike = !likeClicked.value;
|
||||
const isDislike = false;
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }">
|
||||
<i class="bx bx-trash"></i>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class='bx bx-trash' ></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isPushed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
<script>
|
||||
export default {
|
||||
name: 'DeleteButton',
|
||||
methods: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }" @click="toggleText">
|
||||
<button class="btn btn-label-primary btn-icon" @click="toggleText">
|
||||
<i :class="buttonClass"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, defineEmits, watchEffect } from 'vue';
|
||||
import { ref, watch, defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
isToggleEnabled: {
|
||||
type: Boolean,
|
||||
@ -14,25 +16,25 @@
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
isPushed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const buttonClass = ref('bx bx-edit-alt');
|
||||
watchEffect(() => {
|
||||
buttonClass.value = props.isActive ? 'bx bx-x' : 'bx bx-edit-alt';
|
||||
});
|
||||
const toggleText = event => {
|
||||
// 이벤트 객체를 매개변수로 받아옵니다
|
||||
|
||||
watch(
|
||||
() => props.isActive,
|
||||
newVal => {
|
||||
buttonClass.value = newVal ? 'bx bx-x' : 'bx bx-edit-alt';
|
||||
},
|
||||
);
|
||||
|
||||
const toggleText = () => {
|
||||
if (props.isToggleEnabled) {
|
||||
buttonClass.value = buttonClass.value === 'bx bx-edit-alt' ? 'bx bx-x' : 'bx bx-edit-alt';
|
||||
}
|
||||
emit('click', event); // 이벤트 객체를 같이 전달
|
||||
};
|
||||
const resetButton = () => {
|
||||
buttonClass.value = 'bx bx-edit-alt';
|
||||
};
|
||||
|
||||
defineExpose({ resetButton });
|
||||
</script>
|
||||
|
||||
@ -1,72 +1,67 @@
|
||||
<template>
|
||||
<div class="row gx-2 mb-10 mt-1">
|
||||
<div class="col-3">
|
||||
<div class="row gx-2 mb-4">
|
||||
<div class="col-4">
|
||||
<div class="ratio ratio-1x1">
|
||||
<!-- 오전 반차 버튼 -->
|
||||
<button class="vac-btn vac-btn-warning rounded-circle d-flex align-items-center justify-content-center"
|
||||
:class="{ active: halfDayType === 'AM' }"
|
||||
@click="toggleHalfDay('AM')">
|
||||
<i class="bi bi-sun d-flex"></i>
|
||||
</button>
|
||||
<!-- 오전 반차 버튼 -->
|
||||
<button class="vac-btn vac-btn-warning rounded-circle d-flex align-items-center justify-content-center" :class="{ active: halfDayType === 'AM' }"
|
||||
@click="toggleHalfDay('AM')">
|
||||
<i class="bi bi-sun"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="col-4">
|
||||
<div class="ratio ratio-1x1">
|
||||
<!-- 오후 반차 버튼 -->
|
||||
<button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center"
|
||||
:class="{ active: halfDayType === 'PM' }"
|
||||
@click="toggleHalfDay('PM')">
|
||||
<i class="bi bi-moon d-flex"></i>
|
||||
</button>
|
||||
<!-- 오후 반차 버튼 -->
|
||||
<button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center" :class="{ active: halfDayType === 'PM' }"
|
||||
@click="toggleHalfDay('PM')">
|
||||
<i class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="col-4">
|
||||
<div class="ratio ratio-1x1">
|
||||
<!-- 풀 연차 버튼 -->
|
||||
<button class="vac-btn vac-btn-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||
:class="{ active: halfDayType === 'FULL' }"
|
||||
@click="toggleHalfDay('FULL')">
|
||||
<i class="bi bi-calendar d-flex"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="ratio ratio-1x1">
|
||||
<!-- 저장 버튼 -->
|
||||
<button class="vac-btn-success rounded-circle d-flex align-items-center justify-content-center"
|
||||
@click="addVacationRequests"
|
||||
:class="{ active: !isDisabled, disabled: isDisabled }">
|
||||
✔
|
||||
</button>
|
||||
<button class="vac-btn-success rounded-circle d-flex align-items-center justify-content-center" @click="addVacationRequests"
|
||||
:class="{ active: !isDisabled, disabled: isDisabled }">
|
||||
✔
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits, ref, defineProps } from "vue";
|
||||
import { defineEmits, ref, defineProps, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
isDisabled: Boolean
|
||||
isDisabled: Boolean,
|
||||
selectedDate: String // 날짜 선택 값을 props로 받음
|
||||
});
|
||||
|
||||
const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]);
|
||||
const halfDayType = ref(null);
|
||||
|
||||
const toggleHalfDay = (type) => {
|
||||
halfDayType.value = halfDayType.value === type ? null : type;
|
||||
emit("toggleHalfDay", halfDayType.value);
|
||||
halfDayType.value = halfDayType.value === type ? null : type;
|
||||
emit("toggleHalfDay", halfDayType.value);
|
||||
};
|
||||
|
||||
// 날짜 클릭 후 버튼 상태 자동 초기화
|
||||
// `selectedDate`가 변경되면 반차 선택 초기화
|
||||
watch(() => props.selectedDate, (newDate) => {
|
||||
if (newDate) {
|
||||
resetHalfDay();
|
||||
}
|
||||
});
|
||||
|
||||
// 날짜 선택 후 반차 버튼 상태 초기화
|
||||
const resetHalfDay = () => {
|
||||
halfDayType.value = null;
|
||||
emit("resetHalfDay");
|
||||
halfDayType.value = null;
|
||||
emit("resetHalfDay");
|
||||
};
|
||||
|
||||
const addVacationRequests = () => {
|
||||
emit("addVacationRequests");
|
||||
emit("addVacationRequests");
|
||||
};
|
||||
|
||||
defineExpose({ resetHalfDay });
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,18 +1,23 @@
|
||||
<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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SaveButton',
|
||||
props: {
|
||||
isEnabled: {
|
||||
type: Boolean,
|
||||
default: true, // 기본적으로 활성화
|
||||
},
|
||||
export default {
|
||||
name: "SaveButton",
|
||||
props: {
|
||||
isEnabled: {
|
||||
type: Boolean,
|
||||
default: true, // 기본적으로 활성화
|
||||
},
|
||||
emits: ['click'],
|
||||
};
|
||||
},
|
||||
emits: ["click"],
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -1,15 +1,4 @@
|
||||
<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"
|
||||
@ -20,68 +9,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@ -5,33 +5,35 @@
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{
|
||||
'btn-outline-primary': selectedCategory !== 'all',
|
||||
'btn-primary': selectedCategory === 'all'
|
||||
'btn-outline-primary': selectedCategory !== 'all',
|
||||
'btn-primary': selectedCategory === 'all'
|
||||
}"
|
||||
@click="selectCategory('all')"
|
||||
>
|
||||
All
|
||||
All
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="category in lists" :key="category.value" class="mt-2 me-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{
|
||||
'btn-outline-primary': category.value.toString() !== selectedCategory?.toString(),
|
||||
'btn-primary': category.value.toString() === selectedCategory?.toString()
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{
|
||||
'btn-outline-primary': category.value !== selectedCategory,
|
||||
'btn-primary': category.value === selectedCategory
|
||||
}"
|
||||
@click="selectCategory(category.value)"
|
||||
>
|
||||
{{ category.label }}
|
||||
</button>
|
||||
@click="selectCategory(category.value)"
|
||||
>
|
||||
{{ category.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, ref, watch } from 'vue';
|
||||
import { defineProps, ref, watch } from 'vue';
|
||||
|
||||
// lists prop 정의
|
||||
const props = defineProps({
|
||||
lists: {
|
||||
type: Array,
|
||||
@ -42,7 +44,7 @@ const props = defineProps({
|
||||
required: false,
|
||||
},
|
||||
selectedCategory: {
|
||||
type: [String,Number],
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
@ -61,5 +63,4 @@ watch(() => props.selectedCategory, (newVal) => {
|
||||
selectedCategory.value = newVal;
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@ -13,13 +13,11 @@
|
||||
|
||||
<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'"
|
||||
class="btn btn-outline-secondary border-3 w-100 py-0 h-px-50"
|
||||
@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>
|
||||
<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>
|
||||
@ -38,14 +36,10 @@ const props = defineProps({
|
||||
checkedInProject: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
pendingProjectChange: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated', 'projectChangeComplete', 'update:pendingProjectChange']);
|
||||
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated']);
|
||||
|
||||
const workTime = ref(null);
|
||||
const leaveTime = ref(null)
|
||||
@ -57,46 +51,22 @@ const { coords, isSupported, error } = useGeolocation({
|
||||
});
|
||||
|
||||
// 주소 변환 함수
|
||||
const getAddress = async (lat, lng) => {
|
||||
const getAddress = (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();
|
||||
const coord = new kakao.maps.LatLng(lat, lng);
|
||||
|
||||
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}`);
|
||||
}
|
||||
geocoder.coord2Address(coord.getLng(), coord.getLat(), (result, status) => {
|
||||
if (status === kakao.maps.services.Status.OK) {
|
||||
const address = result[0].address.address_name;
|
||||
resolve(address);
|
||||
} else {
|
||||
reject('주소를 가져올 수 없습니다.');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 위치 정보 가져오기 함수
|
||||
const getLocation = async () => {
|
||||
if (!isSupported.value) {
|
||||
@ -109,22 +79,22 @@ const getLocation = async () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!coords.value) {
|
||||
return null;
|
||||
if (coords.value) {
|
||||
userLocation.value = {
|
||||
lat: coords.value.latitude,
|
||||
lng: coords.value.longitude,
|
||||
};
|
||||
|
||||
try {
|
||||
const address = await getAddress(coords.value.latitude, coords.value.longitude);
|
||||
return address;
|
||||
} catch (error) {
|
||||
alert(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
userLocation.value = {
|
||||
lat: coords.value.latitude,
|
||||
lng: coords.value.longitude,
|
||||
};
|
||||
|
||||
try {
|
||||
const address = await getAddress(coords.value.latitude, coords.value.longitude);
|
||||
return address;
|
||||
} catch (error) {
|
||||
alert(error);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 오늘 사용자의 출근 정보 조회
|
||||
@ -154,51 +124,30 @@ const setWorkTime = async () => {
|
||||
// 현재 위치 주소 가져오기
|
||||
const address = await getLocation();
|
||||
|
||||
if (!address) {
|
||||
// 주소를 가져오지 못했을 때도 계속 진행할지 사용자에게 확인
|
||||
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 출근 처리하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$api.post('commuters/insert', {
|
||||
memberSeq: props.userId,
|
||||
projctSeq: props.checkedInProject.PROJCTSEQ,
|
||||
commutLve: null,
|
||||
commutLvt: 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;
|
||||
}
|
||||
}
|
||||
|
||||
const setLeaveTime = () => {
|
||||
$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);
|
||||
// 부모 컴포넌트에 업데이트 이벤트 발생
|
||||
emit('leaveTimeUpdated', true);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -210,6 +159,10 @@ watch(() => props.userId, async () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.checkedInProject, () => {
|
||||
// 프로젝트가 변경되면 필요한 처리 수행
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(async () => {
|
||||
await todayCommuterInfo();
|
||||
});
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="container-xxl flex-grow-1 container-p-y pb-0">
|
||||
<div class="card app-calendar-wrapper">
|
||||
<div class="row g-0">
|
||||
<div class="col-3 border-end text-center" 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'"/>
|
||||
<img v-if="user" :src="`${baseUrl}upload/img/profile/${user.profile}`" alt="Profile Image" class="w-px-50 h-px-50 rounded-circle" @error="$event.target.src = '/img/icons/icon.png'"/>
|
||||
<p class="mt-2 fw-bold">
|
||||
{{ user.name }}
|
||||
</p>
|
||||
@ -12,8 +12,6 @@
|
||||
<CommuterBtn
|
||||
:userId="user.id"
|
||||
:checkedInProject="checkedInProject || {}"
|
||||
:pendingProjectChange="pendingProjectChange"
|
||||
@update:pendingProjectChange="pendingProjectChange = $event"
|
||||
@workTimeUpdated="handleWorkTimeUpdate"
|
||||
@leaveTimeUpdated="handleLeaveTimeUpdate"
|
||||
ref="workTimeComponentRef"
|
||||
@ -42,7 +40,6 @@
|
||||
class="flatpickr-calendar-only"
|
||||
>
|
||||
</full-calendar>
|
||||
<input ref="calendarDatepicker" type="text" class="d-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -52,64 +49,20 @@
|
||||
|
||||
<CenterModal :display="isModalOpen" @close="closeModal">
|
||||
<template #title>
|
||||
{{ eventDate }}
|
||||
{{ 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'">
|
||||
<div class="d-flex align-items-center my-2">
|
||||
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
|
||||
class="rounded-circle me-2 w-px-50 h-px-50"
|
||||
@error="$event.target.src = '/img/icons/icon.png'">
|
||||
|
||||
<span class="fw-bold">{{ commuter.memberName }}</span>
|
||||
</div>
|
||||
<span class="text-white fw-bold rounded py-1 px-3" :style="`background: ${commuter.projctcolor} !important;`">{{ commuter.memberName }}</span>
|
||||
|
||||
<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 class="ms-auto text-start fw-bold">
|
||||
{{ commuter.COMMUTCMT }} ~ {{ commuter.COMMUTLVE || "00:00:00" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -119,7 +72,6 @@
|
||||
<BackBtn @click="closeModal" />
|
||||
</template>
|
||||
</CenterModal>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -127,7 +79,7 @@ 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 { computed, inject, onMounted, reactive, ref, watch } from 'vue';
|
||||
import $api from '@api';
|
||||
import 'flatpickr/dist/flatpickr.min.css';
|
||||
import '@/assets/css/app-calendar.css';
|
||||
@ -137,10 +89,6 @@ 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({});
|
||||
@ -159,19 +107,9 @@ 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 = () => {
|
||||
@ -179,65 +117,36 @@ const handleWorkTimeUpdate = () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
const handleLeaveTimeUpdate = () => {
|
||||
todaysCommuter();
|
||||
};
|
||||
|
||||
// 프로젝트 드롭 이벤트 핸들러 (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
|
||||
);
|
||||
// select 값도 변경
|
||||
selectedProject.value = targetProject.PROJCTSEQ;
|
||||
|
||||
// 드롭 후 CommuterBtn 컴포넌트의 상태를 업데이트 (출근/퇴근 버튼 상태 변경)
|
||||
if (workTimeComponentRef.value && workTimeComponentRef.value.fetchWorkTime) {
|
||||
workTimeComponentRef.value.fetchWorkTime();
|
||||
}
|
||||
$api.patch('commuters/update', {
|
||||
projctSeq: targetProject.PROJCTSEQ,
|
||||
memberSeq: user.value.id,
|
||||
}).then(res => {
|
||||
if (res.status === 200) {
|
||||
todaysCommuter();
|
||||
loadCommuters();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 오늘 출근 모든 사용자 조회
|
||||
@ -394,12 +303,11 @@ const loadCommuters = async () => {
|
||||
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.className = 'rounded-circle w-px-20 h-px-20 mx-1 mb-1 position-relative z-5 m-auto';
|
||||
profileImg.style.border = `2px solid ${commuter.projctcolor}`;
|
||||
profileImg.onerror = () => { profileImg.src = '/img/icons/icon.png'; };
|
||||
|
||||
@ -463,28 +371,12 @@ 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(() => {
|
||||
@ -492,29 +384,21 @@ const selectedDateCommuters = computed(() => {
|
||||
commuter.COMMUTDAY === eventDate.value
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
await userStore.userInfo();
|
||||
user.value = userStore.user;
|
||||
await projectStore.getProjectList('', '', 'true');
|
||||
project.value = projectStore.activeProjectList;
|
||||
project.value = projectStore.projectList;
|
||||
|
||||
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;
|
||||
const storedProject = projectStore.getSelectedProject();
|
||||
if (storedProject) {
|
||||
selectedProject.value = storedProject.PROJCTSEQ;
|
||||
checkedInProject.value = storedProject;
|
||||
}
|
||||
|
||||
datePickerStore.initDatePicker(
|
||||
fullCalendarRef,
|
||||
async (year, month, options) => {
|
||||
// 데이터 다시 불러오기
|
||||
await fetchData();
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,34 +1,23 @@
|
||||
<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"
|
||||
<div class="commuter-list">
|
||||
<div v-for="post in project" :key="post.PROJCTSEQ"
|
||||
class="border border-2 mt-3 card p-2"
|
||||
:style="`border-color: ${post.projctcolor} !important; color: ${post.projctcolor} !important;`"
|
||||
@dragover="allowDrop($event)"
|
||||
@drop="handleDrop($event, post)"
|
||||
>
|
||||
@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 v-for="commuter in commuters.filter(c => c.PROJCTNAM === post.PROJCTNAM)" :key="commuter.COMMUTCMT" class="col-4">
|
||||
<div class="ratio ratio-1x1">
|
||||
<img
|
||||
:src="`${baseUrl}upload/img/profile/${commuter.profile}`"
|
||||
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
|
||||
alt="User Profile"
|
||||
class="rounded-circle object-fit-cover"
|
||||
class="rounded-circle"
|
||||
:class="isCurrentUser(commuter) ? 'cursor-pointer' : ''"
|
||||
:draggable="isCurrentUser(commuter)"
|
||||
@dragstart="isCurrentUser(commuter) ? dragStart($event, post) : null"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
>
|
||||
@error="$event.target.src = '/img/icons/icon.png'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, computed } from 'vue';
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
@ -68,25 +57,9 @@ const props = defineProps({
|
||||
|
||||
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;
|
||||
return props.user && commuter && commuter.MEMBERSEQ === props.user.id;
|
||||
};
|
||||
|
||||
// 드래그 시작 이벤트 핸들러
|
||||
@ -106,6 +79,4 @@ const handleDrop = (event, targetProject) => {
|
||||
event.preventDefault();
|
||||
emit('drop', { event, targetProject });
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@ -41,12 +41,11 @@
|
||||
|
||||
<button class="ql-link">Link</button>
|
||||
<button class="ql-image">Image</button>
|
||||
<button class="ql-video">Video</button>
|
||||
<button class="ql-blockquote">Blockquote</button>
|
||||
<button class="ql-code-block">Code Block</button>
|
||||
</div>
|
||||
<!-- 에디터가 표시될 div -->
|
||||
<div id="qEditor" ref="editor"></div>
|
||||
<div ref="editor"></div>
|
||||
<!-- Alert 메시지 표시 -->
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
|
||||
</div>
|
||||
@ -55,11 +54,8 @@
|
||||
<script setup>
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import $api from '@api';
|
||||
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
|
||||
const toastStore = useToastStore();
|
||||
import $api from '@api';
|
||||
|
||||
const props = defineProps({
|
||||
isAlert: {
|
||||
@ -75,10 +71,7 @@
|
||||
const editor = ref(null); // 에디터 DOM 참조
|
||||
const font = ref('nanum-gothic'); // 기본 폰트
|
||||
const fontSize = ref('16px'); // 기본 폰트 크기
|
||||
const emit = defineEmits(['update:data', 'update:uploadedImgList', 'update:deleteImgIndexList']);
|
||||
const uploadedImgList = ref([]); // 에디터에 이미지 첨부시 업데이트 된 파일 인덱스 번호 리스트
|
||||
const initImageIndex = ref([]); // 에디터 로드 시 이미지 인덱스 정보
|
||||
const deleteImgIndexList = ref([]); // 에디터의 이미지 파일 수정 및 삭제 시 해당 이미지 인덱스 목록
|
||||
const emit = defineEmits(['update:data']);
|
||||
|
||||
onMounted(() => {
|
||||
// 툴바에서 선택할 수 있는 폰트 목록 설정
|
||||
@ -119,54 +112,11 @@
|
||||
quillInstance.format('size', fontSize.value);
|
||||
});
|
||||
|
||||
// 이미지 추가 항목 체크
|
||||
watch(uploadedImgList, () => {
|
||||
emit('update:uploadedImgList', uploadedImgList.value);
|
||||
});
|
||||
|
||||
// 이미지 첨부 리스트가 변경(삭제) 되었을때
|
||||
watch(deleteImgIndexList, () => {
|
||||
emit('update:deleteImgIndexList', deleteImgIndexList.value);
|
||||
});
|
||||
|
||||
// 초기 데이터가 있을 경우, HTML 형식으로 삽입
|
||||
if (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을 추적
|
||||
quillInstance.getModule('toolbar').addHandler('image', () => {
|
||||
@ -183,8 +133,6 @@
|
||||
checkForDeletedImages(); // 삭제된 이미지 확인
|
||||
}
|
||||
});
|
||||
|
||||
checkDeletedImages();
|
||||
emit('update:data', quillInstance.getContents());
|
||||
});
|
||||
|
||||
@ -202,19 +150,9 @@
|
||||
|
||||
// 이미지 서버에 업로드 후 URL 받기
|
||||
uploadImageToServer(formData)
|
||||
.then(data => {
|
||||
const uploadImgIdx = data?.fileIndex; // 업로드 된 파일 DB 인덱스
|
||||
const serverImageUrl = data?.fileUrl; // 업로드 된 파일 url
|
||||
|
||||
// 업로드 된 파일 인덱스 (게시글 저장 시 해당 인덱스 번호에 게시글 인덱스를 업데이트)
|
||||
if (uploadImgIdx) {
|
||||
uploadedImgList.value = [...uploadedImgList.value, uploadImgIdx];
|
||||
initImageIndex.value = [...initImageIndex.value, uploadImgIdx];
|
||||
}
|
||||
|
||||
.then(serverImageUrl => {
|
||||
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
|
||||
//const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
|
||||
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}?imgIndex=${uploadImgIdx}`; // 이미지 경로에 index 정보 추가
|
||||
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
|
||||
|
||||
const range = quillInstance.getSelection();
|
||||
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); // 선택된 위치에 이미지 삽입
|
||||
@ -230,44 +168,44 @@
|
||||
|
||||
// 이미지 서버 업로드
|
||||
async function uploadImageToServer(formData) {
|
||||
try {
|
||||
// Make the POST request to upload the image
|
||||
const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
|
||||
try {
|
||||
// Make the POST request to upload the image
|
||||
const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
|
||||
|
||||
// Check if the response contains the expected data
|
||||
if (response.data && response.data.data) {
|
||||
const imageUrl = response.data.data;
|
||||
return imageUrl; // Return the image URL received from the server
|
||||
} else {
|
||||
throw new Error('Image URL not returned from server');
|
||||
}
|
||||
} catch (error) {
|
||||
// Log detailed error information for debugging purposes
|
||||
console.error('Image upload failed:', error);
|
||||
|
||||
// Handle specific error cases (e.g., network issues, authorization issues)
|
||||
if (error.response) {
|
||||
// If the error is from the server (e.g., 4xx or 5xx error)
|
||||
console.error('Error response:', error.response.data);
|
||||
toastStore.onToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
|
||||
} else if (error.request) {
|
||||
// If no response is received from the server
|
||||
console.error('No response received:', error.request);
|
||||
toastStore.onToast('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
|
||||
} else {
|
||||
// If the error is due to something else (e.g., invalid request setup)
|
||||
console.error('Error message:', error.message);
|
||||
toastStore.onToast('파일 업로드 중 문제가 발생했습니다. 다시 시도해주세요.', 'e');
|
||||
}
|
||||
|
||||
// Throw the error so the caller knows something went wrong
|
||||
throw error;
|
||||
}
|
||||
// Check if the response contains the expected data
|
||||
if (response.data && response.data.data) {
|
||||
const imageUrl = response.data.data;
|
||||
return imageUrl; // Return the image URL received from the server
|
||||
} else {
|
||||
throw new Error('Image URL not returned from server');
|
||||
}
|
||||
} catch (error) {
|
||||
// Log detailed error information for debugging purposes
|
||||
console.error('Image upload failed:', error);
|
||||
|
||||
// Handle specific error cases (e.g., network issues, authorization issues)
|
||||
if (error.response) {
|
||||
// If the error is from the server (e.g., 4xx or 5xx error)
|
||||
console.error('Error response:', error.response.data);
|
||||
toastStore.onToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
|
||||
} else if (error.request) {
|
||||
// If no response is received from the server
|
||||
console.error('No response received:', error.request);
|
||||
toastStore.onToast('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
|
||||
} else {
|
||||
// If the error is due to something else (e.g., invalid request setup)
|
||||
console.error('Error message:', error.message);
|
||||
toastStore.onToast('파일 업로드 중 문제가 발생했습니다. 다시 시도해주세요.', 'e');
|
||||
}
|
||||
|
||||
// Throw the error so the caller knows something went wrong
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제된 이미지 확인
|
||||
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)); // 현재 에디터에 있는 이미지들
|
||||
|
||||
imageUrls.forEach(url => {
|
||||
@ -276,41 +214,6 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 에디터 로드 시 이미지 인덱스 정보 추출
|
||||
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>
|
||||
|
||||
|
||||
@ -2,14 +2,11 @@
|
||||
<div class="mb-4 row">
|
||||
<label :for="inputId" class="col-md-2 col-form-label">{{ title }}</label>
|
||||
<div class="col-md-10">
|
||||
<label :for="inputId" class="btn btn-label-primary">파일 선택</label>
|
||||
<input
|
||||
class="form-control"
|
||||
type="file"
|
||||
style="display: none"
|
||||
:id="inputId"
|
||||
ref="fileInput"
|
||||
:key="autoIncrement"
|
||||
@change="changeHandler"
|
||||
multiple
|
||||
/>
|
||||
@ -21,71 +18,69 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { fileMsg } from '@/common/msgEnum';
|
||||
import { ref ,computed} from 'vue';
|
||||
import { fileMsg } from '@/common/msgEnum';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'fileInput',
|
||||
required: true,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
//:key="autoIncrement" 동일한 파일을 첨부하고 비웠다 다시넣으면 안들어가는 현상 대비.
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'fileInput',
|
||||
required: true,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
const inputId = computed(() => props.name || 'defaultFileInput');
|
||||
|
||||
const inputId = computed(() => props.name || 'defaultFileInput');
|
||||
const emits = defineEmits(['update:data', 'update:isValid']);
|
||||
|
||||
const emits = defineEmits(['update:data', 'update:isValid']);
|
||||
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_COUNT = 5; // 최대 파일 개수
|
||||
const ALLOWED_FILE_TYPES = []; // 모든 파일을 허용
|
||||
|
||||
const autoIncrement = ref(props.autoIncrement);
|
||||
const showError = ref(false);
|
||||
const fileMsgKey = ref(''); // 에러 메시지 키
|
||||
|
||||
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_COUNT = 5; // 최대 파일 개수
|
||||
const ALLOWED_FILE_TYPES = []; // 모든 파일을 허용
|
||||
const changeHandler = (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
const showError = ref(false);
|
||||
const fileMsgKey = ref(''); // 에러 메시지 키
|
||||
// ALLOWED_FILE_TYPES가 비어있으면 모든 파일 허용
|
||||
const invalidFiles = ALLOWED_FILE_TYPES.length > 0
|
||||
? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type))
|
||||
: [];
|
||||
|
||||
const changeHandler = event => {
|
||||
const files = Array.from(event.target.files);
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxSizeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (files.length > MAX_FILE_COUNT) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxLengthMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (invalidFiles.length > 0) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileNotTypeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else {
|
||||
showError.value = false;
|
||||
fileMsgKey.value = '';
|
||||
emits('update:data', files);
|
||||
emits('update:isValid', true);
|
||||
}
|
||||
};
|
||||
|
||||
// ALLOWED_FILE_TYPES가 비어있으면 모든 파일 허용
|
||||
const invalidFiles = ALLOWED_FILE_TYPES.length > 0 ? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type)) : [];
|
||||
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxSizeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (files.length > MAX_FILE_COUNT) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxLengthMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (invalidFiles.length > 0) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileNotTypeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else {
|
||||
showError.value = false;
|
||||
fileMsgKey.value = '';
|
||||
emits('update:data', files);
|
||||
emits('update:isValid', true);
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = computed(() => fileMsg[fileMsgKey.value] || '');
|
||||
const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
|
||||
</script>
|
||||
|
||||
@ -19,9 +19,6 @@
|
||||
@focusout="$emit('focusout', modelValue)"
|
||||
@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="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
|
||||
|
||||
@ -4,37 +4,28 @@
|
||||
{{ title }}
|
||||
<span v-if="isEssential" class="link-danger">*</span>
|
||||
</label>
|
||||
<div :class="isRow ? 'col-md-10' : 'col-md-12'">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<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 } : {}">
|
||||
{{ isCommon ? item.label : item }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div v-if="isBtn" class="ms-2">
|
||||
<slot name="append"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="isColor && selected"
|
||||
class="w-px-40 h-px-30"
|
||||
:style="{backgroundColor: selected}">
|
||||
</div>
|
||||
|
||||
<img v-if="isMbti && selected"
|
||||
role="img"
|
||||
class="w-px-30 h-px-40"
|
||||
:src="`/img/mbti/${selected.toLowerCase()}.png`"
|
||||
alt="MBTI image"/>
|
||||
<div :class="isRow ? 'col-md-10' : 'col-md-12'" 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')">
|
||||
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
|
||||
{{ isCommon ? item.label : item }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="isBtn" class="ms-2">
|
||||
<slot name="append"></slot>
|
||||
</div>
|
||||
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }}를 확인해주세요.</div>
|
||||
<div v-if="isColor && selected"
|
||||
class="w-px-40 h-px-30"
|
||||
:style="{backgroundColor: selected}">
|
||||
</div>
|
||||
|
||||
<img v-if="isMbti && selected"
|
||||
role="img"
|
||||
class="w-px-30 h-px-40"
|
||||
:src="`/img/mbti/${selected.toLowerCase()}.png`"
|
||||
alt="MBTI image"/>
|
||||
</div>
|
||||
<div v-if="isAlert" class="invalid-feedback">{{ title }}을 확인해주세요.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -138,11 +129,10 @@ watch(selectData, (newValue) => {
|
||||
|
||||
|
||||
const selected = computed(() => {
|
||||
// 현재 선택된 값에 해당하는 아이템 찾기
|
||||
const selectedItem = props.data.find(item =>
|
||||
props.isCommon ? item.value === selectData.value : props.data.indexOf(item) === selectData.value
|
||||
);
|
||||
|
||||
return selectedItem ? selectedItem.label : null;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<span :class="isEssential ? 'link-danger' : 'd-none'">*</span>
|
||||
</label>
|
||||
<div class="col-md-12">
|
||||
<div v-if="useInputGroup" class="input-group mb-1">
|
||||
<div v-if="useInputGroup" class="input-group mb-3">
|
||||
<input
|
||||
:id="name"
|
||||
class="form-control"
|
||||
@ -14,7 +14,8 @@
|
||||
:value="computedValue"
|
||||
:disabled="disabled"
|
||||
:maxLength="maxlength"
|
||||
:placeholder="placeholder ? placeholder : title"
|
||||
:minLength="minlength"
|
||||
:placeholder="title"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
<span class="input-group-text">@ localhost.co.kr</span>
|
||||
@ -29,10 +30,9 @@
|
||||
:value="computedValue"
|
||||
:disabled="disabled"
|
||||
:maxLength="maxlength"
|
||||
:placeholder="placeholder ? placeholder : title"
|
||||
:minLength="minlength"
|
||||
:placeholder="title"
|
||||
@blur="$emit('blur')"
|
||||
@click="handleDateClick"
|
||||
ref="inputElement"
|
||||
/>
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }}를 확인해주세요.</div>
|
||||
@ -41,7 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject, computed, ref } from 'vue';
|
||||
import { inject, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@ -79,6 +79,11 @@
|
||||
default: 30,
|
||||
required: false,
|
||||
},
|
||||
minlength: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
required: false,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -89,14 +94,9 @@
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:data', 'update:alert', 'blur']);
|
||||
const inputElement = ref(null);
|
||||
|
||||
// dayjs 인스턴스 가져오기
|
||||
const dayjs = inject('dayjs');
|
||||
@ -125,12 +125,4 @@
|
||||
emits('update:alert', false);
|
||||
}
|
||||
};
|
||||
|
||||
// date 타입일 때 input 클릭 시 달력 열기
|
||||
const handleDateClick = (event) => {
|
||||
if (props.type === 'date' && inputElement.value) {
|
||||
// 프로그래매틱하게 달력 열기: 날짜 선택기 UI를 표시
|
||||
inputElement.value.showPicker();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -3,71 +3,68 @@
|
||||
<div class="row g-0">
|
||||
<div class="card-body">
|
||||
<!-- 제목 -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="d-flex justify-content-between ">
|
||||
<h5 class="card-title fw-bold">
|
||||
{{ title }}
|
||||
</h5>
|
||||
<p v-if="isProjectExpired" class="btn-icon btn-danger rounded-2 pe-none"><i class='bx bx-power-off'></i></p>
|
||||
<div v-if="!isProjectExpired" class="d-flex gap-1">
|
||||
<EditBtn @click.stop="openEditModal" />
|
||||
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날짜 -->
|
||||
<div class="row align-items-center pb-2">
|
||||
<div class="col-3 col-md-2 d-flex align-items-center">
|
||||
<i class="bx bx-calendar"></i>
|
||||
<div class="ms-2">날짜</div>
|
||||
</div>
|
||||
<div class="col-9 col-md-10">
|
||||
{{ strdate }} ~ {{ enddate }}
|
||||
</div>
|
||||
<div class="d-flex flex-sm-row align-items-center pb-2">
|
||||
<i class="bx bx-calendar"></i>
|
||||
<div class="ms-2">날짜</div>
|
||||
<div class="ms-12">{{ strdate }} ~ {{ enddate }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 참여자 -->
|
||||
<div class="row align-items-center pb-2">
|
||||
<div class="col-3 col-md-2 d-flex align-items-center">
|
||||
<i class="bx bxs-user"></i>
|
||||
<div class="ms-2">참여자</div>
|
||||
</div>
|
||||
<div class="col-9 col-md-10">
|
||||
<UserList ref="userListRef" :projctSeq="projctSeq" :showOnlyActive="true" class="mb-0" />
|
||||
</div>
|
||||
<div class="d-flex flex-sm-row align-items-center pb-2">
|
||||
<i class="bx bxs-user"></i>
|
||||
<div class="ms-2">참여자</div>
|
||||
<UserList :projctSeq="projctSeq" :showOnlyActive="isProjectExpired" class="ms-8 mb-0" />
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div class="row align-items-center pb-2">
|
||||
<div class="col-3 col-md-2 d-flex align-items-center">
|
||||
<i class="bx bx-detail"></i>
|
||||
<div class="ms-2">설명</div>
|
||||
</div>
|
||||
<div class="col-9 col-md-10">
|
||||
{{ description || '-' }}
|
||||
</div>
|
||||
<div class="d-flex flex-sm-row align-items-center pb-2">
|
||||
<i class="bx bx-detail"></i>
|
||||
<div class="ms-2">설명</div>
|
||||
<div class="ms-12">{{ description }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 주소 -->
|
||||
<div class="row align-items-center pb-2">
|
||||
<div class="col-3 col-md-2 d-flex align-items-center">
|
||||
<MapPopover
|
||||
: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="d-flex flex-sm-row align-items-center pb-2">
|
||||
<div class="d-flex" @click.stop="isPopoverVisible = !isPopoverVisible">
|
||||
<i class="bx bxs-map cursor-pointer" ref="mapIconRef"></i>
|
||||
<div class="ms-2">주소</div>
|
||||
</div>
|
||||
<div class="ms-12 position-relative">
|
||||
{{ 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>
|
||||
</template>
|
||||
</MapPopover>
|
||||
</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>
|
||||
<button type="button" class="btn ms-auto text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">log</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -75,7 +72,7 @@
|
||||
|
||||
<!-- 로그 모달 -->
|
||||
<CenterModal :display="isModalOpen" @close="closeModal">
|
||||
<template #title> 등록·수정자 </template>
|
||||
<template #title> Log </template>
|
||||
<template #body>
|
||||
<div v-if="logData.length > 0">
|
||||
<div
|
||||
@ -98,7 +95,7 @@
|
||||
<template #title> 프로젝트 수정 </template>
|
||||
<template #body>
|
||||
<FormInput
|
||||
title="프로젝트명"
|
||||
title="이름"
|
||||
name="name"
|
||||
:is-essential="true"
|
||||
:is-alert="nameAlert"
|
||||
@ -113,51 +110,29 @@
|
||||
:is-essential="true"
|
||||
:is-label="true"
|
||||
:is-common="true"
|
||||
:is-color="true"
|
||||
:data="allColors"
|
||||
:value="selectedProject.PROJCTCOL"
|
||||
@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>
|
||||
<FormInput
|
||||
title="시작일"
|
||||
type="date"
|
||||
name="startDay"
|
||||
:is-essential="true"
|
||||
:modelValue="selectedProject.PROJCTSTR"
|
||||
@update:modelValue="selectedProject.PROJCTSTR = $event"
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
title="종료일"
|
||||
type="date"
|
||||
name="endDay"
|
||||
:min="selectedProject.PROJCTSTR"
|
||||
:modelValue="selectedProject.PROJCTEND"
|
||||
@update:modelValue="selectedProject.PROJCTEND = $event"
|
||||
/>
|
||||
|
||||
<!-- 시작일 -->
|
||||
<div @click="openStartDatePicker">
|
||||
<FormInput
|
||||
title="시작일"
|
||||
type="date"
|
||||
name="startDay"
|
||||
:is-essential="true"
|
||||
:is-alert="startDayAlert"
|
||||
:modelValue="selectedProject.PROJCTSTR"
|
||||
@update:modelValue="selectedProject.PROJCTSTR = $event"
|
||||
ref="startDateInput"
|
||||
/>
|
||||
</div>
|
||||
<!-- 종료일 -->
|
||||
<div @click="openEndDatePicker">
|
||||
<FormInput
|
||||
title="종료일"
|
||||
type="date"
|
||||
name="endDay"
|
||||
:min="selectedProject.PROJCTSTR"
|
||||
:modelValue="selectedProject.PROJCTEND"
|
||||
@update:modelValue="selectedProject.PROJCTEND = $event"
|
||||
ref="endDateInput"
|
||||
/>
|
||||
</div>
|
||||
<FormInput
|
||||
title="설명"
|
||||
name="description"
|
||||
@ -190,6 +165,7 @@ import { defineProps, onMounted, ref, computed, watch } from 'vue';
|
||||
import UserList from '@c/user/UserList.vue';
|
||||
import CenterModal from '@c/modal/CenterModal.vue';
|
||||
import $api from '@api';
|
||||
import { KakaoMap, KakaoMapMarker } from 'vue3-kakao-maps';
|
||||
import BackBtn from '@c/button/BackBtn.vue';
|
||||
import BackButton from '@c/button/BackBtn.vue';
|
||||
import SaveButton from '@c/button/SaveBtn.vue';
|
||||
@ -198,10 +174,9 @@ import DeleteBtn from '../button/DeleteBtn.vue';
|
||||
import FormInput from '@c/input/FormInput.vue';
|
||||
import FormSelect from '@c/input/FormSelect.vue';
|
||||
import ArrInput from '@c/input/ArrInput.vue';
|
||||
import MapPopover from '@c/map/MapPopover.vue';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
import commonApi, { refreshColorList } from '@/common/commonApi';
|
||||
import commonApi from '@/common/commonApi';
|
||||
import { useProjectStore } from '@/stores/useProjectStore';
|
||||
|
||||
// 스토어
|
||||
@ -227,7 +202,6 @@ const props = defineProps({
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
@ -256,14 +230,6 @@ const props = defineProps({
|
||||
projctCreatorId: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
resetUserSelection: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
searchParams: {
|
||||
type: Object,
|
||||
default: () => ({ text: '', year: null })
|
||||
}
|
||||
});
|
||||
|
||||
@ -274,54 +240,18 @@ const emit = defineEmits(['update']);
|
||||
const isModalOpen = ref(false);
|
||||
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 originalColor = ref('');
|
||||
const nameAlert = ref(false);
|
||||
const startDayAlert = ref(false);
|
||||
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(() => {
|
||||
return user.value?.id === props.projctCreatorId;
|
||||
});
|
||||
@ -360,23 +290,12 @@ const { colorList } = commonApi({
|
||||
colorType: 'YNP',
|
||||
});
|
||||
|
||||
|
||||
// 기존 컬러 + 사용 가능 한 컬러
|
||||
const allColors = computed(() => {
|
||||
// 먼저 기존 컬러 객체를 생성 (이 컬러는 항상 목록에 포함되어야 함)
|
||||
const existingColor = {
|
||||
value: props.projctCol, // 원래 프로젝트의 컬러 값 사용
|
||||
label: props.projctColor // 원래 프로젝트의 컬러 레이블 사용
|
||||
};
|
||||
|
||||
// 중복 제거를 위해 기존 컬러 값과 다른 컬러만 필터링
|
||||
const otherColors = colorList.value.filter(color => color.value !== existingColor.value);
|
||||
|
||||
// 기존 컬러를 첫 번째로 놓고 나머지 컬러 추가
|
||||
return [existingColor, ...otherColors];
|
||||
const existingColor = { value: selectedProject.value.PROJCTCOL, label: selectedProject.value.projctcolor };
|
||||
return [existingColor, ...colorList.value];
|
||||
});
|
||||
|
||||
|
||||
// 수정 :: 주소
|
||||
const updateAddress = addressData => {
|
||||
selectedProject.value = {
|
||||
@ -407,7 +326,7 @@ const closeModal = () => {
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = async () => {
|
||||
const openEditModal = () => {
|
||||
selectedProject.value = {
|
||||
PROJCTSEQ: props.projctSeq,
|
||||
PROJCTNAM: props.title,
|
||||
@ -423,7 +342,6 @@ const openEditModal = async () => {
|
||||
|
||||
isEditModalOpen.value = true;
|
||||
originalColor.value = props.projctCol;
|
||||
|
||||
};
|
||||
|
||||
// 수정 모달 닫기
|
||||
@ -442,24 +360,11 @@ const closeEditModal = () => {
|
||||
};
|
||||
|
||||
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 basicChanges = selectedProject.value.PROJCTNAM !== props.title ||
|
||||
return selectedProject.value.PROJCTNAM !== props.title ||
|
||||
selectedProject.value.PROJCTSTR !== props.strdate ||
|
||||
selectedProject.value.PROJCTEND !== props.enddate ||
|
||||
selectedProject.value.PROJCTZIP !== props.addressZip ||
|
||||
@ -467,11 +372,6 @@ const hasChanges = computed(() => {
|
||||
selectedProject.value.PROJCTDTL !== props.addressdtail ||
|
||||
selectedProject.value.PROJCTDES !== props.description ||
|
||||
selectedProject.value.PROJCTCOL !== props.projctCol;
|
||||
|
||||
// 사용자 목록 변경 확인
|
||||
const userChanges = editUserListRef.value?.hasUserChanges() || false;
|
||||
|
||||
return basicChanges || userChanges;
|
||||
});
|
||||
|
||||
// 시작일 또는 종료일이 변경될 때 종료일의 최소값을 설정
|
||||
@ -485,19 +385,12 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// resetUserSelection 변경 감지
|
||||
watch(() => props.resetUserSelection, () => {
|
||||
if (editUserListRef.value) {
|
||||
editUserListRef.value.resetSelection();
|
||||
}
|
||||
});
|
||||
|
||||
// 프로젝트 수정
|
||||
const handleUpdate = async () => {
|
||||
const handleUpdate = () => {
|
||||
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
|
||||
startDayAlert.value = selectedProject.value.PROJCTSTR.trim() === '';
|
||||
|
||||
if (nameAlert.value || startDayAlert.value) {
|
||||
if (nameAlert.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -506,9 +399,7 @@ const handleUpdate = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
|
||||
|
||||
const res = await $api.patch('project/update', {
|
||||
$api.patch('project/update', {
|
||||
projctSeq: selectedProject.value.PROJCTSEQ,
|
||||
projctNam: selectedProject.value.PROJCTNAM,
|
||||
projctCol: selectedProject.value.PROJCTCOL,
|
||||
@ -520,40 +411,69 @@ const handleUpdate = async () => {
|
||||
projctDes: selectedProject.value.PROJCTDES || null,
|
||||
projctUmb: user.value?.id,
|
||||
originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value,
|
||||
disabledMembers: disabledMemberSeqs
|
||||
}).then(res => {
|
||||
if (res.status === 200) {
|
||||
toastStore.onToast('수정이 완료 되었습니다.', 's');
|
||||
closeEditModal();
|
||||
|
||||
emit('update');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (res.status === 200) {
|
||||
toastStore.onToast('수정이 완료 되었습니다.', 's');
|
||||
// 주소를 좌표로 변환하는 함수
|
||||
const convertAddressToCoordinates = () => {
|
||||
const geocoder = new window.kakao.maps.services.Geocoder();
|
||||
|
||||
// 프로젝트 목록 새로고침
|
||||
await projectStore.getProjectList(props.searchParams.text, props.searchParams.year, 'false');
|
||||
await projectStore.getMemberProjects();
|
||||
await refreshColorList('YNP');
|
||||
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
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await editUserListRef.value.fetchProjectParticipation();
|
||||
await userListRef.value.fetchProjectParticipation();
|
||||
const onLoadKakaoMap = (mapRef) => {
|
||||
map.value = mapRef;
|
||||
};
|
||||
|
||||
closeEditModal();
|
||||
emit('update', props.searchParams);
|
||||
// 지도 확대
|
||||
const zoomIn = () => {
|
||||
if (map.value) {
|
||||
const level = map.value.getLevel();
|
||||
map.value.setLevel(level + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 지도 축소
|
||||
const zoomOut = () => {
|
||||
if (map.value) {
|
||||
const level = map.value.getLevel();
|
||||
map.value.setLevel(level - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 삭제
|
||||
const handleDelete = () => {
|
||||
if (confirm('프로젝트를 삭제하시겠습니까?')) {
|
||||
$api.patch('project/delete', {
|
||||
projctSeq: props.projctSeq,
|
||||
projctCol: props.projctCol,
|
||||
})
|
||||
.then(res => {
|
||||
if (res.status === 200) {
|
||||
toastStore.onToast('프로젝트가 삭제되었습니다.', 's');
|
||||
projectStore.getProjectList();
|
||||
projectStore.getMemberProjects();
|
||||
}
|
||||
})
|
||||
}
|
||||
$api.patch('project/delete', {
|
||||
projctSeq: props.projctSeq,
|
||||
projctCol: props.projctCol,
|
||||
})
|
||||
.then(res => {
|
||||
if (res.status === 200) {
|
||||
toastStore.onToast('삭제가 완료되었습니다.', 's');
|
||||
projectStore.getProjectList();
|
||||
projectStore.getMemberProjects();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 실행
|
||||
@ -562,18 +482,8 @@ onMounted(async () => {
|
||||
await userStore.userInfo();
|
||||
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"]');
|
||||
}
|
||||
|
||||
convertAddressToCoordinates();
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -6,14 +6,14 @@
|
||||
<button class="close-btn" @click="closeModal">✖</button>
|
||||
</div>
|
||||
<div class="vac-modal-body">
|
||||
<p class="vac-modal-text">선물할 연차 개수를 선택해 주세요.</p>
|
||||
<p class="vac-modal-text">선물할 연차 개수를 선택하세요.</p>
|
||||
<div class="count-container">
|
||||
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
|
||||
<span class="count-value">{{ grantCount }}</span>
|
||||
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@ -23,16 +23,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits, watch, onMounted, computed } from "vue";
|
||||
import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
|
||||
import axios from "@api";
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useUserInfoStore } from "@s/useUserInfoStore";
|
||||
const userStore = useUserInfoStore();
|
||||
|
||||
const toastStore = useToastStore();
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
targetUser: Object,
|
||||
remainingVacationData: Object,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "updateVacation"]);
|
||||
@ -41,13 +39,6 @@ const maxQuota = 2;
|
||||
const sentCount = ref(0);
|
||||
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 () => {
|
||||
try {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal">
|
||||
<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>
|
||||
<!-- 연차 목록 -->
|
||||
<div class="vac-modal-body" v-if="mergedVacations.length > 0">
|
||||
@ -11,6 +11,9 @@
|
||||
:key="vac._expandIndex"
|
||||
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'">
|
||||
{{ vac.category === 'used' ? '-' : '+' }}
|
||||
</span>
|
||||
@ -19,9 +22,6 @@
|
||||
>
|
||||
{{ formatDate(vac.date) }}
|
||||
</span>
|
||||
<span v-if="vac.category === 'used'" class="fw-bold text-dark ms-1">
|
||||
( {{ usedVacationIndexMap[vac._expandIndex] }} )
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<div v-for="post in projectStore.projectList" :key="post.PROJCTSEQ">
|
||||
<ProjectCard
|
||||
:title="post.PROJCTNAM"
|
||||
:description="post.PROJCTDES ?? ''"
|
||||
:description="post.PROJCTDES"
|
||||
:strdate="post.PROJCTSTR"
|
||||
:enddate="post.PROJCTEND"
|
||||
:address="post.PROJCTARR"
|
||||
@ -24,9 +24,7 @@
|
||||
:projctCol="post.PROJCTCOL"
|
||||
:projctColor="post.projctcolor"
|
||||
:projctCreatorId="post.PROJCTCMB"
|
||||
:resetUserSelection="resetUserSelection"
|
||||
:searchParams="{ text: searchText, year: selectedYear }"
|
||||
@update="handleProjectUpdate"
|
||||
@update="getProjectList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,7 +35,7 @@
|
||||
<template #title> 프로젝트 등록 </template>
|
||||
<template #body>
|
||||
<FormInput
|
||||
title="프로젝트명"
|
||||
title="이름"
|
||||
name="name"
|
||||
:is-essential="true"
|
||||
:is-alert="nameAlert"
|
||||
@ -53,48 +51,28 @@
|
||||
:is-label="true"
|
||||
:is-common="true"
|
||||
:is-color="true"
|
||||
:value="color"
|
||||
:data="colorList"
|
||||
@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>
|
||||
<FormInput
|
||||
title="시작 일"
|
||||
name="startDay"
|
||||
:type="'date'"
|
||||
:is-essential="true"
|
||||
:modelValue="startDay"
|
||||
v-model="startDay"
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
title="종료 일"
|
||||
:type="'date'"
|
||||
name="endDay"
|
||||
:modelValue="endDay"
|
||||
:min = "startDay"
|
||||
@update:modelValue="endDay = $event"
|
||||
/>
|
||||
|
||||
<div @click="openStartDatePicker">
|
||||
<FormInput
|
||||
title="시작 일"
|
||||
name="startDay"
|
||||
:type="'date'"
|
||||
:is-alert="startDayAlert"
|
||||
:is-essential="true"
|
||||
:modelValue="startDay"
|
||||
v-model="startDay"
|
||||
ref="startDateInput"
|
||||
/>
|
||||
</div>
|
||||
<div @click="openEndDatePicker">
|
||||
<FormInput
|
||||
title="종료 일"
|
||||
name="endDay"
|
||||
:type="'date'"
|
||||
:modelValue="endDay"
|
||||
:min="startDay"
|
||||
@update:modelValue="endDay = $event"
|
||||
ref="endDateInput"
|
||||
/>
|
||||
</div>
|
||||
<FormInput
|
||||
title="설명"
|
||||
name="description"
|
||||
@ -122,7 +100,7 @@
|
||||
</template>
|
||||
|
||||
<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 ProjectCard from '@c/list/ProjectCard.vue';
|
||||
import CategoryBtn from '@/components/category/CategoryBtn.vue';
|
||||
@ -131,8 +109,7 @@
|
||||
import FormSelect from '@c/input/FormSelect.vue';
|
||||
import FormInput from '@c/input/FormInput.vue';
|
||||
import ArrInput from '@c/input/ArrInput.vue';
|
||||
import UserList from '@c/user/UserList.vue';
|
||||
import commonApi, { refreshColorList } from '@/common/commonApi';
|
||||
import commonApi from '@/common/commonApi';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
import { useProjectStore } from '@/stores/useProjectStore';
|
||||
@ -149,9 +126,6 @@
|
||||
const selectedCategory = ref(null);
|
||||
const searchText = ref('');
|
||||
|
||||
const userListRef = ref(null);
|
||||
const resetUserSelection = ref(false);
|
||||
|
||||
// dayjs 인스턴스 가져오기
|
||||
const dayjs = inject('dayjs');
|
||||
|
||||
@ -161,37 +135,13 @@
|
||||
// 등록 모달 상태
|
||||
const isCreateModalOpen = ref(false);
|
||||
const name = ref('');
|
||||
const color = ref('0');
|
||||
const color = ref('');
|
||||
|
||||
const startDay = ref(today);
|
||||
const endDay = ref('');
|
||||
const description = ref('');
|
||||
const nameAlert = ref(false);
|
||||
const colorAlert = 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({
|
||||
postcode: '',
|
||||
@ -199,17 +149,6 @@
|
||||
detailAddress: ''
|
||||
});
|
||||
|
||||
// 선택된 사용자 목록 관리
|
||||
const selectedUsers = ref({
|
||||
activeUsers: [],
|
||||
disabledUsers: []
|
||||
});
|
||||
|
||||
// UserList 컴포넌트에서 사용자 선택 업데이트 처리
|
||||
const handleUserListUpdate = (userLists) => {
|
||||
selectedUsers.value = userLists;
|
||||
};
|
||||
|
||||
// API 호출
|
||||
const { yearCategory, colorList } = commonApi({
|
||||
loadColor: true,
|
||||
@ -233,7 +172,7 @@
|
||||
|
||||
// 프로젝트 목록 조회
|
||||
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 updatedColors = await refreshColorList('YNP');
|
||||
|
||||
if (updatedColors && updatedColors.length > 0) {
|
||||
color.value = updatedColors[0].value;
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
isCreateModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
isCreateModalOpen.value = false;
|
||||
resetUserSelection.value = !resetUserSelection.value;
|
||||
};
|
||||
|
||||
const formReset = () => {
|
||||
|
||||
name.value = '';
|
||||
if (colorList.value && colorList.value.length > 0) {
|
||||
color.value = colorList.value[0].value;
|
||||
}
|
||||
color.value = '';
|
||||
addressData.value = {
|
||||
postcode: '',
|
||||
address: '',
|
||||
@ -273,24 +202,12 @@
|
||||
description.value = '';
|
||||
nameAlert.value = false;
|
||||
addressAlert.value = false;
|
||||
startDayAlert.value = false;
|
||||
|
||||
selectedUsers.value = {
|
||||
activeUsers: [],
|
||||
disabledUsers: []
|
||||
};
|
||||
|
||||
if (userListRef.value) {
|
||||
userListRef.value.resetSelection();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// 등록 :: 주소 업데이트 핸들러
|
||||
const handleAddressUpdate = (data) => {
|
||||
addressData.value = data;
|
||||
};
|
||||
} ;
|
||||
|
||||
// 시작일이 종료일보다 크면 종료일 리셋
|
||||
watch(startDay, (newStartDate) => {
|
||||
@ -299,41 +216,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
const handleProjectUpdate = async (params) => {
|
||||
if (params) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 등록
|
||||
const handleCreate = async () => {
|
||||
|
||||
nameAlert.value = name.value.trim() === '';
|
||||
startDayAlert.value = startDay.value.trim() === '';
|
||||
addressAlert.value = addressData.value.address.trim() === '';
|
||||
|
||||
if (!colorList.value || colorList.value.length === 0) {
|
||||
colorAlert.value = true;
|
||||
}
|
||||
|
||||
if (nameAlert.value || startDayAlert.value || addressAlert.value || colorAlert.value) {
|
||||
if (nameAlert.value || addressAlert.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 비활성화된 사용자 목록 생성
|
||||
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
|
||||
$api.post('project/insert', {
|
||||
|
||||
const response = await $api.post('project/insert', {
|
||||
projctNam: name.value,
|
||||
projctCol: color.value,
|
||||
projctStr: startDay.value,
|
||||
@ -343,36 +237,20 @@
|
||||
projctDtl: addressData.value.detailAddress,
|
||||
projctZip: addressData.value.postcode,
|
||||
projctCmb: user.value.id,
|
||||
disabledMembers: disabledMemberSeqs
|
||||
}).then(res => {
|
||||
if (res.status === 200) {
|
||||
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
|
||||
closeCreateModal();
|
||||
getProjectList();
|
||||
projectStore.getMemberProjects();
|
||||
formReset();
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
|
||||
|
||||
colorList.value = colorList.value.filter(c => c.value !== color.value);
|
||||
|
||||
formReset();
|
||||
|
||||
await getProjectList();
|
||||
await projectStore.getMemberProjects();
|
||||
|
||||
closeCreateModal();
|
||||
resetUserSelection.value = !resetUserSelection.value;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await getProjectList();
|
||||
await userStore.userInfo();
|
||||
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>
|
||||
|
||||
@ -1,63 +1,42 @@
|
||||
<template>
|
||||
<form @submit.prevent="search">
|
||||
<div class="input-group mb-3 d-flex">
|
||||
<input type="text" class="form-control" placeholder="Search" v-model="searchQuery" @input="preventLeadingSpace" />
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bx bx-search bx-md"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="input-group mb-3 d-flex">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search"
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="search"
|
||||
@input="preventLeadingSpace"
|
||||
/>
|
||||
<button type="button" class="btn btn-primary" @click="search">
|
||||
<i class="bx bx-search bx-md"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
required: false,
|
||||
},
|
||||
initKeyword: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
const props = defineProps({
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:data']);
|
||||
const searchQuery = ref('');
|
||||
const emits = defineEmits(["update:data"]);
|
||||
const searchQuery = ref("");
|
||||
|
||||
watch(
|
||||
() => props.initKeyword,
|
||||
(newVal, oldVal) => {
|
||||
searchQuery.value = newVal;
|
||||
},
|
||||
);
|
||||
const search = function () {
|
||||
// Type Number 일때 maxlength 적용 안됨 방지
|
||||
if (searchQuery.value.length > props.maxlength) {
|
||||
searchQuery.value = searchQuery.value.slice(0, props.maxlength);
|
||||
}
|
||||
emits("update:data", searchQuery.value);
|
||||
};
|
||||
|
||||
// 검색 실행 함수 (버튼 클릭 or 엔터 공통)
|
||||
const search = () => {
|
||||
const trimmedQuery = searchQuery.value.trimStart();
|
||||
if (trimmedQuery === '') {
|
||||
emits('update:data', '');
|
||||
return;
|
||||
}
|
||||
if (trimmedQuery.length < 2) {
|
||||
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 = () => {
|
||||
searchQuery.value = searchQuery.value.trimStart();
|
||||
};
|
||||
const preventLeadingSpace = function () {
|
||||
searchQuery.value = searchQuery.value.trimStart();
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<label class="form-check-label fw-bold" for="rememberCheck"> 자동로그인</label>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
@ -32,7 +32,7 @@
|
||||
<script setup>
|
||||
import $api from '@api';
|
||||
import router from '@/router';
|
||||
import { ref } from 'vue';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import UserFormInput from '@c/input/UserFormInput.vue';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
|
||||
@ -71,15 +71,16 @@
|
||||
remember: remember.value,
|
||||
}, { headers: { isLogin: true } })
|
||||
.then(async res => {
|
||||
// 로그인 실패 확인
|
||||
if (res.data.code > 10000) {
|
||||
// 로그인 실패 확인 (success가 false인 경우)
|
||||
if (res.data && res.data.success === false) {
|
||||
// 로그인 실패 시 에러 메시지 표시
|
||||
errorMessage.value = res.data.message;
|
||||
errorMessage.value = res.data.message || '로그인에 실패했습니다.';
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 성공 처리
|
||||
await userStore.userInfo();
|
||||
userStore.userInfo();
|
||||
await nextTick();
|
||||
router.push('/');
|
||||
})
|
||||
};
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
<div class="text-center">
|
||||
<label
|
||||
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"
|
||||
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(img/avatars/default-Profile.jpg); background-repeat: no-repeat"
|
||||
>
|
||||
</label>
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
@update:alert="idAlert = $event"
|
||||
@blur="checkIdDuplicate"
|
||||
:value="id"
|
||||
@keypress="noSpace"
|
||||
/>
|
||||
<span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span>
|
||||
|
||||
@ -38,9 +37,7 @@
|
||||
@update:data="password = $event"
|
||||
@update:alert="passwordAlert = $event"
|
||||
:value="password"
|
||||
@keypress="noSpace"
|
||||
/>
|
||||
<span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span>
|
||||
|
||||
<UserFormInput
|
||||
title="비밀번호 확인"
|
||||
@ -50,8 +47,8 @@
|
||||
:is-alert="passwordcheckAlert"
|
||||
@update:data="passwordcheck = $event"
|
||||
@update:alert="passwordcheckAlert = $event"
|
||||
@blur="checkPw"
|
||||
:value="passwordcheck"
|
||||
@keypress="noSpace"
|
||||
/>
|
||||
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
|
||||
|
||||
@ -85,7 +82,6 @@
|
||||
@update:data="name = $event"
|
||||
@update:alert="nameAlert = $event"
|
||||
:value="name"
|
||||
@keypress="noSpace"
|
||||
class="me-2 w-50"
|
||||
/>
|
||||
|
||||
@ -99,7 +95,6 @@
|
||||
:is-color="true"
|
||||
:data="colorList"
|
||||
@update:data="handleColorUpdate"
|
||||
:is-alert="colorAlert"
|
||||
class="w-50"
|
||||
/>
|
||||
</div>
|
||||
@ -148,12 +143,13 @@
|
||||
name="phone"
|
||||
:isEssential="true"
|
||||
:is-alert="phoneAlert"
|
||||
@update:data="phone = $event"
|
||||
@update:alert="phoneAlert = $event"
|
||||
@blur="checkPhoneDuplicate"
|
||||
:maxlength="11"
|
||||
:value="phone"
|
||||
@keypress="onlyNumber"
|
||||
@input="inputEvent"
|
||||
|
||||
/>
|
||||
<span v-if="phoneError" class="invalid-feedback d-block">{{ phoneError }}</span>
|
||||
|
||||
@ -184,7 +180,6 @@
|
||||
const id = ref('');
|
||||
const idError = ref('');
|
||||
const password = ref('');
|
||||
const passwordError = ref('');
|
||||
const passwordcheck = ref('');
|
||||
const passwordcheckError = ref('');
|
||||
const pwhintRes = ref('');
|
||||
@ -204,12 +199,10 @@
|
||||
const idAlert = ref(false);
|
||||
const idErrorAlert = ref(false);
|
||||
const passwordAlert = ref(false);
|
||||
const passwordErrorAlert = ref(false);
|
||||
const passwordcheckAlert = ref(false);
|
||||
const passwordcheckErrorAlert = ref(false); // 비밀번호 확인 오류 메시지
|
||||
const pwhintResAlert = ref(false);
|
||||
const nameAlert = ref(false);
|
||||
const colorAlert = ref(false);
|
||||
const birthAlert = ref(false);
|
||||
const addressAlert = ref(false);
|
||||
const phoneAlert = ref(false);
|
||||
@ -218,10 +211,6 @@
|
||||
|
||||
const toastStore = useToastStore();
|
||||
|
||||
const noSpace = (e) => {
|
||||
if (e.key === ' ') e.preventDefault();
|
||||
};
|
||||
|
||||
// 프로필 체크
|
||||
const profileValid = (size, type) => {
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
@ -260,15 +249,8 @@
|
||||
profile.value = file;
|
||||
};
|
||||
|
||||
// 아이디 체크
|
||||
// 아이디 중복체크
|
||||
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}`);
|
||||
if (!response.data.data) {
|
||||
idErrorAlert.value = true;
|
||||
@ -307,6 +289,17 @@
|
||||
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 response = await $api.get(`/user/checkColor?memberCol=${color.value}`);
|
||||
@ -335,45 +328,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
@ -388,27 +342,7 @@
|
||||
addressAlert.value = address.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) {
|
||||
if (!/^\d+$/.test(phone.value)) {
|
||||
phoneAlert.value = true;
|
||||
} else {
|
||||
phoneAlert.value = false;
|
||||
@ -423,13 +357,11 @@
|
||||
profilAlert.value = false;
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
profilAlert.value ||
|
||||
idAlert.value ||
|
||||
idErrorAlert.value ||
|
||||
passwordAlert.value ||
|
||||
passwordErrorAlert.value ||
|
||||
passwordcheckAlert.value ||
|
||||
passwordcheckErrorAlert.value ||
|
||||
pwhintResAlert.value ||
|
||||
@ -438,20 +370,19 @@
|
||||
addressAlert.value ||
|
||||
phoneAlert.value ||
|
||||
phoneErrorAlert.value ||
|
||||
colorAlert.value ||
|
||||
colorErrorAlert.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('memberIds', id.value.trim());
|
||||
formData.append('memberPwd', password.value.trim());
|
||||
formData.append('memberIds', id.value);
|
||||
formData.append('memberPwd', password.value);
|
||||
formData.append('memberPwh', pwhint.value);
|
||||
formData.append('memberPwr', pwhintRes.value.trim());
|
||||
formData.append('memberNam', name.value.trim());
|
||||
formData.append('memberPwr', pwhintRes.value);
|
||||
formData.append('memberNam', name.value);
|
||||
formData.append('memberArr', address.value);
|
||||
formData.append('memberDtl', detailAddress.value.trim());
|
||||
formData.append('memberDtl', detailAddress.value);
|
||||
formData.append('memberZip', postcode.value);
|
||||
formData.append('memberBth', birth.value);
|
||||
formData.append('memberTel', phone.value);
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<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 flex-wrap">
|
||||
<li
|
||||
v-for="(user, index) in displayedUserList"
|
||||
:key="index"
|
||||
class="avatar pull-up "
|
||||
class="avatar pull-up"
|
||||
:class="{ 'opacity-100': isUserDisabled(user) }"
|
||||
@click.stop="showOnlyActive ? null : toggleDisable(index)"
|
||||
:style="showOnlyActive ? 'cursor: default' : ''"
|
||||
@ -14,7 +14,7 @@
|
||||
:data-bs-original-title="getTooltipTitle(user)"
|
||||
>
|
||||
<img
|
||||
class="user-avatar border border-3 rounded-circle object-fit-cover"
|
||||
class="rounded-circle user-avatar border border-3"
|
||||
:class="{ 'grayscaleImg': isUserDisabled(user) }"
|
||||
:src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`"
|
||||
:style="`border-color: ${user.usercolor} !important;`"
|
||||
@ -23,12 +23,12 @@
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else >-</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, nextTick, computed, watch } from 'vue';
|
||||
import { useUserStore } from '@s/userList';
|
||||
import { useProjectStore } from '@s/useProjectStore';
|
||||
import $api from '@api';
|
||||
import { useToastStore } from "@s/toastStore";
|
||||
|
||||
@ -54,32 +54,6 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// 원래 비활성화된 사용자 목록 저장
|
||||
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 속성
|
||||
const sortedUserList = computed(() => {
|
||||
return [...userList.value].sort((a, b) => {
|
||||
@ -111,16 +85,10 @@ const fetchProjectParticipation = async () => {
|
||||
...user,
|
||||
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) {
|
||||
@ -136,9 +104,8 @@ const fetchUserProjectPeriods = async () => {
|
||||
};
|
||||
|
||||
// projctSeq가 변경될 때마다 참여 기간 데이터 다시 불러오기
|
||||
watch(() => props.projctSeq, async (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
await fetchProjectParticipation();
|
||||
watch(() => props.projctSeq, async (newVal) => {
|
||||
if (newVal) {
|
||||
await fetchUserProjectPeriods();
|
||||
}
|
||||
}, { immediate: true });
|
||||
@ -151,9 +118,6 @@ onMounted(async () => {
|
||||
if (props.projctSeq) {
|
||||
await fetchProjectParticipation();
|
||||
await fetchUserProjectPeriods();
|
||||
} else {
|
||||
// projctSeq가 없는 경우, 초기 상태 emit
|
||||
emitUserListUpdate();
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
@ -172,34 +136,55 @@ const initTooltips = () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 사용자 비활성화 상태 확인
|
||||
const isUserDisabled = (user) => {
|
||||
return props.projctSeq ? user.PROJCTYON === '0' : user.disabled;
|
||||
};
|
||||
|
||||
// 사용자 선택 토글 (즉시 API 호출 없이 상태만 변경)
|
||||
const toggleDisable = (index) => {
|
||||
// 클릭 시 활성화/비활성화 및 DB 업데이트
|
||||
// showOnlyActive가 true일 때는 toggleDisable 함수가 실행되지 않음
|
||||
const toggleDisable = async (index) => {
|
||||
if (props.showOnlyActive) return;
|
||||
const user = displayedUserList.value[index];
|
||||
|
||||
// 본인 계정이면 비활성화 방지
|
||||
if (props.role === 'vote') {
|
||||
if (user.MEMBERSEQ === userStore.userInfo.id) {
|
||||
if(user.MEMBERSEQ === userStore.userInfo.id) {
|
||||
toastStore.onToast('본인은 비활성화할 수 없습니다.', 'e');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (user) {
|
||||
// 프로젝트 수정 모드인 경우
|
||||
const newParticipationStatus = props.projctSeq
|
||||
? user.PROJCTYON === '1'
|
||||
: !user.disabled;
|
||||
if (props.projctSeq) {
|
||||
user.PROJCTYON = user.PROJCTYON === '1' ? '0' : '1';
|
||||
} else {
|
||||
user.disabled = !user.disabled;
|
||||
}
|
||||
const response = await $api.patch('project/updateYon', {
|
||||
memberSeq: user.MEMBERSEQ,
|
||||
projctSeq: props.projctSeq,
|
||||
projctYon: newParticipationStatus ? '0' : '1'
|
||||
});
|
||||
if (response.status === 200) {
|
||||
// 원래 userList에서 해당 사용자를 찾아 업데이트
|
||||
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
|
||||
if (originalIndex !== -1) {
|
||||
userList.value[originalIndex].PROJCTYON = newParticipationStatus ? '0' : '1';
|
||||
}
|
||||
|
||||
emitUserListUpdate();
|
||||
// 변경 후 프로젝트 목록 새로고침
|
||||
const projectStore = useProjectStore();
|
||||
await projectStore.getProjectList('', '', 'true');
|
||||
await projectStore.getMemberProjects();
|
||||
}
|
||||
} else {
|
||||
// 원래 userList에서 해당 사용자를 찾아 업데이트
|
||||
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
|
||||
if (originalIndex !== -1) {
|
||||
userList.value[originalIndex].disabled = newParticipationStatus;
|
||||
emitUserListUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -231,30 +216,5 @@ const getTooltipTitle = (user) => {
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<ul class="row gx-2 mb-0 list-inline ">
|
||||
<div class="">
|
||||
<ul class="row gx-2 mb-0 list-inline">
|
||||
<li
|
||||
v-for="(user, index) in sortedUserList"
|
||||
:key="index"
|
||||
@ -9,27 +10,22 @@
|
||||
data-bs-placement="top"
|
||||
:aria-label="user.MEMBERSEQ"
|
||||
>
|
||||
<div class="ratio ratio-1x1 mb-0 profile-list position-relative">
|
||||
<img
|
||||
v-if="user.MEMBERSEQ === employeeId"
|
||||
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)"
|
||||
alt="user"
|
||||
:style="getDynamicStyle(user)"
|
||||
@error="setDefaultImage"
|
||||
@load="showImage"
|
||||
/>
|
||||
</div>
|
||||
<span class="mt-2 text-sm-center d-block fs-6 remaining-vacation">
|
||||
{{ remainingVacationData[user.MEMBERSEQ] || 0 }}
|
||||
</span>
|
||||
<div class="ratio ratio-1x1 mb-0 profile-list">
|
||||
<img
|
||||
class="rounded-circle profile-img"
|
||||
:src="getUserProfileImage(user.MEMBERPRF)"
|
||||
alt="user"
|
||||
:style="getDynamicStyle(user)"
|
||||
@error="setDefaultImage"
|
||||
@load="showImage"
|
||||
/>
|
||||
</div>
|
||||
<span class="mt-2 text-sm-center d-block fs-6 remaining-vacation">
|
||||
{{ remainingVacationData[user.MEMBERSEQ] || 0 }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -99,10 +95,4 @@ borderStyle: "solid",
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crown-icon {
|
||||
width: 90%;
|
||||
height: 70%;
|
||||
z-index: 0;
|
||||
top: -7%
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="card mb-6" >
|
||||
<div class="card-body " :class="{'disabled-class': data.localVote.LOCVOTDDT && (topVoters.length == 1 || data.localVote.LOCVOTRES || voteResult == 0)}" v-if="!data.localVote.LOCVOTDEL" >
|
||||
<div class="card mb-6" :class="{'ps-none opacity-50': data.localVote.LOCVOTDDT && (topVoters.length == 1 || data.localVote.LOCVOTRES || voteResult == 0)}">
|
||||
<div class="card-body" v-if="!data.localVote.LOCVOTDEL" >
|
||||
<h5 class="card-title mb-1">
|
||||
<div class="list-unstyled users-list d-flex align-items-center gap-1">
|
||||
<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 h-px-40"
|
||||
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
|
||||
:style="`border-color: ${data.localVote.usercolor} !important;`"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
alt="user"
|
||||
/>
|
||||
<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">
|
||||
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
|
||||
<!-- 투표완료시 -->
|
||||
@ -28,12 +28,13 @@
|
||||
</button>
|
||||
<DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" />
|
||||
</div>
|
||||
<p v-if="data.localVote.LOCVOTDDT" class="btn-icon btn-danger rounded-2 pe-none"><i class="bx bx-power-off"></i></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</h5>
|
||||
<h5 class="mb-0">{{ data.localVote.LOCVOTTTL }}
|
||||
<h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}
|
||||
<i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i>
|
||||
</h5>
|
||||
<small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small>
|
||||
@ -52,14 +53,14 @@
|
||||
<small v-if="yesVotetotal != 0 && !data.localVote.LOCVOTDDT">투표 완료 : 종료시 투표 결과가 나타납니다.</small>
|
||||
<!-- 투표 결과 -->
|
||||
<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"/>
|
||||
</div>
|
||||
<!-- 투표완/미완 인원 -->
|
||||
<vote-user-list
|
||||
:data="data.voteMembers"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card-body disabled-class">
|
||||
<div v-else class="card-body">
|
||||
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
|
||||
삭제된 투표입니다.
|
||||
</div>
|
||||
@ -67,7 +68,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import EditBtn from '@c/button/EditBtn.vue';
|
||||
import DeleteBtn from '@c/button/DeleteBtn.vue';
|
||||
import voteUserList from '@c/voteboard/voteUserList.vue';
|
||||
@ -104,10 +105,9 @@ const topVoters = computed(() => {
|
||||
|
||||
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
|
||||
const userStore = useUserInfoStore();
|
||||
const offset = new Date().getTimezoneOffset() * 60000
|
||||
const today = new Date(Date.now() - offset);
|
||||
const currentDate = today.toISOString().substring(0,16);
|
||||
const voteEndDate = props.data.localVote.LOCVOTEDT.substring(0,16);
|
||||
const currentDate = new Date();
|
||||
const voteEndDate = new Date(props.data.localVote.formatted_LOCVOTEDT.replace(' ', 'T'));
|
||||
voteEndDate.setDate(voteEndDate.getDate() + 1);
|
||||
// 종료 여부 계산
|
||||
const isVoteEnded = computed(() => {
|
||||
return currentDate > voteEndDate;
|
||||
@ -115,21 +115,9 @@ const isVoteEnded = computed(() => {
|
||||
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','randomList','voteDelete','updateVote']);
|
||||
onMounted(() => {
|
||||
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) =>{
|
||||
emit('addContents',itemList,voteId)
|
||||
}
|
||||
@ -137,6 +125,7 @@ const checkedNames = (numList) =>{
|
||||
emit('checkedNames',numList);
|
||||
}
|
||||
const endBtn = (voteid) =>{
|
||||
voteEndDate.setTime(currentDate.getTime()); // 현재 날짜로 설정
|
||||
emit('endVoteId',voteid);
|
||||
}
|
||||
const voteDelete = (voteid) =>{
|
||||
|
||||
@ -17,24 +17,16 @@
|
||||
:is-alert="contentAlerts[index]"
|
||||
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" class="mb-1"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content align-items-center mt-3">
|
||||
<plus-btn @click="addItem" :disabled=" total >= 10" />
|
||||
<button class="btn btn-primary btn-icon m-1" @click="addContentSave(item.LOCVOTSEQ ,index)" :disabled="isSaveDisabled">
|
||||
<plus-btn @click="addItem" :disabled="total >= 10" />
|
||||
<button class="btn btn-primary btn-icon m-1" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
|
||||
<i class="bx bx-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -54,20 +46,20 @@ import SaveBtn from '@c/button/SaveBtn.vue'
|
||||
import FormInput from '@c/input/FormInput.vue';
|
||||
import voteCardCheckList from '@c/voteboard/voteCardCheckList.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
|
||||
import { voteCommon } from '@s/voteCommon';
|
||||
import DeleteBtn from "@c/button/DeleteBtn.vue";
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import router from '@/router';
|
||||
const toastStore = useToastStore();
|
||||
const contentAlerts = ref([false, false]);
|
||||
const contentAlerts = ref(false);
|
||||
const titleAlert = ref(false);
|
||||
const title = ref('');
|
||||
const rink = ref('');
|
||||
const maxLength = ref(2000);
|
||||
const { itemList, addItem, removeItem } = voteCommon(true);
|
||||
const total = computed(() => props.total + itemList.value.length);
|
||||
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({
|
||||
data: {
|
||||
@ -85,35 +77,12 @@ const props = defineProps({
|
||||
});
|
||||
const emit = defineEmits(['addContents','checkedNames']);
|
||||
//항목추가
|
||||
const addContentSave = (voteId,index) =>{
|
||||
let valid = true;
|
||||
const addContentSave = (voteId) =>{
|
||||
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);
|
||||
itemList.value = [{ content: "", url: "" }];
|
||||
removeItem();
|
||||
}
|
||||
emit('addContents',filteredItemList,voteId);
|
||||
itemList.value = [{ content: "", url: "" }];
|
||||
}
|
||||
|
||||
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 updateCheckedNames = (newValues) => {
|
||||
checkedNames.value = newValues;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="list-group">
|
||||
<label class="list-group-item" style="cursor: pointer;">
|
||||
<label class="list-group-item">
|
||||
<input
|
||||
class="form-check-input me-1"
|
||||
:name="data.LOCVOTSEQ"
|
||||
@ -10,12 +10,7 @@
|
||||
@change="handleChange"
|
||||
>
|
||||
{{ data.LOCVOTCON }}
|
||||
<a :style="{ maxWidth: (data.LOCVOTLIK.length * 1) + 'ch' }"
|
||||
v-if="data.LOCVOTLIK"
|
||||
:href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK"
|
||||
class="text-truncate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<a v-if="data.LOCVOTLIK" :href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK" class="d-block text-truncate" target="_blank" rel="noopener noreferrer">
|
||||
{{ data.LOCVOTLIK }}
|
||||
</a>
|
||||
</label>
|
||||
@ -56,15 +51,11 @@ const handleChange = (event) => {
|
||||
}
|
||||
emit("update:selectedValues", updatedValues);
|
||||
};
|
||||
const preventLinkMove = (event) =>{
|
||||
event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
a {
|
||||
display: block; /* 링크 텍스트에만 영역 적용 */
|
||||
max-width: 500px;
|
||||
max-width: 500px; /* 원하는 너비로 조정 */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<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">
|
||||
<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"/>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
:aria-label="data.MEMBERSEQ"
|
||||
:data-bs-original-title="getTooltipTitle(data)">
|
||||
<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}`"
|
||||
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`"
|
||||
:style="`border-color: ${data.usercolor} !important;`"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
alt="user"
|
||||
/>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<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">
|
||||
<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" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
:aria-label="data.MEMBERSEQ"
|
||||
:data-bs-original-title="getTooltipTitle(data)">
|
||||
<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}`"
|
||||
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`"
|
||||
:style="`border-color: ${data.usercolor} !important;`"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
alt="user"
|
||||
/>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
</span>
|
||||
<div class="timeline-event ps-1">
|
||||
<div class="timeline-header">
|
||||
<small class="text-primary text-uppercase">투표결과</small>
|
||||
<small class="text-primary text-uppercase">투표결과</small>
|
||||
</div>
|
||||
<h6 v-if="data" class="my-50">{{ data[0].LOCVOTCON }}</h6>
|
||||
<h6 v-if="randomResultNum" class="my-50">{{randomResultNum }}</h6>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="d-flex align-items-start mt-3">
|
||||
<!--투표한 사람 목록 -->
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<i class='bx bxs-user-check link-info fa-2x'></i>
|
||||
<i class='bx bxs-user-check link-info fa-3x'></i>
|
||||
<vote-complete-user-list
|
||||
v-for="(item, index) in voetedUsers"
|
||||
:key="index"
|
||||
@ -11,7 +11,7 @@
|
||||
</div>
|
||||
<!-- 투표안한 사람 목록 -->
|
||||
<div class="d-flex align-items-center gap-2 ms-auto flex-wrap">
|
||||
<i class='bx bxs-user-x link-danger fa-2x'></i>
|
||||
<i class='bx bxs-user-x link-danger fa-3x'></i>
|
||||
<vote-in-complete-user-list
|
||||
v-for="(item, index) in noVoetedUsers"
|
||||
:key="index"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<li class="card p-4 mb-2">
|
||||
<li class="card p-5 mb-2">
|
||||
<DictWrite
|
||||
v-if="writeStore.isItemActive(item.WRDDICSEQ)"
|
||||
@close="writeStore.closeAll();"
|
||||
@ -9,60 +9,63 @@
|
||||
:formValue="item.WRDDICCAT"
|
||||
:titleValue="item.WRDDICTTL"
|
||||
:contentValue="item.WRDDICCON"
|
||||
:isDisabled="true"
|
||||
:showEditBtn="true"
|
||||
@toggleEdit="toggleEdit"
|
||||
:isDisabled="userStore.user.role !== 'ROLE_ADMIN'"
|
||||
/>
|
||||
<div v-else>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="btn btn-primary pe-none m-2"
|
||||
style="writing-mode: horizontal-tb;">{{ item.category }}</span>
|
||||
{{ item.WRDDICTTL }}
|
||||
<input
|
||||
v-if="userStore.user.role == 'ROLE_ADMIN'"
|
||||
type="checkbox"
|
||||
class="form-check-input admin-chk"
|
||||
: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>
|
||||
<EditBtn
|
||||
@click="toggleEdit"
|
||||
:isToggleEnabled="true"
|
||||
:isActive="writeStore.isItemActive(item.WRDDICSEQ)"
|
||||
/>
|
||||
</div>
|
||||
<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 flex-wrap align-items-center me-4">
|
||||
<div class="avatar me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar object-fit-cover"
|
||||
:src="getProfileImage(item.author.profileImage)"
|
||||
alt="최초 작성자"
|
||||
:style="{ borderColor: item.author.color }"
|
||||
@error="setDefaultImage"
|
||||
/>
|
||||
<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="avatar avatar-sm me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar"
|
||||
:src="getProfileImage(item.author.profileImage)"
|
||||
alt="최초 작성자"
|
||||
:style="{ borderColor: item.author.color}"
|
||||
@error="setDefaultImage"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.author.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 작성자 (조건부) -->
|
||||
<div
|
||||
v-if="item.author.createdAt !== item.lastEditor.updatedAt"
|
||||
class="d-flex flex-wrap align-items-center"
|
||||
>
|
||||
<div class="avatar me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar object-fit-cover"
|
||||
:src="getProfileImage(item.lastEditor.profileImage)"
|
||||
alt="최근 작성자"
|
||||
:style="{ borderColor: item.lastEditor.color }"
|
||||
@error="setDefaultImage"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.lastEditor.updatedAt) }}</p>
|
||||
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.author.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.author.createdAt !== item.lastEditor.updatedAt"
|
||||
class="d-flex justify-content-between flex-wrap gap-2 mb-2"
|
||||
>
|
||||
<div class="d-flex flex-wrap align-items-center mb-50">
|
||||
<div class="avatar avatar-sm me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar"
|
||||
:src="getProfileImage(item.lastEditor.profileImage)"
|
||||
alt="최근 작성자"
|
||||
:style="{ borderColor: item.lastEditor.color}"
|
||||
@error="setDefaultImage"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.lastEditor.updatedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edit-btn">
|
||||
<EditBtn ref="writeButton" @click="writeStore.toggleItem(item.WRDDICSEQ)" :isToggleEnabled="true"
|
||||
:isActive="writeStore.activeItemId === item.WRDDICSEQ"/>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@ -70,7 +73,7 @@
|
||||
<script setup>
|
||||
import axios from "@api";
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { getCurrentInstance, nextTick, ref } from 'vue';
|
||||
import { getCurrentInstance, ref } from 'vue';
|
||||
import EditBtn from '@/components/button/EditBtn.vue';
|
||||
import $api from '@api';
|
||||
import DictWrite from './DictWrite.vue';
|
||||
@ -126,7 +129,7 @@ const editWord = (data) => {
|
||||
if (writeButton.value) {
|
||||
writeButton.value.resetButton();
|
||||
}
|
||||
emit('refreshWordList',data.category);
|
||||
emit('refreshWordList');
|
||||
} else {
|
||||
console.warn('⚠️ 서버 응답이 예상과 다릅니다:', res.data);
|
||||
toastStore.onToast('용어 수정이 정상적으로 처리되지 않았습니다.', 'e');
|
||||
@ -151,10 +154,11 @@ const setDefaultImage = (event) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@ -164,6 +168,7 @@ const toggleEdit = async () => {
|
||||
|
||||
.user-avatar {
|
||||
border: 3px solid;
|
||||
padding: 0.1px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
@ -178,21 +183,4 @@ const toggleEdit = async () => {
|
||||
top: -0.5rem;
|
||||
--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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="dataList.length > 0" >
|
||||
<FormSelect
|
||||
<div>
|
||||
<FormSelect class="me-5"
|
||||
name="cate"
|
||||
title="카테고리"
|
||||
:data="dataList"
|
||||
@ -9,35 +9,23 @@
|
||||
@change="onChange"
|
||||
:value="formValue"
|
||||
: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>
|
||||
</template>
|
||||
</FormSelect>
|
||||
</div>
|
||||
<div v-if="dataList.length === 0 || showInput">
|
||||
<FormInput
|
||||
class="justify-content-end"
|
||||
ref="categoryInputRef"
|
||||
title="새 카테고리"
|
||||
:isLabel="dataList.length === 0 ?true : false"
|
||||
name="새 카테고리"
|
||||
@update:modelValue="addCategory = $event"
|
||||
:is-cate-alert="addCategoryAlert"
|
||||
@focusout="handleCategoryFocusout(addCategory)"
|
||||
|
||||
/>
|
||||
<div v-if="!isDisabled" class="add-btn">
|
||||
<PlusBtn @click="toggleInput"/>
|
||||
</div>
|
||||
</div>
|
||||
<FormInput
|
||||
|
||||
<div v-if="showInput">
|
||||
<FormInput class="me-5"
|
||||
ref="categoryInputRef"
|
||||
title="새 카테고리"
|
||||
name="새 카테고리"
|
||||
@update:modelValue="addCategory = $event"
|
||||
:is-cate-alert="addCategoryAlert"
|
||||
@focusout="handleCategoryFocusout(addCategory)"
|
||||
/>
|
||||
</div>
|
||||
<FormInput class="me-5"
|
||||
title="용어"
|
||||
type="text"
|
||||
name="word"
|
||||
@ -49,9 +37,9 @@
|
||||
@keyup="ValidHandler('title')"
|
||||
/>
|
||||
<div>
|
||||
<QEditor class="q-editor-container" @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/>
|
||||
<QEditor class="" @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@ -65,8 +53,37 @@ import QEditor from '@/components/editor/QEditor.vue';
|
||||
import FormInput from '@/components/input/FormInput.vue';
|
||||
import FormSelect from '@/components/input/FormSelect.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({
|
||||
dataList: {
|
||||
type: Array,
|
||||
@ -86,45 +103,9 @@ const props = defineProps({
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
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);
|
||||
|
||||
@ -135,7 +116,6 @@ const toggleInput = () => {
|
||||
|
||||
const onChange = (newValue) => {
|
||||
selectCategory.value = newValue.target.value;
|
||||
changed.value = true;
|
||||
};
|
||||
|
||||
const ValidHandler = (field) => {
|
||||
@ -144,22 +124,11 @@ const ValidHandler = (field) => {
|
||||
}
|
||||
if(field == 'content'){
|
||||
wordContentAlert.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
const handleContentUpdate = (newContent) => {
|
||||
content.value = newContent;
|
||||
|
||||
const oldContent = typeof props.contentValue === 'string'? JSON.parse(props.contentValue) : props.contentValue;
|
||||
const newContentOps = newContent?.ops || [];
|
||||
|
||||
const oldContentJson = JSON.stringify(oldContent);
|
||||
const newContentJson = JSON.stringify(newContentOps);
|
||||
|
||||
// 기존 데이터와 새 데이터가 다를 경우 changed = true;
|
||||
changed.value = oldContentJson !== newContentJson;
|
||||
|
||||
ValidHandler("content"); // 유효성 검사 실행
|
||||
};
|
||||
|
||||
@ -232,11 +201,20 @@ const handleCategoryFocusout = (value) => {
|
||||
};
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.q-editor-container * {
|
||||
max-width: 100% !important;
|
||||
word-break: break-all !important;
|
||||
box-sizing: border-box;
|
||||
white-space: normal !important;
|
||||
|
||||
<style scoped>
|
||||
.dict-w {
|
||||
width: 83%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn-margin {
|
||||
margin-top: 2.5rem
|
||||
}
|
||||
}
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
top: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -80,12 +80,12 @@
|
||||
<div class="text-truncate">Authorization</div>
|
||||
</RouterLink>
|
||||
</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>
|
||||
<!-- <li class="menu-item" :class="$route.path.includes('/sample') ? 'active' : ''">
|
||||
<RouterLink class="menu-link" to="/sample"> <i class="bi "></i>
|
||||
<i class="menu-icon tf-icons bx bx-calendar"></i>
|
||||
<div class="text-truncate">Sample</div>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</li> -->
|
||||
</ul>
|
||||
</aside>
|
||||
<!-- / Menu -->
|
||||
@ -94,10 +94,9 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useUserInfoStore } from '@s/useUserInfoStore';
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
const userStore = useUserInfoStore();
|
||||
const allowedUserId = 1; // 특정 ID (변경필요!!)
|
||||
const allowedUserId = 26; // 특정 ID (변경필요!!)
|
||||
|
||||
const userId = computed(() => userStore.user?.id ?? null);
|
||||
</script>
|
||||
|
||||
@ -6,68 +6,156 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 날씨 정보 영역 -->
|
||||
<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">
|
||||
<div class="navbar-nav-right d-flex align-items-center" id="navbar-collapse">
|
||||
<ul class="navbar-nav flex-row align-items-center ms-auto">
|
||||
<select class="form-select py-1 cursor-pointer" id="name" v-model="selectedProject" @change="updateSelectedProject">
|
||||
<select class="form-select py-1" id="name" v-model="selectedProject" @change="updateSelectedProject">
|
||||
<!-- 내가 참여하고 있는 프로젝트 그룹 -->
|
||||
<option v-for="item in myActiveProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
|
||||
<option v-for="item in myProjects" :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">
|
||||
<!-- 전체 프로젝트 그룹 -->
|
||||
<option v-for="item in otherProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
|
||||
{{ item.PROJCTNAM }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<i class="cursor-pointer p-2"></i>
|
||||
<!-- <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> -->
|
||||
|
||||
<i class="bx bx-bell bx-md bx-log-out cursor-pointer p-3" @click="handleLogout"></i>
|
||||
|
||||
<!-- Notification -->
|
||||
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-2 p-0">
|
||||
<a
|
||||
class="nav-link dropdown-toggle hide-arrow p-0"
|
||||
href="javascript:void(0);"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false"
|
||||
class="nav-link dropdown-toggle hide-arrow p-0"
|
||||
href="javascript:void(0);"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="position-relative">
|
||||
<i class="bx bx-bell bx-md"></i>
|
||||
<!-- 알림이 있을 경우에만 뱃지를 표시 -->
|
||||
<span
|
||||
v-if="notificationCount > 0"
|
||||
class="badge rounded-pill bg-danger badge-dot badge-notifications border"
|
||||
></span>
|
||||
</span>
|
||||
<span class="position-relative">
|
||||
<i class="bx bx-bell bx-md"></i>
|
||||
<span class="badge rounded-pill bg-danger badge-dot badge-notifications border"></span>
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end p-0">
|
||||
<li class="dropdown-notifications-list scrollable-container p-3">
|
||||
<!-- 알림이 없으면 "알림이 없습니다." 메시지 표시 -->
|
||||
<div v-if="notificationCount === 0">
|
||||
알림이 없습니다.
|
||||
</div>
|
||||
<!-- 알림이 있을 때 목록 렌더링-->
|
||||
<div v-else>
|
||||
<ul>
|
||||
<li v-for="notification in notifications" :key="notification.id">
|
||||
{{ notification.text }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li class="dropdown-menu-header border-bottom">
|
||||
<div class="dropdown-header d-flex align-items-center py-3">
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="border-top">
|
||||
<div class="d-grid p-4">
|
||||
<a class="btn btn-primary btn-sm d-flex" href="javascript:void(0);">
|
||||
<small class="align-middle">View all notifications</small>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<!--/ Notification -->
|
||||
@ -78,15 +166,32 @@
|
||||
v-if="user"
|
||||
:src="`${baseUrl}upload/img/profile/${user.profile}`"
|
||||
alt="Profile Image"
|
||||
class="w-px-40 h-px-40 rounded-circle border border-3 object-fit-cover"
|
||||
:style="`border-color: ${user.usercolor} !important;`"
|
||||
class="w-px-40 h-px-40 rounded-circle"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
/>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" @click="goToMyPage">
|
||||
<i class="bx bx-user bx-md me-3"></i><span>My Page</span>
|
||||
<a class="dropdown-item" href="pages-account-settings-account.html">
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
@ -94,29 +199,57 @@
|
||||
<i class="bx bx-cog bx-md me-3"></i><span>Settings</span>
|
||||
</a>
|
||||
</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>
|
||||
<div class="dropdown-divider my-1"></div>
|
||||
</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>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<!--/ User -->
|
||||
</ul>
|
||||
</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>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useAuthStore } from '@s/useAuthStore';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
import { useProjectStore } from '@/stores/useProjectStore';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useThemeStore } from '@s/darkmode';
|
||||
import { useWeatherStore } from '@/stores/useWeatherStore';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from '@api';
|
||||
import $api from '@api';
|
||||
|
||||
const baseUrl = import.meta.env.VITE_SERVER;
|
||||
|
||||
@ -124,47 +257,34 @@
|
||||
const userStore = useUserInfoStore();
|
||||
const projectStore = useProjectStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const weatherStore = useWeatherStore();
|
||||
|
||||
const user = ref(null);
|
||||
const selectedProject = ref(null);
|
||||
const weather = ref({});
|
||||
const dailyWeatherList = ref([]);
|
||||
const notifications = ref([]);
|
||||
const notificationCount = ref(0);
|
||||
|
||||
const weatherReady = computed(() => {
|
||||
return (
|
||||
weather.value &&
|
||||
weather.value.tempMin !== null &&
|
||||
weather.value.tempMax !== null &&
|
||||
!!weather.value.description
|
||||
);
|
||||
// 내가 참여하고 있는 프로젝트 목록
|
||||
const myProjects = computed(() => {
|
||||
return projectStore.memberProjectList || [];
|
||||
});
|
||||
|
||||
// 내가 참여하고 있는 진행 중인 프로젝트 목록
|
||||
const myActiveProjects = computed(() => {
|
||||
return projectStore.activeMemberProjectList || [];
|
||||
});
|
||||
// 내가 참여하고 있지 않은 프로젝트 목록
|
||||
const otherProjects = computed(() => {
|
||||
if (!projectStore.projectList || !projectStore.memberProjectList) return [];
|
||||
|
||||
// 내가 참여하고 있지 않은 진행 중인 프로젝트 목록
|
||||
const otherActiveProjects = computed(() => {
|
||||
if (!projectStore.activeProjectList) return [];
|
||||
// 내 프로젝트 ID 목록
|
||||
const myProjectIds = projectStore.memberProjectList.map(p => p.PROJCTSEQ);
|
||||
|
||||
// 내 프로젝트 ID 목록
|
||||
const myProjectIds = myActiveProjects.value.map(p => p.PROJCTSEQ);
|
||||
|
||||
// 내 프로젝트가 아닌 프로젝트만 필터링
|
||||
return projectStore.activeProjectList.filter(p => !myProjectIds.includes(p.PROJCTSEQ));
|
||||
// 내 프로젝트가 아닌 프로젝트만 필터링
|
||||
return projectStore.projectList.filter(p => !myProjectIds.includes(p.PROJCTSEQ));
|
||||
});
|
||||
|
||||
// 프로젝트 선택 변경 시 스토어에 저장
|
||||
const updateSelectedProject = () => {
|
||||
if (!selectedProject.value) return;
|
||||
|
||||
// 모든 진행 중인 프로젝트 리스트에서 선택된 프로젝트 찾기
|
||||
let selected = projectStore.activeProjectList.find(project => project.PROJCTSEQ === selectedProject.value);
|
||||
// 전체 프로젝트 리스트에서 선택된 프로젝트 찾기
|
||||
const selected = projectStore.projectList.find(
|
||||
project => project.PROJCTSEQ === selectedProject.value
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
projectStore.setSelectedProject(selected);
|
||||
@ -172,40 +292,15 @@
|
||||
};
|
||||
|
||||
// 선택된 프로젝트 변경 감지
|
||||
watch(
|
||||
() => 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`;
|
||||
watch(() => projectStore.selectedProject, (newProject) => {
|
||||
if (newProject) {
|
||||
selectedProject.value = newProject.PROJCTSEQ;
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const goToMyPage = () => {
|
||||
router.push('/mypage');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// if (isDarkMode) {
|
||||
// switchToDarkMode();
|
||||
@ -216,70 +311,31 @@
|
||||
await userStore.userInfo();
|
||||
user.value = userStore.user;
|
||||
|
||||
await projectStore.loadAllProjectLists();
|
||||
if (authStore.isAuthenticated) {
|
||||
await projectStore.getProjectList('', '', 'true');
|
||||
}
|
||||
|
||||
// 사용자가 참여하고 있는 프로젝트 목록
|
||||
await projectStore.getMemberProjects();
|
||||
|
||||
if (myActiveProjects.value.length > 0) {
|
||||
const firstProject = myActiveProjects.value[0];
|
||||
selectedProject.value = firstProject.PROJCTSEQ;
|
||||
projectStore.setSelectedProject(firstProject);
|
||||
// 저장된 선택 프로젝트
|
||||
const storedProject = projectStore.getSelectedProject();
|
||||
if (storedProject) {
|
||||
selectedProject.value = storedProject.PROJCTSEQ;
|
||||
} else if (projectStore.memberProjectList.length > 0) {
|
||||
// 저장된 선택 프로젝트가 없으면 첫 번째 참여 프로젝트 선택
|
||||
selectedProject.value = projectStore.memberProjectList[0].PROJCTSEQ;
|
||||
projectStore.setSelectedProject(projectStore.memberProjectList[0]);
|
||||
}
|
||||
|
||||
// 로그인 페이지가 아닐 때만 날씨 정보를 가져오도록
|
||||
if (route.name !== 'login' && route.name !== undefined) {
|
||||
// 날씨 정보 갱신
|
||||
await weatherStore.getWeatherInfoWithCache();
|
||||
weather.value = weatherStore.weather; // 오늘 날씨
|
||||
dailyWeatherList.value = weatherStore.dailyWeatherList; // 주간 날씨
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
</script>
|
||||
<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>
|
||||
|
||||
44
src/main.js
@ -1,31 +1,31 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import piniaPersist from 'pinia-plugin-persist';
|
||||
import App from './App.vue';
|
||||
import router from '@/router';
|
||||
import dayjs from '@p/dayjs';
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPersist from 'pinia-plugin-persist'
|
||||
import App from './App.vue'
|
||||
import router from '@/router'
|
||||
import dayjs from '@p/dayjs'
|
||||
import ToastModal from '@c/modal/ToastModal.vue';
|
||||
import common from '@/common/common.js';
|
||||
import { useKakao } from 'vue3-kakao-maps/@utils';
|
||||
import common from '@/common/common.js'
|
||||
import { useKakao } from 'vue3-kakao-maps/@utils'
|
||||
|
||||
const pinia = createPinia();
|
||||
pinia.use(piniaPersist);
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPersist)
|
||||
|
||||
const kakaoApiKey = import.meta.env.VITE_KAKAO_MAP_KEY;
|
||||
|
||||
useKakao(kakaoApiKey, ['services']);
|
||||
useKakao(kakaoApiKey, ['services'])
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router).use(pinia).use(common).use(dayjs).component('ToastModal', ToastModal);
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
.use(pinia)
|
||||
.use(common)
|
||||
.use(dayjs)
|
||||
.component('ToastModal',ToastModal)
|
||||
.mount('#app')
|
||||
|
||||
// 라우트 로딩 후 앱 마우트
|
||||
router.isReady().then(() => {
|
||||
app.mount('#app');
|
||||
});
|
||||
|
||||
if (import.meta.env.MODE === 'prod') {
|
||||
if (import.meta.env.MODE === "prod") {
|
||||
const console = window.console || {};
|
||||
console.log = function no_console() {}; // console log 막기
|
||||
console.warn = function no_console() {}; // console warning 막기
|
||||
console.error = function () {}; // console error 막기
|
||||
console.log = function no_console() { }; // console log 막기
|
||||
console.warn = function no_console() { }; // console warning 막기
|
||||
console.error = function () { }; // console error 막기
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@s/useAuthStore';
|
||||
import { useUserInfoStore } from '@s/useUserInfoStore';
|
||||
|
||||
@ -6,9 +6,9 @@ import { useUserInfoStore } from '@s/useUserInfoStore';
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
name: "Home",
|
||||
component: () => import('@v/MainView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/board',
|
||||
@ -18,36 +18,28 @@ const routes = [
|
||||
{
|
||||
path: '',
|
||||
name: 'BoardList',
|
||||
component: () => import('@v/board/BoardList.vue'),
|
||||
component: () => import('@v/board/BoardList.vue')
|
||||
},
|
||||
{
|
||||
path: 'write',
|
||||
name: 'BoardWrite',
|
||||
component: () => import('@v/board/BoardWrite.vue'),
|
||||
component: () => import('@v/board/BoardWrite.vue')
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'BoardDetail',
|
||||
component: () => import('@v/board/BoardView.vue'),
|
||||
component: () => import('@v/board/BoardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit/:id',
|
||||
name: 'BoardEdit',
|
||||
component: () => import('@v/board/BoardEdit.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/mypage',
|
||||
name: 'MyPage',
|
||||
component: () => import('@v/mypage/MyPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
component: () => import('@v/board/BoardEdit.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/wordDict',
|
||||
name: 'WordDict',
|
||||
component: () => import('@v/wordDict/wordDict.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@ -69,81 +61,57 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/vacation',
|
||||
name: 'Vacation',
|
||||
component: () => import('@v/vacation/VacationManagement.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/voteboard',
|
||||
name: 'VoteBoard',
|
||||
component: () => import('@v/voteboard/TheVoteBoard.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'VoteBoardList',
|
||||
component: () => import('@v/voteboard/voteBoardList.vue'),
|
||||
component: () => import('@v/voteboard/voteBoardList.vue')
|
||||
},
|
||||
{
|
||||
path: 'write',
|
||||
name: 'VoteboardWrite',
|
||||
component: () => import('@v/voteboard/voteboardWrite.vue'),
|
||||
component: () => import('@v/voteboard/voteboardWrite.vue')
|
||||
},
|
||||
],
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/projectlist',
|
||||
name: 'Projectlist',
|
||||
component: () => import('@v/projectlist/TheProjectList.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/commuters',
|
||||
name: 'Commuters',
|
||||
component: () => import('@v/commuters/TheCommuters.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/authorization',
|
||||
name: 'Authorization',
|
||||
component: () => import('@v/admin/TheAuthorization.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
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: '/people',
|
||||
name: 'people',
|
||||
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' },
|
||||
path: "/:anything(.*)",
|
||||
name: "Error404", component: () => import('@v/error/Error404.vue'), meta: {layout: 'NoLayout'}
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: routes,
|
||||
});
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
await authStore.checkAuthStatus(); // 로그인 상태 확인
|
||||
const allowedUserId = 1; // 특정 ID (변경필요!!)
|
||||
const allowedUserId = 26; // 특정 ID (변경필요!!)
|
||||
const userStore = useUserInfoStore();
|
||||
const userId = userStore.user?.id ?? null;
|
||||
|
||||
@ -153,8 +121,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
|
||||
// Authorization 페이지는 ID가 26이 아니면 접근 차단
|
||||
if (to.path === '/authorization' && userId !== allowedUserId) {
|
||||
return next();
|
||||
if (to.path === "/authorization" && userId !== allowedUserId) {
|
||||
return next("/");
|
||||
}
|
||||
|
||||
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
|
||||
@ -180,7 +148,7 @@ axios.interceptors.response.use(
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
@ -11,16 +11,13 @@ import $api from '@api';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const projectList = ref([]); // 모든 프로젝트 (종료된 프로젝트 포함)
|
||||
const activeProjectList = ref([]); // 진행 중인 프로젝트만 (종료된 프로젝트 제외)
|
||||
const memberProjectList = ref([]); // 사용자가 속한 프로젝트
|
||||
const activeMemberProjectList = ref([]); // 사용자가 속한 진행 중인 프로젝트
|
||||
const projectList = ref([]);
|
||||
const memberProjectList = ref([]);
|
||||
const selectedProject = ref(null);
|
||||
const userStore = useUserInfoStore();
|
||||
|
||||
// 전체 프로젝트 가져오기 (종료된 프로젝트 포함 여부에 따라 다른 배열에 저장)
|
||||
const getProjectList = async (searchText = '', selectedYear = '', excludeEnded = 'false') => {
|
||||
if (!userStore.user) return;
|
||||
// 전체 프로젝트 가져오기
|
||||
const getProjectList = async (searchText = '', selectedYear = '', excludeEnded = '') => {
|
||||
const res = await $api.get('project/select', {
|
||||
params: {
|
||||
searchKeyword: searchText || '',
|
||||
@ -28,22 +25,7 @@ export const useProjectStore = defineStore('project', () => {
|
||||
excludeEnded: excludeEnded
|
||||
},
|
||||
});
|
||||
|
||||
if (excludeEnded === 'true') {
|
||||
// 종료되지 않은 프로젝트만 저장
|
||||
activeProjectList.value = res.data.data.projectList;
|
||||
} else {
|
||||
// 모든 프로젝트 저장 (종료된 프로젝트 포함)
|
||||
projectList.value = res.data.data.projectList;
|
||||
}
|
||||
};
|
||||
|
||||
// 모든 프로젝트 목록 로드 (종료 여부 구분하여 모두 로드)
|
||||
const loadAllProjectLists = async (searchText = '', selectedYear = '') => {
|
||||
// 진행 중인 프로젝트 로드
|
||||
await getProjectList(searchText, selectedYear, 'true');
|
||||
// 모든 프로젝트 로드 (종료된 프로젝트 포함)
|
||||
await getProjectList(searchText, selectedYear, 'false');
|
||||
projectList.value = res.data.data.projectList;
|
||||
};
|
||||
|
||||
// 사용자가 속한 프로젝트 목록 가져오기
|
||||
@ -51,16 +33,10 @@ export const useProjectStore = defineStore('project', () => {
|
||||
if (!userStore.user) return; // 로그인한 사용자 확인
|
||||
|
||||
const res = await $api.get(`project/${userStore.user.id}`);
|
||||
const allMemberProjects = res.data.data;
|
||||
memberProjectList.value = allMemberProjects;
|
||||
memberProjectList.value = res.data.data;
|
||||
|
||||
// 사용자가 속한 프로젝트 중 진행 중인 프로젝트만 필터링
|
||||
activeMemberProjectList.value = allMemberProjects.filter(project =>
|
||||
!project.ENDYN || project.ENDYN === 'N'
|
||||
);
|
||||
|
||||
if (activeMemberProjectList.value.length > 0 && !selectedProject.value) {
|
||||
setSelectedProject(activeMemberProjectList.value[0]);
|
||||
if (memberProjectList.value.length > 0 && !selectedProject.value) {
|
||||
setSelectedProject(memberProjectList.value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
@ -84,19 +60,19 @@ export const useProjectStore = defineStore('project', () => {
|
||||
};
|
||||
|
||||
// 프로젝트 리스트가 변경될 때 자동으로 반응
|
||||
watch(activeProjectList, (newList) => {
|
||||
watch(projectList, (newList) => {
|
||||
// 선택된 프로젝트가 없고 목록이 있는 경우
|
||||
if (!selectedProject.value && newList.length > 0) {
|
||||
// 사용자가 속한 프로젝트가 있는지 먼저 확인
|
||||
if (activeMemberProjectList.value.length > 0) {
|
||||
setSelectedProject(activeMemberProjectList.value[0]);
|
||||
if (memberProjectList.value.length > 0) {
|
||||
setSelectedProject(memberProjectList.value[0]);
|
||||
} else {
|
||||
setSelectedProject(newList[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(activeMemberProjectList, (newList) => {
|
||||
watch(memberProjectList, (newList) => {
|
||||
if (newList.length > 0) {
|
||||
// 현재 선택된 프로젝트가 없는 경우 첫 번째 항목 선택
|
||||
if (!selectedProject.value) {
|
||||
@ -112,16 +88,14 @@ export const useProjectStore = defineStore('project', () => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
projectList, // 종료된 프로젝트 포함한 모든 프로젝트
|
||||
activeProjectList, // 진행 중인 프로젝트만
|
||||
memberProjectList, // 사용자가 속한 모든 프로젝트
|
||||
activeMemberProjectList, // 사용자가 속한 진행 중인 프로젝트
|
||||
projectList,
|
||||
selectedProject,
|
||||
getProjectList,
|
||||
loadAllProjectLists, // 모든 프로젝트 목록 한번에 로드
|
||||
memberProjectList,
|
||||
getMemberProjects,
|
||||
setSelectedProject,
|
||||
getSelectedProject,
|
||||
getSelectedProject
|
||||
};
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
@ -1,35 +1,26 @@
|
||||
<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 class="container text-center flex-grow-1 container-p-y">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex flex-column">
|
||||
<h3>관리자 권한 부여</h3>
|
||||
<div class="user-card-container">
|
||||
<div v-for="user in users" :key="user.id" class="user-card">
|
||||
<!-- 프로필 사진 -->
|
||||
<img :src="getProfileImage(user.photo)" class="profile-img" alt="프로필 사진" @error="setDefaultImage" />
|
||||
<!-- 사용자 정보 -->
|
||||
<div class="user-info">
|
||||
<h5>{{ user.name }}</h5>
|
||||
</div>
|
||||
<!-- 권한 토글 버튼 -->
|
||||
<label class="switch">
|
||||
<input type="checkbox" :checked="user.isAdmin" @change="toggleAdmin(user)" />
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 권한 토글 버튼 (기본 동작 막고 클릭시 직접 토글 처리) -->
|
||||
<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>
|
||||
@ -41,80 +32,62 @@ 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("올바른 데이터 형식이 아닙니다.");
|
||||
try {
|
||||
const response = await axios.get('admin/users'); // API 경로 확인 필요
|
||||
|
||||
// API 응답 구조 확인 후 데이터가 배열인지 체크
|
||||
if (!response.data || !Array.isArray(response.data.data)) {
|
||||
throw new Error("올바른 데이터 형식이 아닙니다.");
|
||||
}
|
||||
|
||||
// 데이터 매핑 (올바른 형식으로 변환)
|
||||
users.value = response.data.data.map(user => ({
|
||||
id: user.MEMBERSEQ,
|
||||
name: user.MEMBERNAM,
|
||||
photo: user.MEMBERPRF ? `${baseUrl}upload/img/profile/${user.MEMBERPRF}` : defaultProfile,
|
||||
color: user.MEMBERCOL,
|
||||
isAdmin: user.MEMBERROL === 'ROLE_ADMIN',
|
||||
}));
|
||||
} catch (error) {
|
||||
toastStore.onToast('사용자 목록을 불러오지 못했습니다.', 'e');
|
||||
}
|
||||
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;
|
||||
return photo || defaultProfile;
|
||||
}
|
||||
|
||||
// 이미지 로드 오류 시 기본 이미지 설정
|
||||
function setDefaultImage(event) {
|
||||
event.target.src = defaultProfile;
|
||||
event.target.src = defaultProfile;
|
||||
}
|
||||
|
||||
// 권한 토글 시 기본 동작 막고 직접 제어하는 함수
|
||||
async function handleToggle(event, user) {
|
||||
// Prevent the default checkbox toggle behavior
|
||||
event.preventDefault();
|
||||
// 관리자 권한 토글 함수
|
||||
async function toggleAdmin(user) {
|
||||
const requestData = {
|
||||
id: user.id,
|
||||
role: user.isAdmin ? 'MEMBER' : 'ADMIN'
|
||||
};
|
||||
try {
|
||||
const response = await axios.put('admin/role', requestData);
|
||||
|
||||
// 저장: 현재 상태를 기반으로 변경 요청 (체크박스는 아직 변하지 않았음)
|
||||
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('권한 변경 실패');
|
||||
if (response.status === 200) {
|
||||
user.isAdmin = !user.isAdmin;
|
||||
toastStore.onToast(`'${user.name}'의 권한이 '${requestData.role}'(으)로 변경되었습니다.`, 's');
|
||||
} else {
|
||||
throw new Error('권한 변경 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
toastStore.onToast('권한 변경에 실패했습니다.', 'e');
|
||||
}
|
||||
} 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>
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
title="첨부파일"
|
||||
name="files"
|
||||
:is-alert="attachFilesAlert"
|
||||
:key="autoIncrement"
|
||||
@update:data="handleFileUpload"
|
||||
@update:isValid="isFileValid = $event"
|
||||
/>
|
||||
@ -56,10 +55,8 @@
|
||||
<div class="col-md-12">
|
||||
<QEditor
|
||||
v-if="contentLoaded"
|
||||
@update:data="handleEditorDataUpdate"
|
||||
@update:data="content = $event"
|
||||
@update:imageUrls="imageUrls = $event"
|
||||
@update:uploadedImgList="handleUpdateEditorImg"
|
||||
@update:deleteImgIndexList="handleDeleteEditorImg"
|
||||
:initialData="content"
|
||||
/>
|
||||
</div>
|
||||
@ -71,7 +68,7 @@
|
||||
<button type="button" class="btn btn-info right" @click="goBack">
|
||||
<i class="bx bx-left-arrow-alt"></i>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@ -90,7 +87,6 @@
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useBoardAccessStore } from '@s/useBoardAccessStore';
|
||||
import axios from '@api';
|
||||
import Quill from 'quill';
|
||||
|
||||
// 공통
|
||||
const $common = inject('common');
|
||||
@ -99,8 +95,7 @@
|
||||
|
||||
// 상태 변수
|
||||
const title = ref('');
|
||||
const content = ref({ ops: [] });
|
||||
const autoIncrement = ref(0);
|
||||
const content = ref('');
|
||||
|
||||
// 경고 상태
|
||||
const titleAlert = ref(false);
|
||||
@ -120,156 +115,60 @@
|
||||
const attachFilesAlert = ref(false);
|
||||
const isFileValid = ref(true);
|
||||
const delFileIdx = ref([]); // 제외할 기존 첨부파일 ID
|
||||
const editorUploadedImgList = 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 additionalFiles = ref([]); // 새로 추가할 첨부파일
|
||||
|
||||
// 게시물 데이터 로드
|
||||
const fetchBoardDetails = async () => {
|
||||
// 수정 데이터 전송
|
||||
let password = accessStore.password;
|
||||
const params = {
|
||||
password: `${password}` || '',
|
||||
};
|
||||
//const response = await axios.get(`board/${currentBoardId.value}`);
|
||||
const { data } = await axios.post(`board/${currentBoardId.value}`, params);
|
||||
|
||||
if (data.code !== 200) {
|
||||
//toastStore.onToast(data.message, 'e');
|
||||
alert(data.message, 'e');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
const boardData = data.data;
|
||||
// 기존 첨부파일 추가
|
||||
if (boardData.hasAttachment && boardData.attachments.length > 0) {
|
||||
const formatted = addDisplayFileName([...boardData.attachments]);
|
||||
attachFiles.value = formatted;
|
||||
originalFiles.value = formatted;
|
||||
attachFiles.value = addDisplayFileName([...boardData.attachments]);
|
||||
}
|
||||
|
||||
// 데이터 설정
|
||||
title.value = boardData.title || '제목 없음';
|
||||
content.value = boardData.content || { ops: [] };
|
||||
originalTitle.value = title.value;
|
||||
originalContent.value = structuredClone(boardData.content);
|
||||
contentInitialized.value = true;
|
||||
content.value = boardData.content || '내용 없음';
|
||||
contentLoaded.value = true;
|
||||
};
|
||||
|
||||
const handleUpdateEditorImg = item => {
|
||||
editorUploadedImgList.value = item;
|
||||
};
|
||||
|
||||
const handleDeleteEditorImg = item => {
|
||||
editorDeleteImgList.value = item;
|
||||
};
|
||||
|
||||
// 기존 첨부파일명을 노출
|
||||
const addDisplayFileName = fileInfos =>
|
||||
fileInfos.map(file => ({
|
||||
...file,
|
||||
name: `${file.originalName}.${file.extension}`
|
||||
name: `${file.originalName}.${file.extension}`,
|
||||
}));
|
||||
|
||||
// 상세 페이지 이동
|
||||
// 목록 페이지로 이동
|
||||
const goList = () => {
|
||||
accessStore.$reset();
|
||||
router.back();
|
||||
router.push('/board');
|
||||
};
|
||||
|
||||
// 전 페이지 이동
|
||||
// 전 페이지로 이동
|
||||
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 = () => {
|
||||
contentAlert.value = isNotValidContent(content.value);
|
||||
contentAlert.value = $common.isNotValidContent(content);
|
||||
titleAlert.value = $common.isNotValidInput(title.value);
|
||||
|
||||
if (titleAlert.value || contentAlert.value || !isFileValid.value) {
|
||||
if (titleAlert.value) {
|
||||
title.value = '';
|
||||
@ -285,6 +184,7 @@
|
||||
|
||||
const handleFileUpload = files => {
|
||||
const validFiles = files.filter(file => file.size <= maxSize);
|
||||
|
||||
if (files.some(file => file.size > maxSize)) {
|
||||
fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.';
|
||||
return;
|
||||
@ -295,16 +195,15 @@
|
||||
}
|
||||
fileError.value = '';
|
||||
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
|
||||
autoIncrement.value++;
|
||||
};
|
||||
|
||||
const removeFile = (index, file) => {
|
||||
if (file.id) delFileIdx.value.push(file.id);
|
||||
|
||||
attachFiles.value.splice(index, 1);
|
||||
if (attachFiles.value.length <= maxFiles) {
|
||||
fileError.value = '';
|
||||
}
|
||||
autoIncrement.value++;
|
||||
};
|
||||
|
||||
watch(attachFiles, () => {
|
||||
@ -317,41 +216,50 @@
|
||||
};
|
||||
////////////////// fileSection[E] ////////////////////
|
||||
|
||||
/** content 변경 감지 (deep 옵션 추가) */
|
||||
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
|
||||
watch(content, () => {
|
||||
contentAlert.value = isNotValidContent(content.value);
|
||||
}, { deep: true });
|
||||
contentAlert.value = $common.isNotValidContent(content);
|
||||
});
|
||||
|
||||
// 글 제목 유효성 검사
|
||||
// 글 제목 유효성
|
||||
const validateTitle = () => {
|
||||
titleAlert.value = title.value.trim().length === 0;
|
||||
};
|
||||
|
||||
// 게시물 수정 함수
|
||||
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
|
||||
// watch(content, () => {
|
||||
// contentAlert.value = $common.isNotValidContent(content);
|
||||
// });
|
||||
|
||||
// 게시물 수정
|
||||
const updateBoard = async () => {
|
||||
if (checkValidation()) return;
|
||||
|
||||
// 수정 데이터 전송
|
||||
const boardData = {
|
||||
LOCBRDTTL: title.value.trim(),
|
||||
LOCBRDCON: JSON.stringify(content.value),
|
||||
LOCBRDSEQ: currentBoardId.value
|
||||
LOCBRDSEQ: currentBoardId.value,
|
||||
};
|
||||
|
||||
// 업로드 된 첨부파일의 삭제목록
|
||||
if (delFileIdx.value && delFileIdx.value.length > 0) {
|
||||
boardData.delFileIdx = [...delFileIdx.value];
|
||||
}
|
||||
if (editorUploadedImgList.value && editorUploadedImgList.value.length > 0) {
|
||||
boardData.editorUploadedImgList = [...editorUploadedImgList.value];
|
||||
}
|
||||
if (editorDeleteImgList.value && editorDeleteImgList.value.length > 0) {
|
||||
boardData.editorDeleteImgList = [...editorDeleteImgList.value];
|
||||
}
|
||||
|
||||
const fileArray = newFileFilter(attachFiles);
|
||||
const formData = new FormData();
|
||||
|
||||
// formData에 boardData 추가
|
||||
Object.entries(boardData).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
// formData에 새로 추가한 파일 추가
|
||||
fileArray.forEach((file, idx) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const { data } = await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
|
||||
if (data.code === 200) {
|
||||
toastStore.onToast('게시물이 수정되었습니다.', 's');
|
||||
|
||||
@ -4,33 +4,26 @@
|
||||
<div class="card-header d-flex flex-column">
|
||||
<!-- 검색창 -->
|
||||
<div class="mb-3 w-100">
|
||||
<search-bar @update:data="search" @keyup.enter="searchOnEnter" :initKeyword="searchText" class="flex-grow-1" />
|
||||
<search-bar @update:data="search" @keyup.enter="searchOnEnter" class="flex-grow-1" />
|
||||
</div>
|
||||
<div class="d-flex align-items-center" style="gap: 15px">
|
||||
<div class="d-flex align-items-center" style="gap: 15px;">
|
||||
<!-- 리스트 갯수 선택 -->
|
||||
<select class="form-select w-auto" v-model="selectedSize" @change="handleSizeChange($event)" style="margin-left: 0">
|
||||
<select class="form-select w-auto" v-model="selectedSize" @change="handleSizeChange" style="margin-left: 0;">
|
||||
<option value="10">10개씩</option>
|
||||
<option value="20">20개씩</option>
|
||||
<option value="30">30개씩</option>
|
||||
<option value="50">50개씩</option>
|
||||
<option value="100">100개씩</option>
|
||||
</select>
|
||||
|
||||
<!-- 셀렉트 박스 -->
|
||||
<select class="form-select w-auto" v-model="selectedOrder" @change="handleSortChange($event)">
|
||||
<option value="date">날짜</option>
|
||||
<select class="form-select w-auto" v-model="selectedOrder" @change="handleSortChange">
|
||||
<option value="date">최신날짜</option>
|
||||
<option value="views">조회수</option>
|
||||
</select>
|
||||
|
||||
<!-- 공지 접기 기능 -->
|
||||
<div class="form-check mb-0">
|
||||
<input
|
||||
class="form-check-input mt-1"
|
||||
type="checkbox"
|
||||
v-model="showNotices"
|
||||
id="hideNotices"
|
||||
@change="handlePageFilter()"
|
||||
/>
|
||||
<input class="form-check-input" type="checkbox" v-model="showNotices" id="hideNotices" />
|
||||
<label class="form-check-label" for="hideNotices">공지 숨기기</label>
|
||||
</div>
|
||||
|
||||
@ -45,67 +38,47 @@
|
||||
<table class="datatables-users table border-top dataTable dtr-column">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 11%" 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: 15%" class="text-center fw-bold">작성일</th>
|
||||
<th style="width: 9%" 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: 10%;" 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- 공지사항 -->
|
||||
<template v-if="pagination.currentPage === 1 && !showNotices">
|
||||
<tr
|
||||
v-for="(notice, index) in noticeList"
|
||||
<tr v-for="(notice, index) in noticeList"
|
||||
:key="'notice-' + index"
|
||||
class="bg-label-gray fw-bold"
|
||||
@click="goDetail(notice.id)"
|
||||
>
|
||||
@click="goDetail(notice.id)">
|
||||
<td class="text-center">공지</td>
|
||||
<td class="cursor-pointer">
|
||||
<div class="d-flex flex-wrap align-items-center">
|
||||
<span class="me-1">📌</span>
|
||||
<span>{{ truncateTitle(notice.title) }}</span>
|
||||
|
||||
<span v-if="notice.commentCount" class="text-danger fw-bold mx-1">
|
||||
[ {{ 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>
|
||||
📌 {{ notice.title }}
|
||||
<span v-if="notice.commentCount" class="text-danger fw-bold me-1">[ {{ notice.commentCount }} ]</span>
|
||||
<i v-if="notice.img" class="bi bi-image me-1 align-middle"></i>
|
||||
<i v-if="Array.isArray(notice.hasAttachment) && notice.hasAttachment.length > 0" class="bi bi-paperclip"></i>
|
||||
<span v-if="isNewPost(notice.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
|
||||
</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.views }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- 일반 게시물 -->
|
||||
<tr
|
||||
v-for="(post, index) in generalList"
|
||||
<tr v-for="(post, index) in generalList"
|
||||
:key="'post-' + index"
|
||||
class="invert-bg-white"
|
||||
@click="goDetail(post.realId)"
|
||||
>
|
||||
@click="goDetail(post.realId)">
|
||||
<td class="text-center">{{ post.id }}</td>
|
||||
<td class="cursor-pointer">
|
||||
<div class="d-flex flex-wrap align-items-center">
|
||||
{{ truncateTitle(post.title) }}
|
||||
<span v-if="post.commentCount" class="comment-count text-danger">[ {{ post.commentCount }} ]</span>
|
||||
<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>
|
||||
<span v-if="isNewPost(post.rawDate)" class="box-new badge text-white ms-2 fs-tiny">N</span>
|
||||
</div>
|
||||
{{ post.title }}
|
||||
<span v-if="post.commentCount" class="comment-count">[ {{ post.commentCount }} ]</span>
|
||||
<i v-if="post.img" class="bi bi-image me-1"></i>
|
||||
<i v-if="Array.isArray(post.hasAttachment) && post.hasAttachment.length > 0" class="bi bi-paperclip"></i>
|
||||
<span v-if="isNewPost(post.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
|
||||
</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.views }}</td>
|
||||
</tr>
|
||||
@ -113,7 +86,9 @@
|
||||
</table>
|
||||
<!-- 게시물이 없을 때 -->
|
||||
<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>
|
||||
@ -121,7 +96,11 @@
|
||||
<!-- 페이지네이션 -->
|
||||
<div class="row g-3">
|
||||
<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>
|
||||
@ -130,244 +109,172 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import Pagination from '@c/pagination/Pagination.vue';
|
||||
import SearchBar from '@c/search/SearchBar.vue';
|
||||
//import { route, router } from '@/router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import WriteButton from '@c/button/WriteBtn.vue';
|
||||
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';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import Pagination from '@c/pagination/Pagination.vue';
|
||||
import SearchBar from '@c/search/SearchBar.vue';
|
||||
import router from '@/router';
|
||||
import WriteButton from '@c/button/WriteBtn.vue';
|
||||
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 generalList = ref([]);
|
||||
const noticeList = ref([]);
|
||||
const searchText = ref('');
|
||||
const selectedOrder = ref('date');
|
||||
const selectedSize = ref(10);
|
||||
const showNotices = ref(false);
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
// 데이터 초기화
|
||||
const generalList = ref([]);
|
||||
const noticeList = ref([]);
|
||||
const searchText = ref('');
|
||||
const selectedOrder = ref('date');
|
||||
const selectedSize = ref(10);
|
||||
const showNotices = ref(false);
|
||||
|
||||
const pagination = ref({
|
||||
currentPage: 1,
|
||||
pages: 1,
|
||||
prePage: 0,
|
||||
nextPage: 1,
|
||||
isFirstPage: true,
|
||||
isLastPage: false,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
navigatePages: 10,
|
||||
navigatepageNums: [1],
|
||||
navigateFirstPage: 1,
|
||||
navigateLastPage: 1,
|
||||
});
|
||||
const pagination = ref({
|
||||
currentPage: 1,
|
||||
pages: 1,
|
||||
prePage: 0,
|
||||
nextPage: 1,
|
||||
isFirstPage: true,
|
||||
isLastPage: false,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
navigatePages: 10,
|
||||
navigatepageNums: [1],
|
||||
navigateFirstPage: 1,
|
||||
navigateLastPage: 1
|
||||
});
|
||||
|
||||
// 상세 페이지 이동
|
||||
const goDetail = id => {
|
||||
saveFilterToStorage(id);
|
||||
router.push({ name: 'BoardDetail', params: { id: id } });
|
||||
};
|
||||
// 상세 페이지 이동
|
||||
const goDetail = (id) => {
|
||||
router.push({ name: 'BoardDetail', params: { id: id } });
|
||||
};
|
||||
|
||||
const truncateTitle = title => {
|
||||
return title.length > 19 ? title.slice(0, 19) + '...' : title;
|
||||
};
|
||||
// 날짜 포맷 변환 함수 (오늘이면 HH:mm, 아니면 YYYY-MM-DD)
|
||||
const formatDate = (dateString) => {
|
||||
const date = dayjs(dateString);
|
||||
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
|
||||
};
|
||||
|
||||
// 로컬 스토리지 필터 저장
|
||||
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 isNewPost = (dateString) => {
|
||||
const date = dayjs(dateString);
|
||||
return date.isToday() || date.isYesterday();
|
||||
};
|
||||
|
||||
// 스토리지 초기화
|
||||
const clearFliterStorage = () => {
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('boardList_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
// 검색 처리
|
||||
const search = (e) => {
|
||||
searchText.value = e.trim();
|
||||
fetchGeneralPosts(1);
|
||||
};
|
||||
|
||||
// 날짜 포맷 변환 함수 (오늘이면 HH:mm, 아니면 YYYY-MM-DD)
|
||||
const formatDate = dateString => {
|
||||
const date = dayjs(dateString);
|
||||
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
|
||||
};
|
||||
// 정렬 변경 핸들러
|
||||
const handleSortChange = () => {
|
||||
fetchGeneralPosts(1);
|
||||
};
|
||||
|
||||
// 새로 올라온 게시물 여부 판단 (오늘 또는 어제 작성된 경우)
|
||||
const isNewPost = dateString => {
|
||||
const date = dayjs(dateString);
|
||||
return date.isToday() || date.isYesterday();
|
||||
};
|
||||
// 리스트 개수 변경 핸들러
|
||||
const handleSizeChange = () => {
|
||||
fetchGeneralPosts(1);
|
||||
};
|
||||
|
||||
// 검색 처리
|
||||
const search = e => {
|
||||
searchText.value = e.trim();
|
||||
handlePageFilter();
|
||||
};
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSortChange = e => {
|
||||
selectedOrder.value = e.target.value;
|
||||
handlePageFilter();
|
||||
};
|
||||
|
||||
// 리스트 개수 변경 핸들러
|
||||
const handleSizeChange = e => {
|
||||
selectedSize.value = e.target.value;
|
||||
handlePageFilter();
|
||||
};
|
||||
|
||||
// 일반 게시물 데이터 로드
|
||||
const fetchGeneralPosts = async (page = 1, keyword = '', orderBy = 'date', size = 10) => {
|
||||
try {
|
||||
const { data } = await axios.get('board/general', {
|
||||
params: {
|
||||
page,
|
||||
size: size,
|
||||
orderBy: orderBy,
|
||||
searchKeyword: keyword,
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.data) {
|
||||
const totalPosts = data.data.total;
|
||||
generalList.value = data.data.list.map((post, index) => ({
|
||||
realId: post.id,
|
||||
id: totalPosts - (page - 1) * selectedSize.value - index,
|
||||
title: post.title,
|
||||
author: post.author || '익명',
|
||||
rawDate: post.date,
|
||||
date: formatDate(post.date), // 날짜 변환 적용
|
||||
views: post.cnt || 0,
|
||||
hasAttachment: post.hasAttachment,
|
||||
nickname: post.nickname || null,
|
||||
img: post.firstImageUrl || null,
|
||||
commentCount: post.commentCount,
|
||||
}));
|
||||
|
||||
pagination.value = {
|
||||
...pagination.value,
|
||||
currentPage: data.data.pageNum,
|
||||
pages: data.data.pages,
|
||||
prePage: data.data.prePage,
|
||||
nextPage: data.data.nextPage,
|
||||
isFirstPage: data.data.isFirstPage,
|
||||
isLastPage: data.data.isLastPage,
|
||||
hasPreviousPage: data.data.hasPreviousPage,
|
||||
hasNextPage: data.data.hasNextPage,
|
||||
navigatePages: data.data.navigatePages,
|
||||
navigatepageNums: data.data.navigatepageNums,
|
||||
navigateFirstPage: data.data.navigateFirstPage,
|
||||
navigateLastPage: data.data.navigateLastPage,
|
||||
};
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
// 공지사항 데이터 로드
|
||||
const fetchNoticePosts = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('board/notices', {
|
||||
params: { searchKeyword: searchText.value },
|
||||
});
|
||||
|
||||
if (data?.data) {
|
||||
noticeList.value = data.data.map(post => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
author: post.author || '관리자',
|
||||
date: formatDate(post.date),
|
||||
rawDate: post.date,
|
||||
views: post.cnt || 0,
|
||||
hasAttachment: post.hasAttachment,
|
||||
img: post.firstImageUrl || null,
|
||||
commentCount: post.commentCount,
|
||||
}));
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
// Enter 키를 눌렀을 때
|
||||
const searchOnEnter = event => {
|
||||
const searchTextValue = event.target.value.trim();
|
||||
|
||||
if (!searchTextValue || searchTextValue[0] === ' ') {
|
||||
return; // 검색어가 비어있거나 첫 글자가 공백이면 실행 안 함
|
||||
}
|
||||
|
||||
searchText.value = searchTextValue;
|
||||
handlePageFilter();
|
||||
};
|
||||
|
||||
// 페이지 변경
|
||||
const handlePageChange = page => {
|
||||
if (page !== pagination.value.currentPage) {
|
||||
handlePageFilter(page);
|
||||
}
|
||||
};
|
||||
|
||||
// 페이지 이동 (검색 필터 유지)
|
||||
const handlePageFilter = (page = 1) => {
|
||||
router.push({
|
||||
path: '/board',
|
||||
query: {
|
||||
page: page,
|
||||
// 일반 게시물 데이터 로드
|
||||
const fetchGeneralPosts = async (page = 1) => {
|
||||
try {
|
||||
const { data } = await axios.get("board/general", {
|
||||
params: {
|
||||
page,
|
||||
size: selectedSize.value,
|
||||
orderBy: selectedOrder.value,
|
||||
searchText: searchText.value,
|
||||
showNotice: showNotices.value,
|
||||
},
|
||||
searchKeyword: searchText.value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 페이징, 검색 필터 감지
|
||||
watch(
|
||||
() => route.query,
|
||||
newQuery => {
|
||||
pagination.currentPage = newQuery.page || 1;
|
||||
const keyword = newQuery?.searchText;
|
||||
const orderBy = newQuery?.orderBy;
|
||||
const size = newQuery?.size;
|
||||
if (data?.data) {
|
||||
const totalPosts = data.data.total;
|
||||
generalList.value = data.data.list.map((post, index) => ({
|
||||
realId: post.id,
|
||||
id: totalPosts - ((page - 1) * selectedSize.value) - index,
|
||||
title: post.title,
|
||||
author: post.author || '익명',
|
||||
rawDate: post.date,
|
||||
date: formatDate(post.date), // 날짜 변환 적용
|
||||
views: post.cnt || 0,
|
||||
hasAttachment: post.hasAttachment,
|
||||
img: post.firstImageUrl || null,
|
||||
commentCount : post.commentCount
|
||||
}));
|
||||
|
||||
fetchGeneralPosts(pagination.currentPage, keyword, orderBy, size);
|
||||
},
|
||||
);
|
||||
// 데이터 로드
|
||||
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;
|
||||
pagination.value = {
|
||||
...pagination.value,
|
||||
currentPage: data.data.pageNum,
|
||||
pages: data.data.pages,
|
||||
prePage: data.data.prePage,
|
||||
nextPage: data.data.nextPage,
|
||||
isFirstPage: data.data.isFirstPage,
|
||||
isLastPage: data.data.isLastPage,
|
||||
hasPreviousPage: data.data.hasPreviousPage,
|
||||
hasNextPage: data.data.hasNextPage,
|
||||
navigatePages: data.data.navigatePages,
|
||||
navigatepageNums: data.data.navigatepageNums,
|
||||
navigateFirstPage: data.data.navigateFirstPage,
|
||||
navigateLastPage: data.data.navigateLastPage
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 항목 세팅
|
||||
if (keyword) searchText.value = keyword;
|
||||
if (orderBy) selectedOrder.value = orderBy;
|
||||
if (size) selectedSize.value = size;
|
||||
if (showNotice) showNotices.value = showNotice == 'false' ? false : true;
|
||||
// 공지사항 데이터 로드
|
||||
const fetchNoticePosts = async () => {
|
||||
try {
|
||||
const { data } = await axios.get("board/notices", {
|
||||
params: { searchKeyword: searchText.value }
|
||||
});
|
||||
|
||||
clearFliterStorage();
|
||||
fetchNoticePosts();
|
||||
fetchGeneralPosts(page, keyword, orderBy, size);
|
||||
});
|
||||
if (data?.data) {
|
||||
noticeList.value = data.data.map(post => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
author: post.author || '관리자',
|
||||
date: formatDate(post.date),
|
||||
rawDate: post.date,
|
||||
views: post.cnt || 0,
|
||||
hasAttachment: post.hasAttachment,
|
||||
img: post.firstImageUrl || null,
|
||||
commentCount : post.commentCount
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
// Enter 키를 눌렀을 때
|
||||
const searchOnEnter = (event) => {
|
||||
const searchTextValue = event.target.value.trim();
|
||||
|
||||
if (!searchTextValue || searchTextValue[0] === ' ') {
|
||||
return; // 검색어가 비어있거나 첫 글자가 공백이면 실행 안 함
|
||||
}
|
||||
|
||||
searchText.value = searchTextValue;
|
||||
fetchGeneralPosts(1);
|
||||
};
|
||||
|
||||
// 페이지 변경
|
||||
const handlePageChange = (page) => {
|
||||
if (page !== pagination.value.currentPage) {
|
||||
fetchGeneralPosts(page);
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
onMounted(() => {
|
||||
fetchNoticePosts();
|
||||
fetchGeneralPosts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -8,30 +8,20 @@
|
||||
<div class="pb-5 border-bottom">
|
||||
<BoardProfile
|
||||
:boardId="currentBoardId"
|
||||
:profileName="displayName"
|
||||
:profileName="profileName"
|
||||
:unknown="unknown"
|
||||
:profileImg="profileImg"
|
||||
:views="views"
|
||||
:nickname="nickname"
|
||||
:commentNum="commentNum"
|
||||
:date="formattedBoardDate"
|
||||
:isLike="false"
|
||||
:isAuthor="isAuthor"
|
||||
:is-edit-pushed="isEditPushed"
|
||||
:is-delete-pushed="isDeletePushed"
|
||||
@editClick="editClick"
|
||||
@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">
|
||||
<input
|
||||
type="password"
|
||||
@ -39,7 +29,6 @@
|
||||
autocomplete="new-password"
|
||||
v-model="password"
|
||||
placeholder="비밀번호 입력"
|
||||
maxlength="8"
|
||||
@input="
|
||||
password = password.replace(/\s/g, '');
|
||||
inputCheck();
|
||||
@ -89,7 +78,7 @@
|
||||
></div>
|
||||
|
||||
<!-- 좋아요 버튼 -->
|
||||
<div v-if="!type" class="row justify-content-center my-10">
|
||||
<div class="row justify-content-center my-10">
|
||||
<BoardRecommendBtn
|
||||
:bigBtn="true"
|
||||
:boardId="currentBoardId"
|
||||
@ -101,21 +90,20 @@
|
||||
@updateReaction="handleUpdateReaction"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!type">
|
||||
<!-- 댓글 입력 영역 -->
|
||||
<BoardCommentArea
|
||||
:profileName="profileName"
|
||||
:unknown="unknown"
|
||||
:commentAlert="commentAlert"
|
||||
:passwordAlert="passwordAlert"
|
||||
:maxLength="500"
|
||||
@submitComment="handleCommentSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 댓글 입력 영역 -->
|
||||
<BoardCommentArea
|
||||
:profileName="profileName"
|
||||
:unknown="unknown"
|
||||
:commentAlert="commentAlert"
|
||||
:passwordAlert="passwordAlert"
|
||||
:maxLength="500"
|
||||
@submitComment="handleCommentSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 댓글 목록 -->
|
||||
<div v-if="!type" class="card-footer">
|
||||
<div class="card-footer">
|
||||
<BoardCommentList
|
||||
:unknown="unknown"
|
||||
:comments="commentsWithAuthStatus"
|
||||
@ -151,7 +139,7 @@
|
||||
import BoardCommentList from '@c/board/BoardCommentList.vue';
|
||||
import BoardRecommendBtn from '@c/button/BoardRecommendBtn.vue';
|
||||
import Pagination from '@c/pagination/Pagination.vue';
|
||||
import { ref, onMounted, computed, inject, provide } from 'vue';
|
||||
import { ref, onMounted, computed, inject } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
@ -163,7 +151,6 @@
|
||||
const profileName = ref('');
|
||||
const boardTitle = ref('제목 없음');
|
||||
const boardContent = ref('');
|
||||
const nickname = ref('');
|
||||
const date = ref('');
|
||||
const views = ref(0);
|
||||
const likes = ref(0);
|
||||
@ -174,8 +161,6 @@
|
||||
const attachment = ref(false);
|
||||
const comments = ref([]);
|
||||
const profileImg = ref('');
|
||||
const isEditPushed = ref(false);
|
||||
const isDeletePushed = ref(false);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -189,10 +174,6 @@
|
||||
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 commentsWithAuthStatus = computed(() => {
|
||||
const updatedComments = comments.value.map(comment => ({
|
||||
@ -241,7 +222,6 @@
|
||||
const isDeleted = ref(true);
|
||||
const commentAlert = ref('');
|
||||
const boardPasswordAlert = ref('');
|
||||
const type = ref('');
|
||||
|
||||
const updatePassword = newPassword => {
|
||||
password.value = newPassword;
|
||||
@ -273,26 +253,16 @@
|
||||
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) {
|
||||
likeClicked.value = true;
|
||||
dislikeClicked.value = false;
|
||||
} else if (boardData?.myReaction == 2) {
|
||||
likeClicked.value = false;
|
||||
dislikeClicked.value = true;
|
||||
}
|
||||
} else {
|
||||
toastStore.onToast(data.message, 'e');
|
||||
router.back();
|
||||
@ -330,9 +300,6 @@
|
||||
});
|
||||
|
||||
fetchComments(pagination.value.currentPage);
|
||||
closeAllEditTextareas();
|
||||
closeAllPasswordAreas();
|
||||
activeCommentBtnClass();
|
||||
};
|
||||
|
||||
// 댓글 목록 조회
|
||||
@ -355,17 +322,14 @@
|
||||
likeCount: comment.likeCount || 0,
|
||||
dislikeCount: comment.dislikeCount || 0,
|
||||
profileImg: comment.profileImg || '',
|
||||
nickname: comment.LOCCMTNIC || '',
|
||||
likeClicked: comment.likeClicked || false,
|
||||
dislikeClicked: comment.dislikeClicked || false,
|
||||
createdAtRaw: comment.LOCCMTRDT, // 작성일
|
||||
// createdAt: formattedDate(comment.LOCCMTRDT), // 작성일(노출용)
|
||||
// createdAtRaw: new Date(comment.LOCCMTUDT), // 수정순
|
||||
createdAt:
|
||||
formattedDate(comment.LOCCMTUDT) +
|
||||
(comment.content === '삭제된 댓글입니다' && comment.LOCCMTUDT !== comment.LOCCMTRDT ? ' (수정됨)' : ''), // 수정일(노출용)
|
||||
createdAt: formattedDate(comment.LOCCMTUDT) + (comment.LOCCMTUDT !== comment.LOCCMTRDT ? ' (수정됨)' : ''), // 수정일(노출용)
|
||||
children: [], // 대댓글을 담을 배열
|
||||
updateAtRaw: comment.LOCCMTUDT,
|
||||
likeClicked: comment.myReaction == 1,
|
||||
dislikeClicked: comment.myReaction == 2,
|
||||
}))
|
||||
.sort((a, b) => b.createdAtRaw - a.createdAtRaw);
|
||||
|
||||
@ -387,7 +351,6 @@
|
||||
parentId: reply.LOCCMTPNT, // 부모 댓글 ID
|
||||
content: reply.LOCCMTRPY || '내용 없음',
|
||||
createdAtRaw: reply.LOCCMTRDT,
|
||||
nickname: reply.LOCCMTNIC || '',
|
||||
// createdAt: formattedDate(reply.LOCCMTRDT),
|
||||
//createdAtRaw: new Date(reply.LOCCMTUDT),
|
||||
createdAt: formattedDate(reply.LOCCMTUDT) + (reply.LOCCMTUDT !== reply.LOCCMTRDT ? ' (수정됨)' : ''),
|
||||
@ -450,7 +413,6 @@
|
||||
LOCCMTRPY: comment,
|
||||
LOCCMTPWD: isCheck ? password : '',
|
||||
LOCCMTPNT: 1,
|
||||
LOCCMTNIC: data.isCheck ? data.nickname : null,
|
||||
LOCBRDTYP: isCheck ? '300102' : null,
|
||||
});
|
||||
|
||||
@ -458,7 +420,6 @@
|
||||
passwordAlert.value = '';
|
||||
commentAlert.value = '';
|
||||
await fetchComments();
|
||||
activeCommentBtnClass();
|
||||
} else {
|
||||
alert('댓글 작성을 실패했습니다.');
|
||||
}
|
||||
@ -471,7 +432,6 @@
|
||||
LOCCMTRPY: reply.comment,
|
||||
LOCCMTPWD: reply.password || null,
|
||||
LOCCMTPNT: reply.parentId,
|
||||
LOCCMTNIC: reply.isCheck ? reply.nickname : null,
|
||||
LOCBRDTYP: reply.isCheck ? '300102' : null,
|
||||
});
|
||||
|
||||
@ -490,9 +450,6 @@
|
||||
const isUnknown = unknown?.unknown ?? false;
|
||||
|
||||
if (isUnknown) {
|
||||
closeAllEditTextareas();
|
||||
closeAllPasswordAreas();
|
||||
activeCommentBtnClass();
|
||||
togglePassword('edit');
|
||||
} else {
|
||||
router.push({ name: 'BoardEdit', params: { id: currentBoardId.value } });
|
||||
@ -504,9 +461,6 @@
|
||||
const isUnknown = unknown?.unknown ?? false;
|
||||
|
||||
if (isUnknown) {
|
||||
closeAllEditTextareas();
|
||||
closeAllPasswordAreas();
|
||||
activeCommentBtnClass();
|
||||
togglePassword('delete');
|
||||
} else {
|
||||
deletePost();
|
||||
@ -526,36 +480,11 @@
|
||||
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 => {
|
||||
acitveButtonType(); //게시글 버튼 클릭 클래스 제거
|
||||
|
||||
password.value = '';
|
||||
passwordCommentAlert.value = '';
|
||||
//currentPasswordCommentId.value = null;
|
||||
isPassword.value = false; // 상단 프로필 비밀번호
|
||||
|
||||
const targetComment = findCommentById(comment.commentId, comments.value);
|
||||
@ -568,20 +497,27 @@
|
||||
if (isMyComment) {
|
||||
if (targetComment.isEditTextarea) {
|
||||
// 수정창이 열려 있는 상태에서 다시 수정 버튼을 누르면 초기화
|
||||
|
||||
targetComment.isEditTextarea = false;
|
||||
|
||||
currentPasswordCommentId.value = comment.commentId;
|
||||
activeCommentBtnClass(targetComment, 3);
|
||||
} else {
|
||||
closeAllEditTextareas(); // 다른 모든 댓글의 수정창 닫기
|
||||
currentPasswordCommentId.value = null; // 현재 댓글만 수정 모드 활성화
|
||||
targetComment.isEditTextarea = true; // 선택 버튼 활성화 상태 제어
|
||||
activeCommentBtnClass(targetComment, 1);
|
||||
// 다른 모든 댓글의 수정창 닫기
|
||||
closeAllEditTextareas();
|
||||
currentPasswordCommentId.value = null;
|
||||
// 현재 댓글만 수정 모드 활성화
|
||||
targetComment.isEditTextarea = true;
|
||||
}
|
||||
} else if (isAnonymous) {
|
||||
if (currentPasswordCommentId.value === comment.commentId) {
|
||||
toggleCommentPassword(comment, 'edit'); // 이미 비밀번호 입력 중이면 유지
|
||||
// 이미 비밀번호 입력 중이면 유지
|
||||
toggleCommentPassword(comment, 'edit');
|
||||
return;
|
||||
} else {
|
||||
closeAllEditTextareas(); // 다른 모든 댓글의 수정창 닫기
|
||||
// 다른 모든 댓글의 수정창 닫기
|
||||
closeAllEditTextareas();
|
||||
|
||||
// 비밀번호 입력
|
||||
targetComment.isEditTextarea = false;
|
||||
toggleCommentPassword(comment, 'edit');
|
||||
}
|
||||
@ -590,28 +526,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 = () => {
|
||||
comments.value.forEach(comment => {
|
||||
@ -629,20 +543,29 @@
|
||||
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) => {
|
||||
if (lastCommentClickedButton.value === button && currentPasswordCommentId.value === comment.commentId) {
|
||||
currentPasswordCommentId.value = null; // 비밀번호 창 닫기
|
||||
password.value = '';
|
||||
passwordCommentAlert.value = '';
|
||||
activeCommentBtnClass(comment, 3);
|
||||
} else {
|
||||
if (button == 'edit') {
|
||||
activeCommentBtnClass(comment, 1);
|
||||
} else if (button == 'delete') {
|
||||
activeCommentBtnClass(comment, 2);
|
||||
}
|
||||
|
||||
currentPasswordCommentId.value = comment.commentId; // 비밀번호 창 열기
|
||||
password.value = '';
|
||||
passwordCommentAlert.value = '';
|
||||
@ -659,48 +582,17 @@
|
||||
isPassword.value = false;
|
||||
boardPasswordAlert.value = '';
|
||||
password.value = '';
|
||||
acitveButtonType();
|
||||
return;
|
||||
}
|
||||
closeAllPasswordAreas();
|
||||
if (lastClickedButton.value === button) {
|
||||
isPassword.value = !isPassword.value;
|
||||
boardPasswordAlert.value = '';
|
||||
acitveButtonType();
|
||||
} else {
|
||||
isPassword.value = true;
|
||||
|
||||
if (button == 'edit') {
|
||||
acitveButtonType(1);
|
||||
} else if (button == 'delete') {
|
||||
acitveButtonType(2);
|
||||
} else {
|
||||
acitveButtonType();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 선택한 게시글 버튼 핸들링(수정, 삭제 버튼)
|
||||
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;
|
||||
}
|
||||
lastClickedButton.value = button;
|
||||
};
|
||||
|
||||
// 게시글 비밀번호 제출
|
||||
@ -820,9 +712,9 @@
|
||||
if (response.data.code === 200) {
|
||||
await fetchComments(pagination.value.currentPage);
|
||||
closeAllPasswordAreas();
|
||||
activeCommentBtnClass();
|
||||
|
||||
if (targetComment) {
|
||||
// console.log('타겟',targetComment)
|
||||
// 댓글 내용만 "삭제된 댓글입니다."로 변경하고, 구조는 유지
|
||||
targetComment.content = '댓글이 삭제되었습니다.';
|
||||
targetComment.author = '알 수 없음'; // 익명 처리
|
||||
@ -849,7 +741,6 @@
|
||||
if (response.status === 200) {
|
||||
togglePassword('close');
|
||||
fetchComments(pagination.value.currentPage);
|
||||
activeCommentBtnClass();
|
||||
return;
|
||||
// const targetComment = findCommentById(comment.commentId, comments.value);
|
||||
|
||||
@ -891,16 +782,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
// 게시글 목록 이동 버튼
|
||||
const goList = () => {
|
||||
// 목록으로 바로 이동시 필터 유지
|
||||
const getFilter = localStorage.getItem(`boardList_${currentBoardId.value}`);
|
||||
router.push({
|
||||
name: 'BoardList',
|
||||
query: getFilter ? JSON.parse(getFilter) : '',
|
||||
});
|
||||
};
|
||||
|
||||
// 댓글 삭제 (대댓글 포함)
|
||||
const handleCommentDeleted = deletedCommentId => {
|
||||
// 댓글 삭제
|
||||
@ -930,14 +811,9 @@
|
||||
|
||||
const formattedBoardDate = computed(() => formattedDate(date.value));
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0 });
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 데이터 로드
|
||||
onMounted(() => {
|
||||
fetchBoardDetails();
|
||||
fetchComments();
|
||||
scrollToTop();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -37,24 +37,11 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'">
|
||||
카테고리를 선택해주세요.
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'">카테고리를 선택해주세요.</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 필드 (익명게시판 선택 시 활성화) -->
|
||||
<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
|
||||
title="비밀번호"
|
||||
name="pw"
|
||||
@ -73,7 +60,6 @@
|
||||
title="첨부파일"
|
||||
name="files"
|
||||
:is-alert="attachFilesAlert"
|
||||
:key="autoIncrement"
|
||||
@update:data="handleFileUpload"
|
||||
@update:isValid="isFileValid = $event"
|
||||
/>
|
||||
@ -97,20 +83,13 @@
|
||||
<div class="mb-4">
|
||||
<label class="col-md-2 col-form-label"> 내용 <span class="text-danger">*</span> </label>
|
||||
<div class="col-md-12">
|
||||
<QEditor
|
||||
@update:data="content = $event"
|
||||
@update:uploadedImgList="handleUpdateEditorImg"
|
||||
@update:deleteImgIndexList="handleDeleteEditorImg"
|
||||
/>
|
||||
</div>
|
||||
<div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">
|
||||
내용을 입력해주세요.
|
||||
<QEditor @update:data="content = $event" />
|
||||
</div>
|
||||
<div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">내용을 입력해주세요.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 d-flex justify-content-end">
|
||||
<BackButton @click="goList" />
|
||||
<!-- 저장 버튼은 항상 활성화 -->
|
||||
<SaveButton @click="write" :isEnabled="isFileValid" />
|
||||
</div>
|
||||
</div>
|
||||
@ -120,7 +99,7 @@
|
||||
</template>
|
||||
|
||||
<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 FormInput from '@c/input/FormInput.vue';
|
||||
import FormFile from '@c/input/FormFile.vue';
|
||||
@ -133,14 +112,12 @@
|
||||
const toastStore = useToastStore();
|
||||
const categoryList = ref([]);
|
||||
const title = ref('');
|
||||
const nickname = ref('');
|
||||
const password = ref('');
|
||||
const categoryValue = ref(null);
|
||||
const content = ref({ ops: [] });
|
||||
const isFileValid = ref(true);
|
||||
|
||||
const titleAlert = ref(false);
|
||||
const nicknameAlert = ref(false);
|
||||
const passwordAlert = ref(false);
|
||||
const contentAlert = ref(false);
|
||||
const categoryAlert = ref(false);
|
||||
@ -150,12 +127,6 @@
|
||||
const maxFiles = 5;
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
const fileError = ref('');
|
||||
const editorUploadedImgList = ref([]);
|
||||
const editorDeleteImgList = ref([]);
|
||||
|
||||
const noSpace = (e) => {
|
||||
if (e.key === ' ') e.preventDefault();
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
const response = await axios.get('board/categories');
|
||||
@ -166,24 +137,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
const autoIncrement = ref(0);
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
|
||||
const fileCount = computed(() => attachFiles.value.length);
|
||||
|
||||
// 업데이트된 에디터 이미지 목록 업데이트
|
||||
const handleUpdateEditorImg = item => {
|
||||
editorUploadedImgList.value = item;
|
||||
};
|
||||
|
||||
// 삭제된 에디터 이미지 목록 업데이트
|
||||
const handleDeleteEditorImg = item => {
|
||||
editorDeleteImgList.value = item;
|
||||
};
|
||||
|
||||
const handleFileUpload = files => {
|
||||
const validFiles = files.filter(file => file.size <= maxSize);
|
||||
if (files.some(file => file.size > maxSize)) {
|
||||
@ -196,7 +155,6 @@
|
||||
}
|
||||
fileError.value = '';
|
||||
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
|
||||
autoIncrement.value++;
|
||||
};
|
||||
|
||||
const removeFile = index => {
|
||||
@ -204,7 +162,6 @@
|
||||
if (attachFiles.value.length <= maxFiles) {
|
||||
fileError.value = '';
|
||||
}
|
||||
autoIncrement.value++;
|
||||
};
|
||||
|
||||
watch(attachFiles, () => {
|
||||
@ -215,15 +172,6 @@
|
||||
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 = () => {
|
||||
if (categoryValue.value === 300102) {
|
||||
password.value = password.value.replace(/\s/g, ''); // 공백 제거
|
||||
@ -233,74 +181,47 @@
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* validateContent:
|
||||
* - 내용이 없으면 contentAlert를 true로 설정
|
||||
* - 텍스트, 이미지, 비디오 중 하나라도 존재하면 유효한 콘텐츠로 판단
|
||||
*/
|
||||
const validateContent = () => {
|
||||
if (!content.value?.ops?.length) {
|
||||
contentAlert.value = true;
|
||||
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 hasVideo = content.value.ops.some(
|
||||
op => op.insert && typeof op.insert === 'object' && op.insert.video
|
||||
);
|
||||
// 이미지 포함 여부 확인
|
||||
const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
|
||||
// 텍스트 포함 여부 확인
|
||||
const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
|
||||
|
||||
contentAlert.value = !(hasText || hasImage || hasVideo);
|
||||
// 텍스트 또는 이미지가 하나라도 있으면 유효한 내용
|
||||
contentAlert.value = !(hasText || hasImage);
|
||||
};
|
||||
|
||||
/** 글쓰기 */
|
||||
const write = async () => {
|
||||
validateTitle();
|
||||
validateNickname();
|
||||
validatePassword();
|
||||
validateContent();
|
||||
categoryAlert.value = categoryValue.value == null;
|
||||
|
||||
if (
|
||||
titleAlert.value ||
|
||||
nicknameAlert.value ||
|
||||
passwordAlert.value ||
|
||||
contentAlert.value ||
|
||||
categoryAlert.value ||
|
||||
!isFileValid.value
|
||||
) {
|
||||
if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const boardData = {
|
||||
LOCBRDTTL: title.value.trim(),
|
||||
LOCBRDTTL: title.value,
|
||||
LOCBRDCON: JSON.stringify(content.value), // Delta 포맷을 JSON으로 변환
|
||||
LOCBRDNIC: categoryValue.value === 300102 ? nickname.value : null,
|
||||
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
|
||||
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 boardId = boardResponse.data;
|
||||
// 첨부파일 업로드 (비동기 병렬 처리)
|
||||
if (attachFiles.value && attachFiles.value.length > 0) {
|
||||
await Promise.all(
|
||||
attachFiles.value.map(async file => {
|
||||
console.log(file);
|
||||
const formData = new FormData();
|
||||
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
|
||||
@ -308,10 +229,10 @@
|
||||
formData.append('CMNFLEORG', fileNameWithoutExt);
|
||||
formData.append('CMNFLEEXT', file.name.split('.').pop());
|
||||
formData.append('CMNFLESIZ', file.size);
|
||||
formData.append('file', file);
|
||||
formData.append('file', file); // 📌 실제 파일 추가
|
||||
|
||||
await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true });
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -327,8 +248,8 @@
|
||||
router.push('/board');
|
||||
};
|
||||
|
||||
/** content 변경 감지 (deep 옵션 추가) */
|
||||
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
|
||||
watch(content, () => {
|
||||
validateContent();
|
||||
}, { deep: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -32,7 +32,6 @@
|
||||
:isOpen="isGrantModalOpen"
|
||||
:targetUser="selectedUser"
|
||||
:remainingQuota="remainingVacationData[selectedUser?.MEMBERSEQ] || 0"
|
||||
:remainingVacationData="remainingVacationData"
|
||||
@close="isGrantModalOpen = false"
|
||||
@updateVacation="fetchRemainingVacation"
|
||||
/>
|
||||
@ -107,7 +106,6 @@ const isGrantModalOpen = ref(false);
|
||||
const fullCalendarRef = ref(null);
|
||||
const calendarEvents = ref([]);
|
||||
const selectedDates = ref(new Map());
|
||||
|
||||
const halfDayType = ref(null);
|
||||
const vacationCodeMap = ref({});
|
||||
const holidayDates = ref(new Set());
|
||||
@ -120,6 +118,7 @@ const lastRemainingMonth = ref(String(new Date().getMonth() + 1).padStart(2, "0"
|
||||
// 데이트피커 인풋 ref
|
||||
const calendarDatepicker = ref(null);
|
||||
let fpInstance = null;
|
||||
|
||||
/* 변경사항 여부 확인 */
|
||||
const hasChanges = computed(() => {
|
||||
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({
|
||||
@ -157,20 +143,6 @@ const calendarOptions = reactive({
|
||||
dateClick: handleDateClick,
|
||||
datesSet: handleMonthChange,
|
||||
events: calendarEvents,
|
||||
customButtons: {
|
||||
prev: {
|
||||
text: 'PREV',
|
||||
click: () => moveCalendar(1),
|
||||
},
|
||||
today: {
|
||||
text: 'TODAY',
|
||||
click: () => moveCalendar(3),
|
||||
},
|
||||
next: {
|
||||
text: 'NEXT',
|
||||
click: () => moveCalendar(2),
|
||||
},
|
||||
},
|
||||
});
|
||||
// 캘린더 월 변경
|
||||
function handleMonthChange(viewInfo) {
|
||||
@ -179,46 +151,6 @@ function handleMonthChange(viewInfo) {
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
|
||||
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) {
|
||||
if (!info.date || !info.dateStr) {
|
||||
@ -241,82 +173,40 @@ function handleDateClick(info) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 값 확인
|
||||
const currentValue = selectedDates.value.get(clickedDateStr);
|
||||
const isMyVacation = myVacations.value.some(vac => {
|
||||
const vacDate = vac.date ? vac.date.substring(0, 10) : "";
|
||||
return vacDate === clickedDateStr && !vac.receiverId;
|
||||
});
|
||||
|
||||
const isMyVacation = myVacations.value.some(vac => vac.date.substring(0, 10) === clickedDateStr && !vac.receiverId);
|
||||
|
||||
if (!selectedDates.value.has(clickedDateStr) && isMyVacation && halfDayType.value) {
|
||||
const existingVacation = myVacations.value.find(vac => vac.date.substring(0, 10) === clickedDateStr && !vac.receiverId);
|
||||
const selectedType =
|
||||
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;
|
||||
return;
|
||||
if (isMyVacation) {
|
||||
if (selectedDates.value.get(clickedDateStr) === "delete") {
|
||||
selectedDates.value.delete(clickedDateStr);
|
||||
} else {
|
||||
selectedDates.value.set(clickedDateStr, "delete");
|
||||
}
|
||||
updateCalendarEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로 클릭 시 연차 초과 여부 검사
|
||||
const upcomingType = halfDayType.value === "AM" ? "700101"
|
||||
: halfDayType.value === "PM" ? "700102"
|
||||
: "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") {
|
||||
if (selectedDates.value.has(clickedDateStr)) {
|
||||
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"; // 풀연차
|
||||
const type = halfDayType.value
|
||||
? (halfDayType.value === "AM" ? "700101" : "700102")
|
||||
: "700103";
|
||||
|
||||
selectedDates.value.set(clickedDateStr, type);
|
||||
|
||||
// 버튼을 한 번 사용 후 자동 해제 (일회성)
|
||||
halfDayType.value = null;
|
||||
if (halfDayType.value) {
|
||||
halfDayType.value = null;
|
||||
}
|
||||
updateCalendarEvents();
|
||||
|
||||
if (halfDayButtonsRef.value) {
|
||||
halfDayButtonsRef.value.resetHalfDay();
|
||||
}
|
||||
|
||||
updateCalendarEvents();
|
||||
}
|
||||
|
||||
function markClickableDates() {
|
||||
@ -586,45 +476,30 @@ function updateCalendarEvents() {
|
||||
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");
|
||||
todayElement.classList.remove("fc-day-today"); // 기본 스타일 제거
|
||||
todayElement.classList.add("selected-event"); // 선택된 날짜 스타일 적용
|
||||
|
||||
// 기존 오버레이 제거
|
||||
const existingOverlay = todayElement.querySelector('.half-day-overlay');
|
||||
if (existingOverlay) {
|
||||
todayElement.removeChild(existingOverlay);
|
||||
// 🔹 오전 반차일 경우 'half-day-am' 클래스 추가
|
||||
if (selectedDates.value.get(todayStr) === "700101") {
|
||||
todayElement.classList.add("half-day-am");
|
||||
todayElement.classList.remove("half-day-pm");
|
||||
}
|
||||
|
||||
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');
|
||||
// 🔹 오후 반차일 경우 'half-day-pm' 클래스 추가
|
||||
else if (selectedDates.value.get(todayStr) === "700102") {
|
||||
todayElement.classList.add("half-day-pm");
|
||||
todayElement.classList.remove("half-day-am");
|
||||
} else {
|
||||
// 전체 연차는 배경 전체 덮기
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.borderRadius = '4px';
|
||||
todayElement.classList.remove("half-day-am", "half-day-pm");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
todayElement.classList.add("fc-day-today"); // 기본 스타일 복원
|
||||
todayElement.classList.remove("selected-event", "half-day-am", "half-day-pm"); // 선택된 상태 해제
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 휴가 종류에 따른 클래스명
|
||||
const getVacationTypeClass = (type) => {
|
||||
if (type === "700101") return "half-day-am";
|
||||
@ -759,7 +634,7 @@ onMounted(async () => {
|
||||
const dpEl = calendarDatepicker.value;
|
||||
dpEl.style.display = 'block';
|
||||
dpEl.style.position = 'fixed';
|
||||
dpEl.style.top = '18%';
|
||||
dpEl.style.top = '25%';
|
||||
dpEl.style.left = '50%';
|
||||
dpEl.style.transform = 'translate(-50%, -50%)';
|
||||
dpEl.style.zIndex = '9999';
|
||||
|
||||
@ -57,7 +57,7 @@ import $api from '@api';
|
||||
import Quill from 'quill';
|
||||
import WriteBtn from '@c/button/WriteBtn.vue';
|
||||
import voteList from '@c/voteboard/voteCardList.vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const toastStore = useToastStore();
|
||||
const category = ref('0');
|
||||
@ -71,14 +71,8 @@ const voteset = ref(0);
|
||||
const ischeked = ref(false);
|
||||
const selectedVote = ref({}); // 선택된 투표 데이터
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const myVote = ref('2');
|
||||
onMounted(() => {
|
||||
const maincvoteset = route.query.voteset || '0';
|
||||
const maincmyVote = route.query.myVote || '0';
|
||||
voteset.value =maincvoteset;
|
||||
category.value = maincvoteset;
|
||||
myVote.value = maincmyVote;
|
||||
|
||||
onMounted(async () => {
|
||||
getvoteList();
|
||||
});
|
||||
//글작성
|
||||
@ -93,16 +87,16 @@ const changeCheck = () =>{
|
||||
//투표목록
|
||||
const getvoteList = () => {
|
||||
$api.get('vote/getVoteList',{
|
||||
//목록조회시 파라미터 전달
|
||||
params:
|
||||
{
|
||||
page: currentPage.value
|
||||
,voteset:voteset.value
|
||||
,myVote:myVote.value == '2' ? myVote.value : ischeked.value ? '1':'0'
|
||||
,myVote:ischeked.value ? '1':'0'
|
||||
}
|
||||
}).then(res => {
|
||||
PageData.value = res.data.data;
|
||||
voteListCardData.value = res.data.data.list;
|
||||
myVote.value = '';
|
||||
})
|
||||
};
|
||||
|
||||
@ -138,15 +132,14 @@ const checkedNames = (numList) => {
|
||||
}
|
||||
//투표종료
|
||||
const endVoteId = (endVoteId) => {
|
||||
if(confirm('투표를 종료하시겠습니까?')){
|
||||
$api.patch('vote/updateEndData',{
|
||||
endVoteId :endVoteId
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
getvoteList();
|
||||
}
|
||||
})
|
||||
}
|
||||
$api.patch('vote/updateEndData',{
|
||||
endVoteId :endVoteId
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
toastStore.onToast('투표가 종료되었습니다.', 's');
|
||||
getvoteList();
|
||||
}
|
||||
})
|
||||
}
|
||||
//기한 지난 투표 종료
|
||||
const voteEnded = async (id) =>{
|
||||
@ -154,16 +147,14 @@ const voteEnded = async (id) =>{
|
||||
}
|
||||
//투표 삭제
|
||||
const voteDelete =(id) =>{
|
||||
if(confirm('투표를 삭제하시겠습니까?')){
|
||||
$api.patch('vote/updateDeleteData',{
|
||||
deleteVoteId :id
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
toastStore.onToast('투표가 삭제되었습니다.', 's');
|
||||
getvoteList();
|
||||
}
|
||||
})
|
||||
}
|
||||
$api.patch('vote/updateDeleteData',{
|
||||
deleteVoteId :id
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
toastStore.onToast('투표가 삭제되었습니다.', 's');
|
||||
getvoteList();
|
||||
}
|
||||
})
|
||||
}
|
||||
//랜덤 1위 뽑기
|
||||
const randomList = (data,id) =>{
|
||||
|
||||
@ -20,22 +20,16 @@
|
||||
v-model="title"
|
||||
@keyup="ValidHandler('title')"
|
||||
/>
|
||||
<div class="date-picker-container" @click="focusDateInput">
|
||||
<form-input
|
||||
title="종료날짜"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
:is-essential="true"
|
||||
:is-alert="endDateAlert"
|
||||
v-model="endDate"
|
||||
:min="minDate"
|
||||
@input="ValidHandlerendDate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 숨겨진 input 태그를 사용하여 강제로 포커스 -->
|
||||
<input ref="dateInput" @input="ValidHandlerendDate" :min="minDate" type="datetime-local" v-model="endDate" class="hidden-date-input">
|
||||
|
||||
<form-input
|
||||
title="종료날짜"
|
||||
name="endDate"
|
||||
type="date"
|
||||
:is-essential="true"
|
||||
:is-alert="endDateAlert"
|
||||
v-model="endDate"
|
||||
:min="today"
|
||||
@change="ValidHandlerendDate"
|
||||
/>
|
||||
<!-- 항목 입력 반복 -->
|
||||
<div v-for="(item, index) in itemList" :key="index">
|
||||
<form-input
|
||||
@ -51,15 +45,8 @@
|
||||
<delete-btn @click="removeItem(index)" :disabled="index < 2" />
|
||||
</template>
|
||||
</form-input>
|
||||
<form-input
|
||||
:title="'URL ' + (index + 1)"
|
||||
:name="'url' + index"
|
||||
v-model="item.url"
|
||||
:is-essential="false"
|
||||
class="mb-1"
|
||||
:maxlength="maxLength"
|
||||
/>
|
||||
<!-- <link-input v-model="item.url" class="mb-1"/> -->
|
||||
|
||||
<link-input v-model="item.url" class="mb-1"/>
|
||||
</div>
|
||||
|
||||
<plus-btn @click="addItem" :disabled="itemList.length >= 10" class="mb-3" />
|
||||
@ -103,7 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, toRaw } from "vue";
|
||||
import { onMounted, ref, toRaw } from "vue";
|
||||
import UserList from "@c/user/UserList.vue";
|
||||
import formInput from "@c/input/FormInput.vue";
|
||||
import { useToastStore } from "@s/toastStore";
|
||||
@ -111,12 +98,13 @@ import PlusBtn from "@c/button/PlusBtn.vue";
|
||||
import DeleteBtn from "@c/button/DeleteBtn.vue";
|
||||
import $api from "@api";
|
||||
import router from "@/router";
|
||||
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
|
||||
import { voteCommon } from '@s/voteCommon';
|
||||
import { useUserStore } from '@s/userList';
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
const toastStore = useToastStore();
|
||||
const activeUserList = ref([]);
|
||||
const disabledUsers = ref([]);
|
||||
@ -130,29 +118,13 @@ const { itemList, addItem, removeItem } = voteCommon();
|
||||
const userListTotal = ref(0);
|
||||
const addvoteitem = ref(false);
|
||||
const addvotemulti= ref(false);
|
||||
const maxLength = ref(2000);
|
||||
const dateInput = ref(null);
|
||||
|
||||
const focusDateInput = () => {
|
||||
if (dateInput.value) {
|
||||
dateInput.value.showPicker(); // 달력 자동 열기 (일부 브라우저에서 지원)
|
||||
dateInput.value.focus(); // 포커스 이동
|
||||
}
|
||||
};
|
||||
|
||||
const minDate = ref('');
|
||||
onMounted(() => {
|
||||
const offset = new Date().getTimezoneOffset() * 60000;
|
||||
const today = new Date(Date.now() - offset);
|
||||
today.setDate(today.getDate() + 1);
|
||||
minDate.value = today.toISOString().substring(0, 16);
|
||||
|
||||
});
|
||||
|
||||
const userSet = ({ userList, userTotal }) => {
|
||||
activeUserList.value = userList;
|
||||
userListTotal.value = userTotal;
|
||||
};
|
||||
|
||||
|
||||
const handleUserListUpdate = ({ activeUsers, disabledUsers: updatedDisabledUsers }) => {
|
||||
activeUserList.value = activeUsers;
|
||||
disabledUsers.value = updatedDisabledUsers;
|
||||
@ -163,6 +135,7 @@ const handleUserListUpdate = ({ activeUsers, disabledUsers: updatedDisabledUsers
|
||||
UserListAlert.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveValid = () => {
|
||||
let valid = true;
|
||||
if (disabledUsers.value.length === 0) {
|
||||
@ -202,6 +175,7 @@ const saveValid = () => {
|
||||
}
|
||||
};
|
||||
const saveVote = () => {
|
||||
console.log('itemList',itemList)
|
||||
const filteredItemList = itemList.value.filter(item => item.content && item.content.trim() !== '');
|
||||
const unwrappedUserList = toRaw(activeUserList.value);
|
||||
const listId = unwrappedUserList.map(item => ({
|
||||
@ -244,14 +218,4 @@ const goList = () => {
|
||||
.item-input {
|
||||
max-width: 200px;
|
||||
}
|
||||
.hidden-date-input {
|
||||
display: block; /* 한 줄 차지 */
|
||||
margin-top: 19.5px; /* form-input과 붙게 조정 */
|
||||
border: none;
|
||||
padding: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none; /* 사용자 클릭 못하게 */
|
||||
position: absolute; /* 시각적으로 띄워두기 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<div class="d-flex">
|
||||
<!-- 단어 갯수, 작성하기 -->
|
||||
<!-- 왼쪽 사이드바 -->
|
||||
<div v-if="cateList.length>0" class="sidebar position-sticky" style="top: 100px; max-width: 250px; min-width: 250px;">
|
||||
<div class="sidebar position-sticky" style="top: 100px; max-width: 250px; min-width: 250px;">
|
||||
<WriteButton ref="writeButton" @click="writeStore.toggleItem(999999)" :isToggleEnabled="true"
|
||||
:isActive="writeStore.activeItemId === 999999"/>
|
||||
<!-- ㄱ ㄴ ㄷ ㄹ -->
|
||||
@ -17,12 +17,8 @@
|
||||
<CategoryBtn :lists="cateList" @update:data="handleSelectedCategoryChange" :showAll="true" :selectedCategory="selectedCategory" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<WriteButton ref="writeButton" @click="writeStore.toggleItem(999999)" :isToggleEnabled="true"
|
||||
:isActive="writeStore.activeItemId === 999999"/>
|
||||
</div>
|
||||
|
||||
<!-- 용어 리스트 컨텐츠 -->
|
||||
<!-- 용어 리스트 컨텐츠 -->
|
||||
<div class="flex-grow-1">
|
||||
<!-- 작성 -->
|
||||
<div v-if="writeStore.isItemActive(999999)" class="ms-3 card p-5 mb-2">
|
||||
@ -31,11 +27,10 @@
|
||||
<!-- 용어 리스트 -->
|
||||
<div>
|
||||
<!-- 에러 메시지 -->
|
||||
<div v-if="error" class="fw-bold text-danger">{{ error }}</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<!-- 단어 목록 -->
|
||||
<ul v-if="total > 0" class="ms-3 list-unstyled">
|
||||
<ul v-if="total > 0" class="ms-3 list-unstyled">
|
||||
<DictCard
|
||||
class="q-editor-container"
|
||||
v-for="item in wordList"
|
||||
:key="item.WRDDICSEQ"
|
||||
:item="item"
|
||||
@ -45,8 +40,7 @@
|
||||
/>
|
||||
</ul>
|
||||
<!-- 데이터가 없을 때 -->
|
||||
|
||||
<div v-if="total == 0" class="text-center mt-5">{{ searchText ? '검색된 목록이 없습니다.':'용어를 선택 / 작성해 주세요' }}</div>
|
||||
<div v-if="total == 0" class="text-center mt-5">용어를 선택 / 작성해 주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -59,21 +53,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, getCurrentInstance, toRaw } from 'vue';
|
||||
import axios from '@api';
|
||||
import SearchBar from '@c/search/SearchBar.vue';
|
||||
import WriteButton from '@c/button/WriteBtn.vue';
|
||||
import CategoryBtn from '@/components/category/CategoryBtn.vue';
|
||||
import DictCard from '@/components/wordDict/DictCard.vue';
|
||||
import DictWrite from '@/components/wordDict/DictWrite.vue';
|
||||
import DictAlphabetFilter from '@/components/wordDict/DictAlphabetFilter.vue';
|
||||
import commonApi from '@/common/commonApi';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useWriteVisibleStore } from '@s/writeVisible';
|
||||
import LoadingSpinner from "@v/LoadingPage.vue";
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
import { ref, computed, onMounted, getCurrentInstance, toRaw } from 'vue';
|
||||
import axios from '@api';
|
||||
import SearchBar from '@c/search/SearchBar.vue';
|
||||
import WriteButton from '@c/button/WriteBtn.vue';
|
||||
import CategoryBtn from '@/components/category/CategoryBtn.vue';
|
||||
import DictCard from '@/components/wordDict/DictCard.vue';
|
||||
import DictWrite from '@/components/wordDict/DictWrite.vue';
|
||||
import DictAlphabetFilter from '@/components/wordDict/DictAlphabetFilter.vue';
|
||||
import commonApi from '@/common/commonApi';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useWriteVisibleStore } from '@s/writeVisible';
|
||||
import LoadingSpinner from "@v/LoadingPage.vue";
|
||||
|
||||
// 작성창 구분
|
||||
const writeStore = useWriteVisibleStore();
|
||||
@ -119,17 +110,9 @@ import { useRoute } from 'vue-router';
|
||||
onMounted(() => {
|
||||
getIndex();
|
||||
writeStore.closeAll();
|
||||
const mainindexKeyword = route.query.indexKeyword || '';
|
||||
const maincategory = route.query.category || '';
|
||||
selectedAlphabet.value = mainindexKeyword;
|
||||
selectedCategory.value = maincategory;
|
||||
if(mainindexKeyword){
|
||||
getwordList('', selectedAlphabet.value, selectedCategory.value );
|
||||
}
|
||||
});
|
||||
|
||||
const refreshWordList = (category) => {
|
||||
selectedCategory.value = category;
|
||||
const refreshWordList = () => {
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
};
|
||||
|
||||
@ -147,7 +130,7 @@ import { useRoute } from 'vue-router';
|
||||
// 용어 목록 저장
|
||||
wordList.value = res.data.data.data;
|
||||
// 총 개수 저장
|
||||
total.value = res.data.data.data.length;
|
||||
total.value = res.data.data.total;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('데이터 로드 오류:', err);
|
||||
@ -166,20 +149,8 @@ import { useRoute } from 'vue-router';
|
||||
// 검색
|
||||
const search = (e) => {
|
||||
searchText.value = e.trim();
|
||||
if(searchText.value){
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
}else{
|
||||
if( selectedCategory.value !== '' && selectedCategory.value !== null){
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
}
|
||||
else if( selectedAlphabet.value !== '' && selectedAlphabet.value !== null){
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
}else{
|
||||
wordList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
};
|
||||
|
||||
// 알파벳 선택
|
||||
const handleSelectedAlphabetChange = (newAlphabet) => {
|
||||
@ -187,9 +158,6 @@ import { useRoute } from 'vue-router';
|
||||
if (newAlphabet !== null) {
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
} else {
|
||||
if( selectedCategory.value !== '' && selectedCategory.value !== null){
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
}
|
||||
wordList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
@ -204,9 +172,6 @@ import { useRoute } from 'vue-router';
|
||||
getwordList(searchText.value, selectedAlphabet.value, '');
|
||||
}
|
||||
} else {
|
||||
if( selectedAlphabet.value !== '' && selectedAlphabet.value !== null){
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
}
|
||||
wordList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
@ -228,7 +193,6 @@ import { useRoute } from 'vue-router';
|
||||
sendWordRequest(category, wordData, newCodName);
|
||||
};
|
||||
const sendWordRequest = (category, wordData, data) => {
|
||||
|
||||
const payload = {
|
||||
WRDDICCAT: category,
|
||||
WRDDICTTL: wordData.title,
|
||||
@ -242,16 +206,14 @@ import { useRoute } from 'vue-router';
|
||||
if (writeButton.value) {
|
||||
writeButton.value.resetButton();
|
||||
}
|
||||
selectedCategory.value = category;
|
||||
const firstChar = getFirstCharacter(wordData.title[0]); // 첫 글자 변환
|
||||
selectedAlphabet.value = firstChar;
|
||||
|
||||
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
|
||||
getwordList();
|
||||
getIndex();
|
||||
selectedCategory.value = 'all';
|
||||
if(res.data.data == '2'){
|
||||
const newCategory = { label: data, value: category };
|
||||
cateList.value = [...cateList.value,newCategory];
|
||||
}
|
||||
selectedAlphabet.value = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -293,32 +255,6 @@ import { useRoute } from 'vue-router';
|
||||
|
||||
};
|
||||
|
||||
//초성 /알파벳 변환
|
||||
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;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -329,18 +265,19 @@ import { useRoute } from 'vue-router';
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 5px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.q-editor-container {
|
||||
max-width: 100%; /* 영역이 넘치지 않게 */
|
||||
overflow: auto; /* 넘치는 내용은 스크롤로 처리 */
|
||||
word-wrap: break-word; /* 긴 단어는 자동으로 줄바꿈 */
|
||||
white-space: normal; /* 내용이 길어지면 자동으로 줄바꿈 */
|
||||
@media (max-width: 768px) {
|
||||
.title {
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||